Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Filter logical-combinator conformance for TursoDriver in LOCAL mode (#3774,
* #5590) — the shared `@objectstack/spec/data` cases, run through this
* driver's own pipeline.
*
* `TursoDriver extends SqlDriver`, so in local (and replica — same local
* engine) mode the `$and` / `$or` / `$not` compilation is inherited and
* nothing here re-implements it. Two things this file pins are nonetheless
* this package's own, and would fail in no other suite in the repo:
*
* 1. **The transport router.** Every read method on this driver is an
* `override` that branches on `this.isRemote` before it reaches
* `super.find`. A branch that sent a local read down the remote path — or
* a `toRemoteQuery` that ran on a query it was never meant to touch —
* changes which compiler answers a filter, and only a row-result assertion
* can see that.
* 2. **The temporal seam the constructor installs.** `filterColumnSql`
* rewrites the SQL on the LEFT of a comparison for temporal columns
* (ADR-0053 D-A1, #937). Every column in {@link FILTER_LOGIC_ROWS} is a
* plain string, so the seam must leave all of them alone; a rewrite that
* over-reached would corrupt exactly the boring predicates this table is
* built from.
*
* "It inherits the compiler, therefore it is fine" is the assumption the
* shared case-sets exist to disprove — `driver-sqlite-wasm` recorded this same
* cell as DEBT for that reason and cleared it with a suite, not with the
* sentence. The REMOTE half of this driver inherits nothing at all and gets
* its own file (`turso-remote-filter-logic-conformance.test.ts`), the same
* two-transport shape the temporal suites next door already use.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data';
import { TursoDriver } from './turso-driver.js';

const CONFORMANCE_OBJECT = {
name: 'conformance',
fields: {
a: { type: 'string' },
b: { type: 'string' },
c: { type: 'string' },
owner: { type: 'string' },
status: { type: 'string' },
parent_object: { type: 'string' },
parent_id: { type: 'string' },
},
};

const ids = (rows: Array<Record<string, unknown>>): string[] =>
rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y));

