Skip to content

Commit c2bb302

Browse files
fix(cli): the organization-ownership ceremony reads its dialect once, through metadata-core's shared resolver, and refuses an unresolved one before any statement (#23647)
Fixes #23558 Clause-②: yes (widening) The grade is `yes (widening)` because this PR exports one resolver from `@objectstack/metadata-core`, `dialectOfDriver`, so the ceremony can share it. The claim graded the card `no` unless that export was added, and it was added. No existing export answered a driver's dialect. ## What changed `os migrate organization-ownership` (the plan and `--apply`) now reads its SQL dialect once, from the stack's driver, before either arm runs a statement. It uses the resolver that metadata-core's legacy-row reader uses: the driver's `dialectName` first, then a recognised `config.client` string. Anything else is refused with `driver-unsupported` and exit 1, and no statement runs. That covers a client class with no recognised `dialectName`, an unrecognised client string, `dialectName: 'unknown'`, and a driver with no dialect at all. The refusal names the driver: its name, its `dialectName`, and its client string or client class name. - `packages/metadata-core/src/legacy-organization-row.ts`: `dialectOfDriver` is exported. Its behaviour is unchanged, and its doc comment says who shares it. - `packages/cli/src/utils/organization-ownership-plan.ts`: - `dialectOf`, which read the client string only, is replaced by `ceremonyDialectOf(driver)`. It calls the shared resolver and throws the refusal. - `PlanReader` carries the resolved `dialect`. `client` stays as the plan file's record, and only when it is a string. - The plan no longer derives a dialect itself, and its catalog statement is keyed on the resolved dialect. - `packages/cli/src/utils/organization-ownership-apply.ts`: the `dialect ?? 'sqlite'` default is gone. The apply takes the executor's resolved dialect. - `packages/cli/src/commands/migrate/organization-ownership.ts`: - Both arms call `ceremonyDialectOf(stack.driver)` right after the raw-seam check, before any statement. In `--apply` this is also before the ledger check. - The template lookup's `dialectOf(executor.client) ?? 'sqlite'` is gone. - Changeset: `@objectstack/cli` patch, `@objectstack/metadata-core` minor. ## Premise check against `main` (`f7cd580bff`) - **The card's harm does not happen on `main`.** The card said "the ceremony would then build SQLite-dialect raw SQL against a PostgreSQL or MySQL database". It would not. Both arms run the plan step first, and the plan's `dialectOf(String(client))` returned null for a client class (stringified) and for an unrecognised string. So the plan refused with `driver-unsupported` before any statement. - The apply's `?? 'sqlite'` and the template lookup's `?? 'sqlite'` were reached only after the plan had resolved the same client to a dialect, so neither ever applied. - What was live: the refusal printed a client class's whole source text as the driver's name, and three readers each kept their own copy of the resolution. - This PR still does what the card's Wanted lines ask: one shared resolver, a refusal naming the driver before any statement, and no SQLite default left. - **Hypothesis (a), falsified in part.** `resolvePlannedDriverExec` reads no `config.client`; it only picks `execute` or `raw`. In that module the string-only read is `readPhysicalTableNames`, which belongs to `os migrate plan`'s unmanaged-table sweep. The ceremony never calls it. It is left unchanged (see Acceptance notes). - **Hypothesis (b).** metadata-core's `dialectOfDriver` / `dialectOfName` were not exported, and no exported entry in the repository answers a driver's dialect this way. That is why there is one export. - **Hypothesis (c).** The refusal happens once, at the command, where the driver is first read. The utils take the resolved dialect. ## No-regression guard: every in-repo driver, measured Each driver was constructed through the shared datasource factory (`createDefaultDatasourceDriverFactory`) or the runtime's Turso factory, from the build of `0fc3ae2575`. No driver package is touched by this PR. The `main` column is the command's own expression, `dialectOf(String(config.client))`. | driver path | `name` | `dialectName` | `config.client` | `main` | shared resolver | |---|---|---|---|---|---| | SqlDriver on better-sqlite3 | `com.objectstack.driver.sql` | `sqlite` | `'better-sqlite3'` | sqlite | sqlite | | SqliteWasmDriver | `com.objectstack.driver.sqlite-wasm` | `sqlite` | class `Client_WasmSqlite2` | refused | sqlite | | SqlDriver on pg | `com.objectstack.driver.sql` | `postgres` | `'pg'` | postgres | postgres | | SqlDriver on mysql2 | `com.objectstack.driver.sql` | `mysql` | `'mysql2'` | mysql | mysql | | TursoDriver, remote `libsql://` | `com.objectstack.driver.turso` | `sqlite` | `'better-sqlite3'` | sqlite | sqlite | | TursoDriver, `file:` | `com.objectstack.driver.turso` | `sqlite` | `'better-sqlite3'` | sqlite | sqlite | Every driver that ran the ceremony before still runs it. SqliteWasmDriver never reaches the resolver through the command today. It was measured through the CLI at `91084b5d5e`: `os migrate organization-ownership --database-url wasm-sqlite://FILE --json` answers `{"error":"plan_refused","reason":"driver-unsupported","detail":"no SQL driver with a raw-SQL seam is active"}`. That is the same answer `main` gives. The schema boot's SQL-driver lookup does not include the service name that driver registers under, so `stack.driver` is null. This is reported as a finding and not fixed here. The SQLite path was also run through the CLI at `f9f69beda5`: `--database-url FILE` (better-sqlite3) writes the plan, records `"client": "better-sqlite3"`, and exits 0. **NOT MEASURED:** a live PostgreSQL or MySQL run of the ceremony. Both shapes were measured on constructed drivers, and the command pins assert that each one's first statement is its own dialect's catalog read. ## Tests - `packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts` (new, `unit` tier): - Five refused shapes, each in both arms: a client class with no `dialectName`; a client class as `SqlDriver` answers it (`dialectName: 'unknown'`); an unrecognised client string (`oracledb`); `dialectName: 'unknown'` beside `pg`; no `dialectName` and no client. - Each one checks: `plan_refused` / `apply_refused`, reason `driver-unsupported`, a detail that names the driver, exit 1, zero statements, no plan file, and the apply step never called. - A client class is named, and its source is never printed. - Six proceeding shapes: each in-repo driver's measured shape, plus a driver with only a recognised client string. Each one's first statement is its dialect's catalog read. - `--apply` hands the executor `dialect`, and `client` only when it is a string. - `packages/metadata-core/src/legacy-organization-row.test.ts`: the exported `dialectOfDriver`, nine rows. `dialectName` first; a client string when there is no `dialectName`; `'unresolved'` for a client class, an unknown string, and `dialectName: 'unknown'` beside `pg`; `'none'` for no dialect and no driver. - `organization-ownership-plan.integration.test.ts`: the `oracledb` refusal now pins that the dialect is refused before the plan issues any statement. - The other readers and executors in the ceremony's tests (`organization-ownership-apply.test.ts:60`, `...served-catalog.integration.test.ts`, `...host-supplement.integration.test.ts`) gain `dialect: 'sqlite'`. Runs on `107a7a923c`, the final head, after merging `main` at `edac2183c0`, which brings in PR #23624's hunks in the same files. The merge was clean, and was followed by `pnpm install --frozen-lockfile` and a rebuild of the cli closure. - `@objectstack/cli` unit project, by path (`organization-ownership.dialect`, `unmanaged-tables`, `organization-ownership.apply-flags`, `organization-ownership-inventory`): 4 files, 90 tests passed. - `@objectstack/cli` integration project, by path (the plan, apply, served-catalog, host-supplement, column-drops, unmanaged-tables and meta-stored-legacy-organization suites, and the command's fate-supplement, occupancy and journal-mode suites): 10 files, 148 tests passed. This layer was run locally because the diff edits integration-tier files. - `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit` and `check:test-typecheck`): exit 0. - `pnpm --filter @objectstack/metadata-core typecheck`: exit 0. `pnpm --filter @objectstack/metadata-core test`: 18 files, 728 tests passed. - `pnpm --filter @objectstack/cli exec vitest run --project unit` (the whole unit layer): 290 of 291 files and 4341 of 4342 tests passed. The one red was `boot-preparation-parity.test.ts > every SERVE_ONLY entry is still used by serve.ts`, a 5000 ms timeout (5160 ms) on a shared box while the gate battery ran beside it. The diff touches neither that file nor `serve.ts`. Re-run alone on the same head: 37 of 37 passed. ## Ablation (one-off proofs, through `scripts/ablation-replace.mjs` with an `EXIT INT TERM` restore trap) Both mutated subjects are `packages/cli/src`, resolved relatively, and metadata-core is aliased to `src` in the cli vitest config, so no `dist/` is involved. **The SQLite fallback restored in `ceremonyDialectOf`, on `107a7a923c`.** The anchor `if (… ) return dialect;` was replaced with `return … ? dialect : 'sqlite';`. On disk: injected 1, anchor 0; blob `25cf4a26b487` became `476f0e239e7b`. - The command pins went 11 red / 8 green. Every refused shape went red in both arms, plus the class-naming pin. All proceeding pins stayed green. - The plan integration suite went 1 red / 11 green: the `oracledb` pin. - Restored: blob equals HEAD (`25cf4a26b487`) and `git diff HEAD` is empty. The same result held on `91084b5d5e`. ## Gates Local, on `107a7a923c`: - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands from the merge base `edac2183c`, with no stale-tree warning. All 67 were run, and each exit code was captured before any pipe: **67 of 67 exit 0**. - `--ran` reconciliation: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN", exit 0. - `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` need build output beyond the cli closure. They were measured after building their named prerequisite closures (the CLI, the examples, and the plugins and services they extract). On the pre-merge head they first answered exit 3 (PREREQUISITE NOT MET) and were re-run green. - Ratchet families in that set: `check:type-check-coverage` and `check:type-check-debt`, exit 0. - `pnpm lint` narrowed to the 10 changed `.ts` files: `eslint --no-inline-config` on them, exit 0, no output. The full repo scan is CI's `Lint & Repo Gates`. - Control-byte self-scan of the changed files and the changeset: no hits. ## Acceptance notes - **`os migrate` finds no SQL driver on a `wasm-sqlite://` or `*.wasm.db` database.** The schema boot looks a SQL driver up by `SQL_DRIVER_SERVICES` in `packages/cli/src/utils/schema-migrate.ts` (`driver.com.objectstack.driver.sql`, `driver.com.objectstack.driver.turso`, `driver.sql`). SqliteWasmDriver registers under none of them. So the ceremony refuses a SQLite database with "No SQL driver with a raw-SQL seam is active … The ceremony supports SQLite, PostgreSQL and MySQL." This was measured through the CLI (above). It is reported to the seat as a finding and is not fixed here, because `schema-migrate.ts` is outside this claim. - **`os migrate plan`'s unmanaged-table sweep still reads `config.client` as a string only** (`readPhysicalTableNames`, `packages/cli/src/utils/unmanaged-tables.ts`). It is the only remaining string-only reader in the cli. It has no reach today: its drivers come from the same lookup, which never plans SqliteWasmDriver. Carrier: none. - **`packages/runtime/src/organization-ownership-boot-guard.ts` keeps a third copy of the resolver.** In that copy, an unrecognised `dialectName` falls through to the client string. No in-repo driver reaches the difference: `SqlDriver` answers `'unknown'` only for a client that the same three spelling sets do not recognise. Carrier: none. - Unchanged: `organization-ownership-plan.ts:181`, `organization-ownership-grants.ts`, and the apply test's `:1979`, which belong to PR #23624; and `packages/drivers/**`. --- _Generated by [Claude Code](https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c7dd48a commit c2bb302

