diff --git a/.changeset/23648-sqlite-wasm-sql-driver.md b/.changeset/23648-sqlite-wasm-sql-driver.md new file mode 100644 index 00000000000..b4f1825c50e --- /dev/null +++ b/.changeset/23648-sqlite-wasm-sql-driver.md @@ -0,0 +1,21 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): `os migrate` finds the SQL driver on a SQLite database served by `SqliteWasmDriver` (`wasm-sqlite://`, `*.wasm.db`, and the dev boot's step-down from better-sqlite3), so the organization-ownership ceremony and every `os migrate` command run on it instead of answering that no SQL driver is active + +Clause-②: no + +**Who this is for.** An operator whose database is served by `SqliteWasmDriver`, and in particular one who must run `os migrate organization-ownership` to bring a 17.x database through 18's boot refusal (ADR-0131 D10). + +**FROM.** The schema boot behind every `os migrate` command looked its SQL driver up by a list of kernel service names that did not include the one `SqliteWasmDriver` is published under (`driver.com.objectstack.driver.sqlite-wasm`). On such a database: + +- `os migrate organization-ownership` refused both the plan and `--apply` with `driver-unsupported` ("no SQL driver with a raw-SQL seam is active"), exit 1, while its own text said the ceremony supports SQLite. +- `os migrate plan` answered `{"error":"no_sql_driver","changes":[]}` and `os migrate apply` answered `{"error":"no_sql_driver"}`, both exit 0, and `apply` created nothing. +- `os migrate multi-value-columns` and `os migrate unmapped-columns` answered `no_sql_driver`, and the other commands in the family named the database `unknown`. + +**TO.** The lookup lists that key, so these commands find the driver and run on the database. The ceremony resolves its dialect to `sqlite` through the driver's `dialectName`. A driver without the schema-migration seam is still not found under any key, and a driver under a key the list does not name is still not found: the ceremony refuses both with `driver-unsupported`, as before. + +`os migrate plan`'s unmanaged-table section on such a database reads the table catalog. It now resolves the dialect from the driver's `dialectName` first; reading `config.client` as a string only, it had reported the section unreadable for this driver, whose client is a knex client class. + +An `os serve` boot pinned to an artifact by `OS_ARTIFACT_URL` uses the same lookup for its boot-time migration gate. On such a database that gate used to find no driver and let the boot go ahead with the artifact's schema changes unapplied, a destructive one included. It now runs as on every other SQL database: safe changes are applied at boot, and a destructive change refuses the boot and names the change and `os migrate apply --allow-destructive`. diff --git a/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts b/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts new file mode 100644 index 00000000000..4b1f4dc4d0d --- /dev/null +++ b/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23648] The `os migrate` family on a SQLite database served by + * `SqliteWasmDriver` (`wasm-sqlite://`, `*.wasm.db`, and the dev boot's + * step-down from better-sqlite3), each command through its own real boot. + * + * Measured before the fix, on this fixture: the schema boot's SQL-driver + * lookup (`SQL_DRIVER_SERVICES`) did not list the key that driver is published + * under, `driver.com.objectstack.driver.sqlite-wasm`, so `stack.driver` was + * `null` and + * - `os migrate apply` answered `{"error":"no_sql_driver"}`, exit 0, and + * created no table; + * - `os migrate plan` answered `{"error":"no_sql_driver","changes":[]}`, exit 0; + * - `os migrate organization-ownership` refused both arms with + * `driver-unsupported` ("no SQL driver with a raw-SQL seam is active"), + * exit 1, on a SQLite database its own text says it supports. + * + * Once the driver is found, `os migrate plan` reaches its unmanaged-table + * sweep, which read `config.client` as a string only. This driver's client is + * a knex client class, so the section came back `unreadable`. The sweep now + * resolves the dialect through `dialectOfDriver`, `dialectName` first. + * + * The fixture is a project with a compiled artifact that declares no object, + * and an auth secret, so the boot composes the platform floor the ceremony's + * apply reads. The database is created by `os migrate apply` itself, and every + * case after it reads the same file. + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isExitSignal } from '../../utils/format.js'; +import MigrateApply from './apply.js'; +import MigratePlan from './plan.js'; +import MigrateOrganizationOwnership from './organization-ownership.js'; + +// [#10126] Pay the first transform of the dist-resolved workspace deps the +// commands reach through dynamic `import()`s at MODULE LOAD, not inside a case. +import '@objectstack/runtime'; +import '@objectstack/metadata-protocol'; +import '@objectstack/driver-sqlite-wasm'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = resolve(HERE, '..', '..', '..'); +const CASE_TIMEOUT_MS = 120_000; + +/** Every variable that would point the boot elsewhere or change what it composes. */ +const ENV_KEYS = [ + 'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME', 'OS_TELEMETRY_DB', + 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ARTIFACT_PATH', 'NODE_ENV', +] as const; + +type Invoke = (argv: string[]) => Promise; +const invoke = (command: { run: (argv: string[], opts: { root: string }) => Promise }): Invoke => + (argv) => command.run(argv, { root: CLI_ROOT }); + +/** Run a command with stdout captured, its exits trapped, and its JSON document parsed. */ +async function runJson(command: Invoke, argv: string[]): Promise<{ payload: any; exitCode: number }> { + const savedExit = process.exitCode; + const swallow = ((_chunk: unknown, ...rest: unknown[]) => { + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write; + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow); + vi.spyOn(process.stderr, 'write').mockImplementation(swallow); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + let processExit: number | undefined; + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + processExit = code ?? 0; + throw new Error(`__PROCESS_EXIT__:${processExit}`); + }) as never); + let thrownExit: number | undefined; + try { + try { + await command(argv); + } catch (error) { + const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__'); + if (!trapped && !isExitSignal(error)) throw error; + if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit; + } + const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim(); + let payload: any; + try { + payload = JSON.parse(out); + } catch { + payload = { unparsed: out.slice(-400) }; + } + return { payload, exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0 }; + } finally { + process.exitCode = savedExit; + vi.restoreAllMocks(); + } +} + +const savedEnv: Record = {}; +let savedCwd: string; +let dir: string; +let file: string; +let url: string; +let seeded: { payload: any; exitCode: number }; + +/** The tables in the database file, read past the driver. */ +function tablesOnDisk(): string[] { + const db = new Database(file, { readonly: true }); + try { + return (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name: string }>) + .map((r) => r.name); + } finally { + db.close(); + } +} + +beforeAll(async () => { + savedCwd = process.cwd(); + for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; + for (const key of ENV_KEYS) delete process.env[key]; + dir = mkdtempSync(join(tmpdir(), 'os-23648-wasm-')); + mkdirSync(join(dir, 'dist'), { recursive: true }); + writeFileSync(join(dir, 'dist', 'objectstack.json'), JSON.stringify({ + manifest: { id: 'com.example.os23648wasm', name: 'wasm', version: '1.0.0', type: 'app' }, + })); + process.env.NODE_ENV = 'production'; + process.env.OS_HOME = join(dir, 'home'); + process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); + process.env.OS_AUTH_SECRET = 'os23648-fixture-secret-at-least-32-characters-long'; + process.chdir(dir); + file = join(dir, 'app.db'); + url = `wasm-sqlite://${file}`; + seeded = await runJson(invoke(MigrateApply), ['--database-url', url, '--yes', '--json']); +}, CASE_TIMEOUT_MS); + +afterAll(() => { + process.chdir(savedCwd); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(dir, { recursive: true, force: true }); +}); + +describe('os migrate on a wasm-sqlite:// database', () => { + it('apply finds the SQL driver and creates the schema in the file', () => { + expect(seeded.exitCode).toBe(0); + expect(seeded.payload.error).toBeUndefined(); + expect(seeded.payload.database).toBe(file); + expect(seeded.payload.created.map((w: { table: string }) => w.table)).toContain('sys_migration'); + expect(tablesOnDisk()).toContain('sys_migration'); + }); + + it('plan finds the SQL driver, and its unmanaged-table sweep reads the catalog', async () => { + const { payload, exitCode } = await runJson(invoke(MigratePlan), ['--database-url', url, '--json']); + expect(exitCode).toBe(0); + expect(payload.error).toBeUndefined(); + expect(payload.database).toBe(file); + expect(payload.pending).toEqual([]); + expect(payload.unmanagedTables).toMatchObject({ status: 'read' }); + expect(payload.unmanagedTables.physicalTables).toBe(tablesOnDisk().length); + }, CASE_TIMEOUT_MS); + + it('organization-ownership plans the database and applies the reviewed plan', async () => { + const planFile = join(dir, 'ownership-plan.json'); + const plan = await runJson(invoke(MigrateOrganizationOwnership), ['--database-url', url, '--out', planFile, '--json']); + expect(plan.exitCode).toBe(0); + expect(plan.payload.error).toBeUndefined(); + expect(plan.payload.plan).toMatchObject({ kind: 'organization-ownership-plan', database: file }); + expect(existsSync(planFile)).toBe(true); + + const apply = await runJson(invoke(MigrateOrganizationOwnership), [ + '--apply', '--backup-taken', '--plan-file', planFile, '--database-url', url, '--out', join(dir, 'ownership-apply.json'), '--json', + ]); + expect(apply.exitCode).toBe(0); + expect(apply.payload.error).toBeUndefined(); + expect(apply.payload.report).toMatchObject({ kind: 'organization-ownership-apply', database: file }); + const steps = apply.payload.report.steps as Array<{ step: string; status: string }>; + expect(steps.length).toBeGreaterThan(0); + expect(steps.filter((s) => s.status !== 'done')).toEqual([]); + + const db = new Database(file, { readonly: true }); + try { + expect(db.prepare('SELECT id FROM sys_migration').all()).toContainEqual({ id: 'adr-0131-organization-ownership' }); + } finally { + db.close(); + } + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts b/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts new file mode 100644 index 00000000000..0ec2adc531d --- /dev/null +++ b/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts @@ -0,0 +1,275 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23648] The schema boot's SQL-driver lookup finds `SqliteWasmDriver`, and + * still finds nothing that is not a SQL driver it lists. + * + * `SQL_DRIVER_SERVICES` is read by `findSqlDriverForKernel` and by + * `bootSchemaStack`'s own lookup, the one every `os migrate` command's + * `stack.driver` comes from. The wasm driver's key is the one a + * `wasm-sqlite://` boot was measured to register: + * `driver.com.objectstack.driver.sqlite-wasm` (`DefaultDatasourcePlugin` + * publishes the default driver as `driver.`). The real boot, + * through the commands, is pinned in + * `src/commands/migrate/sqlite-wasm.integration.test.ts`. + * + * The controls hold both halves of "not a catch-all `driver.*` match": + * - a driver with the schema-migration seam, under a key nobody listed, stays + * unfound — the telemetry sibling's key among them, which has its own lookup; + * - a driver without that seam stays unfound under any key, the wasm key + * included. + * And at the command: `os migrate organization-ownership`, handed the stack + * driver this lookup answers, refuses `driver-unsupported` in both arms on an + * unlisted or non-SQL driver, and proceeds on the wasm driver. + */ + +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const boot = vi.hoisted(() => vi.fn()); +vi.mock('./schema-migrate.js', async (importOriginal) => ({ + ...(await importOriginal()), + bootSchemaStack: boot, +})); +// No case passes --database-url, so the real probe would read whatever database +// the package's own directory resolves to. +vi.mock('./migrate-occupancy-gate.js', () => ({ + OCCUPANCY_STOP_HINT: 'occupancy hint', + probeMigrationTarget: vi.fn(async () => ({ status: 'free' })), +})); +const apply = vi.hoisted(() => vi.fn()); +vi.mock('./organization-ownership-apply.js', async (importOriginal) => ({ + ...(await importOriginal()), + applyOrganizationOwnership: apply, +})); + +import { findSqlDriverForKernel } from './schema-migrate.js'; + +// [#10126] Pay the first transform of what the command reaches through dynamic +// `import()`s at MODULE LOAD, not inside a case. +import '@objectstack/metadata-protocol'; +import '@objectstack/platform-objects/system'; + +type Command = (typeof import('../commands/migrate/organization-ownership.js'))['default']; +let MigrateOrganizationOwnership: Command; + +// The command's module graph is large, and oclif reads the package's config on +// the first run: both are paid outside the case timeout. +beforeAll(async () => { + ({ default: MigrateOrganizationOwnership } = await import('../commands/migrate/organization-ownership.js')); +}, 60_000); + +const CASE_TIMEOUT_MS = 60_000; +const CLI_ROOT = fileURLToPath(new URL('../..', import.meta.url)); + +const WASM_KEY = 'driver.com.objectstack.driver.sqlite-wasm'; + +/** knex's bring-your-own-dialect hatch: `SqliteWasmDriver` hands knex a client CLASS. */ +class Client_WasmSqlite {} + +/** Every statement a driver was handed, in order. */ +let statements: string[]; + +/** A driver with the schema-migration seam and a raw-SQL seam, as `SqlDriver` and its subclasses have. */ +function sqlDriver(shape: { name: string; dialectName: string; client: unknown }) { + return { + name: shape.name, + dialectName: shape.dialectName, + config: { client: shape.client }, + detectManagedDrift: async () => [], + applyMigrationEntries: async () => ({ applied: [], skipped: [] }), + execute: async (sql: string) => { + statements.push(sql); + return []; + }, + }; +} + +const wasmDriver = () => + sqlDriver({ name: 'com.objectstack.driver.sqlite-wasm', dialectName: 'sqlite', client: Client_WasmSqlite }); + +/** A driver with no schema-migration seam: the in-memory and MongoDB drivers' shape. */ +function nonSqlDriver(name: string) { + return { + name, + find: async () => [], + create: async () => ({}), + execute: async (sql: string) => { + statements.push(sql); + return []; + }, + }; +} + +/** A kernel that publishes exactly these services. */ +function kernelWith(services: Record) { + return { + getService: (name: string) => { + if (name in services) return services[name]; + throw new Error(`Service '${name}' not found`); + }, + }; +} + +describe('findSqlDriverForKernel', () => { + beforeEach(() => { + statements = []; + }); + + it('finds SqliteWasmDriver under the key a wasm-sqlite:// boot registers', () => { + const driver = wasmDriver(); + expect(findSqlDriverForKernel(kernelWith({ [WASM_KEY]: driver }))).toBe(driver); + }); + + it('still finds every SQL driver it found before', () => { + for (const key of ['driver.com.objectstack.driver.sql', 'driver.com.objectstack.driver.turso', 'driver.sql']) { + const driver = sqlDriver({ name: key.slice('driver.'.length), dialectName: 'sqlite', client: 'better-sqlite3' }); + expect(findSqlDriverForKernel(kernelWith({ [key]: driver })), key).toBe(driver); + } + }); + + it('control: a SQL driver under a key it does not list stays unfound', () => { + for (const key of ['driver.com.example.driver.sql-compatible', 'driver.telemetry', 'driver.default']) { + const driver = sqlDriver({ name: key.slice('driver.'.length), dialectName: 'sqlite', client: 'better-sqlite3' }); + expect(findSqlDriverForKernel(kernelWith({ [key]: driver })), key).toBeNull(); + } + }); + + it('control: a driver without the schema-migration seam stays unfound, under the wasm key too', () => { + for (const key of ['driver.com.objectstack.driver.memory', 'driver.com.objectstack.driver.mongodb', WASM_KEY]) { + expect(findSqlDriverForKernel(kernelWith({ [key]: nonSqlDriver(key.slice('driver.'.length)) })), key).toBeNull(); + } + }); +}); + +describe('os migrate organization-ownership, on the driver the lookup answers', () => { + let dir: string; + + /** A stack whose driver is whatever the real lookup finds in `services`. */ + function stackOver(services: Record) { + const kernel = kernelWith(services); + return { + driver: findSqlDriverForKernel(kernel), + dbLabel: 'fixture.db', + kernel: { + getService: (name: string) => + name === 'objectql' + ? { registry: { getItem: () => undefined, listItems: () => [], isPackageDisabled: () => false } } + : name === 'metadata' + ? { get: async () => undefined, list: async () => [] } + : undefined, + }, + tableAbsent: () => false, + composition: { securityPlugin: { composed: false, reason: 'no-secret' } }, + shutdown: async () => {}, + }; + } + + /** A plan file as a plan run writes it (the fields the apply reads). */ + function writePlan(): string { + const file = join(dir, 'reviewed-plan.json'); + writeFileSync(file, JSON.stringify({ + kind: 'organization-ownership-plan', + ceremony: 'ADR-0131 D10', + generatedAt: '2026-10-15T00:00:00.000Z', + database: 'fixture.db', + inventoryDigest: 'f'.repeat(64), + posture: 'single', + })); + return file; + } + + async function runJson(argv: string[]): Promise<{ payload: Record; exitCode: unknown }> { + const chunks: string[] = []; + const savedExit = process.exitCode; + vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { + chunks.push(String(chunk)); + const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; + if (cb) cb(); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write); + try { + await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT }); + } catch (error) { + if ((error as { oclif?: unknown }).oclif === undefined) throw error; + } + const exitCode = process.exitCode; + process.exitCode = savedExit; + vi.restoreAllMocks(); + return { payload: JSON.parse(chunks.join('').trim()) as Record, exitCode }; + } + + const out = (name: string): string[] => ['--out', join(dir, name), '--json']; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'os-23648-lookup-')); + statements = []; + apply.mockImplementation(async () => ({ + kind: 'organization-ownership-apply', + generatedAt: '2026-10-15T00:00:00.000Z', + steps: [], + tables: [], + complete: true, + blocking: 0, + advisory: 0, + })); + }); + + afterEach(() => { + boot.mockReset(); + apply.mockReset(); + rmSync(dir, { recursive: true, force: true }); + }); + + const REFUSED: Array<[string, Record]> = [ + ['a SQL driver under a key the lookup does not list', { + 'driver.com.example.driver.sql-compatible': + sqlDriver({ name: 'com.example.driver.sql-compatible', dialectName: 'sqlite', client: 'better-sqlite3' }), + }], + ['a non-SQL driver under its own key', { 'driver.com.objectstack.driver.mongodb': nonSqlDriver('com.objectstack.driver.mongodb') }], + ['a non-SQL driver under the wasm key', { [WASM_KEY]: nonSqlDriver('com.objectstack.driver.sqlite-wasm') }], + ]; + + for (const [label, services] of REFUSED) { + it(`control: the plan refuses driver-unsupported on ${label}, before any statement`, async () => { + boot.mockImplementation(async () => stackOver(services)); + const { payload, exitCode } = await runJson(out('plan.json')); + expect(payload).toMatchObject({ error: 'plan_refused', reason: 'driver-unsupported' }); + expect(exitCode).toBe(1); + expect(statements).toEqual([]); + expect(existsSync(join(dir, 'plan.json'))).toBe(false); + }, CASE_TIMEOUT_MS); + + it(`control: --apply refuses driver-unsupported on ${label}, and the apply step is never called`, async () => { + boot.mockImplementation(async () => stackOver(services)); + const { payload, exitCode } = await runJson(['--apply', '--backup-taken', '--plan-file', writePlan(), ...out('apply.json')]); + expect(payload).toMatchObject({ error: 'apply_refused', reason: 'driver-unsupported' }); + expect(exitCode).toBe(1); + expect(statements).toEqual([]); + expect(apply).not.toHaveBeenCalled(); + }, CASE_TIMEOUT_MS); + } + + it('the plan proceeds on SqliteWasmDriver, its first statement the SQLite catalog read', async () => { + boot.mockImplementation(async () => stackOver({ [WASM_KEY]: wasmDriver() })); + const { payload } = await runJson(out('plan.json')); + // The fake answers no tables, so the plan stops at its catalog read: past + // the driver and the dialect, which the statement it issued names. The + // whole plan, on a real wasm-sqlite:// database, is the integration pin's. + expect(payload).toMatchObject({ error: 'plan_refused', reason: 'not-an-objectstack-database' }); + expect(statements[0]).toMatch(/FROM sqlite_master/); + }, CASE_TIMEOUT_MS); + + it('--apply proceeds on SqliteWasmDriver and hands the apply step the sqlite dialect', async () => { + boot.mockImplementation(async () => stackOver({ [WASM_KEY]: wasmDriver() })); + const { payload } = await runJson(['--apply', '--backup-taken', '--plan-file', writePlan(), ...out('apply.json')]); + expect(payload.error).toBeUndefined(); + expect(payload).toMatchObject({ report: { kind: 'organization-ownership-apply' } }); + expect(apply).toHaveBeenCalledTimes(1); + expect(apply.mock.calls[0]![0]).toMatchObject({ executor: { dialect: 'sqlite' } }); + }, CASE_TIMEOUT_MS); +}); diff --git a/packages/cli/src/utils/schema-migrate.ts b/packages/cli/src/utils/schema-migrate.ts index 05bb4da34c3..b1fcdf46db7 100644 --- a/packages/cli/src/utils/schema-migrate.ts +++ b/packages/cli/src/utils/schema-migrate.ts @@ -184,9 +184,23 @@ export interface SchemaStack { shutdown: () => Promise; } +/** + * The kernel service names a SQL driver is published under, by driver: the + * `driver.` key `DefaultDatasourcePlugin` / `DriverPlugin` + * register. A name is listed per driver, never matched as a `driver.*` + * pattern: a non-SQL driver must stay unfound, and the two methods + * {@link findSqlDriverVia} checks are the second half of that guard. + * + * [#23648] `SqliteWasmDriver` (`wasm-sqlite://`, `*.wasm.db`, and the dev + * boot's step-down from better-sqlite3) is a `SqlDriver` published as + * `driver.com.objectstack.driver.sqlite-wasm`, measured on a `wasm-sqlite://` + * boot. Without it every `os migrate` command found no SQL driver on such a + * database. + */ const SQL_DRIVER_SERVICES = [ 'driver.com.objectstack.driver.sql', 'driver.com.objectstack.driver.turso', + 'driver.com.objectstack.driver.sqlite-wasm', 'driver.sql', ]; diff --git a/packages/cli/src/utils/unmanaged-tables.ts b/packages/cli/src/utils/unmanaged-tables.ts index 79269b2509d..c647aec174b 100644 --- a/packages/cli/src/utils/unmanaged-tables.ts +++ b/packages/cli/src/utils/unmanaged-tables.ts @@ -113,6 +113,7 @@ * byte-identical to a reader. */ +import { dialectOfDriver } from '@objectstack/metadata-core'; import { PLATFORM_OBJECT_PREFIXES, StorageNameMapping, hasPlatformObjectPrefix } from '@objectstack/spec/system'; // The result-set/no-answer predicate `os migrate duplicates` already publishes // (#10677). Imported rather than re-spelled: a second copy would drift, and the @@ -370,12 +371,19 @@ async function readPhysicalTableNames( return { detail: 'the planned driver exposes no raw SQL seam, so its table catalog could not be read' }; } - const client = (driver as { config?: { client?: unknown } } | null | undefined)?.config?.client; - const clientName = typeof client === 'string' ? client : ''; - const sql = physicalTableListSql(clientName); + // [#23648] The dialect through the one shared resolver, `dialectName` first: + // `SqliteWasmDriver`'s `config.client` is a knex client CLASS, which names no + // spelling, while its `dialectName` says `sqlite`. Read as a client string + // alone, every `wasm-sqlite://` plan reported this section unreadable. + const d = driver as { dialectName?: unknown; config?: { client?: unknown } } | null | undefined; + const dialect = dialectOfDriver(d); + const sql = dialect === 'none' || dialect === 'unresolved' ? null : physicalTableListSql(dialect); if (sql === null) { + const named = typeof d?.dialectName === 'string' + ? d.dialectName + : typeof d?.config?.client === 'string' ? d.config.client : ''; return { - detail: `the connected dialect (${clientName || 'unnamed client'}) is not one this sweep enumerates tables on ` + detail: `the connected dialect (${named || 'unnamed client'}) is not one this sweep enumerates tables on ` + '(sqlite / postgres / mysql)', }; }