diff --git a/.changeset/turso-loader-single-owner.md b/.changeset/turso-loader-single-owner.md new file mode 100644 index 0000000000..cceea83004 --- /dev/null +++ b/.changeset/turso-loader-single-owner.md @@ -0,0 +1,50 @@ +--- +"@objectstack/runtime": patch +"@objectstack/cli": patch +--- + +refactor(runtime,cli): give the optional Turso/libSQL loader ONE owner (#6268) + +`@objectstack/driver-turso` is an optional install, so neither host can let the +open-core datasource factory build the `default` datasource for a `libsql://` +selection — both inject a host driver factory instead. That loader was written +out **twice**: `packages/runtime/src/turso-driver-factory.ts` (`os migrate` / +`createStandaloneStack` / embedded hosts, #5820) and +`packages/cli/src/utils/storage-driver.ts` (`os serve` / `os start`, #5602). The +two were kept equal **by hand**, which is the #3741 → #3758 shape: one decision, +two implementations, one of them fixed and the other missed for three months. + +It had already begun. #6345 moved the CLI's `isTursoDriverId` onto +`@objectstack/spec`'s shared driver vocabulary and left the runtime half on a +private `Set(['turso', 'libsql'])` — equal only because the spec table's `turso` +row happens to list exactly those two aliases today. + +**The runtime now owns it and the CLI consumes it.** `@objectstack/runtime` +exports `loadTursoDriverFactory`, `isTursoDriverId`, `MissingDriverPackageError`, +`TURSO_DRIVER_PACKAGE` and `TURSO_DRIVER_INSTALL_COMMAND`; +`packages/cli/src/utils/storage-driver.ts` re-exports them, so every existing CLI +import site is unchanged. `UnsupportedDriverError` stays in the CLI — it is +CLI-only semantics (a `turso` selection with no URL), not a copy. + +**One class identity, deliberately.** `serve.ts` decides whether a boot failure +is fatal with `e instanceof MissingDriverPackageError`. A convergence that left +two same-named classes would make that predicate silently stop matching and +degrade a fatal branch to a non-fatal one with no diagnostic anywhere, so the +CLI re-exports the runtime's class rather than declaring its own, and a test pins +that an error raised by the runtime loader still satisfies the CLI-side +`instanceof`. + +**Behaviour, for operators:** unchanged, with one exception. Missing package +still fails loudly with the same `npm install @objectstack/driver-turso`, the +same error fields and no SQLite fallback; a present package still yields the same +factory handle shape. The exception is the missing-package **message**, which is +now one wording for both hosts and therefore names both consequences (a server +booted against an empty local database, and an `os migrate` DDL run against that +same one) instead of only the one its host used to mention. + +Two things stay host-owned because moving them would change behaviour: the +dynamic `import()` specifier (it resolves from the node_modules tree of whichever +module evaluates it, and the package is an optional **peer** of +`@objectstack/cli` that `@objectstack/runtime` does not declare at all), and the +error TYPE for a url-less turso config. Only the message for the latter is +shared. diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index 5c86ca3bd7..3e3cf2a264 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -10,9 +10,22 @@ import { resolveDriverType, resolveStorageDefinition, loadTursoDriverFactory, + isTursoDriverId, MissingDriverPackageError, + TURSO_DRIVER_INSTALL_COMMAND, UnsupportedDriverError, } from './storage-driver.js'; +// #6268: the OTHER host's bindings, imported under their own names so the +// identity assertions below compare two real module paths rather than one alias +// of the same import. `@objectstack/runtime` resolves to the BUILT package here, +// exactly as it does for `storage-driver.ts` itself — which is what makes the +// identity pinned below the identity that ships. +import { + loadTursoDriverFactory as loadRuntimeTursoDriverFactory, + isTursoDriverId as runtimeIsTursoDriverId, + MissingDriverPackageError as RuntimeMissingDriverPackageError, + TURSO_DRIVER_INSTALL_COMMAND as RUNTIME_TURSO_DRIVER_INSTALL_COMMAND, +} from '@objectstack/runtime'; describe('inferDriverTypeFromUrl', () => { it('maps each recognized URL scheme to its canonical driver kind', () => { @@ -397,3 +410,97 @@ describe('loadTursoDriverFactory: the optional driver package (#5602)', () => { expect(pinned.authToken).toBe('jwt-token'); }); }); + +// #6268 — the loader has ONE owner (`@objectstack/runtime`), and this file's +// exports are that owner's declarations rather than hand-aligned copies. +// +// The property under test is CLASS IDENTITY, not wording. `serve.ts:1136` decides +// whether a boot failure is fatal with +// +// if (e instanceof MissingDriverPackageError) throw e; +// +// so a convergence that left two same-named classes — one per package — would +// make that predicate stop matching and degrade a fatal branch to a non-fatal one +// with no diagnostic anywhere. Nothing in the message would change, which is why +// the first case below demonstrates, in-suite, that a message assertion is blind +// to exactly this defect. +describe('#6268 — one loader, one class identity across cli and runtime', () => { + it('the CLI export IS the runtime class object, not a same-named twin', () => { + expect(MissingDriverPackageError).toBe(RuntimeMissingDriverPackageError); + expect(isTursoDriverId).toBe(runtimeIsTursoDriverId); + expect(TURSO_DRIVER_INSTALL_COMMAND).toBe(RUNTIME_TURSO_DRIVER_INSTALL_COMMAND); + }); + + // THE pin. An error raised by the RUNTIME loader must satisfy the instanceof + // that `serve.ts` performs against the CLI-side binding. + it("an error raised by the runtime loader satisfies serve.ts's CLI-side instanceof", async () => { + const err = await loadRuntimeTursoDriverFactory({ + importDriverPackage: async () => { throw new Error("Cannot find module '@objectstack/driver-turso'"); }, + }).then(() => null, (e: unknown) => e); + + // The predicate serve.ts runs, spelled the way serve.ts spells it. + expect(err instanceof MissingDriverPackageError).toBe(true); + + // …and the demonstration that a message assertion could NOT have caught a + // broken identity: a twin declared right here carries the same message and + // the same fields, and passes every assertion except the one above. + class MissingDriverPackageErrorTwin extends Error { + constructor(readonly installCommand: string, message: string) { + super(message); + this.name = 'MissingDriverPackageError'; + } + } + const twin = new MissingDriverPackageErrorTwin( + (err as MissingDriverPackageError).installCommand, + (err as Error).message, + ); + expect(twin.message).toBe((err as Error).message); + expect(twin.name).toBe((err as Error).name); + expect(twin.installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND); + expect(twin instanceof MissingDriverPackageError).toBe(false); + }); + + it('and the reverse: the CLI loader raises an error the runtime binding matches', async () => { + const err = await loadTursoDriverFactory({ + importDriverPackage: async () => { throw new Error('nope'); }, + }).then(() => null, (e: unknown) => e); + expect(err instanceof RuntimeMissingDriverPackageError).toBe(true); + expect((err as MissingDriverPackageError).installCommand).toBe('npm install @objectstack/driver-turso'); + }); + + // The one thing the convergence deliberately did NOT move: the dynamic + // import's specifier, whose RESOLUTION ROOT is the module that evaluates it. + // `@objectstack/driver-turso` is an optional PEER of `@objectstack/cli` and is + // not declared by `@objectstack/runtime` at all, so under pnpm's strict layout + // it is linked into the CLI's node_modules and not the runtime's. Had the CLI + // taken the runtime's default thunk, an operator who ran the exact install + // command this error prints would still be told the package was missing. + // + // This case pins the CLI half — the default thunk finds the package that is + // installed next to the CLI. The runtime half is pinned from the other side by + // `standalone-stack.libsql.test.ts`, where a `libsql://` boot with no injected + // thunk takes the missing-package arm precisely because the runtime does not + // declare it. Together they assert that the two roots are still distinct. + it('resolves the optional package from the CLI’s own node_modules by default', async () => { + const factory = await loadTursoDriverFactory(); + expect(factory.supports('turso')).toBe(true); + expect(factory.supports('libsql')).toBe(true); + expect(factory.supports('sqlite')).toBe(false); + }); + + // The CLI-only semantics the ruling keeps on this side: a url-less turso config + // is refused as `UnsupportedDriverError`, which serve.ts re-throws as fatal — + // while the runtime's own default keeps raising its `[StandaloneStack]` error + // for the same message. Only the TYPE is host-chosen; the wording is shared. + it('keeps UnsupportedDriverError as the CLI’s url-less refusal, with the shared wording', async () => { + const factory = await loadTursoDriverFactory({ + importDriverPackage: async () => ({ TursoDriver: class { constructor(_c: unknown) {} } }), + }); + let err: unknown; + try { factory.create({ name: 'default', driver: 'turso', config: {} }); } catch (e) { err = e; } + expect(err).toBeInstanceOf(UnsupportedDriverError); + expect((err as Error).message).toMatch(/needs a libSQL url/); + // Not the standalone stack's prefix — that host keeps its own error type. + expect((err as Error).message).not.toMatch(/\[StandaloneStack\]/); + }); +}); diff --git a/packages/cli/src/utils/storage-driver.ts b/packages/cli/src/utils/storage-driver.ts index 666fa05de4..0fd6d0cdc2 100644 --- a/packages/cli/src/utils/storage-driver.ts +++ b/packages/cli/src/utils/storage-driver.ts @@ -48,13 +48,37 @@ * the exact install command ({@link MissingDriverPackageError}); it never falls * back to SQLite, which is the #3276 lesson kept intact: a silent step-down onto * a *different* engine writes an operator's data into the wrong database. + * + * ## #6268 — the loader itself lives in the runtime now + * + * That loader used to be written out twice: here, and in + * `packages/runtime/src/turso-driver-factory.ts` (#5820, for `os migrate` / + * `createStandaloneStack`). The two were kept equal BY HAND — one decision, two + * implementations, which is the #3741 → #3758 shape that goes wrong three months + * later. It had already started: #6345 moved this half onto `@objectstack/spec`'s + * shared driver vocabulary and left the runtime half on a private + * `Set(['turso', 'libsql'])`. + * + * The dependency direction allows cli → runtime, so the runtime owns it and this + * file consumes it. `MissingDriverPackageError` is RE-EXPORTED, not re-declared: + * `serve.ts` decides fatality with `e instanceof MissingDriverPackageError`, and + * two same-named classes would make that predicate silently stop matching — + * a fatal branch degraded to a non-fatal one with no diagnostic anywhere. + * `storage-driver.test.ts` pins that identity directly. + * + * What stays on this side is what is genuinely CLI semantics, not a copy: + * {@link UnsupportedDriverError} (the ruling keeps it here), the definition + * resolver below, and the dynamic-import thunk — see + * {@link loadTursoDriverFactory} for why the import specifier cannot move. */ import type { - DatasourceConnectionSpec, - DatasourceDriverHandle, IDatasourceDriverFactory, } from '@objectstack/service-datasource'; +import { + loadTursoDriverFactory as loadRuntimeTursoDriverFactory, + type LoadTursoDriverFactoryOptions, +} from '@objectstack/runtime'; import { type BuiltinDriverId, DATABASE_DRIVER_SELECTION_ALIASES, @@ -62,12 +86,23 @@ import { resolveDatabaseDriverId, } from '@objectstack/spec/data'; +/** + * The libSQL/Turso loader's single-sourced surface, re-exported so this module + * stays the CLI's one door to storage-driver concerns (#6268). These are the + * runtime's declarations, not copies of them — in particular + * {@link MissingDriverPackageError} is ONE class across both packages. + */ +export { + isTursoDriverId, + MissingDriverPackageError, + TURSO_DRIVER_PACKAGE, + TURSO_DRIVER_INSTALL_COMMAND, +} from '@objectstack/runtime'; +export type { LoadTursoDriverFactoryOptions } from '@objectstack/runtime'; + /** Engines the shared sqlite step-down (`resolveSqliteDriver`) can produce. */ export type SqliteFamilyEngine = 'better-sqlite3' | 'sqlite-wasm' | 'memory'; -/** The optional package that provides the libSQL/Turso driver. */ -export const TURSO_DRIVER_PACKAGE = '@objectstack/driver-turso'; - /** * Where each no-local-default kind's connection target actually comes from — * the one clause that differs between their otherwise identical refusals. @@ -174,30 +209,6 @@ export class UnsupportedDriverError extends Error { } } -/** - * Thrown by {@link loadTursoDriverFactory} when the OPTIONAL driver package the - * selected kind needs is not installed (#5602). - * - * Carries the install command as data as well as prose, so a caller can render it - * however it likes, and so the pin test asserts the command rather than a sentence - * shape. `serve.ts` re-throws it as a fatal boot error — there is deliberately NO - * fallback branch: degrading a `libsql://` selection to SQLite would boot the - * server against an empty local file while the operator's remote data sits - * untouched, and every write would land in the wrong database (#3276). - */ -export class MissingDriverPackageError extends Error { - readonly driverType: string; - readonly packageName: string; - readonly installCommand: string; - constructor(args: { driverType: string; packageName: string; installCommand: string; message: string }) { - super(args.message); - this.name = 'MissingDriverPackageError'; - this.driverType = args.driverType; - this.packageName = args.packageName; - this.installCommand = args.installCommand; - } -} - /** * Infer a canonical driver kind from an `OS_DATABASE_URL` scheme. * Returns `''` when the URL is absent or its scheme is unrecognized (the caller @@ -476,125 +487,40 @@ export function resolveStorageDefinition( return null; } -/** - * True for the driver ids {@link loadTursoDriverFactory}'s factory builds. - * - * Resolved through the shared table since #6345 rather than a local `Set`, so - * "which spellings mean libSQL" has one answer across the CLI, the standalone - * stack and the metadata gate. - */ -export function isTursoDriverId(driverId: string): boolean { - return resolveDatabaseDriverId(driverId) === 'turso'; -} - -/** The exact command an operator runs to install the optional libSQL driver. */ -export const TURSO_DRIVER_INSTALL_COMMAND = `npm install ${TURSO_DRIVER_PACKAGE}`; - -export interface LoadTursoDriverFactoryOptions { - /** - * Test seam: substitute the dynamic `import('@objectstack/driver-turso')`. - * Production passes nothing. Tests pass a stub module (dispatch WITH the package) - * or a rejecting thunk (dispatch WITHOUT it) — neither needs a real Turso - * endpoint, and the missing-package path must be testable in a workspace where - * the package happens to be installed. - */ - importDriverPackage?: () => Promise; -} - /** * Load the OPTIONAL libSQL/Turso driver package and wrap it as the host driver * factory `DefaultDatasourcePlugin` accepts (#5602). * - * The package is an **optional peer** of the CLI: it drags `@libsql/client` - * (native bindings included), so making it a hard dependency would weigh down every - * `npx create-objectstack` install for a backend most projects do not use. The - * import therefore happens here, at boot, only for a selection that actually asks - * for libSQL. + * A thin delegation to the runtime's single owner since #6268 — the loading, the + * error class, the install command, the missing-package wording and the handle + * shape all live in `@objectstack/runtime`'s `turso-driver-factory.ts`. What this + * wrapper supplies is the two things that are genuinely the CLI's, and would be a + * behaviour change if they moved: * - * Absent package ⇒ {@link MissingDriverPackageError}, carrying the exact install - * command. There is no other branch: the caller must not fall back to SQLite (see - * the class docstring), and the failure is raised BEFORE the plugin is registered so - * the operator gets one clear message instead of a connect error later in boot. + * 1. **The import thunk — the module-resolution ROOT.** + * `@objectstack/driver-turso` is an **optional peer** of `@objectstack/cli` + * (it drags `@libsql/client` with native bindings, so making it a hard + * dependency would weigh down every `npx create-objectstack` install for a + * backend most projects do not use) and it is not declared by + * `@objectstack/runtime` at all. A dynamic `import()` resolves from the + * node_modules tree of the module that EVALUATES it, so letting the runtime's + * default thunk serve the CLI would look for the package under + * `@objectstack/runtime` — which pnpm's strict layout does not link. An + * operator who ran the exact install command the error tells them to run + * would still be told the package is missing. The specifier therefore stays + * written here, in the package that peer-declares it, and is typed rather + * than `as any` for the same reason. + * 2. **{@link UnsupportedDriverError} for a url-less turso config.** CLI-only + * semantics by the #6268 ruling — `serve.ts` re-throws it as a fatal boot + * error. Only the error TYPE is chosen here; the message comes from the + * runtime, so the wording is not duplicated. */ export async function loadTursoDriverFactory( opts: LoadTursoDriverFactoryOptions = {}, ): Promise { - const load = opts.importDriverPackage ?? (() => import('@objectstack/driver-turso')); - - let mod: unknown; - try { - mod = await load(); - } catch (err) { - throw new MissingDriverPackageError({ - driverType: 'turso', - packageName: TURSO_DRIVER_PACKAGE, - installCommand: TURSO_DRIVER_INSTALL_COMMAND, - message: - `A libSQL/Turso database was selected, but the driver package ${TURSO_DRIVER_PACKAGE} ` - + `is not installed. Install it next to the CLI:\n\n ${TURSO_DRIVER_INSTALL_COMMAND}\n\n` - + `(pnpm add ${TURSO_DRIVER_PACKAGE} / yarn add ${TURSO_DRIVER_PACKAGE}.) It is an ` - + 'OPTIONAL peer dependency, so a default install stays free of @libsql/client. ' - + 'The boot refuses rather than falling back to SQLite: a silent fallback would start ' - + 'the server against an empty local database while your libSQL data stays untouched, ' - + 'and every write would land in the wrong place. To use SQLite deliberately, set ' - + 'OS_DATABASE_URL=file:./data/objectstack.db (or --database file:./data/objectstack.db). ' - + `Import error: ${err instanceof Error ? err.message : String(err)}`, - }); - } - - const record = (mod ?? {}) as { TursoDriver?: unknown; default?: { TursoDriver?: unknown } }; - const TursoDriverCtor = (record.TursoDriver ?? record.default?.TursoDriver) as - | (new (config: { url: string; authToken?: string }) => object) - | undefined; - if (typeof TursoDriverCtor !== 'function') { - // Resolvable but not the module we expect (a shadowing stub, a truncated - // install, an incompatible major that renamed its export). Same operator - // remedy, so the same typed error — never a fallback. - throw new MissingDriverPackageError({ - driverType: 'turso', - packageName: TURSO_DRIVER_PACKAGE, - installCommand: TURSO_DRIVER_INSTALL_COMMAND, - message: - `${TURSO_DRIVER_PACKAGE} resolved but exports no TursoDriver class, so the libSQL ` - + `database cannot be opened. Reinstall it:\n\n ${TURSO_DRIVER_INSTALL_COMMAND}\n\n` - + 'The boot refuses rather than falling back to SQLite, which would write your data ' - + 'into a different database than the one you configured.', - }); - } - - return { - supports: (driverId: string) => isTursoDriverId(driverId), - create: (spec: DatasourceConnectionSpec): DatasourceDriverHandle => { - const config = (spec.config ?? {}) as { url?: unknown; authToken?: unknown }; - const url = typeof config.url === 'string' ? config.url : ''; - if (!url) { - throw new UnsupportedDriverError( - 'turso', - `datasource '${spec.name ?? 'default'}': driver '${spec.driver}' needs a libSQL url in its ` - + 'config (e.g. libsql://my-db.turso.io or file:./data/objectstack.db).', - ); - } - const driver = new TursoDriverCtor({ - url, - ...(typeof config.authToken === 'string' && config.authToken - ? { authToken: config.authToken } - : {}), - }) as { - connect?: () => Promise; - disconnect?: () => Promise; - checkHealth?: () => Promise; - }; - // Same handle shape the open-core factory builds (`toHandle`): ownership - // stays the default `'factory'` — this instance was built for THIS connect, - // so kernel teardown disconnects it. - return { - ...(typeof driver.connect === 'function' ? { connect: () => driver.connect!() } : {}), - ...(typeof driver.disconnect === 'function' ? { disconnect: () => driver.disconnect!() } : {}), - ...(typeof driver.checkHealth === 'function' - ? { checkHealth: () => driver.checkHealth!(), ping: () => driver.checkHealth!() } - : {}), - driver, - }; - }, - }; + return loadRuntimeTursoDriverFactory({ + importDriverPackage: opts.importDriverPackage ?? (() => import('@objectstack/driver-turso')), + missingUrlError: + opts.missingUrlError ?? ((message: string) => new UnsupportedDriverError('turso', message)), + }); } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 55338a3df7..024dd5521b 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -11,6 +11,20 @@ export type { RuntimeConfig } from './runtime.js'; export { createStandaloneStack, resolveObjectStackHome, resolveStandaloneDatabase } from './standalone-stack.js'; export type { StandaloneStackConfig, StandaloneStackResult, ResolvedStandaloneDatabase } from './standalone-stack.js'; +// The ONE libSQL/Turso loader (#6268). Public because `@objectstack/cli` is a +// consumer, not a second implementation: `utils/storage-driver.ts` delegates to +// `loadTursoDriverFactory` and RE-EXPORTS `MissingDriverPackageError`, so +// `serve.ts`'s `e instanceof MissingDriverPackageError` fatal branch tests one +// class identity rather than one of two same-named twins. +export { + loadTursoDriverFactory, + isTursoDriverId, + MissingDriverPackageError, + TURSO_DRIVER_PACKAGE, + TURSO_DRIVER_INSTALL_COMMAND, +} from './turso-driver-factory.js'; +export type { LoadTursoDriverFactoryOptions } from './turso-driver-factory.js'; + // The ONE default-database resolution shared by `os dev` / `os start` / // `os migrate` (#6469) — commands map their flags onto it; no command carries // its own fallback filename. diff --git a/packages/runtime/src/turso-driver-factory.ts b/packages/runtime/src/turso-driver-factory.ts index 1a02cbb3a8..e27ab84232 100644 --- a/packages/runtime/src/turso-driver-factory.ts +++ b/packages/runtime/src/turso-driver-factory.ts @@ -1,22 +1,17 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * libSQL/Turso driver loading for the standalone (runtime-only) stack (#5820). + * libSQL/Turso driver loading — the SINGLE owner for both hosts (#6268). * - * `standalone-stack.ts` translates a database URL into the `default` datasource - * DEFINITION (ADR-0062 D1, #3826) and never constructs a driver itself — the - * shared `DatasourceConnectionService` connects it. That works for every kind - * the open-core factory can build; `turso` is the one kind it cannot, because - * `@objectstack/driver-turso` drags `@libsql/client` (native bindings included) - * and is therefore an OPTIONAL install rather than a dependency. - * - * So this module builds the host driver factory `DefaultDatasourcePlugin` - * accepts (`options.factory`) — the documented seam for exactly this case: "a - * host whose `default` needs a driver the open-core factory cannot build". - * Everything else stays identical to every other kind: same connect path, same - * `bootCritical` fail-fast verdict, same `OS_ALLOW_DRIVER_CONNECT_FAILURE` - * escape hatch, same retained status in Setup → Datasources. Only the - * construction differs. + * `@objectstack/driver-turso` drags `@libsql/client` (native bindings included), + * so it is an OPTIONAL install rather than a dependency. Neither host can + * therefore let the shared open-core factory build it for the `default` + * datasource; both inject a host driver factory instead (`options.factory` on + * `DefaultDatasourcePlugin` — the documented seam for "a host whose `default` + * needs a driver the open-core factory cannot build"). Everything else stays + * identical to every other kind: same connect path, same `bootCritical` + * fail-fast verdict, same `OS_ALLOW_DRIVER_CONNECT_FAILURE` escape hatch, same + * retained status in Setup → Datasources. Only the construction differs. * * ## Why the loud failure, and never a SQLite fallback * @@ -24,19 +19,48 @@ * {@link MissingDriverPackageError}, carrying the exact install command as DATA * (not only prose). There is deliberately no other branch. Degrading a * `libsql://` selection to SQLite would open an empty local file while the - * operator's remote database sits untouched, and every write — including an - * `os migrate` DDL — would land in the wrong database. That is the #3276 lesson - * (a driver kind advertised but silently resolved to a *different* engine), and - * it is the same ruling the CLI side landed under (#5602 / PR #5819). + * operator's remote database sits untouched, and every write — a server write or + * an `os migrate` DDL — would land in the wrong database. That is the #3276 + * lesson (a driver kind advertised but silently resolved to a *different* + * engine), and it is the ruling both halves landed under (#5602 / PR #5819 for + * the CLI, #5820 for the standalone stack). + * + * ## #6268 — why this module owns it, and what the hosts still own + * + * Until #6268 this shape existed TWICE: here, and in + * `packages/cli/src/utils/storage-driver.ts`. They were kept equal by hand + * because the dependency direction forbids the reverse import (cli → runtime, + * never runtime → cli) and each of the two rulings that created them had a + * single-package file face. Hand alignment had already started to fail — the CLI + * half moved onto `@objectstack/spec`'s shared driver vocabulary in #6345 while + * this half still carried a private `Set(['turso', 'libsql'])` — which is the + * #3741 → #3758 shape: one decision, two implementations, one of them fixed. + * + * So this module is now the owner and the CLI delegates to it. The one thing + * that CANNOT move, and is therefore a host-supplied input rather than a + * duplicate: * - * ## Relationship to the CLI's `loadTursoDriverFactory` + * - **{@link LoadTursoDriverFactoryOptions.importDriverPackage} — the module + * resolution root.** `@objectstack/driver-turso` is an optional PEER of + * `@objectstack/cli` and is not declared by `@objectstack/runtime` at all. + * A bare `import('@objectstack/driver-turso')` resolves from the node_modules + * tree of the module that *evaluates* it, so moving the CLI's import into + * this file would look for the package under `@objectstack/runtime` — which + * under pnpm's strict layout does not link it. An operator who ran the + * install command the error tells them to run would still be told the package + * is missing. The CLI therefore keeps passing its own thunk; the default + * below is this package's own root, for the standalone stack. + * - **{@link LoadTursoDriverFactoryOptions.missingUrlError} — the error TYPE + * for a config with no url.** The CLI raises its own `UnsupportedDriverError` + * (a CLI-only semantic: `serve.ts` re-throws it as a fatal boot error), which + * by ruling stays in the CLI. Only the type is host-chosen; the message is + * single-sourced here. * - * `packages/cli/src/utils/storage-driver.ts` carries the same shape for the - * `os serve` / `os start` path. The two are independent today because the - * dependency direction forbids the reverse import (cli → runtime, never - * runtime → cli), and because #5602's file face was the CLI alone. Collapsing - * them onto one owner — this module, with the CLI delegating — is filed as a - * follow-up rather than done here, so this PR stays inside #5820's face. + * The install command, the missing-package message, the driver-id vocabulary, + * the handle shape and the error CLASS are all single-sourced — one + * {@link MissingDriverPackageError} identity across both packages, which is what + * keeps `serve.ts`'s `e instanceof MissingDriverPackageError` fatal branch + * matching an error this module raised. */ import type { @@ -44,6 +68,7 @@ import type { DatasourceDriverHandle, IDatasourceDriverFactory, } from '@objectstack/service-datasource'; +import { resolveDatabaseDriverId } from '@objectstack/spec/data'; /** The optional package that provides the libSQL/Turso driver. */ export const TURSO_DRIVER_PACKAGE = '@objectstack/driver-turso'; @@ -51,12 +76,19 @@ export const TURSO_DRIVER_PACKAGE = '@objectstack/driver-turso'; /** The exact command an operator runs to install the optional libSQL driver. */ export const TURSO_DRIVER_INSTALL_COMMAND = `npm install ${TURSO_DRIVER_PACKAGE}`; -/** Driver ids this factory builds — the same pair the CLI's resolver treats as libSQL. */ -const TURSO_DRIVER_IDS = new Set(['turso', 'libsql']); - -/** True for the driver ids {@link loadTursoDriverFactory}'s factory builds. */ +/** + * True for the driver ids {@link loadTursoDriverFactory}'s factory builds. + * + * Resolved through `@objectstack/spec`'s shared driver table rather than a local + * `Set`, so "which spellings mean libSQL" has ONE answer across the CLI, the + * standalone stack, the open-core factory and the metadata gate. The private + * `Set(['turso', 'libsql'])` this replaced (#6268) happened to agree with the + * table on the day it was written — the table's `turso` row lists exactly those + * two aliases — and would have silently stopped agreeing the moment a third + * spelling was added on one side only. + */ export function isTursoDriverId(driverId: string): boolean { - return TURSO_DRIVER_IDS.has(driverId.trim().toLowerCase()); + return resolveDatabaseDriverId(driverId) === 'turso'; } /** @@ -68,6 +100,16 @@ export function isTursoDriverId(driverId: string): boolean { * The install command rides as a field as well as inside the message so a * caller can render it however it likes, and so the pin test asserts the * command rather than a sentence shape. + * + * ## One class, deliberately (#6268) + * + * `packages/cli/src/commands/serve.ts` decides whether a boot failure is FATAL + * with `e instanceof MissingDriverPackageError`. Two same-named classes — one + * per package — would make that predicate silently stop matching, degrading a + * fatal branch to a non-fatal one with no diagnostic anywhere. The CLI therefore + * RE-EXPORTS this class rather than declaring its own; `storage-driver.test.ts` + * pins the identity (`===`) and pins that an error raised by this module still + * satisfies the CLI-side `instanceof`. */ export class MissingDriverPackageError extends Error { readonly driverType: string; @@ -84,13 +126,35 @@ export class MissingDriverPackageError extends Error { export interface LoadTursoDriverFactoryOptions { /** - * Test seam: substitute the dynamic `import('@objectstack/driver-turso')`. - * Production passes nothing. Tests pass a stub module (dispatch WITH the - * package) or a rejecting thunk (dispatch WITHOUT it) — neither needs a real - * Turso endpoint, and the missing-package path must stay testable in a - * workspace where the package happens to be installed. + * How the optional package is resolved — and, in tests, a substitute for it. + * + * NOT merely a test seam: the specifier resolves from the node_modules tree of + * whichever module evaluates the `import()`, and the package is an optional + * peer of `@objectstack/cli` while `@objectstack/runtime` does not declare it. + * The CLI passes its own thunk so its operators keep resolving the package + * they installed next to the CLI (see the module docstring). The default is + * this package's own root, which is what the standalone stack wants. + * + * Tests pass a stub module (dispatch WITH the package) or a rejecting thunk + * (dispatch WITHOUT it) — neither needs a real Turso endpoint, and the + * missing-package path must stay testable in a workspace where the package + * happens to be installed. */ importDriverPackage?: () => Promise; + /** + * Build the error raised when a `turso` connection spec carries no libSQL url. + * + * Host-chosen because the TYPE is host semantics: the CLI raises its own + * `UnsupportedDriverError`, which `serve.ts` re-throws as a fatal boot error + * and which by the #6268 ruling stays in the CLI. The MESSAGE is passed in + * from here, so the wording is still single-sourced. + * + * Defaults to the standalone stack's plain `Error` with its `[StandaloneStack]` + * prefix — the prefix lives here rather than in `standalone-stack.ts` because + * that is the only caller taking the default, and moving it would change the + * message that host has raised since #5820. + */ + missingUrlError?: (message: string) => Error; } /** @@ -100,6 +164,12 @@ export interface LoadTursoDriverFactoryOptions { * The import happens here, at boot, and only for a selection that actually asks * for libSQL — never at module load, so a stack that never sees a `libsql://` * URL pays nothing for this arm existing. + * + * Absent package ⇒ {@link MissingDriverPackageError}, carrying the exact install + * command. There is no other branch: the caller must not fall back to SQLite + * (see the class docstring), and the failure is raised BEFORE the plugin is + * registered so the operator gets one clear message instead of a connect error + * later in boot. */ export async function loadTursoDriverFactory( opts: LoadTursoDriverFactoryOptions = {}, @@ -109,6 +179,8 @@ export async function loadTursoDriverFactory( // must not be type-resolved. Same shape the shared factory uses for the other // optional drivers (`default-datasource-driver-factory.ts`). const load = opts.importDriverPackage ?? (() => import('@objectstack/driver-turso' as any)); + const missingUrlError = + opts.missingUrlError ?? ((message: string) => new Error(`[StandaloneStack] ${message}`)); let mod: unknown; try { @@ -118,15 +190,21 @@ export async function loadTursoDriverFactory( driverType: 'turso', packageName: TURSO_DRIVER_PACKAGE, installCommand: TURSO_DRIVER_INSTALL_COMMAND, + // One wording for both hosts (#6268). It names BOTH consequences rather + // than picking one, because one message now answers a failed `os serve` + // boot and a failed `os migrate` / embedded `createStandaloneStack` alike, + // and an operator who is told only about the other host's symptom would + // reasonably conclude the message is not about their situation. message: `A libSQL/Turso database was selected, but the driver package ${TURSO_DRIVER_PACKAGE} ` - + `is not installed. Install it next to your app:\n\n ${TURSO_DRIVER_INSTALL_COMMAND}\n\n` + + `is not installed. Install it next to the app or CLI that boots it:\n\n ${TURSO_DRIVER_INSTALL_COMMAND}\n\n` + `(pnpm add ${TURSO_DRIVER_PACKAGE} / yarn add ${TURSO_DRIVER_PACKAGE}.) It is an ` - + 'OPTIONAL package, so a default install stays free of @libsql/client and its native ' - + 'bindings. The boot refuses rather than falling back to SQLite: a silent fallback would ' - + 'open an empty local database while your libSQL data stays untouched, and every write — ' - + 'including an `os migrate` DDL — would land in the wrong database. To use SQLite ' - + 'deliberately, set OS_DATABASE_URL=file:./data/objectstack.db. ' + + 'OPTIONAL package — an optional peer dependency of the CLI — so a default install stays ' + + 'free of @libsql/client and its native bindings. The boot refuses rather than falling ' + + 'back to SQLite: a silent fallback would start the server against an empty local database ' + + '(and let an `os migrate` DDL run against that one) while your libSQL data stays ' + + 'untouched, and every write would land in the wrong database. To use SQLite deliberately, ' + + 'set OS_DATABASE_URL=file:./data/objectstack.db (or --database file:./data/objectstack.db). ' + `Import error: ${err instanceof Error ? err.message : String(err)}`, }); } @@ -136,6 +214,9 @@ export async function loadTursoDriverFactory( | (new (config: { url: string; authToken?: string }) => object) | undefined; if (typeof TursoDriverCtor !== 'function') { + // Resolvable but not the module we expect (a shadowing stub, a truncated + // install, an incompatible major that renamed its export). Same operator + // remedy, so the same typed error — never a fallback. throw new MissingDriverPackageError({ driverType: 'turso', packageName: TURSO_DRIVER_PACKAGE, @@ -154,12 +235,14 @@ export async function loadTursoDriverFactory( const config = (spec.config ?? {}) as { url?: unknown; authToken?: unknown }; const url = typeof config.url === 'string' ? config.url : ''; if (!url) { - // Defensive: the standalone stack always resolves a URL before it - // selects this kind. A host composing the definition by hand can still - // get here, and an empty libSQL url has no default to fall back on. - throw new Error( - `[StandaloneStack] datasource '${spec.name ?? 'default'}': driver '${spec.driver}' needs a ` - + 'libSQL url in its config (e.g. libsql://my-db.turso.io or file:./data/objectstack.db).', + // Defensive: both hosts resolve a URL before they select this kind (the + // CLI refuses a URL-less `turso` selection in `resolveStorageDefinition`, + // the standalone stack in its own resolution). A host composing the + // definition by hand can still get here, and an empty libSQL url has no + // default to fall back on. + throw missingUrlError( + `datasource '${spec.name ?? 'default'}': driver '${spec.driver}' needs a libSQL url in its ` + + 'config (e.g. libsql://my-db.turso.io or file:./data/objectstack.db).', ); } const driver = new TursoDriverCtor({