describe('TursoDriver — filter logic conformance (local mode)', () => {
let driver: TursoDriver;

beforeAll(async () => {
driver = new TursoDriver({ url: ':memory:' });
// The mode this suite is about — the SqlDriver-inherited engine. Replica
// shares it; remote does not (see module doc).
expect(driver.transportMode).toBe('local');
await driver.initObjects([CONFORMANCE_OBJECT]);
for (const row of FILTER_LOGIC_ROWS) {
await driver.create('conformance', { ...row }, { bypassTenantAudit: true });
}
});

afterAll(async () => {
await driver.disconnect();
});

/**
* The fixture as a whole, first: a case that returns nothing because the
* seed failed must not read as a case that correctly excluded everything.
*/
it('the fixture really is all four rows', async () => {
const rows = await driver.find('conformance', { object: 'conformance' });
expect(ids(rows)).toEqual(['1', '2', '3', '4']);
});

for (const c of FILTER_LOGIC_CASES) {
it(c.name, async () => {
const rows = await driver.find(
'conformance',
{ object: 'conformance', where: c.filter },
{ bypassTenantAudit: true },
);
expect(ids(rows), c.note).toEqual([...c.expected]);
});
}
});
128 changes: 128 additions & 0 deletions packages/drivers/driver-turso/src/turso-pagination-conformance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Deterministic paged reads for TursoDriver in LOCAL mode (objectui#3106,
* #4363, #5590) — the contract on `IDataDriver.find`, run against the shared
* `@objectstack/spec/data` cases like every other driver.
*
* `TursoDriver extends SqlDriver`, so the tie-breaking ORDER BY is *built* by
* inherited code and nothing here re-implements it. What this pins is that it
* still reaches the engine on this driver: every read method is an `override`
* that branches on `this.isRemote` before delegating to `super.find`, so the
* inherited clause travels through one more layer here than it does in the
* base package. A router that mangled or bypassed the query — or a future
* override that rebuilt the paged read itself — would produce exactly the
* failure the contract rules out (full pages, real rows, one served twice and
* one never served), and it would fail in no other suite in the repo.
*
* The REMOTE transport keeps its own file
* (`turso-remote-pagination-conformance.test.ts`): it does not go through knex
* at all and assembles its own ORDER BY / LIMIT / OFFSET, which is a second
* implementation of this contract rather than a second engine under the same
* one.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import {
PAGINATION_ALL_IDS,
PAGINATION_CASES,
PAGINATION_ROWS,
PAGINATION_UNORDERED_CASES,
} from '@objectstack/spec/data';
import { TursoDriver } from './turso-driver.js';

const TICKET_OBJECT = {
name: 'ticket',
fields: {
status: { type: 'string' },
rank: { type: 'integer' },
name: { type: 'string' },
},
};

describe('TursoDriver — paged reads are a partition of the result set (local mode)', () => {
let driver: TursoDriver;

beforeAll(async () => {
driver = new TursoDriver({ url: ':memory:' });
expect(driver.transportMode).toBe('local');
await driver.initObjects([TICKET_OBJECT]);
for (const row of PAGINATION_ROWS) {
await driver.create('ticket', { ...row }, { bypassTenantAudit: true });
}
});

afterAll(async () => {
await driver.disconnect();
});

/** Walk the whole table page by page, collecting the ids in visit order. */
const walk = async (
pageSize: number,
orderBy?: ReadonlyArray<{ field: string; order: 'asc' | 'desc' }>,
): Promise<string[]> => {
const seen: string[] = [];
for (let offset = 0; offset < PAGINATION_ROWS.length; offset += pageSize) {
const page: Array<Record<string, unknown>> = await driver.find(
'ticket',
{ ...(orderBy ? { orderBy: [...orderBy] } : {}), limit: pageSize, offset },
{ bypassTenantAudit: true },
);
seen.push(...page.map((r) => String(r.id)));
}
return seen;
};

it('the fixture really is all twelve rows', async () => {
const rows: Array<Record<string, unknown>> = await driver.find(
'ticket',
{ object: 'ticket' },
{ bypassTenantAudit: true },
);
expect(rows.map((r) => String(r.id)).sort()).toEqual([...PAGINATION_ALL_IDS].sort());
});

for (const testCase of PAGINATION_CASES) {
it(`visits every row exactly once — ${testCase.name}`, async () => {
const seen = await walk(testCase.pageSize, testCase.orderBy);
expect(seen).toHaveLength(PAGINATION_ALL_IDS.length);
expect(new Set(seen).size).toBe(PAGINATION_ALL_IDS.length);
expect([...seen].sort()).toEqual([...PAGINATION_ALL_IDS].sort());
});

it(`page boundaries are invisible — ${testCase.name}`, async () => {
const paged = await walk(testCase.pageSize, testCase.orderBy);
const whole: Array<Record<string, unknown>> = await driver.find(
'ticket',
{ orderBy: [...testCase.orderBy] },
{ bypassTenantAudit: true },
);
expect(paged).toEqual(whole.map((r) => String(r.id)));
});
}

for (const testCase of PAGINATION_UNORDERED_CASES) {
it(`visits every row exactly once with NO orderBy at all — ${testCase.name}`, async () => {
const seen = await walk(testCase.pageSize);
expect(seen).toHaveLength(PAGINATION_ALL_IDS.length);
expect(new Set(seen).size).toBe(PAGINATION_ALL_IDS.length);
expect([...seen].sort()).toEqual([...PAGINATION_ALL_IDS].sort());
});

it(`walks an unsorted read in id order — ${testCase.name}`, async () => {
// The fixture's ids are shuffled relative to insertion order, so this
// distinguishes "the inherited tie-breaking ORDER BY reached the engine"
// from "SQLite happened to hand back rowid order".
expect(await walk(testCase.pageSize)).toEqual([...PAGINATION_ALL_IDS].sort());
});
}

it('leaves an UNPAGED unordered read alone — no sort is imposed on a caller who asked for none', async () => {
const rows: Array<Record<string, unknown>> = await driver.find(
'ticket',
{},
{ bypassTenantAudit: true },
);
expect(rows.map((r) => String(r.id))).toEqual(PAGINATION_ROWS.map((r) => r.id));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Filter logical-combinator conformance for TursoDriver's REMOTE transport
* (#3774, #5590) — the shared `@objectstack/spec/data` cases, on rows.
*
* The local twin of this suite passes largely by INHERITANCE: `TursoDriver
* extends SqlDriver`, so `applyFilterCondition` compiles the combinators.
* Remote mode inherits none of it. `RemoteTransport.buildWhereSQL`
* (`src/remote-transport.ts`) is an independent filter compiler — its own
* `$and` / `$or` / `$not` nesting, its own operator vocabulary, its own
* comparand refusal, its own identity elements for the empty combinators. It
* is the "independent Nth backend" #3774 wrote this table for, and the seam
* has demonstrably diverged before: six semantic fixes landed in this one
* function while the package lived in `objectstack-ai/cloud` (#1071, #1074,
* #1078, #1080, #1116, #1117), every one of them a case where remote answered
* a filter differently from local.
*
* That history is also why the boolean-identity rows matter here more than
* anywhere else. `$and: []` is TRUE, `$or: []` is FALSE, a `{}` disjunct
* absorbs its `$or`, and `$not: {}` is FALSE (the #5322 ruling) — this
* transport reaches each of those through a *hand-written* branch rather than
* through knex, and "compiled to no clause" is the same string for TRUE and
* for "something was silently dropped". A dropped predicate leaves valid SQL,
* just wider, so it is invisible to a SQL-string assertion; only the row set
* tells them apart, which is what this file compares.
*
* ## Why a SQLite-backed client stub
*
* Same reason as the remote temporal suite next door: libsql IS SQLite, so
* `makeLibsqlSqliteStub` gives the transport real value and ordering semantics
* with no network and no credentials. The network itself stays the concern of
* the suites that mock `execute` and assert on the SQL string.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data';
import { TursoDriver } from './turso-driver.js';
import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';

const CONFORMANCE_OBJECT = {
name: 'conformance',
fields: {
a: { type: 'string' },
b: { type: 'string' },
c: { type: 'string' },
owner: { type: 'string' },
status: { type: 'string' },
parent_object: { type: 'string' },
parent_id: { type: 'string' },
},
};

const ids = (rows: Array<Record<string, unknown>>): string[] =>
rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y));

describe('TursoDriver remote — filter logic conformance', () => {
let driver: TursoDriver;
let stub: LibsqlSqliteStub;

beforeAll(async () => {
stub = makeLibsqlSqliteStub();
driver = new TursoDriver({ url: 'libsql://conformance.turso.io', client: stub as never });
await driver.connect();
// The mode this suite is about — the one that inherits nothing.
expect(driver.transportMode).toBe('remote');
// `syncSchema` is what creates the table and registers the field metadata
// in remote mode (`registerRemoteFieldMetadata`); there is no `initObjects`
// DDL path here.
await driver.syncSchema(CONFORMANCE_OBJECT.name, CONFORMANCE_OBJECT);
for (const row of FILTER_LOGIC_ROWS) {
await driver.create('conformance', { ...row });
}
});

afterAll(async () => {
await driver.disconnect();
stub.close();
});

/**
* The fixture as a whole, first — and read below the transport, so a case
* that returns nothing because the seed never landed cannot read as a case
* that correctly excluded everything.
*/
it('the fixture really is all four rows, as stored', () => {
const rows = stub.raw.prepare('select id, a, b, c from conformance order by id').all() as Array<{
id: string;
a: string;
b: string;
c: string;
}>;
expect(rows.map((r) => r.id)).toEqual(['1', '2', '3', '4']);
for (const row of rows) {
const seeded = FILTER_LOGIC_ROWS.find((r) => r.id === row.id)!;
expect([row.a, row.b, row.c], row.id).toEqual([seeded.a, seeded.b, seeded.c]);
}
});

for (const c of FILTER_LOGIC_CASES) {
it(c.name, async () => {
const rows = await driver.find('conformance', { where: c.filter });
expect(ids(rows), c.note).toEqual([...c.expected]);
});
}

/**
* `count()` compiles its WHERE through the same `buildWhereSQL` but a
* different statement builder, so a combinator that is right for `find` can
* still be wrong for `count` — which is how a list view shows a page of rows
* under a total that disagrees with it. One assertion over the whole table
* rather than a second copy of it.
*/
it('count() answers the same row set find() does, case for case', async () => {
for (const c of FILTER_LOGIC_CASES) {
expect(await driver.count('conformance', { where: c.filter }), c.name).toBe(c.expected.length);
}
});
});
Loading
Loading