From 3a3ce0995aadff6dfb5227690d969a15dcadc3f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 08:27:07 +0000 Subject: [PATCH] fix(service-datasource): the open-core libSQL arm tells you how to install the driver it is missing (#7314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/driver-turso` is an OPTIONAL install, so both loaders that can build a libSQL datasource have to answer "the package is not here". They answered it very differently. The HOST loader (`@objectstack/runtime`'s `loadTursoDriverFactory`, single owner since #6268) raises `MissingDriverPackageError` carrying the install command as data, plus a message naming the command, the consequence, and why the boot refuses instead of quietly opening a SQLite file. The shared open-core factory's `turso` arm — the one serving every OTHER door: a datasource added in Setup, `testConnection`, a declared non-default datasource — said only turso driver requested but @objectstack/driver-turso is not installed (…). the fault and nothing else. Same missing package; whether you were told how to fix it depended on whether your datasource happened to be named `default`. That arm now answers with the same quality of remedy, in `missingTursoDriverMessage()`, pinned by CONTENT rather than by `toThrow()` — the defect is what the message OMITS, and a throw-only assertion stayed green throughout the years it omitted it. Two deliberate differences from the host loader's wording, because this arm serves different doors. It NAMES THE DATASOURCE (here there may be several and only one is libSQL). And it names no `OS_DATABASE_URL` / `--database` / `OS_ALLOW_DRIVER_CONNECT_FAILURE`: those select or bypass the HOST's `default` and can do nothing for the datasource that actually failed — pointing a stuck reader at a knob that cannot affect their problem is the failure `connect-failure-remedy.ts` was written to end (#5794). One fix, stated once. The original import error is still interpolated in full, which is load-bearing: this re-throw drops the error's `code`, so `isUnbuiltWorkspaceFailure` can only recognise a half-built checkout from the `Cannot find package` TEXT the message carries. A test pins that classification, not just the wording. Deliberately NOT done here (they are `domain:cli` territory, #7314 points 2/3): the typed `MissingDriverPackageError` is not mirrored — that class lives in `@objectstack/runtime`, which DEPENDS on this package, so importing it inverts the dependency and declaring a second same-named class is precisely the identity hazard #6268 closed. And the host loader's narrow `url`/`authToken`-only config read is untouched; this arm already reads the full `TursoConfigSchema` surface. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015fkdTyGmMD5s8ZtEifvuGy --- .changeset/turso-missing-package-remedy.md | 61 +++++++++++ .../default-datasource-driver-factory.test.ts | 103 +++++++++++++++++- .../src/default-datasource-driver-factory.ts | 78 ++++++++++++- .../services/service-datasource/src/index.ts | 11 ++ 4 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 .changeset/turso-missing-package-remedy.md diff --git a/.changeset/turso-missing-package-remedy.md b/.changeset/turso-missing-package-remedy.md new file mode 100644 index 0000000000..45102c6107 --- /dev/null +++ b/.changeset/turso-missing-package-remedy.md @@ -0,0 +1,61 @@ +--- +"@objectstack/service-datasource": patch +--- + +fix(service-datasource): the open-core libSQL arm tells you how to install the driver it is missing (#7314) + +`@objectstack/driver-turso` is an OPTIONAL install — it drags `@libsql/client` +and its native bindings — so both loaders that can build a libSQL datasource +have to answer "the package is not here". Until now they answered it very +differently. + +The HOST loader (`@objectstack/runtime`'s `loadTursoDriverFactory`, the single +owner since #6268) raises `MissingDriverPackageError` carrying the install +command as data, plus a message naming the command, the consequence, and why the +boot refuses instead of quietly opening a SQLite file. The shared open-core +factory's `turso` arm — the one that serves **every other door**: a datasource +added in Setup, `testConnection`, a declared non-default datasource — said only: + +```text +turso driver requested but @objectstack/driver-turso is not installed (…). +``` + +The fault and nothing else. Same missing package, and whether you were told how +to fix it depended on whether your datasource happened to be named `default`. + +That arm now answers with the same quality of remedy: + +```text +datasource 'warehouse': a libSQL/Turso datasource was requested, but the driver +package @objectstack/driver-turso is not installed. Install it next to the +server that opens this datasource: + + npm install @objectstack/driver-turso + +(pnpm add … / yarn add ….) It is an OPTIONAL package, so a default install stays +free of @libsql/client and its native bindings. This refuses rather than falling +back to another engine: a silent fallback would open an empty local database +that accepts writes while your libSQL data stays untouched, and every write +would land in the wrong database. Import error: … +``` + +Two deliberate differences from the host loader's wording, because this arm +serves different doors. It **names the datasource** — here there may be several +and only one of them is libSQL. And it names **no** `OS_DATABASE_URL` / +`--database` / `OS_ALLOW_DRIVER_CONNECT_FAILURE`: those select or bypass the +HOST's `default` datasource and can do nothing for the datasource that actually +failed, and pointing a stuck reader at a knob that cannot affect their problem +is the failure `connect-failure-remedy.ts` was written to end (#5794). One fix, +stated once, no escape hatch named. + +The original import error is still interpolated in full, which is load-bearing +rather than context: this re-throw drops the error's `code`, so the +unbuilt-workspace classifier can only recognise a half-built checkout from the +`Cannot find package` text the message carries. + +`TURSO_DRIVER_PACKAGE`, `TURSO_DRIVER_INSTALL_COMMAND` and +`missingTursoDriverMessage` are exported, so a host that renders the remedy +itself reads one declaration instead of re-typing a sentence. + +Behaviour is otherwise unchanged: the same failure at the same moment, still a +refusal and never a fallback to a different engine. Only the message differs. 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 2dff075e37..d95f901351 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 @@ -9,7 +9,13 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; +import { + createDefaultDatasourceDriverFactory, + missingTursoDriverMessage, + TURSO_DRIVER_INSTALL_COMMAND, + TURSO_DRIVER_PACKAGE, +} from '../default-datasource-driver-factory.js'; +import { isUnbuiltWorkspaceFailure } from '../connect-failure-remedy.js'; const factory = () => createDefaultDatasourceDriverFactory({ dev: false }); @@ -354,3 +360,98 @@ describe('createDefaultDatasourceDriverFactory — legacy config spellings are n expect(driver.config.url).toBe('mongodb://svc:pw@mongo.internal:27017/events'); }); }); + +// #7314 — the OPTIONAL libSQL driver is missing, and until now this arm said so +// and stopped: `turso driver requested but @objectstack/driver-turso is not +// installed (…)`. The HOST loader (`@objectstack/runtime`'s +// `loadTursoDriverFactory`, single owner since #6268) has answered the same +// missing package with the install command, the consequence and the reason for +// refusing since #5602 — so the SAME fault got two qualities of answer depending +// on whether the datasource happened to be the host's `default` (told how to fix +// it) or one added in Setup / probed by `testConnection` / declared as a +// non-default (told only that it was broken). +// +// Pinned by CONTENT rather than by `toThrow()`: the defect is what the message +// omits, and a throw-only assertion was green throughout the years this arm +// omitted it. +describe('createDefaultDatasourceDriverFactory — the missing libSQL package is answered with a remedy (#7314)', () => { + const message = (cause: unknown, datasource?: string) => + missingTursoDriverMessage({ cause, ...(datasource ? { datasource } : {}) }); + + const notInstalled = Object.assign( + new Error(`Cannot find package '${TURSO_DRIVER_PACKAGE}' imported from /app/node_modules/x.mjs`), + { code: 'ERR_MODULE_NOT_FOUND' }, + ); + + it('states the exact install command, on its own copy-pasteable line', () => { + expect(TURSO_DRIVER_INSTALL_COMMAND).toBe('npm install @objectstack/driver-turso'); + expect(message(notInstalled)).toContain(`\n\n ${TURSO_DRIVER_INSTALL_COMMAND}\n\n`); + }); + + it('names the package that is missing', () => { + expect(TURSO_DRIVER_PACKAGE).toBe('@objectstack/driver-turso'); + expect(message(notInstalled)).toContain(TURSO_DRIVER_PACKAGE); + }); + + it('states the consequence and that the refusal is deliberate', () => { + const text = message(notInstalled); + // Not decoration: without it the reader's next move is to look for the + // fallback, and a libSQL selection quietly served by another engine is the + // #3276 class — writes accepted into the wrong database. + expect(text).toContain('refuses rather than falling back'); + expect(text).toContain('wrong database'); + }); + + it('names the datasource that failed, and falls back to `default`', () => { + expect(message(notInstalled, 'warehouse')).toContain("datasource 'warehouse'"); + expect(message(notInstalled)).toContain("datasource 'default'"); + }); + + it('keeps the import error verbatim, so the unbuilt-workspace classifier still fires', () => { + // Load-bearing: this arm re-throws a NEW Error and therefore drops the + // original `code`, so `isUnbuiltWorkspaceFailure` can only recognise an + // unbuilt/uninstalled workspace from the `Cannot find package` TEXT the + // message carries. Drop the interpolation and a half-built worktree silently + // goes back to being told "Fix the datasource configuration" (#5794). + const text = message(notInstalled); + expect(text).toContain(notInstalled.message); + expect(isUnbuiltWorkspaceFailure(new Error(text))).toBe(true); + }); + + it('names no escape hatch and no host-boot knob — one fix, stated once', () => { + const text = message(notInstalled); + // `OS_ALLOW_DRIVER_CONNECT_FAILURE` would only hide a package that does not + // exist (#5794), and `OS_DATABASE_URL` / `--database` select the HOST's + // `default` datasource — neither can affect the datasource that failed here. + expect(text).not.toContain('OS_ALLOW_DRIVER_CONNECT_FAILURE'); + expect(text).not.toContain('OS_DATABASE_URL'); + expect(text).not.toContain('--database'); + expect(text.match(new RegExp(TURSO_DRIVER_INSTALL_COMMAND.replace(/\//g, '\\/'), 'g'))).toHaveLength(1); + }); + + it('is what the turso arm actually raises when the optional package is absent', async () => { + // `@objectstack/driver-turso` is deliberately not a dependency of this + // package — that is what "optional" means — so the missing-package path is + // reachable here for a real reason and needs no stub. + let raised: unknown; + try { + await factory().create({ + driver: 'turso', + name: 'warehouse', + config: { url: 'libsql://my-db.turso.io', authToken: 'tok' }, + }); + } catch (err) { + raised = err; + } + if (raised === undefined) { + throw new Error( + `${TURSO_DRIVER_PACKAGE} 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 above needs a stubbed import instead.', + ); + } + 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'"); + }); +}); 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 c2f9d77601..89cf12234b 100644 --- a/packages/services/service-datasource/src/default-datasource-driver-factory.ts +++ b/packages/services/service-datasource/src/default-datasource-driver-factory.ts @@ -72,6 +72,71 @@ function resolveKind(driverId: string): ResolvedKind | undefined { return resolveDriverId(driverId); } +/** + * The optional package that provides the libSQL/Turso driver, and the exact + * command an operator runs to install it. + * + * 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). + */ +export const TURSO_DRIVER_PACKAGE = '@objectstack/driver-turso'; + +/** @see {@link TURSO_DRIVER_PACKAGE} */ +export const TURSO_DRIVER_INSTALL_COMMAND = `npm install ${TURSO_DRIVER_PACKAGE}`; + +/** + * What this factory says when the OPTIONAL libSQL driver package is absent + * (#7314). + * + * Until #7314 this arm said only *"turso driver requested but + * @objectstack/driver-turso is not installed (…)"* — the fault and nothing + * else. The host loader (`@objectstack/runtime`'s `loadTursoDriverFactory`, + * single owner since #6268) has answered the SAME missing package with the + * install command, the consequence and the reason for refusing since #5602, so + * an operator who booted with a libSQL url was told how to fix it while an + * admin who added the identical datasource in Setup was not. One missing + * package, two qualities of answer, decided by which door the request came + * through. + * + * Two deliberate differences from the host loader's wording, because this arm + * serves different doors — a datasource created in Setup, `testConnection`, a + * declared NON-default datasource — rather than a `default` that a host boots: + * + * - It names the datasource, like the url-less refusal in the same arm, since + * here there may be several and only one of them is libSQL. + * - It does NOT mention `OS_DATABASE_URL` / `--database`. Those select the + * HOST's `default` datasource and would do nothing for the datasource that + * actually failed — advice that sends the reader to a knob which cannot + * affect their problem is the `connect-failure-remedy.ts` failure (#5794) in + * a new spelling. One fix, stated once, and no escape hatch named. + * + * The underlying import error is interpolated at the END and in full. That is + * load-bearing beyond context: this re-throw drops the original `code`, so + * `isUnbuiltWorkspaceFailure` (via `isModuleNotFoundError`) can only recognise + * an unbuilt/uninstalled workspace from the `Cannot find package` / + * `Cannot find module` TEXT it carries — the same reason the `sqlite-wasm` and + * `mongodb` arms interpolate theirs. + */ +export function missingTursoDriverMessage(args: { datasource?: string; cause: unknown }): string { + const cause = args.cause instanceof Error ? args.cause.message : String(args.cause); + return ( + `datasource '${args.datasource ?? 'default'}': a libSQL/Turso datasource was requested, but the ` + + `driver package ${TURSO_DRIVER_PACKAGE} is not installed. Install it next to the server that ` + + `opens this datasource:\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. This ' + + 'refuses rather than falling back to another engine: a silent fallback would open an empty ' + + 'local database that accepts writes while your libSQL data stays untouched, and every write ' + + `would land in the wrong database. Import error: ${cause}` + ); +} + /** * Wrap a concrete engine driver in a probe handle. `ping`/`checkHealth` reuse * the driver's own health check; `driver` is the escape hatch the admin service @@ -500,13 +565,20 @@ export function createDefaultDatasourceDriverFactory( // seam), which wins over this one; this arm is what serves every OTHER // door — a runtime datasource created in Setup, `testConnection`, a // declared non-default datasource. + // + // 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`). let TursoDriver: any; try { ({ TursoDriver } = await import('@objectstack/driver-turso' as any)); } catch (err: any) { - throw new Error( - `turso driver requested but @objectstack/driver-turso is not installed (${err?.message ?? err}).`, - ); + throw new Error(missingTursoDriverMessage({ datasource: spec.name, cause: err })); } const url = typeof cfg.url === 'string' ? cfg.url.trim() : ''; if (!url) { diff --git a/packages/services/service-datasource/src/index.ts b/packages/services/service-datasource/src/index.ts index 60832cc570..6f976afde4 100644 --- a/packages/services/service-datasource/src/index.ts +++ b/packages/services/service-datasource/src/index.ts @@ -89,6 +89,17 @@ export type { PoolUnsupportedDriverId } from './datasource-pool-support.js'; // Host glue: dev driver factory + fail-closed secret binder. export { createDefaultDatasourceDriverFactory } from './default-datasource-driver-factory.js'; +// 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. +export { + TURSO_DRIVER_PACKAGE, + TURSO_DRIVER_INSTALL_COMMAND, + missingTursoDriverMessage, +} from './default-datasource-driver-factory.js'; // The "adopt a host-built driver instance" seam (ADR-0062 D1, #3826) — for // driver kinds outside open-core (cloud turso) and pooled instances whose // lifecycle outlives one kernel; keeps the connect + failure verdict on the