11 files changed

Lines changed: 371 additions & 34 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/metadata-core': minor
4+
---
5+
6+
fix(cli): `os migrate organization-ownership` reads its SQL dialect once, through the driver resolver it shares with metadata-core, and refuses an unresolved dialect before any statement, naming the driver, with no SQLite default left
7+
8+
Clause-②: yes (widening)
9+
10+
**Who this is for.** An operator who runs the organization-ownership ceremony (plan or `--apply`) on a PostgreSQL or MySQL database whose driver's knex `config.client` is a client CLASS (knex's bring-your-own-dialect hatch) or a client string the ceremony does not recognise.
11+
12+
**FROM.** Both arms took the dialect from `String(config.client)` alone, with a reader of their own.
13+
14+
- Such a run was refused (`driver-unsupported`, exit 1) by the plan step, which runs first. A client class was stringified, so the refusal printed the class's source text in place of the driver's name.
15+
- The apply step and its template lookup each carried a `'sqlite'` default for an unresolved dialect. Neither was reachable, only because the plan step had already refused.
16+
17+
**TO.** The dialect is resolved once, from the stack's driver, before either arm runs a statement. The resolver is the one metadata-core's legacy-row reader uses: the driver's `dialectName` first, then a recognised `config.client` string.
18+
19+
- Refused: a client class with no recognised `dialectName`, an unrecognised client string, `dialectName: 'unknown'` (what `SqlDriver` answers for either), and a driver with no dialect at all. Each gets `driver-unsupported`, exit 1, with no statement run and no plan file written. The refusal names the driver: its name, its `dialectName`, and its client string or client class name.
20+
- No `'sqlite'` default remains. The plan and the apply take the resolved dialect and never re-derive it.
21+
- The plan file's `client` field records the client only when it is a string.
22+
23+
Measured on each in-repo SQL driver: `SqlDriver` on better-sqlite3, pg and mysql2, and `TursoDriver`, resolve to the dialect they did before.
24+
25+
**metadata-core.** `dialectOfDriver(driver)` is exported. It is the one resolver behind `readLegacyOrganizationRows`, and answers:
26+
27+
- `'sqlite' | 'postgres' | 'mysql'`;
28+
- `'none'` for a driver that is not a SQL driver;
29+
- `'unresolved'` otherwise.
30+
31+
A `dialectName` string is the whole answer, recognised or not. A `config.client` is read only when there is no `dialectName`, and only as a string.
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate organization-ownership` reads its SQL dialect off the stack's
5+
* driver ONCE, before the first statement, through the resolver it shares
6+
* with `@objectstack/metadata-core` (`ceremonyDialectOf` → `dialectOfDriver`):
7+
* the driver's `dialectName` first, then a recognised `config.client` string.
8+
* On anything else both arms refuse, naming the driver, with exit 1 and no
9+
* statement run; neither arm reads an unresolved dialect as SQLite.
10+
*
11+
* The boot is replaced by a stack whose driver records every statement and
12+
* answers each with no rows; the apply step is replaced by a recorder (its own
13+
* suite pins what it does with the executor it is handed). The driver shapes
14+
* are the ones each in-repo driver presents, measured on the drivers
15+
* themselves: `SqliteWasmDriver`'s knex `client` is a dialect CLASS and its
16+
* `dialectName` is `'sqlite'`; `SqlDriver` answers `dialectName: 'unknown'`
17+
* for a client it does not recognise, a client class included.
18+
*
19+
* The `SqliteWasmDriver` row pins the resolver's answer for that SHAPE. The
20+
* command's boot does not hand that driver over today (the schema boot looks
21+
* a SQL driver up by service names that do not include it, so a
22+
* `wasm-sqlite://` run is refused earlier, as having no SQL driver at all);
23+
* the row keeps the answer right for the day it does.
24+
*/
25+
26+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
27+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
28+
import { tmpdir } from 'node:os';
29+
import { join } from 'node:path';
30+
import { fileURLToPath } from 'node:url';
31+
32+
const boot = vi.hoisted(() => vi.fn());
33+
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot }));
34+
// The siblings' occupancy stub: no case here passes --database-url, so the real
35+
// probe would read whatever database the package's own directory resolves to.
36+
vi.mock('../../utils/migrate-occupancy-gate.js', () => ({
37+
OCCUPANCY_STOP_HINT: 'occupancy hint',
38+
probeMigrationTarget: vi.fn(async () => ({ status: 'free' })),
39+
}));
40+
41+
const apply = vi.hoisted(() => vi.fn());
42+
vi.mock('../../utils/organization-ownership-apply.js', async (importOriginal) => ({
43+
...(await importOriginal<typeof import('../../utils/organization-ownership-apply.js')>()),
44+
applyOrganizationOwnership: apply,
45+
}));
46+
47+
type Command = (typeof import('./organization-ownership.js'))['default'];
48+
let MigrateOrganizationOwnership: Command;
49+
50+
// The command's module graph is large, and oclif reads the package's config on
51+
// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
52+
beforeAll(async () => {
53+
({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
54+
}, 60_000);
55+
56+
const CASE_TIMEOUT_MS = 60_000;
57+
const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
58+
59+
/** knex's bring-your-own-dialect hatch: a Client constructor as `config.client`. */
60+
class Client_PG {}
61+
class Client_WasmSqlite {}
62+
63+
interface DriverShape {
64+
name?: string;
65+
dialectName?: string;
66+
config?: { client?: unknown };
67+
}
68+
69+
/** Refused: no dialect the ceremony can write statements for. */
70+
const REFUSED: Array<[string, DriverShape]> = [
71+
['a client class with no dialectName', { name: 'com.example.driver', config: { client: Client_PG } }],
72+
['a client class, as SqlDriver answers it (dialectName unknown)', { name: 'com.objectstack.driver.sql', dialectName: 'unknown', config: { client: Client_PG } }],
73+
['an unrecognised client string', { name: 'com.objectstack.driver.sql', config: { client: 'oracledb' } }],
74+
['dialectName unknown beside a recognised client string', { name: 'com.objectstack.driver.sql', dialectName: 'unknown', config: { client: 'pg' } }],
75+
['a driver with neither a dialectName nor a client', { name: 'com.example.driver' }],
76+
];
77+
78+
/** Proceeds: each in-repo driver's measured shape, and the first statement its dialect's catalog read. */
79+
const PROCEEDS: Array<[string, DriverShape, RegExp]> = [
80+
['SqlDriver on better-sqlite3', { name: 'com.objectstack.driver.sql', dialectName: 'sqlite', config: { client: 'better-sqlite3' } }, /FROM sqlite_master/],
81+
['SqliteWasmDriver (a client class, dialectName sqlite)', { name: 'com.objectstack.driver.sqlite-wasm', dialectName: 'sqlite', config: { client: Client_WasmSqlite } }, /FROM sqlite_master/],
82+
['SqlDriver on pg', { name: 'com.objectstack.driver.sql', dialectName: 'postgres', config: { client: 'pg' } }, /current_schemas\(false\)/],
83+
['SqlDriver on mysql2', { name: 'com.objectstack.driver.sql', dialectName: 'mysql', config: { client: 'mysql2' } }, /table_schema = DATABASE\(\)/],
84+
['TursoDriver', { name: 'com.objectstack.driver.turso', dialectName: 'sqlite', config: { client: 'better-sqlite3' } }, /FROM sqlite_master/],
85+
['a driver with no dialectName and a recognised client string', { name: 'com.example.driver', config: { client: 'postgresql' } }, /current_schemas\(false\)/],
86+
];
87+
88+
let dir: string;
89+
/** Every statement the driver was handed, in order. */
90+
let statements: string[];
91+
92+
function stackOver(shape: DriverShape) {
93+
return {
94+
driver: {
95+
...shape,
96+
execute: async (sql: string) => {
97+
statements.push(sql);
98+
return [];
99+
},
100+
},
101+
dbLabel: 'fixture.db',
102+
kernel: {
103+
getService: (name: string) =>
104+
name === 'objectql'
105+
? { registry: { getItem: () => undefined, listItems: () => [], isPackageDisabled: () => false } }
106+
: name === 'metadata'
107+
? { get: async () => undefined, list: async () => [] }
108+
: undefined,
109+
},
110+
tableAbsent: () => false,
111+
composition: { securityPlugin: { composed: false, reason: 'no-secret' } },
112+
shutdown: async () => {},
113+
};
114+
}
115+
116+
beforeEach(() => {
117+
dir = mkdtempSync(join(tmpdir(), 'os-ownership-dialect-'));
118+
statements = [];
119+
apply.mockImplementation(async () => ({
120+
kind: 'organization-ownership-apply',
121+
generatedAt: '2026-10-15T00:00:00.000Z',
122+
steps: [],
123+
tables: [],
124+
complete: true,
125+
blocking: 0,
126+
advisory: 0,
127+
}));
128+
});
129+
130+
afterEach(() => {
131+
boot.mockReset();
132+
apply.mockReset();
133+
rmSync(dir, { recursive: true, force: true });
134+
});
135+
136+
/** A plan file as a plan run writes it (the fields the apply reads). */
137+
function writePlan(): string {
138+
const file = join(dir, 'reviewed-plan.json');
139+
writeFileSync(file, JSON.stringify({
140+
kind: 'organization-ownership-plan',
141+
ceremony: 'ADR-0131 D10',
142+
generatedAt: '2026-10-15T00:00:00.000Z',
143+
database: 'fixture.db',
144+
inventoryDigest: 'f'.repeat(64),
145+
posture: 'single',
146+
}));
147+
return file;
148+
}
149+
150+
async function runJson(argv: string[]): Promise<{ payload: Record<string, unknown>; exitCode: unknown }> {
151+
const chunks: string[] = [];
152+
const savedExit = process.exitCode;
153+
vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
154+
chunks.push(String(chunk));
155+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
156+
if (cb) cb();
157+
return true;
158+
}) as typeof process.stdout.write);
159+
vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
160+
try {
161+
await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT });
162+
} catch (error) {
163+
if ((error as { oclif?: unknown }).oclif === undefined) throw error;
164+
}
165+
const exitCode = process.exitCode;
166+
process.exitCode = savedExit;
167+
vi.restoreAllMocks();
168+
return { payload: JSON.parse(chunks.join('').trim()) as Record<string, unknown>, exitCode };
169+
}
170+
171+
describe('an unresolved dialect is refused, naming the driver, before any statement runs', () => {
172+
for (const [label, shape] of REFUSED) {
173+
it(`plan: ${label}`, async () => {
174+
boot.mockImplementation(async () => stackOver(shape));
175+
const target = join(dir, 'plan.json');
176+
const { payload, exitCode } = await runJson(['--out', target, '--json']);
177+
expect(payload).toMatchObject({ error: 'plan_refused', reason: 'driver-unsupported' });
178+
expect(String(payload.detail)).toContain(shape.name!);
179+
expect(exitCode).toBe(1);
180+
expect(statements).toEqual([]);
181+
expect(existsSync(target)).toBe(false);
182+
}, CASE_TIMEOUT_MS);
183+
184+
it(`--apply: ${label}`, async () => {
185+
boot.mockImplementation(async () => stackOver(shape));
186+
const { payload, exitCode } = await runJson(['--apply', '--backup-taken', '--plan-file', writePlan(), '--out', join(dir, 'apply.json'), '--json']);
187+
expect(payload).toMatchObject({ error: 'apply_refused', reason: 'driver-unsupported' });
188+
expect(String(payload.detail)).toContain(shape.name!);
189+
expect(exitCode).toBe(1);
190+
expect(statements).toEqual([]);
191+
expect(apply).not.toHaveBeenCalled();
192+
}, CASE_TIMEOUT_MS);
193+
}
194+
195+
it('a client class is named, never printed as its source', async () => {
196+
boot.mockImplementation(async () => stackOver({ name: 'com.example.driver', config: { client: Client_PG } }));
197+
const { payload } = await runJson(['--out', join(dir, 'plan.json'), '--json']);
198+
expect(String(payload.detail)).toContain('client class Client_PG');
199+
expect(String(payload.detail)).not.toContain('class Client_PG {');
200+
}, CASE_TIMEOUT_MS);
201+
});
202+
203+
describe('a recognised dialect proceeds, in that dialect', () => {
204+
for (const [label, shape, catalog] of PROCEEDS) {
205+
it(`plan: ${label}`, async () => {
206+
boot.mockImplementation(async () => stackOver(shape));
207+
const { payload } = await runJson(['--out', join(dir, 'plan.json'), '--json']);
208+
// The driver answers no tables, so the plan stops at its catalog read,
209+
// past the dialect, which the catalog statement it issued names.
210+
expect(payload).toMatchObject({ error: 'plan_refused', reason: 'not-an-objectstack-database' });
211+
expect(statements[0]).toMatch(catalog);
212+
}, CASE_TIMEOUT_MS);
213+
}
214+
215+
it('--apply hands the apply step the dialect it resolved, and the client string only as a record', async () => {
216+
boot.mockImplementation(async () => stackOver({ name: 'com.objectstack.driver.sqlite-wasm', dialectName: 'sqlite', config: { client: Client_WasmSqlite } }));
217+
await runJson(['--apply', '--backup-taken', '--plan-file', writePlan(), '--out', join(dir, 'apply.json'), '--json']);
218+
expect(apply).toHaveBeenCalledTimes(1);
219+
const { executor } = apply.mock.calls[0]![0] as { executor: Record<string, unknown> };
220+
expect(executor.dialect).toBe('sqlite');
221+
expect(executor).not.toHaveProperty('client');
222+
}, CASE_TIMEOUT_MS);
223+
224+
it('--apply on pg: the executor speaks postgres', async () => {
225+
boot.mockImplementation(async () => stackOver({ name: 'com.objectstack.driver.sql', dialectName: 'postgres', config: { client: 'pg' } }));
226+
await runJson(['--apply', '--backup-taken', '--plan-file', writePlan(), '--out', join(dir, 'apply.json'), '--json']);
227+
expect(apply).toHaveBeenCalledTimes(1);
228+
expect(apply.mock.calls[0]![0]).toMatchObject({ executor: { dialect: 'postgres', client: 'pg' } });
229+
}, CASE_TIMEOUT_MS);
230+
});

