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
20 changes: 20 additions & 0 deletions .changeset/optional-driver-package-remedy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/service-datasource': patch
---

The `sqlite-wasm` and `mongodb` arms of the shared datasource driver factory now tell you how to install the optional driver package they are missing (#7385)

All three of `sqlite-wasm`, `mongodb` and `turso` are built from OPTIONAL packages, so all three have to answer "the package is not here". After #7314 fixed the libSQL arm, the other two still answered with the fault and nothing else:

```text
sqlite-wasm driver requested but @objectstack/driver-sqlite-wasm is not installed (…).
mongodb driver requested but @objectstack/driver-mongodb is not installed (…).
```

No install command, no statement of what happens next, and not even the name of the datasource that failed — while the `turso` arm beside them stated all three. An admin who added a mongo datasource in Setup and one who added a libSQL datasource hit the same class of problem and got two different qualities of answer, decided by nothing but which driver they picked.

Both arms now answer through a shared builder, keeping the two discipline points #7384 landed under: the message NAMES THE DATASOURCE (several may be declared and only one of them is this engine), and it names exactly one fix with no escape hatch — no `OS_ALLOW_DRIVER_CONNECT_FAILURE` (it would only hide a package that does not exist) and no `OS_DATABASE_URL` / `--database` (they select the HOST's `default` datasource and can do nothing for the one that failed). The underlying import error is still interpolated in full, which is what keeps `isUnbuiltWorkspaceFailure` able to recognise a half-built checkout from these arms and re-route the remedy to `pnpm install && pnpm build`.

The consequence sentence is per-engine rather than copied. Mongo, like libSQL, is a server this process connects to, so a silent fallback would open a local database while the real server stayed untouched. `sqlite-wasm` has no remote to shadow, so it states its own truth instead: stepping down to the in-process memory driver would accept every write and drop it at shutdown, leaving the configured file empty, and stepping down to native `better-sqlite3` would need exactly the native addon a WASM datasource is chosen to avoid.

New exports, mirroring the libSQL pair, so a host that renders its own remedy reads one declaration instead of re-typing a command: `SQLITE_WASM_DRIVER_PACKAGE`, `SQLITE_WASM_DRIVER_INSTALL_COMMAND`, `missingSqliteWasmDriverMessage`, `MONGODB_DRIVER_PACKAGE`, `MONGODB_DRIVER_INSTALL_COMMAND`, `missingMongodbDriverMessage`.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
GENERIC_CONNECT_FAILURE_REMEDY,
isUnbuiltWorkspaceFailure,
} from '../connect-failure-remedy.js';
import { missingSqliteWasmDriverMessage } from '../default-datasource-driver-factory.js';

/** One `markDatasourceUnavailable` call, as the engine would receive it. */
type UnavailableCall = { name: string; kind: 'blocked' | 'failed'; publicDetail?: string };
Expand Down Expand Up @@ -484,11 +485,21 @@ describe('fail-fast remedy is chosen by CAUSE (#5794)', () => {
});

