Skip to content

Commit ea08ce5

Browse files
fix(service-datasource): a missing database client (pg, mysql2, better-sqlite3) is refused with its install, not the unbuilt-workspace build advice (#24933)
Fixes #24909 Clause-②: no ## What was wrong When a boot-critical datasource could not connect because a module could not be resolved, `connectFailureRemedy()` always ended the refusal on `UNBUILT_WORKSPACE_REMEDY`: "… That is a build precondition … Run `pnpm install && pnpm build`, then start again." That is right for the case it was written for, a monorepo worktree whose driver `dist/` is missing. It is wrong for a project that points at PostgreSQL or MySQL without installing the database client. `pg` and `mysql2` are optional peers of `@objectstack/driver-sql`, and `better-sqlite3` is an optional dependency of it. The project installs them itself, so neither command adds them, and a reader who followed the advice was refused again with the same text. ## What changed All of the code change is in `packages/services/service-datasource/src/connect-failure-remedy.ts`. It is a message branch only. - The chooser reads WHICH module could not be resolved: the first `Cannot find module|package 'X'` in the error text. - If X is exactly one of the closed list `OPTIONAL_DRIVER_CLIENT_PACKAGES` (`pg`, `mysql2`, `better-sqlite3`), the refusal ends on `missingDriverClientRemedy(X)`. That list is the clients the factory's knex arms load. The remedy names the install of that client once (`npm install X`, with `pnpm add X / yarn add X` beside it). It names no build step, no environment variable and no configuration key. - `isUnbuiltWorkspaceFailure()` no longer answers true for that case. - Everything else is unchanged. A path, any `@objectstack/…` package, a subpath and any other bare name keep `UNBUILT_WORKSPACE_REMEDY`. Every cause that is not a module-not-found keeps `GENERIC_CONNECT_FAILURE_REMEDY` byte for byte. When the boot is refused, the thrown error type, the retained connection state and the `OS_ALLOW_DRIVER_CONNECT_FAILURE` degraded boot do not change. - `packages/types/**`, `packages/spec/**` and `packages/drivers/**` are untouched. `isModuleNotFoundError` is still the gate in front of the new read. The outside suggestion in comment 6292687799 proposed the same split: keep the unbuilt remedy for missing build output, add an install remedy for a missing optional peer, pin both, and keep `ECONNREFUSED` unchanged. This PR adopts that idea. Its point about keeping the original thrown error long enough to classify it was already true on this path, because `handleFailure` receives the thrown value. The measurement below shows the specifier exists only in the text anyway. ## Measurements (dispatch assumptions H1 to H4) Taken on the branch base `b42c621c05`. Between the `@objectstack/service-datasource@18.0.0` tag and that base, `connect-failure-remedy.ts` and `packages/types/src/module-not-found.ts` are byte-identical (empty `git diff`), so the readings hold for the published release. - **H1, confirmed.** The base chooser was `isModuleNotFoundError(cause) ? UNBUILT : GENERIC`. Nothing on the path read the specifier. - **H2, measured.** The real factory was run (`createDefaultDatasourceDriverFactory({ dev: false })`, built ESM and CJS dist), with the client made unresolvable through Node's own resolver. postgres/`pg`, mysql/`mysql2` and sqlite/`better-sqlite3` all throw knex's wrapper `Knex: run` / `$ npm install X --save` / `Cannot find module 'X'` / `Require stack: …`, with `code` undefined. The signal is in the text only, the same as for the sqlite-wasm and mongodb arms. Node's own errors for a bare specifier carry no field naming it either: CJS has `code` and `requireStack`, and ESM `Cannot find package` has `code` only. - **H3, measured on every arm, ESM and CJS.** - Package not installed: a bare specifier (`Cannot find package '@objectstack/driver-…'` under ESM, `Cannot find module '@objectstack/driver-…'` under CJS). - Installed but unbuilt: an absolute path into `…/dist/index.mjs` or `…/dist/index.cjs`. - Missing client: a bare third-party name. The split holds on every arm. A missing `@objectstack/driver-turso` (and mongodb, sqlite-wasm) stays on the unbuilt remedy in this PR; see open question 2. - **H4.** The server cannot know the package manager. The chosen wording is one command, `npm install X`, with the pnpm and yarn spellings of the same command beside it. That is how the factory's optional-driver messages in the same package already spell an install. See open question 1. ## The card's own path: a published 18.0.0 scaffold outside any workspace `npm create objectstack@18.0.0 my-app -- --skip-skills` (installed with pnpm, no `pg`), then `npx os dev --no-watch -p PORT -d postgres://postgres@127.0.0.1:1/db`: - **Before** (published code): exit 1, ending "… Run `pnpm install && pnpm build`, then start again." This is the card's refusal, reproduced. - **After**: this PR's compiled remedy functions were transplanted into that scaffold's copy of the published bundle. Exit 1 (the boot is still refused), ending "The database client package `pg` is not installed, so no connection was attempted. … Install it next to the server that opens this datasource — `npm install pg` (pnpm add pg / yarn add pg) — then start again." `-d mysql://…` gives the same ending with `mysql2`. - **Following the remedy**: `pnpm add pg` (exit 0), then start again. The boot gets past the client and is refused for the next real fault, `connect ECONNREFUSED 127.0.0.1:1`, ending on the generic sentence unchanged. ## Tests (head `649414d3b8`) - `pnpm --filter @objectstack/service-datasource typecheck`: exit 0. `tsc --listFiles` confirms it compiles both changed test files. - `pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2`: 49 files, 883 tests passed. - Unit pins (`datasource-connection-service.test.ts`): - Each measured knex client shape (`pg`, `mysql2`, `better-sqlite3`), and ESM `Cannot find package 'pg'`, ends on that client's install. The remedy contains `npm install X` exactly once and never `pnpm build`. No `OS_` variable or `--database` appears. - The unbuilt-workspace pins stay unchanged and green. The new boundary cases also keep `UNBUILT_WORKSPACE_REMEDY`: `@objectstack/driver-sql` not installed, unbuilt `dist`, a file inside `pg`, the `pg/lib/native` subpath, and `knex` missing. - `ECONNREFUSED` and `28P01` (with their codes) still end on the generic sentence. - Real boot-refusal path (new `missing-driver-client-boot-refusal.test.ts`): - The real factory and the real `DatasourceConnectionService` connect the host's boot-critical `default` under `declared-auto`. The client is made unresolvable through Node's own resolver for that one specifier, so the error is Node's and knex's, not the test's. - For pg, mysql2 and better-sqlite3 (sqlite with `dev: false`), the boot is still refused with a plain `Error`. The message carries `Cannot find module 'X'`, ends on that client's install and never names `pnpm build`. The verdict is retained (`failed-degraded`, availability `failed`). - The escape hatch still boots degraded. - Control: with the client present, the same harness is refused for `ECONNREFUSED` and ends on the generic sentence. - The SQL driver is loaded at module top, so no case's clock pays the cold load. The pg case went from 9496 ms to 12 ms. - Ablation: two legs, one per changed behaviour, both through `node scripts/ablation-replace.mjs` (anchor hit 1 to 0 on disk, blob changed). The subject is imported from `src`, so no rebuild is involved. Each restore was proven by blob equal to HEAD (`78e1c14f40fb`) and an empty `git diff HEAD`. 1. Deleting the client branch in `connectFailureRemedy`: 7 red, exactly the predicted set (the 3 knex unit cases, the ESM `pg` case and the 3 real-path arms). The 83 others stayed green. 2. Deleting the client exclusion in `isUnbuiltWorkspaceFailure`: 1 red, the classifier pin. The 89 others stayed green. ## Gates Local, on head `649414d3b8`. The list was derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; merge base `74fcec1ed`). Each exit code was captured before any pipe. - 66 commands derived. 65 exited 0. - 1 is NOT MEASURED: `pnpm check:dual-build-cjs-loads` exited 3 with PREREQUISITE NOT MET. That gate reads every package's built output, and this worktree built only the service-datasource closure. CI runs it. - `--ran` reconciliation, with each exit recorded: "66 derived famil(ies) accounted for — 65 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)". - The 65 include `check:test-source-alias`, `check:cross-package-test-inputs`, `check:engine-double-contract`, `check:doc-authoring`, `check:nul-bytes`, `check:type-check-coverage`, `check:type-check-debt`, `check-adr-0087-registration --base origin/main` and `check-empty-changeset --base origin/main`. - Not run locally, left to CI: - the `test:repo` vitest project (the diff touches none of its tests); - repo-wide scans, `pnpm lint` first among them; - the local-only families `check:dev-prereqs` and `check:skill-frame-freshness`, which nothing in the diff triggers (no `packages/spec` build and no skill-frame file). - The PR's check-runs had not finished when this PR was opened; the dev report cites them. ## Open questions 1. **Install wording (H4).** A: one command, `npm install X` only. B: the module name and a neutral sentence ("add `X` with your package manager"). C (chosen): `npm install X` with `pnpm add X / yarn add X` beside it. - Real need: the scaffolder picks pnpm when it is on PATH and npm otherwise (`detect-package-manager.ts`), and the card's reader used `npm create` and then pnpm. So both npm and pnpm projects are real. A serves only one of them; B and C serve both. - Long-term fit: C is the spelling the factory's optional-driver messages in this package already use. B would add a second spelling for the same situation. - Harder to get wrong: C gives a copy-pasteable command for each package manager, and an agent can pick the one matching the lockfile. A invites `npm install` inside a pnpm project. B leaves the reader to work out the command. - Scope: none of the three adds a mechanism (no package-manager detection on the server). - Recommendation: C. 2. **A missing optional `@objectstack/driver-*` package (turso, mongodb, sqlite-wasm), reachable today.** Measured through `os dev` on the published 18.0.0 scaffold: a declared non-default `turso` datasource with a bound object, and no `@objectstack/driver-turso` installed. The refusal names two fixes. The factory's own text says `npm install @objectstack/driver-turso`, and the appended remedy says "That is a build precondition … Run `pnpm install && pnpm build`". This PR leaves that case on the unbuilt remedy, because the bare specifier means two things. In a scaffold it is a missing install. In the workspace it is a stale install, which `pnpm install` repairs, and `npm install` inside a pnpm workspace would be wrong. The #7385 factory pins also assert that these texts classify as unbuilt. - A: leave it as it is. - B: for a bare specifier naming one of the three optional peers this package declares, end on a sentence that defers to the install the factory text already names, and keep paths on the unbuilt remedy. The cost is small: the same file, plus revising the three #7385 classifier pins to path-shaped causes. - C: as B, but for `@objectstack/driver-turso` only. The CLI and runtime depend on mongodb and sqlite-wasm outright, so a scaffold normally has them. - Real need: only C's case is measured as reachable from a scaffold. Long-term fit: B is the one rule, "a bare optional install is an install". Harder to get wrong: B and C give one fix per refusal, A gives two. Scope: all are small. - Recommendation: B, with the workspace stale-install trade-off stated for the maintainer to accept or reject. ## Acceptance notes - `content/docs/getting-started/your-first-project.mdx` (the package table and the paragraph under it) says `@objectstack/runtime` "brings the database drivers with it — you do not install one separately". It also says to "point `OS_DATABASE_URL` at Postgres / MySQL / MongoDB for production — no code changes". It never says that `pg` or `mysql2` must be installed. This is the step the card's reproduction followed. Not changed here: it does not quote the refusal. It is reported to the seat as a finding. - `drivers.mdx` already says `pg` and `mysql2` are optional peers to install yourself. No page quotes the old refusal for the missing-client case, so no doc changed. - A workspace contributor with a stale install that lacks `pg` would now be told `npm install pg (pnpm add pg / …)` rather than `pnpm install`. pnpm links `pg` into the workspace as a devDependency of `driver-sql` on every install, so this needs an install that predates `pg`. The module note says so. - No `CHANGELOG.md` or `content/docs/releases/` edit. The changeset `.changeset/24909-missing-driver-client-remedy.md` is a `patch` for `@objectstack/service-datasource`. > **Seat's append** (`domain:services` seat 1, `session_011TdZ8GjixNZFLz1xJD9zzK`): the dev's round-2 markdown from os-dev report `6296337366` on #24909, verbatim. The dev writes the body once and never PATCHes it. The finding above about `your-first-project.mdx` is now fixed in this PR, by claim amendment `6295994725`. ## Round 2: the getting-started page (claim amendment 6295994725) `content/docs/getting-started/your-first-project.mdx`: only the package table's `@objectstack/runtime` row and the paragraph under it changed. No new section. - The row no longer says "you do not install one separately". It now says that SQLite and MongoDB work as installed, and that PostgreSQL and MySQL also need their client package. - The paragraph keeps the dependency sentence, which was measured true, and adds that the runtime installs `driver-mongodb` as an optional dependency. - SQLite (the default) needs nothing more. - MongoDB needs only `OS_DATABASE_URL`. - Postgres and MySQL need their client installed first (`npm install pg` / `npm install mysql2`, the page's npm spelling). It is an optional peer of `driver-sql`, and without it the boot is refused. - The bare "no code changes" link is now "No code changes either way — see [Drivers](/docs/data-modeling/drivers)". Measured on a fresh published `create-objectstack@18.0.0` scaffold (pnpm-installed, outside any workspace) and on the 18.0.0 npm manifests: - `@objectstack/runtime@18.0.0`: dependencies `driver-sql`, `driver-memory`, `driver-sqlite-wasm`; optionalDependencies `driver-mongodb`; optional peer `driver-turso`. The workspace head has the same edges. - `@objectstack/cli@18.0.0`: dependencies `driver-sql`, `driver-memory`, `driver-mongodb`, `driver-sqlite-wasm`; optional peer `driver-turso`. - `@objectstack/driver-mongodb@18.0.0` depends on `mongodb`. `@objectstack/driver-sql@18.0.0` has optional peers `pg` and `mysql2` and the optional dependency `better-sqlite3`. - In the scaffold, `driver-mongodb`, `driver-sqlite-wasm` and `driver-sql` resolve from service-datasource, and `mongodb` resolves from `driver-mongodb`. `pg` and `mysql2` do not resolve. - `os dev` with no database flag: "Server is ready" on `SqlDriver(better-sqlite3)`, and `GET /api/v1/discovery` answered 200. The server was stopped afterwards. - `os dev -d mongodb://127.0.0.1:1/db`: exit 1 on `connect ECONNREFUSED 127.0.0.1:1` with the generic sentence. The driver and client loaded; only the absent server refused. - Postgres and MySQL without their client: refused with exit 1, as measured in round 1. A correction to round 1: open question 2 said "the CLI and runtime depend on mongodb and sqlite-wasm outright". That holds for the CLI. The runtime carries `driver-mongodb` as an optionalDependency, which is installed by default and skipped under `--omit=optional`. The changeset is unchanged: `content/docs/**` publishes nothing, and the existing `patch` changeset covers the code. Gates on head `907bd42fdb`, re-derived: 95 commands (the 66 from round 1 plus 29 docs families). The 29 added ones and 8 docs-reading round-1 families (`check:doc-authoring` among them) were re-run, each under the lock. All exit 0 once the `lint`, `formula` and `client-react` closures were built. Before those builds, 4 had exited 3 (prerequisite). The derived link family is `check:doc-anchors`, exit 0; CI's lychee lane is `Check Documentation Links`. `--ran`: 95 accounted, 94 run, 1 NOT MEASURED (`check:dual-build-cjs-loads`, which needs every package built). --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e7befa7 commit ea08ce5

5 files changed

Lines changed: 451 additions & 7 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource): a datasource whose database client is not installed (`pg`, `mysql2`, `better-sqlite3`) is refused with the install of that client, not with "Run `pnpm install && pnpm build`"
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** When a boot-critical datasource could not connect because a module could not be resolved, the refusal always ended on the unbuilt-workspace remedy: "The driver package could not be LOADED at all … That is a build precondition … Run `pnpm install && pnpm build`, then start again." That is right for a monorepo checkout whose driver build output is missing. It is wrong for a project that points at PostgreSQL or MySQL without installing the database client: `pg` and `mysql2` are optional peers of `@objectstack/driver-sql` (and `better-sqlite3` an optional dependency) that the project installs itself, so neither command adds them, and a reader who followed the advice was refused again with the same text.
10+
- **Now.** When the module that could not be resolved is one of those three clients (knex reports it as `Cannot find module 'pg'`), the refusal ends on the install of that client instead: `npm install pg` (with the `pnpm add` and `yarn add` spellings beside it), and no build step. It names no environment variable.
11+
- **Unchanged.** A driver package whose build output is missing, or an `@objectstack/…` driver package that is not installed, still ends on `pnpm install && pnpm build`. A refused connection, a wrong DSN or a rejected password still ends on the same sentence as before. When the boot is refused, the error type, the retained connection state and the `OS_ALLOW_DRIVER_CONNECT_FAILURE` degraded boot do not change: only the closing sentence of the refusal is different.
12+
13+
What to do: install the client the refusal names next to the server that opens the datasource, then start again.

