Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/turso-missing-package-remedy.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -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'");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions packages/services/service-datasource/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading