Skip to content

Commit 040fbcc

Browse files
committed
test(cli): pin the remote posture without a database — the ceremony's two opt-ins, transportRefusesDeferredDdl and the primary exemption
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lr3rg6qTp9r38exA5DHEEH
1 parent ec063d8 commit 040fbcc

3 files changed

Lines changed: 281 additions & 2 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23816 — `os migrate organization-ownership` declares, at both of its boots,
5+
* that a datasource whose transport cannot defer DDL (the Turso REMOTE
6+
* transport) boots with schema sync skipped instead
7+
* (`deferredDdlUnsupported: 'skip-schema-sync'`), and the apply reads the
8+
* ledger's presence from the catalog when its boot measured no table absent.
9+
*
10+
* Pinned on the command's own boot call, with `bootSchemaStack` stubbed: the
11+
* posture itself is `schema-migrate.remote-posture.test.ts`'s, and the
12+
* end-to-end run on a real `sqld` is
13+
* `organization-ownership.remote-face.integration.test.ts` (opt-in, through
14+
* `OS_TEST_SQLD_BIN`). Without these two opt-ins the plan stops at its boot on
15+
* every hosted tenant again, and nothing else in CI would say so.
16+
*/
17+
18+
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
19+
import { mkdtempSync, writeFileSync } from 'node:fs';
20+
import { tmpdir } from 'node:os';
21+
import { join } from 'node:path';
22+
import { fileURLToPath } from 'node:url';
23+
24+
const boot = vi.hoisted(() => vi.fn(async (_opts: Record<string, unknown>): Promise<unknown> => {
25+
throw new Error('the database was opened');
26+
}));
27+
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot }));
28+
// The occupancy gate is the siblings' stub (`organization-ownership.apply-flags.test.ts`).
29+
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
30+
OCCUPANCY_STOP_HINT: 'occupancy hint',
31+
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
32+
}));
33+
34+
type Command = (typeof import('./organization-ownership.js'))['default'];
35+
let MigrateOrganizationOwnership: Command;
36+
37+
beforeAll(async () => {
38+
({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
39+
}, 60_000);
40+
41+
const CASE_TIMEOUT_MS = 60_000;
42+
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
43+
44+
const PLAN_DIR = mkdtempSync(join(tmpdir(), 'os-23816-posture-'));
45+
const PLAN_FILE = join(PLAN_DIR, 'plan.json');
46+
writeFileSync(PLAN_FILE, JSON.stringify({
47+
kind: 'organization-ownership-plan',
48+
ceremony: 'ADR-0131 D10',
49+
generatedAt: '2026-10-16T00:00:00Z',
50+
database: 'libsql://tenant.example.turso.io',
51+
inventoryDigest: 'f'.repeat(64),
52+
posture: 'single',
53+
}));
54+
55+
async function runJson(argv: string[]): Promise<{ payload: Record<string, unknown>; exitCode: unknown }> {
56+
const chunks: string[] = [];
57+
const savedExit = process.exitCode;
58+
vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
59+
chunks.push(String(chunk));
60+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
61+
if (cb) cb();
62+
return true;
63+
}) as typeof process.stdout.write);
64+
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
65+
try {
66+
await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT });
67+
} catch (error) {
68+
if ((error as { oclif?: unknown }).oclif === undefined) throw error;
69+
}
70+
const exitCode = process.exitCode;
71+
process.exitCode = savedExit;
72+
vi.restoreAllMocks();
73+
return { payload: JSON.parse(chunks.join('').trim()) as Record<string, unknown>, exitCode };
74+
}
75+
76+
afterEach(() => {
77+
boot.mockReset();
78+
boot.mockImplementation(async () => {
79+
throw new Error('the database was opened');
80+
});
81+
});
82+
83+
const POSTURE = { deferSchemaDdl: true, deferredDdlUnsupported: 'skip-schema-sync' };
84+
85+
describe('both ceremony boots declare the skip-schema-sync posture', () => {
86+
it('the plan boot: deferred, read-only, and skip-schema-sync where deferral is refused', async () => {
87+
await runJson(['--json', '--out', join(PLAN_DIR, 'never-written.json')]);
88+
expect(boot).toHaveBeenCalledTimes(1);
89+
expect(boot.mock.calls[0]![0]).toEqual(expect.objectContaining({ ...POSTURE, readOnlyProbe: true }));
90+
}, CASE_TIMEOUT_MS);
91+
92+
it('the apply boot: deferred, the served composition, and skip-schema-sync where deferral is refused', async () => {
93+
await runJson(['--apply', '--plan-file', PLAN_FILE, '--backup-taken', '--json', '--out', join(PLAN_DIR, 'never-report.json')]);
94+
expect(boot).toHaveBeenCalledTimes(1);
95+
expect(boot.mock.calls[0]![0]).toEqual(expect.objectContaining({ ...POSTURE, composeHostStack: true }));
96+
}, CASE_TIMEOUT_MS);
97+
});
98+
99+
describe('the apply reads the ledger from the catalog when its boot skipped schema sync', () => {
100+
/** A booted stack as `bootSchemaStack` answers it, over a raw-SQL seam that answers `catalogRows`. */
101+
function stackWith(schemaSync: 'skipped' | 'deferred', opts: { catalogRows: unknown[]; tableAbsent: boolean }) {
102+
const execute = vi.fn(async (sql: string) => (/pragma_table_info/.test(sql) ? opts.catalogRows : []));
103+
return {
104+
execute,
105+
stack: {
106+
schemaSync,
107+
driver: { name: 'com.objectstack.driver.turso', dialectName: 'sqlite', config: { client: 'better-sqlite3' }, execute },
108+
dbLabel: 'libsql://tenant.example.turso.io',
109+
tableAbsent: vi.fn(() => opts.tableAbsent),
110+
kernel: { getService: () => { throw new Error('no kernel in this pin'); } },
111+
composition: { securityPlugin: false },
112+
allObjects: () => [],
113+
shutdown: async () => {},
114+
},
115+
};
116+
}
117+
const apply = () => runJson(['--apply', '--plan-file', PLAN_FILE, '--backup-taken', '--json', '--out', join(PLAN_DIR, 'never-report.json')]);
118+
119+
it('skipped, and the catalog has no sys_migration: refused ledger-absent, whatever tableAbsent says', async () => {
120+
const { stack, execute } = stackWith('skipped', { catalogRows: [], tableAbsent: false });
121+
boot.mockImplementation(async () => stack);
122+
const { payload } = await apply();
123+
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'ledger-absent', table: 'sys_migration' });
124+
expect(execute).toHaveBeenCalledWith(expect.stringContaining('pragma_table_info'), ['sys_migration']);
125+
expect(stack.tableAbsent).not.toHaveBeenCalled();
126+
}, CASE_TIMEOUT_MS);
127+
128+
it('skipped, and the catalog has sys_migration: not refused for the ledger', async () => {
129+
const { stack } = stackWith('skipped', { catalogRows: [{ column_name: 'id' }], tableAbsent: true });
130+
boot.mockImplementation(async () => stack);
131+
const { payload } = await apply();
132+
expect(payload.reason).not.toBe('ledger-absent');
133+
}, CASE_TIMEOUT_MS);
134+
135+
it('the control: a deferred boot answers from tableAbsent, without a catalog read', async () => {
136+
const { stack, execute } = stackWith('deferred', { catalogRows: [{ column_name: 'id' }], tableAbsent: true });
137+
boot.mockImplementation(async () => stack);
138+
const { payload } = await apply();
139+
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'ledger-absent' });
140+
expect(execute).not.toHaveBeenCalledWith(expect.stringContaining('pragma_table_info'), ['sys_migration']);
141+
}, CASE_TIMEOUT_MS);
142+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #23816 — the two pieces of `bootSchemaStack`'s `deferredDdlUnsupported:
5+
* 'skip-schema-sync'` posture that decide it, pinned without a database:
6+
*
7+
* - `transportRefusesDeferredDdl` answers `true` exactly for the transport
8+
* whose driver refuses deferred DDL (a `turso` datasource whose URL
9+
* `TursoDriver.detectMode` reads as `remote`), asked of the driver package,
10+
* and `false` for every other resolved database;
11+
* - `DeferSchemaDdlPlugin({ exemptPrimary: true })` leaves the engine's
12+
* DEFAULT driver un-armed and arms every other driver — the ones it finds
13+
* at `init()` and the ones registered afterwards — exactly as without the
14+
* exemption; without it, a primary that refuses the arm fails the boot.
15+
*
16+
* The end-to-end case (a real `sqld`, the real command) is
17+
* `organization-ownership.remote-face.integration.test.ts`, which runs only
18+
* where `OS_TEST_SQLD_BIN` names a server binary; these hold the CLI half in
19+
* every CI run.
20+
*/
21+
22+
import { describe, expect, it, vi } from 'vitest';
23+
import { DeferSchemaDdlPlugin, transportRefusesDeferredDdl, type SqlDriverLike } from './schema-migrate.js';
24+
25+
describe('transportRefusesDeferredDdl', () => {
26+
it.each([
27+
['turso', 'libsql://tenant.example.turso.io', true],
28+
['turso', 'libsql://127.0.0.1:8080?tls=0', true],
29+
['turso', 'https://tenant.example.turso.io', true],
30+
['turso', 'file:/tmp/os-remote-posture.db', false],
31+
['turso', ':memory:', false],
32+
['sqlite', 'file:/tmp/os-remote-posture.db', false],
33+
['postgres', 'postgres://localhost/app', false],
34+
['mysql', 'mysql://localhost/app', false],
35+
] as const)('%s %s -> %s', async (driver, url, expected) => {
36+
expect(await transportRefusesDeferredDdl({ driver, url })).toBe(expected);
37+
});
38+
});
39+
40+
/** A driver whose arm the remote face refuses, as `TursoDriver.setDeferredDdl` does there. */
41+
function refusingDriver(name: string) {
42+
return {
43+
name,
44+
setDeferredDdl: vi.fn((deferred: boolean) => {
45+
if (deferred) throw Object.assign(new Error('Deferred schema DDL is not supported by the Turso REMOTE transport'), { code: 'NOT_IMPLEMENTED', status: 501 });
46+
}),
47+
detectManagedDrift: vi.fn(async () => []),
48+
applyMigrationEntries: vi.fn(async () => ({ applied: [], skipped: [] })),
49+
};
50+
}
51+
/** A driver that defers. */
52+
function deferringDriver(name: string) {
53+
return {
54+
name,
55+
setDeferredDdl: vi.fn(),
56+
detectManagedDrift: vi.fn(async () => []),
57+
applyMigrationEntries: vi.fn(async () => ({ applied: [], skipped: [] })),
58+
};
59+
}
60+
61+
/** A plugin ctx over `services`, with an engine whose default driver is `primary`. */
62+
function ctxWith(primary: unknown, others: Record<string, unknown>) {
63+
const byName: Record<string, unknown> = { primary, ...others };
64+
const engine = {
65+
registerDriver: vi.fn(),
66+
getDefaultDriverName: () => 'primary',
67+
getDriverByName: (n: string) => byName[n],
68+
getDriverForObject: () => primary,
69+
registry: { getAllObjects: () => [{ name: 'sys_job' }] },
70+
};
71+
const services = new Map<string, unknown>([
72+
['objectql', engine],
73+
['driver.com.objectstack.driver.turso', primary],
74+
...Object.entries(others).map(([n, d]) => [`driver.${n}`, d] as [string, unknown]),
75+
]);
76+
return {
77+
engine,
78+
ctx: {
79+
getService: (name: string) => {
80+
if (!services.has(name)) throw new Error(`no service ${name}`);
81+
return services.get(name);
82+
},
83+
getServices: () => services,
84+
logger: { debug: () => {} },
85+
},
86+
};
87+
}
88+
89+
describe('DeferSchemaDdlPlugin — the primary exemption', () => {
90+
it('exemptPrimary: the primary is left un-armed, every other driver is armed, at init and after', async () => {
91+
const primary = refusingDriver('remote');
92+
const other = deferringDriver('telemetry');
93+
const later = deferringDriver('declared');
94+
const { ctx, engine } = ctxWith(primary, { other });
95+
const plugin = new DeferSchemaDdlPlugin({ exemptPrimary: true });
96+
97+
await plugin.init(ctx);
98+
99+
expect(primary.setDeferredDdl).not.toHaveBeenCalled();
100+
expect(other.setDeferredDdl).toHaveBeenCalledWith(true);
101+
expect(plugin.drivers).toEqual([other]);
102+
103+
// A driver handed to the engine afterwards is armed before the engine holds
104+
// it; the primary handed again is still not.
105+
(engine as unknown as { registerDriver: (d: unknown) => unknown }).registerDriver(later);
106+
(engine as unknown as { registerDriver: (d: unknown) => unknown }).registerDriver(primary);
107+
expect(later.setDeferredDdl).toHaveBeenCalledWith(true);
108+
expect(primary.setDeferredDdl).not.toHaveBeenCalled();
109+
expect(plugin.drivers).toEqual([other, later] as unknown as SqlDriverLike[]);
110+
plugin.release();
111+
});
112+
113+
it('the control: without the exemption, a primary that refuses the arm fails the boot', async () => {
114+
const primary = refusingDriver('remote');
115+
const { ctx } = ctxWith(primary, {});
116+
const plugin = new DeferSchemaDdlPlugin();
117+
118+
await expect(plugin.init(ctx)).rejects.toThrow('Deferred schema DDL is not supported by the Turso REMOTE transport');
119+
expect(primary.setDeferredDdl).toHaveBeenCalledWith(true);
120+
plugin.release();
121+
});
122+
123+
it('exemptPrimary exempts only the engine\'s default driver: another refusing driver still fails the boot', async () => {
124+
const primary = deferringDriver('local');
125+
const remoteSibling = refusingDriver('remote');
126+
const { ctx } = ctxWith(primary, { remoteSibling });
127+
const plugin = new DeferSchemaDdlPlugin({ exemptPrimary: true });
128+
129+
await expect(plugin.init(ctx)).rejects.toThrow('Deferred schema DDL is not supported by the Turso REMOTE transport');
130+
expect(primary.setDeferredDdl).not.toHaveBeenCalled();
131+
plugin.release();
132+
});
133+
});

‎packages/cli/src/utils/schema-migrate.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,11 @@ async function planPackageTable(
384384
* {@link drivers} keeps what was armed, so the stack can report every
385385
* datasource's held-back work and flush all of it on the operator's say-so.
386386
* No driver changes: each one keeps its own deferred set and its own flush.
387+
*
388+
* Exported for its unit pin (`schema-migrate.remote-posture.test.ts`, #23816),
389+
* which holds the primary exemption without booting a kernel.
387390
*/
388-
class DeferSchemaDdlPlugin {
391+
export class DeferSchemaDdlPlugin {
389392
name = 'com.objectstack.cli.defer-schema-ddl';
390393
version = '1.0.0';
391394
/** Ordering, not optionality: our init must follow the one that registers `driver.*`. */
@@ -530,8 +533,9 @@ function defaultDriverOf(ctx: { getService?: (name: string) => unknown }): unkno
530533
* Loaded lazily, as every driver is from this module (#5726). A package that
531534
* cannot be loaded answers `false`, and the boot then fails where it always
532535
* has, in `@objectstack/runtime`'s loader, naming the package to install.
536+
* Exported for its unit pin (`schema-migrate.remote-posture.test.ts`).
533537
*/
534-
async function transportRefusesDeferredDdl(resolved: { driver: string; url: string }): Promise<boolean> {
538+
export async function transportRefusesDeferredDdl(resolved: { driver: string; url: string }): Promise<boolean> {
535539
if (resolved.driver !== 'turso') return false;
536540
let detectMode: ((config: { url: string }) => string) | undefined;
537541
try {

0 commit comments

Comments
 (0)