‎content/docs/getting-started/your-first-project.mdx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,16 +242,20 @@ framework surface you install:
242242
| Package | What it does |
243243
|:---|:---|
244244
| `@objectstack/spec` | The protocol: `defineStack`, `ObjectSchema`, `Field.*` builders, all Zod schemas. Your metadata imports only this. |
245-
| `@objectstack/runtime` | The kernel that interprets metadata at runtime. Brings the database drivers with it — you do not install one separately. |
245+
| `@objectstack/runtime` | The kernel that interprets metadata at runtime. Brings the database drivers with it — SQLite and MongoDB work as installed; PostgreSQL and MySQL also need their client package (below). |
246246
| `@objectstack/plugin-hono-server` | The HTTP server that mounts the generated REST API. |
247247
| `@objectstack/connector-rest` · `-openapi` · `-mcp` | The three generic connector executors — register the `rest` / `openapi` / `mcp` provider factories so declarative `connectors:` entries materialize ([ADR-0097](/docs/automation/flows)). |
248248
| `@objectstack/cli` *(dev)* | The `os` / `objectstack` CLI: `dev`, `validate`, `build`, `start`. |
249249
| `@objectstack/verify` *(dev)* | The test harness: `bootStack` boots this app's own stack in-process, on an in-memory database and with no server port, and returns the handle a test asserts through. `npm test` runs the starter test `note.test.ts` with it. |
250250

251251
No driver package is listed because none is installed directly: `@objectstack/runtime`
252-
depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory`, and the CLI carries
253-
them too. `objectstack dev` resolves **SQLite** by default; point `OS_DATABASE_URL` at
254-
Postgres / MySQL / MongoDB for production — [no code changes](/docs/data-modeling/drivers).
252+
depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory` and installs
253+
`driver-mongodb` as an optional dependency, and the CLI carries them too.
254+
`objectstack dev` resolves **SQLite** by default, which needs nothing more. For
255+
production, MongoDB needs only `OS_DATABASE_URL` pointed at it. Postgres and MySQL need
256+
one install first: their database client (`npm install pg`, or `npm install mysql2`) is
257+
an optional peer of `driver-sql` that your project adds itself, and without it the boot
258+
is refused. No code changes either way — see [Drivers](/docs/data-modeling/drivers).
255259

256260
## 3. Run it
257261

‎packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import type { IDataDriver } from '@objectstack/spec/contracts';
1212
import type { DatasourceConnectPolicy } from '../contracts/connect-policy.js';
1313
import {
1414
GENERIC_CONNECT_FAILURE_REMEDY,
15+
UNBUILT_WORKSPACE_REMEDY,
1516
isUnbuiltWorkspaceFailure,
17+
missingDriverClient,
18+
missingDriverClientRemedy,
1619
} from '../connect-failure-remedy.js';
1720
import { missingSqliteWasmDriverMessage } from '../default-datasource-driver-factory.js';
1821

