From 43ada92cf79e69ac71add301aaab197d7c24a91a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 10:50:50 +0000 Subject: [PATCH] fix(runtime,service-datasource): converge the two libSQL loaders on one config read and one error class (#7314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two loaders build the libSQL/Turso driver and which one runs is decided by whether the datasource happens to be the host's `default`. #6268 converged the two HOST-injected loaders; it could not reach the third — the open-core `turso` arm in `@objectstack/service-datasource` — and the two had drifted. Point 3: the host loader read `url` and `authToken`; the open-core arm read nine keys. So a `default` libSQL datasource silently lost `encryptionKey` / `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` / `schemaMode`, all accepted by `TursoConfigSchema` and all honoured the moment the datasource was renamed. Both loaders now build through one `buildTursoDriverConfig`, whose key set is DERIVED from a reader table rather than hand-listed; a `packages/cli` compile-time pin fails when that builder and the driver's own `TursoDriverConfig` stop covering the same keys. The host loader also trims the url, as the open-core arm always has. Point 2: `MissingDriverPackageError` was declared in `@objectstack/runtime`, which `service-datasource` cannot import, so the open-core arm raised a plain `Error`. The class moves DOWN to the lowest package that raises it and is re-exported from its old home, so every existing importer keeps compiling — against the same class object, which is what `serve.ts`'s `e instanceof MissingDriverPackageError` fatal branch depends on. Pinned on the constructor argument (not a successful boot) and by object identity (not by name or message), because a dropped key and a twin class both produce a driver that constructs cleanly and a message that reads correctly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RjUepKTxiGcJX6WQtwFmcf --- .changeset/turso-host-loader-convergence.md | 48 +++++ packages/cli/src/utils/storage-driver.test.ts | 93 +++++++++ packages/runtime/src/index.ts | 6 + .../turso-driver-factory.convergence.test.ts | 192 ++++++++++++++++++ packages/runtime/src/turso-driver-factory.ts | 117 ++++++----- .../default-datasource-driver-factory.test.ts | 12 ++ .../src/__tests__/turso-driver-config.test.ts | 123 +++++++++++ .../src/datasource-schema-mode.ts | 33 +++ .../src/default-datasource-driver-factory.ts | 77 +++---- .../services/service-datasource/src/index.ts | 27 ++- .../src/missing-driver-package-error.ts | 63 ++++++ .../src/turso-driver-config.ts | 185 +++++++++++++++++ 12 files changed, 890 insertions(+), 86 deletions(-) create mode 100644 .changeset/turso-host-loader-convergence.md create mode 100644 packages/runtime/src/turso-driver-factory.convergence.test.ts create mode 100644 packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts create mode 100644 packages/services/service-datasource/src/datasource-schema-mode.ts create mode 100644 packages/services/service-datasource/src/missing-driver-package-error.ts create mode 100644 packages/services/service-datasource/src/turso-driver-config.ts diff --git a/.changeset/turso-host-loader-convergence.md b/.changeset/turso-host-loader-convergence.md new file mode 100644 index 0000000000..73b648c6c5 --- /dev/null +++ b/.changeset/turso-host-loader-convergence.md @@ -0,0 +1,48 @@ +--- +"@objectstack/service-datasource": minor +"@objectstack/runtime": patch +--- + +fix(runtime,service-datasource): a `default` libSQL datasource keeps its whole config, and one missing-package class (#7314) + +Two loaders build the libSQL/Turso driver, and which one runs is decided by +something the author cannot see — whether the datasource happens to be the +host's `default`. `@objectstack/runtime`'s host loader serves that one; +`createDefaultDatasourceDriverFactory`'s `turso` arm in +`@objectstack/service-datasource` serves every other door (a datasource created +in Setup, `testConnection`, a declared non-default). #6268 converged the two +HOST loaders onto one owner; it could not reach the third, one layer down, and +the two had drifted in two ways. + +**Half the config was silently dropped for `default`.** The host loader built +`new TursoDriver({ url, authToken })` — two keys — while the open-core arm read +nine. `TursoConfigSchema` accepts all nine, so an encrypted or +embedded-replica `default` lost `encryptionKey` / `syncUrl` / `sync` / +`concurrency` / `timeout` / `mode` / `schemaMode` with no diagnostic anywhere, +and got them back the moment the datasource was renamed. Both loaders now build +through one exported `buildTursoDriverConfig`, whose key set is derived from a +reader table rather than hand-listed — a corrected second copy would only have +agreed until the next key. A `packages/cli` pin fails to compile if that builder +and the driver's own `TursoDriverConfig` stop covering the same keys. + +The host loader also now trims the url before testing it, as the open-core arm +always has: a whitespace-only url is refused by name instead of being handed to +`@libsql/client`. + +**One `MissingDriverPackageError`, reachable from both sides.** The class was +declared in `@objectstack/runtime`, which `@objectstack/service-datasource` +cannot import (the dependency runs the other way), so the open-core arm raised a +plain `Error` — matched by no `instanceof`, and pinnable only by message text. +The declaration moves DOWN to `@objectstack/service-datasource`, the lowest +package that raises it, and both loaders now throw the same class object. + +**No import changes.** `MissingDriverPackageError`, `TURSO_DRIVER_PACKAGE` and +`TURSO_DRIVER_INSTALL_COMMAND` are still exported from `@objectstack/runtime` +(and from `@objectstack/cli`'s `utils/storage-driver.ts` through it) — they are +re-exports now rather than declarations. Code written against either spelling +keeps compiling, and against the same class: `serve.ts`'s +`e instanceof MissingDriverPackageError` fatal-boot branch depends on that +identity, so it is asserted by object identity rather than by name or message. +`@objectstack/service-datasource` additionally exports the class, the builder +(`buildTursoDriverConfig`, `resolveTursoUrl`, `TURSO_DRIVER_CONFIG_KEYS`) and +`resolveDatasourceSchemaMode` for hosts that build their own driver factory. diff --git a/packages/cli/src/utils/storage-driver.test.ts b/packages/cli/src/utils/storage-driver.test.ts index 3e3cf2a264..7e8b120d2a 100644 --- a/packages/cli/src/utils/storage-driver.test.ts +++ b/packages/cli/src/utils/storage-driver.test.ts @@ -5,6 +5,16 @@ import { describe, it, expect } from 'vitest'; // this file). `@objectstack/driver-turso` is an OPTIONAL peer of the CLI — this // import is erased, so nothing here requires it at runtime. import type { TursoDriverConfig } from '@objectstack/driver-turso'; +// #7314: the shared libSQL config builder both loaders call, and the class both +// now raise. Imported from `@objectstack/service-datasource` — the lowest package +// of the three, and the only import direction the dependency graph allows. +import { + buildTursoDriverConfig, + resolveTursoUrl, + TURSO_DRIVER_CONFIG_KEYS, + MissingDriverPackageError as OpenCoreMissingDriverPackageError, + type TursoDriverConfigInput, +} from '@objectstack/service-datasource'; import { inferDriverTypeFromUrl, resolveDriverType, @@ -411,6 +421,73 @@ describe('loadTursoDriverFactory: the optional driver package (#5602)', () => { }); }); +// #7314 — the CONFIG half of the convergence, pinned from the only package that +// can see both sides. +// +// Two loaders build the libSQL driver — `@objectstack/runtime`'s host loader for +// a host's `default` datasource, `@objectstack/service-datasource`'s open-core +// arm for every other door — and each used to hand-list the keys it read. The +// lists drifted: nine keys in the open-core arm, `url` and `authToken` in the +// host loader, so an encrypted or embedded-replica `default` silently lost +// `encryptionKey` / `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` and +// got them back the moment it was renamed. Both now build through +// `buildTursoDriverConfig`, so they cannot disagree with EACH OTHER. +// +// This file pins the remaining gap: whether that one builder still agrees with +// the DRIVER. `@objectstack/driver-turso` is deliberately not resolvable from +// `@objectstack/runtime` or `@objectstack/service-datasource` (that is what +// "optional" means, and the open-core missing-package pin depends on it staying +// so), while the CLI carries it as a dev dependency for exactly this check. The +// assertions are compile-time — `packages/cli`'s `typecheck` compiles this file +// — because a key the driver added and the builder never read is a defect with +// no runtime symptom at all: the config constructs, the driver connects, and the +// setting is simply absent. +describe('#7314 — the shared libSQL config builder against the real TursoDriverConfig', () => { + /** Compile-time `T must be never`; the alias is only satisfiable when it is. */ + type AssertNever = T; + /** A key the DRIVER accepts that the shared builder does not read. */ + type UnreadByBuilder = Exclude, keyof TursoDriverConfigInput>; + /** + * A key the builder emits that the driver does not declare. `schemaMode` is + * excluded on purpose: ADR-0015 ownership is honoured by the `SqlDriver` base + * `TursoDriver` extends, not declared on `TursoDriverConfig` itself. + */ + type UnknownToDriver = Exclude; + + it('reads every authorable key of the driver config, and emits none it does not know', () => { + // `client` is excluded from the driver side: a pre-built `@libsql/client` + // instance is a host-composition escape hatch, never authorable config. + const unreadByBuilder: AssertNever[] = []; + const unknownToDriver: AssertNever[] = []; + expect([...unreadByBuilder, ...unknownToDriver]).toEqual([]); + }); + + it('produces a config the real driver accepts, carrying every declared key', () => { + const spec = { + name: 'default', + driver: 'turso', + schemaMode: 'external', + config: { + url: 'libsql://my-db.turso.io', + authToken: 'jwt-token', + encryptionKey: 'aes-256-key', + concurrency: 7, + syncUrl: 'libsql://replica.turso.io', + sync: { intervalSeconds: 30, onConnect: false }, + timeout: 9000, + mode: 'replica', + }, + } as const; + const built = buildTursoDriverConfig(spec, resolveTursoUrl(spec)); + const pinned: TursoDriverConfig = built; + + expect(pinned.encryptionKey).toBe('aes-256-key'); + expect(pinned.syncUrl).toBe('libsql://replica.turso.io'); + expect(pinned.sync).toEqual({ intervalSeconds: 30, onConnect: false }); + expect(Object.keys(built).sort()).toEqual([...TURSO_DRIVER_CONFIG_KEYS].sort()); + }); +}); + // #6268 — the loader has ONE owner (`@objectstack/runtime`), and this file's // exports are that owner's declarations rather than hand-aligned copies. // @@ -444,6 +521,15 @@ describe('#6268 — one loader, one class identity across cli and runtime', () = // …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. + // + // #7314 revisited this declaration and KEPT it. It is not a stand-in for a + // class that could not be reached — the real one is imported at the top of + // this file and asserted identical two cases up — it is the NEGATIVE CONTROL + // that gives the positive assertion its meaning: without something that + // matches on message and name and still fails `instanceof`, nothing here + // shows that the passing assertion is testing identity rather than wording. + // Deleting it "in favour of the real import" would have removed the only + // evidence that the pin has teeth. class MissingDriverPackageErrorTwin extends Error { constructor(readonly installCommand: string, message: string) { super(message); @@ -458,6 +544,13 @@ describe('#6268 — one loader, one class identity across cli and runtime', () = expect(twin.name).toBe((err as Error).name); expect(twin.installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND); expect(twin instanceof MissingDriverPackageError).toBe(false); + // …and the twin fails the OPEN-CORE binding too (#7314). The class moved + // down into `@objectstack/service-datasource` so the open-core loader could + // raise it; the seam now has three bindings and one class object, and the + // twin must be outside all of them. + expect(twin instanceof OpenCoreMissingDriverPackageError).toBe(false); + expect(err).toBeInstanceOf(OpenCoreMissingDriverPackageError); + expect(MissingDriverPackageError).toBe(OpenCoreMissingDriverPackageError); }); it('and the reverse: the CLI loader raises an error the runtime binding matches', async () => { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 024dd5521b..82e405d555 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -16,6 +16,12 @@ export type { StandaloneStackConfig, StandaloneStackResult, ResolvedStandaloneDa // `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. +// +// Since #7314 that class — and the package / install-command pair — is DECLARED +// one layer down, in `@objectstack/service-datasource`, so the open-core loader +// can raise it too. This export surface is deliberately unchanged: that is what +// keeps every importer written against `@objectstack/runtime` compiling, and +// against the same class object. export { loadTursoDriverFactory, isTursoDriverId, diff --git a/packages/runtime/src/turso-driver-factory.convergence.test.ts b/packages/runtime/src/turso-driver-factory.convergence.test.ts new file mode 100644 index 0000000000..b71ac70f45 --- /dev/null +++ b/packages/runtime/src/turso-driver-factory.convergence.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7314 — the third libSQL loader, and the two ways it disagreed with this one. +// +// #6268 converged the two HOST-injected loaders (CLI + standalone stack) onto +// `turso-driver-factory.ts`. A third arm it could not reach — +// `createDefaultDatasourceDriverFactory`'s `turso` case in +// `@objectstack/service-datasource` — serves every door that is NOT a host's +// `default` datasource: one created in Setup, one probed by `testConnection`, a +// declared non-default. Two things differed across that seam, and an author +// could see neither: +// +// 1. THE CONFIG SURFACE (the half a user could actually hit). This loader +// built `new TursoDriver({ url, authToken })` — two keys — while the +// open-core arm read nine. `TursoConfigSchema` accepts all nine, so an +// encrypted or embedded-replica datasource silently lost `encryptionKey` / +// `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` / `schemaMode` +// with no diagnostic, and got them back the moment it was renamed away from +// `default`. +// 2. THE ERROR CLASS. `MissingDriverPackageError` was declared in this package, +// which `service-datasource` cannot import (runtime depends on it, not the +// reverse), so that arm raised a plain `Error` — matched by no `instanceof`. +// +// The assertions below are deliberately of two kinds, because the two defects +// hide from different tests: +// +// - the config pin asserts THE CONSTRUCTOR ARGUMENT, not a successful boot. A +// dropped key produces a driver that constructs perfectly and connects to +// the wrong thing; every boot-level assertion stayed green through the years +// this loader read two keys. +// - the identity pin asserts CLASS IDENTITY (`===` / `instanceof`), never +// `name` or message text. Two same-named classes produce byte-identical +// messages — that is exactly what makes the defect invisible. +// +// No test here touches a real libSQL endpoint: the optional package is +// substituted through `importDriverPackage`. + +import { describe, it, expect } from 'vitest'; +import { + buildTursoDriverConfig, + createDefaultDatasourceDriverFactory, + MissingDriverPackageError as OpenCoreMissingDriverPackageError, + TURSO_DRIVER_CONFIG_KEYS, + type DatasourceConnectionSpec, +} from '@objectstack/service-datasource'; +import { loadTursoDriverFactory, MissingDriverPackageError } from './turso-driver-factory.js'; + +/** + * A `default` libSQL datasource declaring EVERY key the config contract accepts + * — the case the narrow read silently degraded. + * + * `schemaMode` rides on the spec rather than in `config`, which is where a + * datasource actually declares it (#4410); the builder reads all three of its + * sources, and a loader that only looked inside `config` would drop it. + */ +const FULL_SPEC: DatasourceConnectionSpec = { + name: 'default', + driver: 'turso', + schemaMode: 'external', + config: { + url: 'libsql://my-db.turso.io', + authToken: 'jwt-token', + encryptionKey: 'aes-256-key', + concurrency: 7, + syncUrl: 'libsql://replica.turso.io', + sync: { intervalSeconds: 30, onConnect: false }, + timeout: 9000, + mode: 'replica', + }, +}; + +/** Substitute the optional package with a ctor that records what it was handed. */ +function capturingDriverPackage() { + const seen: unknown[] = []; + class TursoDriver { + constructor(config: unknown) { + seen.push(config); + } + } + return { seen, importDriverPackage: async () => ({ TursoDriver }) }; +} + +describe('#7314 point 3 — the host loader reads the whole libSQL config, not two keys of it', () => { + it('reaches the driver with every declared key, asserted on the constructor argument', async () => { + const { seen, importDriverPackage } = capturingDriverPackage(); + const factory = await loadTursoDriverFactory({ importDriverPackage }); + factory.create(FULL_SPEC); + + expect(seen).toHaveLength(1); + // Spelled out rather than compared to the builder alone: this is the list an + // author can point at, and it is what the open-core arm has always honoured. + expect(seen[0]).toEqual({ + url: 'libsql://my-db.turso.io', + authToken: 'jwt-token', + encryptionKey: 'aes-256-key', + concurrency: 7, + syncUrl: 'libsql://replica.turso.io', + sync: { intervalSeconds: 30, onConnect: false }, + timeout: 9000, + mode: 'replica', + schemaMode: 'external', + }); + }); + + // The anti-drift half. The pin above would go green again on a SECOND + // hand-written list that happened to agree today — which is precisely how the + // first two lists came to disagree. This one fails unless the loader is + // actually building through the shared derivation. + it('builds through the shared builder, so a new key cannot reach one loader only', async () => { + const { seen, importDriverPackage } = capturingDriverPackage(); + const factory = await loadTursoDriverFactory({ importDriverPackage }); + factory.create(FULL_SPEC); + + expect(seen[0]).toEqual(buildTursoDriverConfig(FULL_SPEC, 'libsql://my-db.turso.io')); + expect(Object.keys(seen[0] as object).sort()).toEqual([...TURSO_DRIVER_CONFIG_KEYS].sort()); + }); + + // Absent ≠ present-and-undefined: `@libsql/client` reads some options by + // presence, and the open-core arm has always spread-omitted rather than + // passing `undefined` through. + it('omits keys the datasource did not declare rather than passing undefined', async () => { + const { seen, importDriverPackage } = capturingDriverPackage(); + const factory = await loadTursoDriverFactory({ importDriverPackage }); + factory.create({ name: 'default', driver: 'turso', config: { url: 'file:./data/objectstack.db' } }); + + expect(seen[0]).toEqual({ url: 'file:./data/objectstack.db' }); + expect(Object.keys(seen[0] as object)).toEqual(['url']); + }); + + // Shared url resolution, which this loader did not have: it tested + // `typeof url === 'string'` without trimming, so a whitespace-only url reached + // `@libsql/client` instead of the named refusal the open-core arm gives. + it('refuses a whitespace-only url by name instead of handing it to the driver', async () => { + const { seen, importDriverPackage } = capturingDriverPackage(); + const factory = await loadTursoDriverFactory({ + importDriverPackage, + missingUrlError: (message) => new Error(message), + }); + + expect(() => factory.create({ name: 'default', driver: 'turso', config: { url: ' ' } })) + .toThrow(/needs a libSQL url/); + expect(seen).toHaveLength(0); + }); +}); + +describe('#7314 point 2 — one MissingDriverPackageError class across the seam', () => { + // The declaration moved DOWN (runtime depends on service-datasource, so this + // is the only legal direction) and is re-exported from its old home. Asserted + // by object identity: a second same-named class would satisfy every other + // assertion in this file. + it('the runtime export IS the service-datasource class object', () => { + expect(MissingDriverPackageError).toBe(OpenCoreMissingDriverPackageError); + }); + + it('the host loader raises an error the open-core binding matches', async () => { + const err = await loadTursoDriverFactory({ + importDriverPackage: async () => { throw new Error("Cannot find package '@objectstack/driver-turso'"); }, + }).then(() => null, (e: unknown) => e); + + expect(err).toBeInstanceOf(OpenCoreMissingDriverPackageError); + expect((err as InstanceType).driverType).toBe('turso'); + }); + + // THE pin, and the direction that was impossible before this change: the + // open-core arm's failure now satisfies the predicate `serve.ts` runs + // (`e instanceof MissingDriverPackageError`, against the runtime binding). + // Until #7314 that arm raised a plain `Error` and this assertion could not + // have been written. + it('the open-core arm raises an error the runtime binding matches', async () => { + // `@objectstack/driver-turso` is deliberately not a dependency of + // `@objectstack/service-datasource` — that is what "optional" means — so its + // missing-package arm is reachable here without a stub. + let err: unknown = null; + try { + await createDefaultDatasourceDriverFactory() + .create({ name: 'warehouse', driver: 'turso', config: { url: 'libsql://my-db.turso.io' } }); + } catch (e) { + err = e; + } + + if (err === null) { + throw new Error( + '@objectstack/driver-turso resolved from @objectstack/service-datasource, so this case no ' + + 'longer exercises the missing-package arm. If the package was made a dependency, this ' + + 'assertion is the notice that the pin needs a stubbed import instead.', + ); + } + expect(err).toBeInstanceOf(MissingDriverPackageError); + expect((err as InstanceType).installCommand) + .toBe('npm install @objectstack/driver-turso'); + }); +}); diff --git a/packages/runtime/src/turso-driver-factory.ts b/packages/runtime/src/turso-driver-factory.ts index e27ab84232..814820cca7 100644 --- a/packages/runtime/src/turso-driver-factory.ts +++ b/packages/runtime/src/turso-driver-factory.ts @@ -61,20 +61,68 @@ * {@link MissingDriverPackageError} identity across both packages, which is what * keeps `serve.ts`'s `e instanceof MissingDriverPackageError` fatal branch * matching an error this module raised. + * + * ## #7314 — and the THIRD loader, one layer down + * + * #6268 converged the two HOST-injected loaders. It could not reach the + * open-core one: `createDefaultDatasourceDriverFactory`'s `turso` arm in + * `@objectstack/service-datasource`, which serves every door that is not a + * host's `default` — a datasource created in Setup, `testConnection`, a declared + * non-default datasource. Two things disagreed across that seam, and both were + * invisible to the author: + * + * - **The config surface.** This module read `url` and `authToken`; the + * open-core arm read nine keys. So the SAME declared libSQL config was + * honoured or silently dropped depending on whether the datasource happened + * to be named `default`. + * - **The error class.** {@link MissingDriverPackageError} was declared here, + * in a package `service-datasource` cannot import (the dependency runs the + * other way), so that arm raised a plain `Error` — unmatched by any + * `instanceof`, and unpinnable except by message text. + * + * Both are fixed by moving the shared declaration DOWN into the package both + * sides can reach, and consuming it here: `buildTursoDriverConfig` / + * `resolveTursoUrl` for the config, the error class for the identity. Neither + * is re-listed or re-declared in this file — a corrected copy would only have + * agreed until the next key. */ import type { DatasourceConnectionSpec, DatasourceDriverHandle, IDatasourceDriverFactory, + TursoDriverConfigInput, +} from '@objectstack/service-datasource'; +import { + buildTursoDriverConfig, + resolveTursoUrl, + MissingDriverPackageError, + TURSO_DRIVER_PACKAGE, + TURSO_DRIVER_INSTALL_COMMAND, } 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'; - -/** The exact command an operator runs to install the optional libSQL driver. */ -export const TURSO_DRIVER_INSTALL_COMMAND = `npm install ${TURSO_DRIVER_PACKAGE}`; +/** + * The optional package that provides the libSQL/Turso driver, the exact command + * an operator runs to install it, and the typed failure raised when it is + * absent. + * + * All three are RE-EXPORTED rather than declared here since #7314. This module + * carried its own equal copies of the first two and the sole declaration of the + * third; the open-core loader in `@objectstack/service-datasource` could reach + * none of them, because `runtime` depends on that package and not the reverse. + * Hand-aligned copies across a dependency edge is exactly the shape that let the + * two loaders drift, so the declarations moved DOWN to the package both sides + * can import and this module consumes them. + * + * They stay exported from here because the export surface is the contract: + * `@objectstack/cli`'s `storage-driver.ts` re-exports them from this module and + * `serve.ts` decides boot fatality with + * `e instanceof MissingDriverPackageError`. A move that dropped the old export + * would be an API break performed for a refactor's convenience — and, for the + * error class, would break the identity `serve.ts` depends on. + */ +export { TURSO_DRIVER_PACKAGE, TURSO_DRIVER_INSTALL_COMMAND, MissingDriverPackageError }; /** * True for the driver ids {@link loadTursoDriverFactory}'s factory builds. @@ -91,38 +139,9 @@ export function isTursoDriverId(driverId: string): boolean { return resolveDatabaseDriverId(driverId) === 'turso'; } -/** - * Thrown by {@link loadTursoDriverFactory} when the OPTIONAL driver package the - * selection needs is not installed, or resolves to something that is not the - * driver (a shadowing stub, a truncated install, a major that renamed the - * export). - * - * 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; - 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; - } -} +// `MissingDriverPackageError` was declared here until #7314 and is now +// re-exported from `@objectstack/service-datasource` at the top of this file — +// see that export's comment for why it moved and why it still ships from here. export interface LoadTursoDriverFactoryOptions { /** @@ -210,8 +229,11 @@ export async function loadTursoDriverFactory( } const record = (mod ?? {}) as { TursoDriver?: unknown; default?: { TursoDriver?: unknown } }; + // The ctor's parameter is the SHARED config type, not a local list of the two + // keys this loader used to read (#7314) — so widening the read cannot be + // undone by a narrow signature quietly rejecting the rest. const TursoDriverCtor = (record.TursoDriver ?? record.default?.TursoDriver) as - | (new (config: { url: string; authToken?: string }) => object) + | (new (config: TursoDriverConfigInput) => object) | undefined; if (typeof TursoDriverCtor !== 'function') { // Resolvable but not the module we expect (a shadowing stub, a truncated @@ -232,8 +254,7 @@ export async function loadTursoDriverFactory( 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 : ''; + const url = resolveTursoUrl(spec); if (!url) { // Defensive: both hosts resolve a URL before they select this kind (the // CLI refuses a URL-less `turso` selection in `resolveStorageDefinition`, @@ -245,12 +266,16 @@ export async function loadTursoDriverFactory( + '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 { + // Every key the spec declares, read ONCE for both loaders (#7314). This + // arm used to build `{ url, authToken }` from a list of its own, so a + // `default` libSQL datasource silently lost `encryptionKey` / `syncUrl` / + // `sync` / `concurrency` / `timeout` / `mode` / `schemaMode` — all of them + // accepted by `TursoConfigSchema`, all of them honoured on the open-core + // path, and the only difference being that this datasource happened to be + // named `default`. Corrected by DERIVATION rather than by a second, longer + // hand-written list: the first two lists agreed on the day they were + // written too. + const driver = new TursoDriverCtor(buildTursoDriverConfig(spec, url)) as { connect?: () => Promise; disconnect?: () => Promise; checkHealth?: () => Promise; diff --git a/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts index 52ea88e3ac..38c151aece 100644 --- a/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts +++ b/packages/services/service-datasource/src/__tests__/default-datasource-driver-factory.test.ts @@ -22,6 +22,7 @@ import { MONGODB_DRIVER_PACKAGE, } from '../default-datasource-driver-factory.js'; import { isUnbuiltWorkspaceFailure } from '../connect-failure-remedy.js'; +import { MissingDriverPackageError } from '../missing-driver-package-error.js'; const factory = () => createDefaultDatasourceDriverFactory({ dev: false }); @@ -459,6 +460,17 @@ describe('createDefaultDatasourceDriverFactory — the missing libSQL package is expect((raised as Error).message).toContain(TURSO_DRIVER_INSTALL_COMMAND); expect((raised as Error).message).toContain(TURSO_DRIVER_PACKAGE); expect((raised as Error).message).toContain("datasource 'warehouse'"); + + // …and it is the TYPED failure since #7314's second half, not a plain + // `Error` that happens to carry the same words. The class moved DOWN into + // this package precisely so this arm could raise it: `serve.ts` decides boot + // fatality with `e instanceof MissingDriverPackageError`, a predicate no + // amount of correct wording can satisfy. The install command rides as DATA + // here, so a host can render the remedy without parsing the sentence. + expect(raised).toBeInstanceOf(MissingDriverPackageError); + expect((raised as MissingDriverPackageError).driverType).toBe('turso'); + expect((raised as MissingDriverPackageError).packageName).toBe(TURSO_DRIVER_PACKAGE); + expect((raised as MissingDriverPackageError).installCommand).toBe(TURSO_DRIVER_INSTALL_COMMAND); }); }); diff --git a/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts new file mode 100644 index 0000000000..cdf44ab549 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/turso-driver-config.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #7314 — the single read of a libSQL datasource's config. +// +// Two loaders build the libSQL driver and which one runs is decided by whether +// the datasource happens to be a host's `default`: `@objectstack/runtime`'s +// `loadTursoDriverFactory` for that one, this package's open-core arm for every +// other door. Each used to hand-list the keys it read, and the lists had drifted +// — nine here, `url` and `authToken` there — so the same declared config was +// honoured or silently dropped depending on the datasource's NAME. +// +// These cases pin the builder both loaders now share. The cross-loader identity +// of the result is pinned from the runtime side +// (`turso-driver-factory.convergence.test.ts`), and the correspondence with the +// driver's own `TursoDriverConfig` from `packages/cli` — the only one of the +// three packages that carries `@objectstack/driver-turso`. + +import { describe, it, expect } from 'vitest'; +import { + buildTursoDriverConfig, + resolveTursoUrl, + TURSO_DRIVER_CONFIG_KEYS, +} from '../turso-driver-config.js'; +import { resolveDatasourceSchemaMode } from '../datasource-schema-mode.js'; +import type { DatasourceConnectionSpec } from '../contracts/index.js'; + +describe('buildTursoDriverConfig (#7314)', () => { + it('carries every declared key onto the driver config', () => { + const spec: DatasourceConnectionSpec = { + name: 'warehouse', + driver: 'turso', + schemaMode: 'validate-only', + config: { + url: 'libsql://my-db.turso.io', + authToken: 'jwt-token', + encryptionKey: 'aes-256-key', + concurrency: 7, + syncUrl: 'libsql://replica.turso.io', + sync: { intervalSeconds: 30, onConnect: false }, + timeout: 9000, + mode: 'replica', + }, + }; + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))).toEqual({ + url: 'libsql://my-db.turso.io', + authToken: 'jwt-token', + encryptionKey: 'aes-256-key', + concurrency: 7, + syncUrl: 'libsql://replica.turso.io', + sync: { intervalSeconds: 30, onConnect: false }, + timeout: 9000, + mode: 'replica', + schemaMode: 'validate-only', + }); + }); + + // The key list is DERIVED from the reader table rather than written a second + // time, which is what makes "a new key is read by both loaders" structural: a + // key added to `TursoDriverConfigInput` without a reader does not compile. + it('exposes exactly the keys it can emit', () => { + const spec: DatasourceConnectionSpec = { + driver: 'turso', + schemaMode: 'managed', + config: { + url: 'libsql://x', + authToken: 'a', + encryptionKey: 'k', + concurrency: 1, + syncUrl: 'libsql://y', + sync: {}, + timeout: 1, + mode: 'remote', + }, + }; + expect(Object.keys(buildTursoDriverConfig(spec, resolveTursoUrl(spec))).sort()) + .toEqual([...TURSO_DRIVER_CONFIG_KEYS].sort()); + }); + + it('omits undeclared keys rather than emitting undefined', () => { + const spec: DatasourceConnectionSpec = { driver: 'turso', config: { url: 'file:./a.db' } }; + const config = buildTursoDriverConfig(spec, resolveTursoUrl(spec)); + expect(config).toEqual({ url: 'file:./a.db' }); + expect(Object.keys(config)).toEqual(['url']); + }); + + // Empty strings are unset, not credentials of length zero — the open-core + // arm's own type-tests, carried over unchanged. The number keys deliberately + // have no truthiness check: `concurrency: 0` / `timeout: 0` are values the + // driver reads. + it('treats empty string credentials as unset and keeps zero-valued numbers', () => { + const spec: DatasourceConnectionSpec = { + driver: 'turso', + config: { url: 'libsql://x', authToken: '', encryptionKey: '', syncUrl: '', concurrency: 0, timeout: 0 }, + }; + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec))) + .toEqual({ url: 'libsql://x', concurrency: 0, timeout: 0 }); + }); + + // ADR-0015 ownership reaches the driver from the datasource's OWN key, not + // only from inside `config` — the #4410 fix. A loader reading `config` alone + // constructs an `external` database as `managed`, with DDL ungated. + it('reads schemaMode from the spec, the external block and config, in that order', () => { + const base = { driver: 'turso', config: { url: 'libsql://x' } } as const; + expect(resolveDatasourceSchemaMode({ ...base, schemaMode: 'external' })).toBe('external'); + expect(resolveDatasourceSchemaMode({ ...base, external: { schemaMode: 'validate-only' } })) + .toBe('validate-only'); + expect(resolveDatasourceSchemaMode({ driver: 'turso', config: { url: 'libsql://x', schemaMode: 'external' } })) + .toBe('external'); + expect(resolveDatasourceSchemaMode(base)).toBeUndefined(); + + const spec: DatasourceConnectionSpec = { ...base, schemaMode: 'external' }; + expect(buildTursoDriverConfig(spec, resolveTursoUrl(spec)).schemaMode).toBe('external'); + }); +}); + +describe('resolveTursoUrl (#7314)', () => { + it('trims, and reports a whitespace-only or absent url as none', () => { + expect(resolveTursoUrl({ driver: 'turso', config: { url: ' libsql://x ' } })).toBe('libsql://x'); + expect(resolveTursoUrl({ driver: 'turso', config: { url: ' ' } })).toBe(''); + expect(resolveTursoUrl({ driver: 'turso', config: {} })).toBe(''); + expect(resolveTursoUrl({ driver: 'turso', config: { url: 42 } as Record })).toBe(''); + }); +}); diff --git a/packages/services/service-datasource/src/datasource-schema-mode.ts b/packages/services/service-datasource/src/datasource-schema-mode.ts new file mode 100644 index 0000000000..94cf743f06 --- /dev/null +++ b/packages/services/service-datasource/src/datasource-schema-mode.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { DatasourceConnectionSpec } from './contracts/index.js'; + +/** + * Resolve ADR-0015's schema-ownership mode for a connection spec — whether + * ObjectStack owns this schema or is a guest in a database it must never run + * DDL against. + * + * Extracted to its own declaration in #7314 because it now has TWO callers: + * every arm of {@link createDefaultDatasourceDriverFactory}, and the shared + * libSQL config builder ({@link buildTursoDriverConfig}) that + * `@objectstack/runtime`'s host loader also calls. Left inline it would have + * been hand-copied into the second one — and a hand-copied read of a config key + * is the defect #7314 exists to close, not a shape to repeat. + * + * The order is load-bearing and dates from #4410. `spec.schemaMode` — the + * datasource's OWN declared key — is first; before #4410 only the two fallbacks + * existed and neither could ever hold it (`external` is the federation-settings + * block and has no such key; nothing wrote the `config` copy), so a datasource + * declaring `schemaMode: 'external'` reached the driver as `undefined` and a + * database ObjectStack is a guest in was constructed as `managed`, with DDL + * ungated at the driver. + * + * Returns the raw string rather than the narrowed union: the two fallbacks come + * from untyped bags, and narrowing here would mean silently dropping a stored + * value the driver would otherwise refuse loudly. + */ +export function resolveDatasourceSchemaMode(spec: DatasourceConnectionSpec): string | undefined { + return spec.schemaMode + ?? (spec.external as { schemaMode?: string } | undefined)?.schemaMode + ?? ((spec.config as Record | undefined)?.schemaMode as string | undefined); +} diff --git a/packages/services/service-datasource/src/default-datasource-driver-factory.ts b/packages/services/service-datasource/src/default-datasource-driver-factory.ts index a4451e6e7f..21f5fdb7e9 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -53,6 +53,9 @@ import type { DatasourceDriverHandle, } from './contracts/index.js'; import { assertDatasourcePoolSupported } from './datasource-pool-support.js'; +import { resolveDatasourceSchemaMode } from './datasource-schema-mode.js'; +import { buildTursoDriverConfig, resolveTursoUrl } from './turso-driver-config.js'; +import { MissingDriverPackageError } from './missing-driver-package-error.js'; import type { SqliteAbsentFileMode } from '@objectstack/driver-sql'; /** @@ -78,12 +81,10 @@ function resolveKind(driverId: string): ResolvedKind | undefined { * * Declared as constants rather than left inline so the pin test asserts the * COMMAND rather than a sentence shape, and so a host that wants to render the - * remedy itself has one place to read it from. `@objectstack/runtime` currently - * declares its own equal pair (`TURSO_DRIVER_PACKAGE` / - * `TURSO_DRIVER_INSTALL_COMMAND` in `turso-driver-factory.ts`); converging the - * two onto these is a runtime-lane change — runtime already depends on this - * package, so that import direction is the legal one, while the reverse is not - * (#7314). + * remedy itself has one place to read it from. `@objectstack/runtime`'s host + * loader (`turso-driver-factory.ts`) declared its own equal pair until #7314 and + * now re-exports these — runtime depends on this package, so that import + * direction is the legal one, while the reverse is not. */ export const TURSO_DRIVER_PACKAGE = '@objectstack/driver-turso'; @@ -604,16 +605,12 @@ export function createDefaultDatasourceDriverFactory( // through, so it is where "declared = honoured" is actually guaranteed. assertDatasourcePoolSupported({ driver: spec.driver, pool: spec.pool, name: spec.name }); - // ADR-0015's ownership mode. `spec.schemaMode` — the datasource's own - // declared key — is FIRST since #4410; before that the first two arms - // were all there was, and neither could ever hold it: `external` is the - // federation-settings block (no `schemaMode` key), and nothing wrote the - // `config` copy. So `schemaMode: 'external'` on a datasource reached the - // driver as `undefined` and a database ObjectStack is a guest in was - // treated as managed — DDL ungated at the driver. - const schemaMode = spec.schemaMode - ?? (spec.external as { schemaMode?: string } | undefined)?.schemaMode - ?? ((spec.config as Record | undefined)?.schemaMode as string | undefined); + // ADR-0015's ownership mode. The three-source fallback moved to + // `resolveDatasourceSchemaMode` in #7314 — unchanged in behaviour, but it + // now has a second caller (the shared libSQL config builder, which + // `@objectstack/runtime`'s host loader also uses) and an inline read would + // have been hand-copied into it. + const schemaMode = resolveDatasourceSchemaMode(spec); // Host-composition passthroughs (#3826): the CLI's declared `default` // definition carries the dev loosen-only self-heal (#2186) and the wasm // persistence mode in `config`. Connection builders ignore both keys, so @@ -750,12 +747,22 @@ export function createDefaultDatasourceDriverFactory( // // The missing-package message states the install command, the // consequence and the refusal — the same quality of answer the host - // loader has given since #5602, which this arm did not (#7314). The - // typed `MissingDriverPackageError` the host raises is deliberately NOT - // mirrored here: that class lives in `@objectstack/runtime`, which - // DEPENDS on this package, so importing it would invert the dependency, - // and declaring a second same-named class is precisely the identity - // hazard #6268 closed (`serve.ts` decides fatality with `instanceof`). + // loader has given since #5602, which this arm did not (#7314). + // + // It is now also the SAME CLASS. `MissingDriverPackageError` used to be + // declared in `@objectstack/runtime`, which DEPENDS on this package, so + // this arm could neither import it (dependency inversion) nor declare + // its own (the identity hazard #6268 closed — `serve.ts` decides boot + // fatality with `instanceof`). #7314 moved the one class DOWN to + // `missing-driver-package-error.ts` here; runtime re-exports it from its + // old home, so both loaders now raise an error that satisfies the same + // `instanceof` no matter which door the request came through. + // + // The CONFIG READ is single-sourced too, and that was the half a user + // could hit: this arm read nine keys while the host loader read `url` + // and `authToken`, so an encrypted or embedded-replica `default` + // silently lost `encryptionKey` / `syncUrl` / `sync` / `concurrency` / + // `timeout` / `mode`. Both now call `buildTursoDriverConfig`. // // `spec.pool` is not read here and never was: `TursoDriverConfig` has // no `min` / `max` — a `file:` url runs the local better-sqlite3 engine @@ -771,9 +778,14 @@ export function createDefaultDatasourceDriverFactory( try { ({ TursoDriver } = await import('@objectstack/driver-turso' as any)); } catch (err: any) { - throw new Error(missingTursoDriverMessage({ datasource: spec.name, cause: err })); + throw new MissingDriverPackageError({ + driverType: 'turso', + packageName: TURSO_DRIVER_PACKAGE, + installCommand: TURSO_DRIVER_INSTALL_COMMAND, + message: missingTursoDriverMessage({ datasource: spec.name, cause: err }), + }); } - const url = typeof cfg.url === 'string' ? cfg.url.trim() : ''; + const url = resolveTursoUrl(spec); if (!url) { // `TursoConfigSchema.url` is required, so the authoring and wizard // gates already refuse this. A stored row written before #6345 had no @@ -784,19 +796,10 @@ export function createDefaultDatasourceDriverFactory( + 'config (e.g. libsql://my-db.turso.io or file:./data/objectstack.db).', ); } - const driver = new TursoDriver({ - url, - ...(typeof cfg.authToken === 'string' && cfg.authToken ? { authToken: cfg.authToken } : {}), - ...(typeof cfg.encryptionKey === 'string' && cfg.encryptionKey - ? { encryptionKey: cfg.encryptionKey } - : {}), - ...(typeof cfg.concurrency === 'number' ? { concurrency: cfg.concurrency } : {}), - ...(typeof cfg.syncUrl === 'string' && cfg.syncUrl ? { syncUrl: cfg.syncUrl } : {}), - ...(cfg.sync && typeof cfg.sync === 'object' ? { sync: cfg.sync } : {}), - ...(typeof cfg.timeout === 'number' ? { timeout: cfg.timeout } : {}), - ...(typeof cfg.mode === 'string' ? { mode: cfg.mode } : {}), - ...(schemaMode ? { schemaMode } : {}), - }); + // `schemaMode` is read by the builder (from all three of its sources), + // not taken from the local `schemaMode` above — one read, so the host + // loader cannot be handed a narrower one. + const driver = new TursoDriver(buildTursoDriverConfig(spec, url)); return toHandle(driver, () => sqlServerVersion(driver, 'sqlite')); } diff --git a/packages/services/service-datasource/src/index.ts b/packages/services/service-datasource/src/index.ts index 030948fee2..19addfaa32 100644 --- a/packages/services/service-datasource/src/index.ts +++ b/packages/services/service-datasource/src/index.ts @@ -100,14 +100,35 @@ export { createDefaultDatasourceDriverFactory } from './default-datasource-drive // The OPTIONAL libSQL/Turso package and its install command, plus the // missing-package message this factory raises (#7314) — exported so the answer // to "how do I install it" has one declaration a host can read rather than a -// sentence to re-type. `@objectstack/runtime`'s host loader keeps its own equal -// pair today; it depends on this package, so converging onto these is a legal -// import direction whenever that lane takes it up. +// sentence to re-type. `@objectstack/runtime`'s host loader consumes these +// (it depends on this package, which is the legal import direction) rather than +// keeping its own equal pair. export { TURSO_DRIVER_PACKAGE, TURSO_DRIVER_INSTALL_COMMAND, missingTursoDriverMessage, } from './default-datasource-driver-factory.js'; +// The typed "that OPTIONAL package is not installed" failure (#7314). Declared +// HERE, in the lowest package that raises it, so the host loaders in +// `@objectstack/runtime` / `@objectstack/cli` and the open-core arm in this +// package throw ONE class object — which is what keeps `serve.ts`'s +// `e instanceof MissingDriverPackageError` fatal branch matching. `runtime` +// re-exports it from its old home, so importers written before the move keep +// compiling against the same class. +export { MissingDriverPackageError } from './missing-driver-package-error.js'; +// The single read of a spec's libSQL config (#7314) — the host loader and the +// open-core arm both build the driver from THIS, instead of each hand-listing +// the keys it happens to know about. Exported so the runtime lane can call it +// and so a pin can assert the surface without re-typing the list. +export { + buildTursoDriverConfig, + resolveTursoUrl, + TURSO_DRIVER_CONFIG_KEYS, +} from './turso-driver-config.js'; +export type { TursoDriverConfigInput } from './turso-driver-config.js'; +// ADR-0015 schema-ownership resolution, shared by every driver arm and by the +// libSQL config builder above (#7314). +export { resolveDatasourceSchemaMode } from './datasource-schema-mode.js'; // The other two OPTIONAL driver packages this factory can be asked for, and the // messages it raises when they are absent (#7385) — same seam as the libSQL pair // above, because the three arms answer one class of problem and had answered it diff --git a/packages/services/service-datasource/src/missing-driver-package-error.ts b/packages/services/service-datasource/src/missing-driver-package-error.ts new file mode 100644 index 0000000000..54d69705c1 --- /dev/null +++ b/packages/services/service-datasource/src/missing-driver-package-error.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The typed "an OPTIONAL driver package is not installed" failure — declared + * here, in the LOWEST package that raises it, so there is exactly one class + * object across every loader (#7314). + * + * ## Why it lives here rather than where it was written + * + * It was declared in `@objectstack/runtime` + * (`turso-driver-factory.ts`) under #6268, which converged the two HOST-injected + * libSQL loaders (CLI + standalone stack) onto one owner. That convergence was + * complete for the hosts and could not reach the third loader: the open-core + * `createDefaultDatasourceDriverFactory` in THIS package, which serves every + * door that is not a host's `default` datasource — a datasource created in + * Setup, `testConnection`, a declared non-default datasource. + * + * `@objectstack/runtime` depends on `@objectstack/service-datasource`, never the + * reverse, so the open-core arm could not import the class and raised a plain + * `Error` instead. The two legal ways out were "declare a second same-named + * class here" — precisely the identity hazard #6268 closed — or move the one + * class DOWN to where both sides can reach it. This is the move. `runtime` + * RE-EXPORTS it from its old home, so every existing importer + * (`@objectstack/runtime`, `@objectstack/cli`'s `storage-driver.ts`, and + * `serve.ts` through it) keeps compiling and keeps testing the same class. + * + * ## What depends on there being ONE class + * + * `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 and degrade a + * fatal branch to a non-fatal one with no diagnostic anywhere: nothing in the + * message would change, so no message assertion could see it. That is why the + * pins on this class assert IDENTITY (`===`, `instanceof`) rather than `name` + * or message text. + */ + +/** + * Thrown when the OPTIONAL driver package a datasource selection needs is not + * installed, or resolves to something that is not the driver (a shadowing stub, + * a truncated install, a major that renamed the export). + * + * The install command rides as a FIELD as well as inside the message, so a + * caller can render the remedy however it likes and a pin test can assert the + * command rather than a sentence shape. + * + * `driverType` is the engine that was asked for, not a libSQL-only field: the + * `mongodb` and `sqlite-wasm` arms of the same factory answer the same class of + * problem with an untyped `Error` today (#7385), and adopting this class is a + * constructor call away when that lane takes it up. + */ +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; + } +} diff --git a/packages/services/service-datasource/src/turso-driver-config.ts b/packages/services/service-datasource/src/turso-driver-config.ts new file mode 100644 index 0000000000..aff4295f9a --- /dev/null +++ b/packages/services/service-datasource/src/turso-driver-config.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ONE place a `DatasourceConnectionSpec` is turned into a libSQL/Turso + * driver config (#7314). + * + * ## The defect this replaces + * + * Two loaders build the libSQL driver, and which one runs is decided by nothing + * the author can see — whether the datasource happens to be the host's + * `default`: + * + * - `@objectstack/runtime`'s `loadTursoDriverFactory` (the HOST-injected + * loader, single owner for the CLI and the standalone stack since #6268) + * serves the `default` datasource; + * - the open-core `createDefaultDatasourceDriverFactory` in this package serves + * every other door — a datasource created in Setup, `testConnection`, a + * declared non-default datasource. + * + * Each read the spec's `config` with its own hand-written list, and the lists + * had drifted: the open-core arm read nine keys, the host loader read `url` and + * `authToken`. So an encrypted or embedded-replica `default` silently lost + * `encryptionKey` / `syncUrl` / `sync` / `concurrency` / `timeout` / `mode` — + * `TursoConfigSchema` accepts all of them, so this was declared-but-not-enforced + * in one of the two positions, with no diagnostic anywhere. The same + * datasource, renamed away from `default`, was honoured in full. + * + * Both loaders now call {@link buildTursoDriverConfig}. The fix is DERIVATION, + * not a corrected copy: a second hand-transcribed list is exactly how the first + * one came to disagree, and would have started drifting again at the next key. + * + * ## How "every key" is kept true + * + * {@link TURSO_CONFIG_READERS} is a mapped type over + * `Required`, so TypeScript REFUSES to compile a config + * key that has no reader — the key list ({@link TURSO_DRIVER_CONFIG_KEYS}) is + * then derived from that table at runtime rather than written a third time. + * That closes drift between the interface and the builder. Drift between this + * interface and the DRIVER's own `TursoDriverConfig` is pinned separately, in + * `packages/cli/src/utils/storage-driver.test.ts`, because + * `@objectstack/driver-turso` is deliberately not resolvable from this package + * (that is what "optional" means here, and the missing-package arm's own pin + * depends on it staying that way) while the CLI already carries it as a dev + * dependency for exactly this kind of compile-time check. + */ + +import type { DatasourceConnectionSpec } from './contracts/index.js'; +import { resolveDatasourceSchemaMode } from './datasource-schema-mode.js'; + +/** + * The libSQL driver config both loaders build. + * + * Structural rather than imported from `@objectstack/driver-turso`: that package + * is an OPTIONAL install and is not a dependency of this package. Keys and value + * types mirror `TursoDriverConfig`, minus `client` (a pre-built `@libsql/client` + * instance — a host-composition escape hatch, never authorable config) and plus + * `schemaMode` (ADR-0015 ownership, honoured by the `SqlDriver` base + * `TursoDriver` extends rather than declared on `TursoDriverConfig` itself). + * The `packages/cli` pin fails to compile if that relationship stops holding. + */ +export interface TursoDriverConfigInput { + /** libSQL url — `libsql://`, `https://`, `file:` or `:memory:`. */ + url: string; + /** JWT auth token for a remote Turso database. */ + authToken?: string; + /** AES-256 key for the local database file (local/replica modes). */ + encryptionKey?: string; + /** Max concurrent requests to the remote database (replica/remote modes). */ + concurrency?: number; + /** Remote sync url for embedded-replica mode. */ + syncUrl?: string; + /** Embedded-replica sync settings (requires `syncUrl`). */ + sync?: { intervalSeconds?: number; onConnect?: boolean }; + /** Operation timeout in ms for remote operations. */ + timeout?: number; + /** Force a transport mode instead of detecting it from the url. */ + mode?: 'local' | 'replica' | 'remote'; + /** + * ADR-0015 schema ownership. Typed as the raw string on purpose — see + * {@link resolveDatasourceSchemaMode}. + */ + schemaMode?: string; +} + +/** What every reader in {@link TURSO_CONFIG_READERS} is handed. */ +interface TursoConfigSource { + /** The whole spec — `schemaMode` is resolved from three places, not just `config`. */ + spec: DatasourceConnectionSpec; + /** `spec.config`, narrowed to a bag so each reader can type-test its own key. */ + config: Record; + /** The already-resolved, already-refused-if-empty url (see {@link resolveTursoUrl}). */ + url: string; +} + +/** + * One reader per config key — the table that makes "read every key" a property + * TypeScript enforces rather than a list someone remembers to extend. + * + * A reader returns `undefined` for "not declared", and the builder omits the key + * entirely rather than passing `undefined` through: `@libsql/client` distinguishes + * an absent option from an explicit `undefined` in places, and the open-core arm + * this was lifted from had always spread-omitted. + * + * The type-tests are the open-core arm's, unchanged — including the truthiness + * check on the string keys (an empty `authToken` / `syncUrl` / `encryptionKey` is + * an unset one, never a credential of length zero) and its absence on the number + * keys (`concurrency: 0` and `timeout: 0` are meaningful values the driver reads). + */ +const TURSO_CONFIG_READERS: { + readonly [K in keyof Required]: ( + src: TursoConfigSource, + ) => TursoDriverConfigInput[K] | undefined; +} = { + url: ({ url }) => url, + authToken: ({ config }) => + typeof config.authToken === 'string' && config.authToken ? config.authToken : undefined, + encryptionKey: ({ config }) => + typeof config.encryptionKey === 'string' && config.encryptionKey ? config.encryptionKey : undefined, + concurrency: ({ config }) => (typeof config.concurrency === 'number' ? config.concurrency : undefined), + syncUrl: ({ config }) => + typeof config.syncUrl === 'string' && config.syncUrl ? config.syncUrl : undefined, + sync: ({ config }) => + config.sync && typeof config.sync === 'object' + ? (config.sync as TursoDriverConfigInput['sync']) + : undefined, + timeout: ({ config }) => (typeof config.timeout === 'number' ? config.timeout : undefined), + mode: ({ config }) => + typeof config.mode === 'string' ? (config.mode as TursoDriverConfigInput['mode']) : undefined, + schemaMode: ({ spec }) => resolveDatasourceSchemaMode(spec), +}; + +/** + * Every key {@link buildTursoDriverConfig} can put on a libSQL driver config, + * DERIVED from the reader table rather than declared again. + * + * Exported so a test can assert the surface the two loaders share without + * re-typing it — a third copy of the list would be the same defect one layer + * up. + */ +export const TURSO_DRIVER_CONFIG_KEYS = Object.keys(TURSO_CONFIG_READERS) as ReadonlyArray< + keyof TursoDriverConfigInput +>; + +/** + * The libSQL url a spec declares, trimmed — `''` when it declares none. + * + * Shared so both loaders agree on what "no url" means. They deliberately do NOT + * share the REFUSAL: the CLI raises its own `UnsupportedDriverError` (which + * `serve.ts` re-throws as a fatal boot error), the standalone stack a prefixed + * `Error`, and the open-core arm a plain one naming the datasource. Only the + * predicate is single-sourced. + * + * The trim came from the open-core arm; the host loader did not have it, so a + * whitespace-only url reached `@libsql/client` there instead of being refused + * by name. + */ +export function resolveTursoUrl(spec: DatasourceConnectionSpec): string { + const config = (spec.config ?? {}) as Record; + return typeof config.url === 'string' ? config.url.trim() : ''; +} + +/** + * Build the libSQL driver config for a connection spec — the single read of the + * spec both the host loader and the open-core arm perform. + * + * `url` is passed in rather than re-read so each caller keeps its own refusal + * for a spec that declares none (see {@link resolveTursoUrl}); by the time this + * runs, the url has already been resolved and accepted. + */ +export function buildTursoDriverConfig( + spec: DatasourceConnectionSpec, + url: string, +): TursoDriverConfigInput { + const src: TursoConfigSource = { + spec, + config: (spec.config ?? {}) as Record, + url, + }; + const out: Record = {}; + for (const key of TURSO_DRIVER_CONFIG_KEYS) { + const value = TURSO_CONFIG_READERS[key](src); + if (value !== undefined) out[key] = value; + } + return out as unknown as TursoDriverConfigInput; +}