Skip to content

Commit 3de5ec8

Browse files
os-zhuangclaude
andauthored
test(runtime,dogfood): migrate the two real driver-memory test backends to sqlite :memory: (#5704 batch 0) (#5715)
Batch 0 of the driver-memory test-surface replacement program (#5704, whose memory-side restart condition is #5499). These are the only two files the Phase 1 survey classified as class A "真依赖" and that carry no pending ruling: - `packages/qa/dogfood/test/read-coercion-conformance.test.ts` — drop the driver-memory arm from `DRIVERS`; the SqlDriver `:memory:` arm was already present and green. SQLite is the driver that actually stores booleans as integers, so it is the arm that carries the read-coercion invariant. - `packages/runtime/src/datasource-autoconnect.test.ts` — the host default driver (3 sites) and the declared datasources move to `@objectstack/driver-sql` + better-sqlite3 `:memory:`, the repo's canonical ephemeral store (examples/app-crm, `cli db clean`). The `ADR-0062 D1 … (#4083)` block is rewritten, not deleted and not relocated (PM ruling Q3-B on #5704): its acceptance target is the runtime property "a federated in-memory pool leaves nothing on the host and a restart starts empty", not driver-memory's file adapter. `memory-driver.json`, `flush()` and the autosave timer were adapter specifics; the replacement asserts that a fresh pool holds no `ext_note` TABLE at all and that nothing lands under `.objectstack/`. `schemaMode: 'external'` forbids DDL through the driver, so the federated table is now created out-of-band via raw `execute()` — a mingo store materialised the collection on first write, which is precisely the fidelity the migration buys. No runtime source changes; test-behavior only. Claude-Session: https://claude.ai/code/session_01WyvqvKMG6asi9aXjKE6xtx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 978fed2 commit 3de5ec8

2 files changed

Lines changed: 120 additions & 47 deletions

File tree

packages/qa/dogfood/test/read-coercion-conformance.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22
//
33
// Driver read-coercion conformance — exercises the reusable `checkReadCoercion`
4-
// helper (from @objectstack/verify) against the framework's own SQL + memory
5-
// drivers. A stored value must read back as its DECLARED type on every driver:
6-
// a boolean as a boolean (not the integer 0/1 SQLite stores), a json field as
7-
// an object, an integer as a number.
4+
// helper (from @objectstack/verify) against the framework's own SQL driver
5+
// (better-sqlite3 `:memory:`). A stored value must read back as its DECLARED
6+
// type: a boolean as a boolean (not the integer 0/1 SQLite stores), a json
7+
// field as an object, an integer as a number.
8+
//
9+
// The `driver-memory` arm was removed in #5704 (batch 0): driver-memory is the
10+
// project's legacy test-convenience backend and its in-project test surface is
11+
// being replaced by sqlite `:memory:` (#5499). Nothing is lost here — SQLite is
12+
// the driver that actually stores booleans as integers, so it is the arm that
13+
// carries the invariant; a mingo store that never had to coerce anything could
14+
// only ever be green.
815
//
916
// This is the invariant behind the 2026-07-06 case_escalation incident: a
1017
// boolean guard `field != true` read the field back as integer `1` on Turso, so
@@ -15,7 +22,6 @@
1522
import { describe, it, expect } from 'vitest';
1623
import { checkReadCoercion } from '@objectstack/verify';
1724
import { SqlDriver } from '@objectstack/driver-sql';
18-
import { InMemoryDriver } from '@objectstack/driver-memory';
1925

2026
const DRIVERS = [
2127
{
@@ -27,12 +33,6 @@ const DRIVERS = [
2733
useNullAsDefault: true,
2834
}),
2935
},
30-
{
31-
name: 'driver-memory',
32-
// `persistence: false` → pure in-memory, so the probe object does not leak to
33-
// a shared on-disk snapshot and collide with other suites in the full run.
34-
make: () => new InMemoryDriver({ persistence: false }),
35-
},
3636
];
3737

3838
describe.each(DRIVERS)('read-coercion conformance: $name', ({ make }) => {

packages/runtime/src/datasource-autoconnect.test.ts

Lines changed: 109 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,18 @@
77
//
88
// This boots the host-config shape (instantiated plugins, no MetadataPlugin —
99
// the same shape `examples/app-showcase` runs under `os dev`) with the REAL
10-
// driver factory (`createDefaultDatasourceDriverFactory`) building an in-memory
11-
// driver, so the full AppPlugin → `datasource-connection` → engine path runs
12-
// without any native driver dependency.
10+
// driver factory (`createDefaultDatasourceDriverFactory`) building a real
11+
// sqlite `:memory:` pool, so the full AppPlugin → `datasource-connection` →
12+
// engine path runs against the same engine production uses.
13+
//
14+
// Backend note (#5704 batch 0): both the host default driver and the declared
15+
// datasources ran on `@objectstack/driver-memory` until this file was migrated
16+
// to `@objectstack/driver-sql` + better-sqlite3 `:memory:` — the repo's
17+
// canonical ephemeral store (`examples/app-crm`, `cli db clean`). driver-memory
18+
// is the project's legacy test-convenience backend and its in-project test
19+
// surface is being retired (#5499). `:memory:` keeps every acceptance below
20+
// hermetic: the database lives and dies inside the process, so nothing reaches
21+
// the host filesystem and each boot starts empty.
1322

1423
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
1524
import { existsSync, rmSync } from 'node:fs';
@@ -21,6 +30,32 @@ import type { DatasourceConnectPolicy } from '@objectstack/service-datasource';
2130

2231
const BOOT_TIMEOUT = 60_000;
2332

33+
/** The host's default driver — sqlite `:memory:`, constructed the canonical way. */
34+
async function makeDefaultDriver() {
35+
const { SqlDriver } = await import('@objectstack/driver-sql');
36+
return new SqlDriver({
37+
client: 'better-sqlite3',
38+
connection: { filename: ':memory:' },
39+
useNullAsDefault: true,
40+
});
41+
}
42+
43+
/**
44+
* `schemaMode: 'external'` forbids DDL through the driver (ADR-0015 — ObjectStack
45+
* is a guest in that database), so the federated table has to already exist,
46+
* exactly as the real remote database an external datasource points at would
47+
* hold it. Raw `execute()` is the out-of-band channel that stands in for
48+
* "somebody else's migration already ran".
49+
*
50+
* With driver-memory this step did not exist: a mingo store materialises a
51+
* collection on first write, so an undeclared table was indistinguishable from a
52+
* declared one. Making it explicit is the point of the migration, not a
53+
* workaround for it.
54+
*/
55+
async function ensureRemoteExtNoteTable(driver: any): Promise<void> {
56+
await driver.execute('CREATE TABLE IF NOT EXISTS ext_note (id text primary key, title text)');
57+
}
58+
2459
// One external datasource (auto-connect target) + one managed, unrouted
2560
// datasource (must stay metadata-only). NO `onEnable` anywhere.
2661
function artifact() {
@@ -41,11 +76,11 @@ function artifact() {
4176
datasources: [
4277
{
4378
name: 'autoconn_ext',
44-
label: 'External (in-memory)',
45-
driver: 'memory',
79+
label: 'External (sqlite :memory:)',
80+
driver: 'sqlite',
4681
schemaMode: 'external',
4782
origin: 'code',
48-
config: {},
83+
config: { filename: ':memory:' },
4984
external: { allowWrites: false, validation: { onMismatch: 'warn', checkOnBoot: false } },
5085
active: true,
5186
},
@@ -54,10 +89,10 @@ function artifact() {
5489
{
5590
name: 'decorative',
5691
label: 'Decorative (unrouted)',
57-
driver: 'memory',
92+
driver: 'sqlite',
5893
schemaMode: 'managed',
5994
origin: 'code',
60-
config: {},
95+
config: { filename: ':memory:' },
6196
active: true,
6297
},
6398
],
@@ -66,17 +101,13 @@ function artifact() {
66101

67102
async function boot(opts: { connectPolicy?: DatasourceConnectPolicy } = {}) {
68103
const { ObjectQLPlugin } = await import('@objectstack/objectql');
69-
const { InMemoryDriver } = await import('@objectstack/driver-memory');
70104
const { DatasourceAdminServicePlugin, createDefaultDatasourceDriverFactory } = await import(
71105
'@objectstack/service-datasource'
72106
);
73107

74108
const runtime = new Runtime({ cluster: false });
75109
const kernel = runtime.getKernel();
76-
// `persistence: false` keeps the acceptance hermetic. The driver's own default
77-
// is `'auto'` — in Node a file adapter at `.objectstack/data/…` under the CWD,
78-
// which both reloads and rewrites ambient state between runs (#4083).
79-
await kernel.use(new DriverPlugin(new InMemoryDriver({ persistence: false }))); // default driver
110+
await kernel.use(new DriverPlugin(await makeDefaultDriver())); // default driver
80111
await kernel.use(new ObjectQLPlugin());
81112
await kernel.use(new AppPlugin(artifact()));
82113
await kernel.use(
@@ -127,6 +158,7 @@ describe('ADR-0062 declared-datasource auto-connect', () => {
127158
// Seed the live external driver directly (bypassing the read-only write gate,
128159
// exactly as a real remote DB would already hold the rows).
129160
const driver = engine.getDriverByName('autoconn_ext');
161+
await ensureRemoteExtNoteTable(driver);
130162
await driver.bulkCreate('ext_note', [
131163
{ id: 'n1', title: 'first' },
132164
{ id: 'n2', title: 'second' },
@@ -136,20 +168,36 @@ describe('ADR-0062 declared-datasource auto-connect', () => {
136168
});
137169
});
138170

139-
// #4083 — the acceptance above passed on a clean checkout and failed on every
140-
// subsequent run, reading 2×N rows on the Nth: the auto-connected `memory`
171+
// ADR-0062 D1 acceptance — an auto-connected in-memory federated pool leaves
172+
// nothing behind and does not outlive its kernel.
173+
//
174+
// ## What this pinned before, and why it still says the same thing
175+
//
176+
// #4083: the acceptance above passed on a clean checkout and failed on every
177+
// subsequent run, reading 2×N rows on the Nth. The auto-connected `memory`
141178
// datasource inherited `InMemoryDriver`'s then-default `persistence: 'auto'`
142-
// (#4065 has since made that default `false`), so it
143-
// flushed `ext_note` into `.objectstack/data/memory-driver.json` under the CWD
144-
// and the next boot's connect() loaded those rows back before this file seeded
145-
// its own. CI never caught it because CI always runs #1 on a fresh checkout.
179+
// (#4065 has since made that default `false`), so it flushed `ext_note` into
180+
// `.objectstack/data/memory-driver.json` under the CWD and the next boot's
181+
// connect() loaded those rows back before this file seeded its own. CI never
182+
// caught it because CI always runs #1 on a fresh checkout. The intermittency
183+
// ("passes once in four") came from WHEN the flush landed — the file adapter
184+
// wrote on a 2s unref'd autosave timer — so the original block called
185+
// `driver.flush?.()` to force the timer's work and remove the race.
186+
//
187+
// Those three things — `memory-driver.json`, `flush()`, the autosave timer —
188+
// are driver-memory FILE-ADAPTER specifics, and this file no longer runs on
189+
// driver-memory (#5704 batch 0). The acceptance target was never the file
190+
// adapter: ADR-0062 D1 asks for a runtime property — a federated in-memory pool
191+
// leaves nothing on the host and a restart starts empty — so the block is
192+
// rewritten against sqlite `:memory:` rather than deleted (deleting it would
193+
// drop D1's acceptance) and rather than moved into driver-memory's own suite
194+
// (the factory-level #4083 pin already lives in service-datasource's tests).
146195
//
147-
// The intermittency ("passes once in four") came from WHEN the flush lands: the
148-
// file adapter writes on a 2s unref'd autosave timer, so a run short enough to
149-
// finish first left nothing behind. `flush()` below stands in for that timer, so
150-
// this pins the property that was actually broken — a federated in-memory pool
151-
// leaves nothing behind and does not outlive its kernel — without a timing race
152-
// and without depending on run-to-run state.
196+
// Under `:memory:` the property is asserted more directly than before: a fresh
197+
// pool has no `ext_note` TABLE at all, not merely no rows — the one thing a
198+
// reloaded snapshot could never look like. The filesystem half is unchanged and
199+
// still load-bearing: `filename: ':memory:'` is one config typo away from a
200+
// relative file path, and that typo is exactly what #4083 was.
153201
describe('ADR-0062 D1 — the auto-connected in-memory pool leaves nothing behind (#4083)', () => {
154202
const STATE_DIR = join(process.cwd(), '.objectstack');
155203
const clearState = () => { try { rmSync(STATE_DIR, { recursive: true, force: true }); } catch { /* noop */ } };
@@ -159,25 +207,49 @@ describe('ADR-0062 D1 — the auto-connected in-memory pool leaves nothing behin
159207
beforeAll(clearState);
160208
afterAll(clearState);
161209

210+
function externalDriver(kernel: Awaited<ReturnType<typeof boot>>) {
211+
const engine = kernel.getService<{ getDriverByName(n: string): any }>('data');
212+
return engine.getDriverByName('autoconn_ext');
213+
}
214+
215+
/** Rows knex returns for a raw SELECT, whichever envelope the dialect uses. */
216+
function rowsOf(result: any): any[] {
217+
if (Array.isArray(result)) return result;
218+
if (Array.isArray(result?.rows)) return result.rows;
219+
return result == null ? [] : [result];
220+
}
221+
222+
/**
223+
* Does this pool's database already hold the federated table? A pool that
224+
* reloaded a previous boot's state would; a genuinely fresh `:memory:` one
225+
* cannot, because nothing in this composition is allowed to run DDL on an
226+
* `external` datasource.
227+
*/
228+
async function hasExtNoteTable(kernel: Awaited<ReturnType<typeof boot>>) {
229+
const result = await externalDriver(kernel).execute(
230+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ext_note'",
231+
);
232+
return rowsOf(result).length > 0;
233+
}
234+
162235
async function seedAndRead(kernel: Awaited<ReturnType<typeof boot>>) {
163236
const engine = kernel.getService<{
164237
getDriverByName(n: string): any;
165238
find(object: string, query?: any): Promise<any[]>;
166239
}>('data');
167240
const driver = engine.getDriverByName('autoconn_ext');
241+
await ensureRemoteExtNoteTable(driver);
168242
await driver.bulkCreate('ext_note', [
169243
{ id: 'n1', title: 'first' },
170244
{ id: 'n2', title: 'second' },
171245
]);
172-
const titles = (await engine.find('ext_note')).map((r) => r.title).sort();
173-
// Whatever the autosave timer would have written, written now.
174-
await driver.flush?.();
175-
return titles;
246+
return (await engine.find('ext_note')).map((r) => r.title).sort();
176247
}
177248

178249
it('writes no state file, and a second boot in the same process starts empty', async () => {
179250
const first = await boot();
180251
try {
252+
expect(await hasExtNoteTable(first)).toBe(false);
181253
expect(await seedAndRead(first)).toEqual(['first', 'second']);
182254
// The seeded rows must not have reached the host filesystem at all.
183255
expect(existsSync(STATE_DIR)).toBe(false);
@@ -187,8 +259,11 @@ describe('ADR-0062 D1 — the auto-connected in-memory pool leaves nothing behin
187259

188260
const second = await boot();
189261
try {
190-
// Was ['first','first','second','second'] — the first boot's rows, reloaded.
262+
// The whole point: not "no rows carried over" but "no database carried
263+
// over". Was ['first','first','second','second'] under the #4083 defect.
264+
expect(await hasExtNoteTable(second)).toBe(false);
191265
expect(await seedAndRead(second)).toEqual(['first', 'second']);
266+
expect(existsSync(STATE_DIR)).toBe(false);
192267
} finally {
193268
try { await (second as any)?.stop?.(); } catch { /* noop */ }
194269
}
@@ -206,10 +281,10 @@ describe('ADR-0062 credentials fail-closed (D3)', () => {
206281
datasources: [
207282
{
208283
name: 'needs_secret',
209-
driver: 'memory',
284+
driver: 'sqlite',
210285
schemaMode: 'external',
211286
origin: 'code',
212-
config: {},
287+
config: { filename: ':memory:' },
213288
external: {
214289
allowWrites: false,
215290
credentialsRef: 'sys_secret:does-not-exist',
@@ -223,13 +298,12 @@ describe('ADR-0062 credentials fail-closed (D3)', () => {
223298

224299
it('bricks boot with a clear message when a required credential cannot be resolved', async () => {
225300
const { ObjectQLPlugin } = await import('@objectstack/objectql');
226-
const { InMemoryDriver } = await import('@objectstack/driver-memory');
227301
const { DatasourceAdminServicePlugin, createDefaultDatasourceDriverFactory } = await import(
228302
'@objectstack/service-datasource'
229303
);
230304
const runtime = new Runtime({ cluster: false });
231305
const kernel = runtime.getKernel();
232-
await kernel.use(new DriverPlugin(new InMemoryDriver()));
306+
await kernel.use(new DriverPlugin(await makeDefaultDriver()));
233307
await kernel.use(new ObjectQLPlugin());
234308
await kernel.use(new AppPlugin(credArtifact()));
235309
await kernel.use(
@@ -275,13 +349,12 @@ describe('ADR-0062 D5 — an explicitly-bound datasource that cannot connect bri
275349

276350
async function bootBound() {
277351
const { ObjectQLPlugin } = await import('@objectstack/objectql');
278-
const { InMemoryDriver } = await import('@objectstack/driver-memory');
279352
const { DatasourceAdminServicePlugin, createDefaultDatasourceDriverFactory } = await import(
280353
'@objectstack/service-datasource'
281354
);
282355
const runtime = new Runtime({ cluster: false });
283356
const kernel = runtime.getKernel();
284-
await kernel.use(new DriverPlugin(new InMemoryDriver()));
357+
await kernel.use(new DriverPlugin(await makeDefaultDriver()));
285358
await kernel.use(new ObjectQLPlugin());
286359
await kernel.use(new AppPlugin(boundArtifact()));
287360
await kernel.use(

0 commit comments

Comments
 (0)