@@ -589,6 +592,85 @@ describe('fail-fast remedy is chosen by CAUSE (#5794)', () => {
589592
.then(() => undefined, (e: Error) => e);
590593
expect(err!.message.endsWith(GENERIC)).toBe(true);
591594
});
595+
596+
// The driver-level codes a real refused or rejected connection carries:
597+
// neither is a module-resolution failure, so neither may reach the
598+
// missing-client branch (#24909) any more than the unbuilt one.
599+
it.each([
600+
['ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:5432'],
601+
['28P01', 'password authentication failed for user "app"'],
602+
])('a driver error with code %s still ends on it, byte for byte', async (code, text) => {
603+
const err = await failFast(factoryThrowing(withCode(new Error(text), code)));
604+
expect(err.message.endsWith(GENERIC)).toBe(true);
605+
// `\b`: "pnpm install" contains "npm install" as a substring.
606+
expect(err.message).not.toMatch(/\bnpm install\b/);
607+
expect(err.message).not.toContain('pnpm build');
608+
});
609+
});
610+
611+
// #24909: `@objectstack/driver-sql` reaches PostgreSQL, MySQL and native
612+
// SQLite through knex, and knex loads a database client the PROJECT
613+
// installs. A missing one is a missing install, not an unbuilt workspace:
614+
// `pnpm install && pnpm build` adds no package the project never declared, so
615+
// a scaffolded app that followed the build advice was refused again.
616+
describe('a missing database client is answered with ITS install (#24909)', () => {
617+
/**
618+
* knex's own wrapper (`Client.initializeDriver`), in the shape measured on
619+
* the real factory arms: a NEW Error that keeps the text and drops `code`.
620+
* The genuine article is pinned on the real path in
621+
* `missing-driver-client-boot-refusal.test.ts`.
622+
*/
623+
const knexMissingClient = (client: string, dialect: string) =>
624+
new Error(
625+
`Knex: run\n$ npm install ${client} --save\nCannot find module '${client}'\n` +
626+
`Require stack:\n- /app/node_modules/knex/lib/dialects/${dialect}/index.js`,
627+
);
628+
629+
it.each([
630+
['pg', 'postgres'],
631+
['mysql2', 'mysql2'],
632+
['better-sqlite3', 'better-sqlite3'],
633+
])('%s: the refusal ends on the install of that client and names no build', async (client, dialect) => {
634+
const err = await failFast(factoryThrowing(knexMissingClient(client, dialect)));
635+
const remedy = missingDriverClientRemedy(client);
636+
expect(err.message.endsWith(remedy)).toBe(true);
637+
// The install of THAT module, named once in the remedy (the earlier
638+
// `npm install … --save` belongs to knex's interpolated text).
639+
expect(remedy.split(`npm install ${client}`)).toHaveLength(2);
640+
expect(err.message).not.toContain('pnpm build');
641+
expect(err.message).not.toContain(UNBUILT_WORKSPACE_REMEDY);
642+
expectNoHarmfulAdvice(err.message);
643+
});
644+
645+
it("ESM's `Cannot find package 'pg'`, code intact, is the same missing install", async () => {
646+
const err = await failFast(
647+
factoryThrowing(withCode(new Error("Cannot find package 'pg' imported from /app/x.js"), 'ERR_MODULE_NOT_FOUND')),
648+
);
649+
expect(err.message.endsWith(missingDriverClientRemedy('pg'))).toBe(true);
650+
});
651+
652+
it('names no environment variable and no configuration key', () => {
653+
for (const client of ['pg', 'mysql2', 'better-sqlite3']) {
654+
const remedy = missingDriverClientRemedy(client);
655+
expect(remedy).not.toMatch(/\bOS_[A-Z_]+/);
656+
expect(remedy).not.toContain('--database');
657+
expect(remedy).not.toContain('pnpm build');
658+
}
659+
});
660+
661+
// The other side of the split: only a BARE specifier on the closed list is
662+
// a missing client. Each of these is still the unbuilt/broken-install case.
663+
it.each([
664+
['the driver package itself, not installed', "Cannot find package '@objectstack/driver-sql' imported from /w/x.js"],
665+
['the driver package, unbuilt', "Cannot find module '/w/node_modules/@objectstack/driver-sql/dist/index.mjs'"],
666+
['a file inside an installed client', "Cannot find module '/w/node_modules/pg/lib/index.js'"],
667+
['a subpath of an installed client', "Cannot find module 'pg/lib/native'"],
668+
['a hard dependency of the driver (a broken install)', "Cannot find module 'knex'"],
669+
])('%s keeps the unbuilt-workspace remedy', async (_label, text) => {
670+
const err = await failFast(factoryThrowing(new Error(text)));
671+
expect(err.message.endsWith(UNBUILT_WORKSPACE_REMEDY)).toBe(true);
672+
expect(err.message).not.toMatch(/\bnpm install\b/);
673+
});
592674
});
593675