‎packages/cli/src/commands/migrate/organization-ownership.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
OrganizationOwnershipPlanRefusal,
2929
columnListSql,
3030
columnNameOf,
31-
dialectOf,
31+
ceremonyDialectOf,
3232
type OrganizationOwnershipPlan,
3333
} from '../../utils/organization-ownership-plan.js';
3434
import {
@@ -87,6 +87,12 @@ async function confirm(question: string): Promise<boolean> {
8787
* ObjectStack one) refuses the whole plan, naming it — exit 1 and no file. ⛔
8888
* Never a table reported as empty.
8989
*
90+
* The SQL dialect is read once, off the stack's driver, before either arm
91+
* runs a statement (`ceremonyDialectOf`, the resolver `@objectstack/metadata-core`
92+
* shares): `dialectName` first, then a recognised `config.client` string. A
93+
* driver whose dialect that cannot name is refused, naming the driver, exit 1
94+
* — ⛔ never read as SQLite.
95+
*
9096
* ## Why a subcommand, not a flag on `os migrate`
9197
*
9298
* Bare `os migrate` is the schema-drift plan (#2186) and stays exactly that.
@@ -346,11 +352,15 @@ export default class MigrateOrganizationOwnership extends Command {
346352
);
347353
return;
348354
}
355+
// The dialect, once, before the first statement: an unresolved one is
356+
// refused here, naming the driver, and never read as SQLite.
357+
const dialect = ceremonyDialectOf(stack.driver);
349358
const { normalizeRows } = await import('@objectstack/metadata-protocol');
350359
const client = (stack.driver.config as { client?: unknown } | undefined)?.client;
351360
plan = await buildOrganizationOwnershipPlan({
352361
reader: {
353-
client: client === undefined ? undefined : String(client),
362+
dialect,
363+
...(typeof client === 'string' ? { client } : {}),
354364
query: async (sql, params) => normalizeRows(await exec(sql, params ? [...params] : [])),
355365
},
356366
posture,
@@ -517,6 +527,9 @@ export default class MigrateOrganizationOwnership extends Command {
517527
);
518528
return;
519529
}
530+
// The dialect, once, before the first statement: an unresolved one is
531+
// refused here, naming the driver, and never read as SQLite.
532+
const dialect = ceremonyDialectOf(stack.driver);
520533
if (stack.tableAbsent('sys_migration')) {
521534
await refuse(
522535
{ reason: 'ledger-absent', table: 'sys_migration', detail: 'the deployment ledger the ceremony checkpoints into does not exist' },
@@ -542,7 +555,8 @@ export default class MigrateOrganizationOwnership extends Command {
542555
});
543556
const client = (stack.driver.config as { client?: unknown } | undefined)?.client;
544557
const executor: ApplyExecutor = {
545-
client: client === undefined ? undefined : String(client),
558+
dialect,
559+
...(typeof client === 'string' ? { client } : {}),
546560
query: async (sql, params) => normalizeRows(await exec(sql, params ? [...params] : [])),
547561
execute: async (sql, params) => {
548562
await exec(sql, params ? [...params] : []);
@@ -619,7 +633,7 @@ export default class MigrateOrganizationOwnership extends Command {
619633
// overwrite), then what the registry serves for the bundle member.
620634
find: async (name, locale) => {
621635
// Once column-drops has run, every row is the environment's (C7b).
622-
const scoped = (await executor.query(columnListSql(dialectOf(executor.client) ?? 'sqlite'), ['sys_metadata']))
636+
const scoped = (await executor.query(columnListSql(executor.dialect), ['sys_metadata']))
623637
.some((row) => columnNameOf(row) === 'organization_id');
624638
const stored = await executor.query(
625639
`SELECT metadata FROM sys_metadata WHERE type = ? AND name = ? AND state = ?${scoped ? ' AND organization_id IS NULL' : ''} ` +

‎packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ async function ceremony(posture: string, db: Database.Database): Promise<Organiz
136136
let checkpoint: CeremonyCheckpoint | null = null;
137137
return applyOrganizationOwnership({
138138
executor: {
139+
dialect: 'sqlite',
139140
client: 'better-sqlite3',
140141
query: async (sql, params = []) => db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[],
141142
execute: async (sql, params = []) => { db.prepare(sql).run(...(params as unknown[])); },

‎packages/cli/src/utils/organization-ownership-apply.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ let writes: string[];
5757
function executor(opts: { failOnceOn?: RegExp } = {}): ApplyExecutor {
5858
let failed = false;
5959
return {
60+
dialect: 'sqlite',
6061
client: 'better-sqlite3',
6162
async query(sql, params = []) {
6263
return db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[];

‎packages/cli/src/utils/organization-ownership-apply.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
SEEDED_BY,
2525
TEMPLATE_OBJECT,
2626
columnNameOf,
27-
dialectOf,
2827
predicateSql,
2928
Sql,
3029
type AttributionSink,
@@ -2559,11 +2558,12 @@ function refuseUnreviewedChoices(plan: OrganizationOwnershipPlan, opts: ApplyOpt
25592558
* the report.
25602559
*/
25612560
export async function applyOrganizationOwnership(opts: ApplyOptions): Promise<OrganizationOwnershipApplyReport> {
2562-
const dialect = dialectOf(opts.executor.client);
25632561
const now = opts.now ?? (() => new Date());
25642562
const run: Run = {
25652563
opts,
2566-
sql: new Sql(opts.executor, dialect ?? 'sqlite'),
2564+
// The executor's dialect was resolved from the driver before this call
2565+
// (`ceremonyDialectOf`, which refuses an unresolved one): never a default.
2566+
sql: new Sql(opts.executor, opts.executor.dialect),
25672567
plan: undefined as unknown as OrganizationOwnershipPlan,
25682568
columns: new Map(),
25692569
attributed: new Map(),

‎packages/cli/src/utils/organization-ownership-host-supplement.integration.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ function open(name: string, ddl: string): { db: Database.Database; file: string;
7575
return {
7676
db,
7777
file,
78-
reader: { client: 'better-sqlite3', query: async (sql, params = []) => db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[] },
78+
reader: { dialect: 'sqlite', client: 'better-sqlite3', query: async (sql, params = []) => db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[] },
7979
};
8080
}
8181

0 commit comments

Comments
 (0)