it('by message alone: the factory-wrapped optional-driver form, no code', async () => {
// Built from the factory's OWN message rather than a copy of its wording
// (#7385): this fixture used to spell the pre-#7385 sentence by hand, so
// it would have gone on asserting a shape the factory no longer emits.
// The property under test is unchanged — the wrapper drops the `code`, so
// the classifier has only the interpolated `Cannot find module` text to
// work with — and it is now pinned against the real wrapper.
const err = await failFast(
factoryThrowing(
new Error(
'sqlite-wasm driver requested but @objectstack/driver-sqlite-wasm is not installed ' +
"(Cannot find module '/w/node_modules/@objectstack/driver-sqlite-wasm/dist/index.mjs').",
missingSqliteWasmDriverMessage({
datasource: 'default',
cause: new Error(
"Cannot find module '/w/node_modules/@objectstack/driver-sqlite-wasm/dist/index.mjs'",
),
}),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// driver — builds through the same `create({driver,config})` as every other
// kind. These are the first direct tests of the factory's id → driver mapping.

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
Expand All @@ -14,6 +14,12 @@ import {
missingTursoDriverMessage,
TURSO_DRIVER_INSTALL_COMMAND,
TURSO_DRIVER_PACKAGE,
missingSqliteWasmDriverMessage,
SQLITE_WASM_DRIVER_INSTALL_COMMAND,
SQLITE_WASM_DRIVER_PACKAGE,
missingMongodbDriverMessage,
MONGODB_DRIVER_INSTALL_COMMAND,
MONGODB_DRIVER_PACKAGE,
} from '../default-datasource-driver-factory.js';
import { isUnbuiltWorkspaceFailure } from '../connect-failure-remedy.js';

Expand Down Expand Up @@ -455,3 +461,212 @@ describe('createDefaultDatasourceDriverFactory — the missing libSQL package is
expect((raised as Error).message).toContain("datasource 'warehouse'");
});
});

// #7385 — the same generalisation over the two SIBLING optional arms.
//
// `sqlite-wasm` and `mongodb` ride in optional packages exactly like `turso`,
// and after #7314 they were the two arms still answering an absent one with
//
// sqlite-wasm driver requested but @objectstack/driver-sqlite-wasm is not installed (…).
// mongodb driver requested but @objectstack/driver-mongodb is not installed (…).
//
// — the fault, no install command, no consequence, and not even the name of the
// datasource that failed. One class of problem, two qualities of answer,
// decided by which driver the admin picked.
//
// Pinned by CONTENT for the reason #7384 gave: the defect is what the message
// OMITS, so `toThrow()` was green throughout.
//
// Unlike the libSQL package, BOTH of these resolve inside this workspace (they
// are `devDependencies` of `@objectstack/service-datasource`, which is how the
// construction suites above build real drivers). So the arm-level cases cannot
// simply ask for a driver and watch it fail the way the turso one does — they
// make the optional import fail on purpose instead.
const notInstalled = (pkg: string) =>
Object.assign(new Error(`Cannot find package '${pkg}' imported from /app/node_modules/x.mjs`), {
code: 'ERR_MODULE_NOT_FOUND',
});

/**
* Build a datasource whose OPTIONAL driver package is absent, and return what
* the arm raised. The package is a real dependency here, so absence is staged:
* the module registry is reset, the specifier is mocked with a factory that
* throws the resolver's own error, and the factory module is re-imported so its
* lazy `await import(...)` hits the mock. Both are undone in `finally`, so the
* construction suites in this file keep seeing the real drivers.
*/
async function raiseWithPackageAbsent(
pkg: string,
spec: { driver: string; name?: string; config?: Record<string, unknown> },
): Promise<unknown> {
vi.resetModules();
vi.doMock(pkg, () => {
throw notInstalled(pkg);
});
try {
const mod = await import('../default-datasource-driver-factory.js');
await mod.createDefaultDatasourceDriverFactory({ dev: false }).create(spec as never);
return undefined;
} catch (err) {
return err;
} finally {
vi.doUnmock(pkg);
vi.resetModules();
}
}

describe('createDefaultDatasourceDriverFactory — the missing WASM SQLite package is answered with a remedy (#7385)', () => {
const message = (cause: unknown, datasource?: string) =>
missingSqliteWasmDriverMessage({ cause, ...(datasource ? { datasource } : {}) });
const cause = notInstalled(SQLITE_WASM_DRIVER_PACKAGE);

it('states the exact install command, on its own copy-pasteable line', () => {
expect(SQLITE_WASM_DRIVER_INSTALL_COMMAND).toBe('npm install @objectstack/driver-sqlite-wasm');
expect(message(cause)).toContain(`\n\n ${SQLITE_WASM_DRIVER_INSTALL_COMMAND}\n\n`);
});

it('names the package that is missing', () => {
expect(SQLITE_WASM_DRIVER_PACKAGE).toBe('@objectstack/driver-sqlite-wasm');
expect(message(cause)).toContain(SQLITE_WASM_DRIVER_PACKAGE);
});

it('states a consequence that is TRUE for this engine, not the libSQL one', () => {
const text = message(cause);
expect(text).toContain('refuses rather than falling back');
// What a fallback would actually cost here: durability (the memory driver
// accepts writes and drops them at shutdown, #4083), or the native addon
// this driver id exists to avoid.
expect(text).toContain('drop it at shutdown');
expect(text).toContain('better-sqlite3');
// And what it must NOT claim: `sqlite-wasm` opens a local file, so there is
// no remote database for a fallback to shadow. #7384's "your libSQL data
// stays untouched … the wrong database" reads well here and would be a lie.
expect(text).not.toContain('wrong database');
expect(text).not.toContain('stays untouched');
});

it('names the datasource that failed, and falls back to `default`', () => {
expect(message(cause, 'wasm-store')).toContain("datasource 'wasm-store'");
expect(message(cause)).toContain("datasource 'default'");
});

it('keeps the import error verbatim, so the unbuilt-workspace classifier still fires', () => {
// Load-bearing exactly as in the turso arm: this re-throw drops the original
// `code`, so `isUnbuiltWorkspaceFailure` can only recognise a half-built
// checkout from the `Cannot find package` TEXT carried here. That case is
// the COMMON one for this package — `@objectstack/runtime` and the CLI both
// depend on it outright, so a reader who hits this is usually unbuilt rather
// than uninstalled, and must not be told to install what they already have.
const text = message(cause);
expect(text).toContain(cause.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(cause);
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(SQLITE_WASM_DRIVER_INSTALL_COMMAND.replace(/\//g, '\\/'), 'g')))
.toHaveLength(1);
});

it('is what the sqlite-wasm arm actually raises when the optional package is absent', async () => {
const raised = await raiseWithPackageAbsent(SQLITE_WASM_DRIVER_PACKAGE, {
driver: 'sqlite-wasm',
name: 'wasm-store',
config: { filename: 'data/app.db' },
});
expect(raised).toBeInstanceOf(Error);
expect((raised as Error).message).toContain(SQLITE_WASM_DRIVER_INSTALL_COMMAND);
expect((raised as Error).message).toContain(SQLITE_WASM_DRIVER_PACKAGE);
expect((raised as Error).message).toContain("datasource 'wasm-store'");
expect((raised as Error).message).toContain('refuses rather than falling back');
});
});

describe('createDefaultDatasourceDriverFactory — the missing MongoDB package is answered with a remedy (#7385)', () => {
const message = (cause: unknown, datasource?: string) =>
missingMongodbDriverMessage({ cause, ...(datasource ? { datasource } : {}) });
const cause = notInstalled(MONGODB_DRIVER_PACKAGE);

it('states the exact install command, on its own copy-pasteable line', () => {
expect(MONGODB_DRIVER_INSTALL_COMMAND).toBe('npm install @objectstack/driver-mongodb');
expect(message(cause)).toContain(`\n\n ${MONGODB_DRIVER_INSTALL_COMMAND}\n\n`);
});

it('names the package that is missing', () => {
expect(MONGODB_DRIVER_PACKAGE).toBe('@objectstack/driver-mongodb');
expect(message(cause)).toContain(MONGODB_DRIVER_PACKAGE);
});

it('states the consequence and that the refusal is deliberate', () => {
const text = message(cause);
expect(text).toContain('refuses rather than falling back');
// Mongo is a server this process CONNECTS to, so #7384's consequence is
// true here in substance: a local store shadowing a remote database is the
// #3276 class — writes accepted into the wrong place.
expect(text).toContain('stays untouched');
expect(text).toContain('wrong database');
});

it('names the datasource that failed, and falls back to `default`', () => {
expect(message(cause, 'events')).toContain("datasource 'events'");
expect(message(cause)).toContain("datasource 'default'");
});

it('keeps the import error verbatim, so the unbuilt-workspace classifier still fires', () => {
const text = message(cause);
expect(text).toContain(cause.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(cause);
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(MONGODB_DRIVER_INSTALL_COMMAND.replace(/\//g, '\\/'), 'g')))
.toHaveLength(1);
});

it('is what the mongodb arm actually raises when the optional package is absent', async () => {
const raised = await raiseWithPackageAbsent(MONGODB_DRIVER_PACKAGE, {
driver: 'mongodb',
name: 'events',
config: { host: 'mongo.internal', database: 'events' },
});
expect(raised).toBeInstanceOf(Error);
expect((raised as Error).message).toContain(MONGODB_DRIVER_INSTALL_COMMAND);
expect((raised as Error).message).toContain(MONGODB_DRIVER_PACKAGE);
expect((raised as Error).message).toContain("datasource 'events'");
expect((raised as Error).message).toContain('refuses rather than falling back');
});
});

// The point of the card, stated as one assertion: the three optional arms now
// give ONE quality of answer. Skeleton parity rather than byte equality —
// `missingTursoDriverMessage` is deliberately left as its own function (it
// merged hours earlier and #7384's tests pin its wording), and this is what
// makes converging it onto the shared builder a provably inert change later.
describe('createDefaultDatasourceDriverFactory — all three optional-driver arms answer in the same shape (#7385)', () => {
const messages = [
missingTursoDriverMessage({ datasource: 'd', cause: notInstalled(TURSO_DRIVER_PACKAGE) }),
missingSqliteWasmDriverMessage({ datasource: 'd', cause: notInstalled(SQLITE_WASM_DRIVER_PACKAGE) }),
missingMongodbDriverMessage({ datasource: 'd', cause: notInstalled(MONGODB_DRIVER_PACKAGE) }),
];

it.each([
["datasource 'd'", 'names the datasource'],
['is not installed. Install it next to the server that opens this datasource:', 'states the fault + where to install'],
['It is an OPTIONAL package,', 'says the package is optional'],
['This refuses rather than falling back to another engine:', 'says the refusal is deliberate'],
['Import error: ', 'ends on the verbatim import error'],
])('every arm carries %j (%s)', (fragment) => {
for (const text of messages) expect(text).toContain(fragment);
});

it('every arm is classified as an unbuilt workspace when that is the real cause', () => {
for (const text of messages) expect(isUnbuiltWorkspaceFailure(new Error(text))).toBe(true);
});
});
Loading
Loading