Skip to content

Commit 5b60b36

Browse files
os-zhuangclaude
andauthored
test(driver-turso): drive FILTER_LOGIC / PAGINATION shared case-sets on BOTH transports, clearing the three DEBT cells (#5590) (#5656)
`TursoDriver` is dual-transport. Local/replica inherits SqlDriver's filter compiler and its paged-read tie-breaker; remote does not go through knex at all -- `src/remote-transport.ts` carries its own `buildWhereSQL` and its own ORDER BY / LIMIT / OFFSET assembly. That is the independent Nth backend objectstack#3774 and objectstack#4363 wrote the shared case-sets for, so each cell takes two suites, one per transport, in the shape the temporal pair in this package already established: turso-filter-logic-conformance.test.ts local, :memory: turso-remote-filter-logic-conformance.test.ts remote, sqlite stub turso-pagination-conformance.test.ts local, :memory: turso-remote-pagination-conformance.test.ts remote, sqlite stub All four are hermetic -- the remote half runs over `libsql-sqlite-stub.testkit.ts`, so no network and no credentials -- and all four are green, which is what lets the three DEBT entries leave the ledger in this same commit (25 covered cells, 0 DEBT). The remote PAGINATION half passes WITHOUT the mechanism the contract names: `buildSelectSQL` maps the caller's `orderBy` verbatim and appends no unique column, so the cases hold on a twelve-row better-sqlite3 table rather than by a promise the transport makes. Filed as objectstack#5653 and stated plainly in both the suite's module doc and the gate's ledger note; two `records the measured mechanism` tests pin the current no-tie-breaker behaviour so it cannot go quiet under a green cell. Per this issue's boundary the transport itself is untouched here. Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0ee4b3c commit 5b60b36

5 files changed

Lines changed: 562 additions & 50 deletions
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Filter logical-combinator conformance for TursoDriver in LOCAL mode (#3774,
5+
* #5590) — the shared `@objectstack/spec/data` cases, run through this
6+
* driver's own pipeline.
7+
*
8+
* `TursoDriver extends SqlDriver`, so in local (and replica — same local
9+
* engine) mode the `$and` / `$or` / `$not` compilation is inherited and
10+
* nothing here re-implements it. Two things this file pins are nonetheless
11+
* this package's own, and would fail in no other suite in the repo:
12+
*
13+
* 1. **The transport router.** Every read method on this driver is an
14+
* `override` that branches on `this.isRemote` before it reaches
15+
* `super.find`. A branch that sent a local read down the remote path — or
16+
* a `toRemoteQuery` that ran on a query it was never meant to touch —
17+
* changes which compiler answers a filter, and only a row-result assertion
18+
* can see that.
19+
* 2. **The temporal seam the constructor installs.** `filterColumnSql`
20+
* rewrites the SQL on the LEFT of a comparison for temporal columns
21+
* (ADR-0053 D-A1, #937). Every column in {@link FILTER_LOGIC_ROWS} is a
22+
* plain string, so the seam must leave all of them alone; a rewrite that
23+
* over-reached would corrupt exactly the boring predicates this table is
24+
* built from.
25+
*
26+
* "It inherits the compiler, therefore it is fine" is the assumption the
27+
* shared case-sets exist to disprove — `driver-sqlite-wasm` recorded this same
28+
* cell as DEBT for that reason and cleared it with a suite, not with the
29+
* sentence. The REMOTE half of this driver inherits nothing at all and gets
30+
* its own file (`turso-remote-filter-logic-conformance.test.ts`), the same
31+
* two-transport shape the temporal suites next door already use.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data';
36+
import { TursoDriver } from './turso-driver.js';
37+
38+
const CONFORMANCE_OBJECT = {
39+
name: 'conformance',
40+
fields: {
41+
a: { type: 'string' },
42+
b: { type: 'string' },
43+
c: { type: 'string' },
44+
owner: { type: 'string' },
45+
status: { type: 'string' },
46+
parent_object: { type: 'string' },
47+
parent_id: { type: 'string' },
48+
},
49+
};
50+
51+
const ids = (rows: Array<Record<string, unknown>>): string[] =>
52+
rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y));
53+
54+
describe('TursoDriver — filter logic conformance (local mode)', () => {
55+
let driver: TursoDriver;
56+
57+
beforeAll(async () => {
58+
driver = new TursoDriver({ url: ':memory:' });
59+
// The mode this suite is about — the SqlDriver-inherited engine. Replica
60+
// shares it; remote does not (see module doc).
61+
expect(driver.transportMode).toBe('local');
62+
await driver.initObjects([CONFORMANCE_OBJECT]);
63+
for (const row of FILTER_LOGIC_ROWS) {
64+
await driver.create('conformance', { ...row }, { bypassTenantAudit: true });
65+
}
66+
});
67+
68+
afterAll(async () => {
69+
await driver.disconnect();
70+
});
71+
72+
/**
73+
* The fixture as a whole, first: a case that returns nothing because the
74+
* seed failed must not read as a case that correctly excluded everything.
75+
*/
76+
it('the fixture really is all four rows', async () => {
77+
const rows = await driver.find('conformance', { object: 'conformance' });
78+
expect(ids(rows)).toEqual(['1', '2', '3', '4']);
79+
});
80+
81+
for (const c of FILTER_LOGIC_CASES) {
82+
it(c.name, async () => {
83+
const rows = await driver.find(
84+
'conformance',
85+
{ object: 'conformance', where: c.filter },
86+
{ bypassTenantAudit: true },
87+
);
88+
expect(ids(rows), c.note).toEqual([...c.expected]);
89+
});
90+
}
91+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Deterministic paged reads for TursoDriver in LOCAL mode (objectui#3106,
5+
* #4363, #5590) — the contract on `IDataDriver.find`, run against the shared
6+
* `@objectstack/spec/data` cases like every other driver.
7+
*
8+
* `TursoDriver extends SqlDriver`, so the tie-breaking ORDER BY is *built* by
9+
* inherited code and nothing here re-implements it. What this pins is that it
10+
* still reaches the engine on this driver: every read method is an `override`
11+
* that branches on `this.isRemote` before delegating to `super.find`, so the
12+
* inherited clause travels through one more layer here than it does in the
13+
* base package. A router that mangled or bypassed the query — or a future
14+
* override that rebuilt the paged read itself — would produce exactly the
15+
* failure the contract rules out (full pages, real rows, one served twice and
16+
* one never served), and it would fail in no other suite in the repo.
17+
*
18+
* The REMOTE transport keeps its own file
19+
* (`turso-remote-pagination-conformance.test.ts`): it does not go through knex
20+
* at all and assembles its own ORDER BY / LIMIT / OFFSET, which is a second
21+
* implementation of this contract rather than a second engine under the same
22+
* one.
23+
*/
24+
25+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
26+
import {
27+
PAGINATION_ALL_IDS,
28+
PAGINATION_CASES,
29+
PAGINATION_ROWS,
30+
PAGINATION_UNORDERED_CASES,
31+
} from '@objectstack/spec/data';
32+
import { TursoDriver } from './turso-driver.js';
33+
34+
const TICKET_OBJECT = {
35+
name: 'ticket',
36+
fields: {
37+
status: { type: 'string' },
38+
rank: { type: 'integer' },
39+
name: { type: 'string' },
40+
},
41+
};
42+
43+
describe('TursoDriver — paged reads are a partition of the result set (local mode)', () => {
44+
let driver: TursoDriver;
45+
46+
beforeAll(async () => {
47+
driver = new TursoDriver({ url: ':memory:' });
48+
expect(driver.transportMode).toBe('local');
49+
await driver.initObjects([TICKET_OBJECT]);
50+
for (const row of PAGINATION_ROWS) {
51+
await driver.create('ticket', { ...row }, { bypassTenantAudit: true });
52+
}
53+
});
54+
55+
afterAll(async () => {
56+
await driver.disconnect();
57+
});
58+
59+
/** Walk the whole table page by page, collecting the ids in visit order. */
60+
const walk = async (
61+
pageSize: number,
62+
orderBy?: ReadonlyArray<{ field: string; order: 'asc' | 'desc' }>,
63+
): Promise<string[]> => {
64+
const seen: string[] = [];
65+
for (let offset = 0; offset < PAGINATION_ROWS.length; offset += pageSize) {
66+
const page: Array<Record<string, unknown>> = await driver.find(
67+
'ticket',
68+
{ ...(orderBy ? { orderBy: [...orderBy] } : {}), limit: pageSize, offset },
69+
{ bypassTenantAudit: true },
70+
);
71+
seen.push(...page.map((r) => String(r.id)));
72+
}
73+
return seen;
74+
};
75+
76+
it('the fixture really is all twelve rows', async () => {
77+
const rows: Array<Record<string, unknown>> = await driver.find(
78+
'ticket',
79+
{ object: 'ticket' },
80+
{ bypassTenantAudit: true },
81+
);
82+
expect(rows.map((r) => String(r.id)).sort()).toEqual([...PAGINATION_ALL_IDS].sort());
83+
});
84+
85+
for (const testCase of PAGINATION_CASES) {
86+
it(`visits every row exactly once — ${testCase.name}`, async () => {
87+
const seen = await walk(testCase.pageSize, testCase.orderBy);
88+
expect(seen).toHaveLength(PAGINATION_ALL_IDS.length);
89+
expect(new Set(seen).size).toBe(PAGINATION_ALL_IDS.length);
90+
expect([...seen].sort()).toEqual([...PAGINATION_ALL_IDS].sort());
91+
});
92+
93+
it(`page boundaries are invisible — ${testCase.name}`, async () => {
94+
const paged = await walk(testCase.pageSize, testCase.orderBy);
95+
const whole: Array<Record<string, unknown>> = await driver.find(
96+
'ticket',
97+
{ orderBy: [...testCase.orderBy] },
98+
{ bypassTenantAudit: true },
99+
);
100+
expect(paged).toEqual(whole.map((r) => String(r.id)));
101+
});
102+
}
103+
104+
for (const testCase of PAGINATION_UNORDERED_CASES) {
105+
it(`visits every row exactly once with NO orderBy at all — ${testCase.name}`, async () => {
106+
const seen = await walk(testCase.pageSize);
107+
expect(seen).toHaveLength(PAGINATION_ALL_IDS.length);
108+
expect(new Set(seen).size).toBe(PAGINATION_ALL_IDS.length);
109+
expect([...seen].sort()).toEqual([...PAGINATION_ALL_IDS].sort());
110+
});
111+
112+
it(`walks an unsorted read in id order — ${testCase.name}`, async () => {
113+
// The fixture's ids are shuffled relative to insertion order, so this
114+
// distinguishes "the inherited tie-breaking ORDER BY reached the engine"
115+
// from "SQLite happened to hand back rowid order".
116+
expect(await walk(testCase.pageSize)).toEqual([...PAGINATION_ALL_IDS].sort());
117+
});
118+
}
119+
120+
it('leaves an UNPAGED unordered read alone — no sort is imposed on a caller who asked for none', async () => {
121+
const rows: Array<Record<string, unknown>> = await driver.find(
122+
'ticket',
123+
{},
124+
{ bypassTenantAudit: true },
125+
);
126+
expect(rows.map((r) => String(r.id))).toEqual(PAGINATION_ROWS.map((r) => r.id));
127+
});
128+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Filter logical-combinator conformance for TursoDriver's REMOTE transport
5+
* (#3774, #5590) — the shared `@objectstack/spec/data` cases, on rows.
6+
*
7+
* The local twin of this suite passes largely by INHERITANCE: `TursoDriver
8+
* extends SqlDriver`, so `applyFilterCondition` compiles the combinators.
9+
* Remote mode inherits none of it. `RemoteTransport.buildWhereSQL`
10+
* (`src/remote-transport.ts`) is an independent filter compiler — its own
11+
* `$and` / `$or` / `$not` nesting, its own operator vocabulary, its own
12+
* comparand refusal, its own identity elements for the empty combinators. It
13+
* is the "independent Nth backend" #3774 wrote this table for, and the seam
14+
* has demonstrably diverged before: six semantic fixes landed in this one
15+
* function while the package lived in `objectstack-ai/cloud` (#1071, #1074,
16+
* #1078, #1080, #1116, #1117), every one of them a case where remote answered
17+
* a filter differently from local.
18+
*
19+
* That history is also why the boolean-identity rows matter here more than
20+
* anywhere else. `$and: []` is TRUE, `$or: []` is FALSE, a `{}` disjunct
21+
* absorbs its `$or`, and `$not: {}` is FALSE (the #5322 ruling) — this
22+
* transport reaches each of those through a *hand-written* branch rather than
23+
* through knex, and "compiled to no clause" is the same string for TRUE and
24+
* for "something was silently dropped". A dropped predicate leaves valid SQL,
25+
* just wider, so it is invisible to a SQL-string assertion; only the row set
26+
* tells them apart, which is what this file compares.
27+
*
28+
* ## Why a SQLite-backed client stub
29+
*
30+
* Same reason as the remote temporal suite next door: libsql IS SQLite, so
31+
* `makeLibsqlSqliteStub` gives the transport real value and ordering semantics
32+
* with no network and no credentials. The network itself stays the concern of
33+
* the suites that mock `execute` and assert on the SQL string.
34+
*/
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data';
38+
import { TursoDriver } from './turso-driver.js';
39+
import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
40+
41+
const CONFORMANCE_OBJECT = {
42+
name: 'conformance',
43+
fields: {
44+
a: { type: 'string' },
45+
b: { type: 'string' },
46+
c: { type: 'string' },
47+
owner: { type: 'string' },
48+
status: { type: 'string' },
49+
parent_object: { type: 'string' },
50+
parent_id: { type: 'string' },
51+
},
52+
};
53+
54+
const ids = (rows: Array<Record<string, unknown>>): string[] =>
55+
rows.map((r) => String(r.id)).sort((x, y) => x.localeCompare(y));
56+
57+
describe('TursoDriver remote — filter logic conformance', () => {
58+
let driver: TursoDriver;
59+
let stub: LibsqlSqliteStub;
60+
61+
beforeAll(async () => {
62+
stub = makeLibsqlSqliteStub();
63+
driver = new TursoDriver({ url: 'libsql://conformance.turso.io', client: stub as never });
64+
await driver.connect();
65+
// The mode this suite is about — the one that inherits nothing.
66+
expect(driver.transportMode).toBe('remote');
67+
// `syncSchema` is what creates the table and registers the field metadata
68+
// in remote mode (`registerRemoteFieldMetadata`); there is no `initObjects`
69+
// DDL path here.
70+
await driver.syncSchema(CONFORMANCE_OBJECT.name, CONFORMANCE_OBJECT);
71+
for (const row of FILTER_LOGIC_ROWS) {
72+
await driver.create('conformance', { ...row });
73+
}
74+
});
75+
76+
afterAll(async () => {
77+
await driver.disconnect();
78+
stub.close();
79+
});
80+
81+
/**
82+
* The fixture as a whole, first — and read below the transport, so a case
83+
* that returns nothing because the seed never landed cannot read as a case
84+
* that correctly excluded everything.
85+
*/
86+
it('the fixture really is all four rows, as stored', () => {
87+
const rows = stub.raw.prepare('select id, a, b, c from conformance order by id').all() as Array<{
88+
id: string;
89+
a: string;
90+
b: string;
91+
c: string;
92+
}>;
93+
expect(rows.map((r) => r.id)).toEqual(['1', '2', '3', '4']);
94+
for (const row of rows) {
95+
const seeded = FILTER_LOGIC_ROWS.find((r) => r.id === row.id)!;
96+
expect([row.a, row.b, row.c], row.id).toEqual([seeded.a, seeded.b, seeded.c]);
97+
}
98+
});
99+
100+
for (const c of FILTER_LOGIC_CASES) {
101+
it(c.name, async () => {
102+
const rows = await driver.find('conformance', { where: c.filter });
103+
expect(ids(rows), c.note).toEqual([...c.expected]);
104+
});
105+
}
106+
107+
/**
108+
* `count()` compiles its WHERE through the same `buildWhereSQL` but a
109+
* different statement builder, so a combinator that is right for `find` can
110+
* still be wrong for `count` — which is how a list view shows a page of rows
111+
* under a total that disagrees with it. One assertion over the whole table
112+
* rather than a second copy of it.
113+
*/
114+
it('count() answers the same row set find() does, case for case', async () => {
115+
for (const c of FILTER_LOGIC_CASES) {
116+
expect(await driver.count('conformance', { where: c.filter }), c.name).toBe(c.expected.length);
117+
}
118+
});
119+
});

0 commit comments

Comments
 (0)