Skip to content

Commit 8d14563

Browse files
committed
test(driver-turso): measure deferred-DDL boot against the remote face
Replays the calls a deferSchemaDdl boot makes (setDeferredDdl, the engine's syncSchemasBatch door, the syncSchema/initObjects doors, preview and flush) against a remote-mode TursoDriver over the libsql SQLite stub, recording every statement. Measured on origin/main: arming is accepted, DDL runs on every door, the canonical backfill rewrites rows on the syncSchema/initObjects doors, and preview/flush answer nothing. Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1cacfe4 commit 8d14563

1 file changed

Lines changed: 215 additions & 0 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Deferred schema DDL on the Turso REMOTE face — the MEASUREMENT.
5+
*
6+
* `os migrate plan` / `apply` / `duplicates` / `account-issuer` /
7+
* `multi-value-columns` boot with `deferSchemaDdl: true`, which arms the
8+
* driver through the inherited `SqlDriver.setDeferredDdl(true)` and then reads
9+
* `previewDeferredSchemaWork()` for the plan. This file replays, against a
10+
* remote-mode `TursoDriver` over a real SQLite database wearing the
11+
* `@libsql/client` interface, the exact driver calls that boot makes:
12+
*
13+
* 1. `setDeferredDdl(true)` — `DeferSchemaDdlPlugin.init`;
14+
* 2. `syncSchemasBatch(...)` — `ObjectQLPlugin.start()`'s boot sync, which
15+
* takes the batch door whenever `supports.batchSchemaSync` is true and the
16+
* method exists (both hold for this driver, asserted below);
17+
* 3. `syncSchema(...)` — the composed-host coverage pass
18+
* (`engine.syncObjectSchema`) on a `plan`/`apply` boot;
19+
* 4. `previewDeferredSchemaWork()` / `flushDeferredSchemaDdl()` — what the
20+
* plan prints and what `apply` performs after its confirm prompt.
21+
*
22+
* Every statement the transport sends is recorded, so "did DDL run" and "did a
23+
* row get rewritten" are read off the wire and off the disk, not inferred.
24+
*
25+
* ## The three predictions, as measured on origin/main
26+
*
27+
* | prediction | door | verdict |
28+
* |:--|:--|:--|
29+
* | (a) the dry run performs DDL | `syncSchemasBatch` (engine boot sync) | MEASURED — `CREATE TABLE "fresh"`, `ALTER TABLE "probe" ADD COLUMN "why"` |
30+
* | (a) | `syncSchema` / `initObjects` (coverage pass, direct) | MEASURED — the same CREATE/ALTER |
31+
* | (b) the dry run rewrites rows (canonical temporal backfill) | `syncSchemasBatch` | REFUTED — no row write on this door |
32+
* | (b) | `syncSchema` / `initObjects` | MEASURED — `update "probe" set "at" = …`, the naive value is rewritten on disk |
33+
* | (c) the plan reports no pending work | every door | MEASURED — `previewDeferredSchemaWork()` and `flushDeferredSchemaDdl()` both answer `[]` |
34+
*
35+
* No door emitted a destructive statement (no `DROP`, no type change); the
36+
* backfill rewrites a value's spelling, not the instant it names.
37+
*/
38+
39+
import { describe, it, expect } from 'vitest';
40+
import { TursoDriver } from './turso-driver.js';
41+
import { makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js';
42+
43+
/** A pre-existing remote table missing one declared column, holding a non-canonical datetime. */
44+
const PROBE = {
45+
name: 'probe',
46+
fields: { at: { type: 'datetime' }, why: { type: 'string' } },
47+
};
48+
/** A declared object with no table yet. */
49+
const FRESH = {
50+
name: 'fresh',
51+
fields: { label: { type: 'string' } },
52+
};
53+
54+
const NAIVE = '2025-07-28 00:00:00';
55+
56+
const sqlOf = (stmt: unknown): string =>
57+
typeof stmt === 'string' ? stmt : String((stmt as { sql?: unknown }).sql ?? '');
58+
59+
const DDL = /^\s*(create|alter|drop)\b/i;
60+
const ROW_WRITE = /^\s*(insert|update|delete|replace)\b/i;
61+
62+
interface Recorder {
63+
client: unknown;
64+
statements: string[];
65+
ddl(): string[];
66+
rowWrites(): string[];
67+
}
68+
69+
/** Wrap the stub so every statement the transport sends is recorded. */
70+
function record(stub: LibsqlSqliteStub): Recorder {
71+
const statements: string[] = [];
72+
const client = {
73+
async execute(stmt: unknown) {
74+
statements.push(sqlOf(stmt));
75+
return stub.execute(stmt);
76+
},
77+
async batch(stmts: unknown[]) {
78+
for (const s of stmts) statements.push(sqlOf(s));
79+
return stub.batch(stmts);
80+
},
81+
close() {
82+
stub.close();
83+
},
84+
};
85+
return {
86+
client,
87+
statements,
88+
ddl: () => statements.filter((s) => DDL.test(s)),
89+
rowWrites: () => statements.filter((s) => ROW_WRITE.test(s)),
90+
};
91+
}
92+
93+
/** The remote database as it stands before the migration command runs. */
94+
function seededRemote(): LibsqlSqliteStub {
95+
const stub = makeLibsqlSqliteStub();
96+
stub.raw
97+
.prepare(
98+
`create table "probe" ("id" TEXT PRIMARY KEY, "created_at" TEXT, "updated_at" TEXT, "at" TEXT)`,
99+
)
100+
.run();
101+
stub.raw.prepare(`insert into probe (id, at) values ('naive', ?)`).run(NAIVE);
102+
return stub;
103+
}
104+
105+
const tablesOf = (stub: LibsqlSqliteStub) =>
106+
(stub.raw.prepare(`select name from sqlite_master where type='table' order by name`).all() as Array<{
107+
name: string;
108+
}>).map((r) => r.name);
109+
110+
const columnsOf = (stub: LibsqlSqliteStub, table: string) =>
111+
(stub.raw.prepare(`pragma table_info("${table}")`).all() as Array<{ name: string }>).map((r) => r.name);
112+
113+
const atOf = (stub: LibsqlSqliteStub) =>
114+
(stub.raw.prepare(`select at from probe where id = 'naive'`).all() as Array<{ at: string }>)[0]?.at;
115+
116+
async function remoteDriver(rec: Recorder): Promise<TursoDriver> {
117+
const driver = new TursoDriver({ url: 'libsql://probe.turso.io', client: rec.client as never });
118+
await driver.connect();
119+
expect(driver.transportMode).toBe('remote');
120+
return driver;
121+
}
122+
123+
describe('measured on origin/main: a deferred-DDL boot against a REMOTE TursoDriver', () => {
124+
it('the engine boot sync takes the batch door on this driver', async () => {
125+
const stub = seededRemote();
126+
const driver = await remoteDriver(record(stub));
127+
// `ObjectQLPlugin.syncRegisteredSchemas` ANDs exactly these two.
128+
expect(driver.supports.batchSchemaSync).toBe(true);
129+
expect(typeof driver.syncSchemasBatch).toBe('function');
130+
stub.close();
131+
});
132+
133+
it('prediction (a) DDL — MEASURED: arming is accepted and the boot sync still issues CREATE/ALTER', async () => {
134+
const stub = seededRemote();
135+
const rec = record(stub);
136+
const driver = await remoteDriver(rec);
137+
138+
expect(() => driver.setDeferredDdl(true)).not.toThrow();
139+
await driver.syncSchemasBatch([
140+
{ object: 'probe', schema: PROBE },
141+
{ object: 'fresh', schema: FRESH },
142+
]);
143+
144+
expect(rec.ddl()).toEqual([
145+
expect.stringMatching(/^CREATE TABLE "fresh"/),
146+
'ALTER TABLE "probe" ADD COLUMN "why" TEXT',
147+
]);
148+
expect(tablesOf(stub)).toEqual(['fresh', 'probe']);
149+
expect(columnsOf(stub, 'probe')).toContain('why');
150+
stub.close();
151+
});
152+
153+
it('prediction (b) backfill — batch door: REFUTED (no row rewrite on the engine boot path)', async () => {
154+
const stub = seededRemote();
155+
const rec = record(stub);
156+
const driver = await remoteDriver(rec);
157+
158+
driver.setDeferredDdl(true);
159+
await driver.syncSchemasBatch([{ object: 'probe', schema: PROBE }]);
160+
161+
expect(rec.rowWrites()).toEqual([]);
162+
expect(atOf(stub)).toBe(NAIVE);
163+
stub.close();
164+
});
165+
166+
it('prediction (b) backfill — syncSchema door: MEASURED (the coverage pass rewrites stored rows)', async () => {
167+
const stub = seededRemote();
168+
const rec = record(stub);
169+
const driver = await remoteDriver(rec);
170+
171+
driver.setDeferredDdl(true);
172+
await driver.syncSchema('probe', PROBE);
173+
174+
expect(rec.ddl()).toEqual(['ALTER TABLE "probe" ADD COLUMN "why" TEXT']);
175+
expect(rec.rowWrites().length).toBeGreaterThan(0);
176+
expect(rec.rowWrites().every((s) => /^\s*update "probe" set "at"/i.test(s))).toBe(true);
177+
expect(atOf(stub)).not.toBe(NAIVE);
178+
stub.close();
179+
});
180+
181+
it('prediction (b) backfill — initObjects door: MEASURED', async () => {
182+
const stub = seededRemote();
183+
const rec = record(stub);
184+
const driver = await remoteDriver(rec);
185+
186+
driver.setDeferredDdl(true);
187+
await driver.initObjects([PROBE]);
188+
189+
expect(rec.ddl()).toEqual(['ALTER TABLE "probe" ADD COLUMN "why" TEXT']);
190+
expect(rec.rowWrites().length).toBeGreaterThan(0);
191+
expect(atOf(stub)).not.toBe(NAIVE);
192+
stub.close();
193+
});
194+
195+
it('prediction (c) no pending work — MEASURED: preview and flush both answer nothing', async () => {
196+
const stub = seededRemote();
197+
const rec = record(stub);
198+
const driver = await remoteDriver(rec);
199+
200+
driver.setDeferredDdl(true);
201+
await driver.syncSchemasBatch([
202+
{ object: 'probe', schema: PROBE },
203+
{ object: 'fresh', schema: FRESH },
204+
]);
205+
await driver.syncSchema('probe', PROBE);
206+
207+
expect(driver.deferredSchemaObjectCount).toBe(0);
208+
expect(await driver.previewDeferredSchemaWork()).toEqual([]);
209+
// What `apply` reports as performed after its confirm prompt — the work
210+
// it confirmed had already happened during boot.
211+
expect(await driver.flushDeferredSchemaDdl()).toEqual([]);
212+
expect(rec.ddl().length).toBeGreaterThan(0);
213+
stub.close();
214+
});
215+
});

0 commit comments

Comments
 (0)