594676
// "Diagnostic classification, zero behaviour change" is the whole claim of
@@ -679,8 +761,40 @@ describe('isUnbuiltWorkspaceFailure (#5794)', () => {
679761
it('classifies nothing when there was no thrown value to read', () => {
680762
expect(isUnbuiltWorkspaceFailure(undefined)).toBe(false);
681763
});
764+
765+
it('leaves a missing database client alone — an install, not an unbuilt workspace (#24909)', () => {
766+
const knexPg = new Error("Knex: run\n$ npm install pg --save\nCannot find module 'pg'\nRequire stack:\n- /a/knex.js");
767+
expect(isUnbuiltWorkspaceFailure(knexPg)).toBe(false);
768+
expect(missingDriverClient(knexPg)).toBe('pg');
769+
});
770+
});
771+
772+
describe('missingDriverClient (#24909)', () => {
773+
it('names the client only for a module-resolution failure', () => {
774+
expect(missingDriverClient(new Error("Cannot find module 'mysql2'"))).toBe('mysql2');
775+
// The same name in a message that is NOT a module-not-found is not this case.
776+
expect(missingDriverClient(new Error('mysql2: connect ECONNREFUSED'))).toBeUndefined();
777+
expect(missingDriverClient(undefined)).toBeUndefined();
778+
});
779+
780+
it('reads the FIRST unresolved specifier — the wrapper words come first, the import error after', () => {
781+
expect(
782+
missingDriverClient(new Error("Cannot find module 'pg'\nRequire stack:\n- /w/node_modules/knex/lib/x.js")),
783+
).toBe('pg');
784+
expect(
785+
missingDriverClient(new Error("Cannot find package '@objectstack/driver-sql' (… 'pg' …)")),
786+
).toBeUndefined();
787+
});
788+
789+
it('a module-not-found carrying only its code names no client', () => {
790+
expect(missingDriverClient(withCodeOnly('MODULE_NOT_FOUND'))).toBeUndefined();
791+
});
682792
});
683793

794+
function withCodeOnly(code: string): Error {
795+
return Object.assign(new Error('the entry point is not on disk'), { code });
796+
}
797+
684798
describe('D3 credential resolution — fail-closed', () => {
685799
const credExternal: ConnectableDatasource = {
686800
name: 'warehouse',

0 commit comments

Comments
 (0)