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