Skip to content

Commit b811c73

Browse files
fix(service-datasource): a missing optional driver package (turso, mongodb, sqlite-wasm) is refused with the factory's install alone, not the unbuilt-workspace build advice (#24971)
Fixes #24937 Clause-②: no ## What was wrong A `turso`, `mongodb` or `sqlite-wasm` datasource whose optional driver package is not installed is refused at boot. The factory's own text already says so and names the install (`npm install @objectstack/driver-turso`, with the `pnpm add` and `yarn add` spellings beside it). The refusal then ended on `UNBUILT_WORKSPACE_REMEDY` anyway: "… That is a build precondition … Run `pnpm install && pnpm build`, then start again." That is two fixes that contradict each other, and the second does nothing for a project, which adds these packages itself. `connectFailureRemedy()` chose the unbuilt remedy for every module-not-found except the three database clients PR #24933 split off (`pg`, `mysql2`, `better-sqlite3`). A bare `@objectstack/driver-*` specifier still classified as unbuilt, and four factory classifier pins asserted that. ## What changed Seat ruling **B** on the card. The code change is in `packages/services/service-datasource/src/connect-failure-remedy.ts`, and it is a message branch only. - New closed list `OPTIONAL_DRIVER_PACKAGES`, built from the factory's own constants (`TURSO_DRIVER_PACKAGE`, `MONGODB_DRIVER_PACKAGE`, `SQLITE_WASM_DRIVER_PACKAGE`). A pin holds it equal to the optional peers `package.json` declares (`peerDependenciesMeta.*.optional`). Measured set: exactly those three. - `missingOptionalDriverPackage(cause)` reads the same first `Cannot find module|package 'X'` specifier PR #24933 introduced. It answers X only when X is one of those three, as a bare name. - `connectFailureRemedy()` then ends on `missingOptionalDriverPackageRemedy(X)`. That is ONE sentence deferring to the install the factory text already names: "The fix is the install of `X` named above, then start again: it is an OPTIONAL driver package the project adds itself, so a build cannot produce it and no boot-time override can stand in for it." It names no command, no build, no environment variable and no configuration key. - `isUnbuiltWorkspaceFailure()` answers false for that case. - These keep their remedies: - A PATH into one of those packages (`…/driver-turso/dist/index.mjs`) keeps `UNBUILT_WORKSPACE_REMEDY`, and so does a subpath of one. - `@objectstack/driver-sql` and any other `@objectstack/…` name keep it too. - The three clients keep PR #24933's remedy. - Every other cause keeps `GENERIC_CONNECT_FAILURE_REMEDY`, byte for byte. - The module note gains a section for this case. Unchanged, per the hard stops: - The factory's own messages: `default-datasource-driver-factory.ts` is not in the diff. - When boot is refused, the error type, the retained state and the `OS_ALLOW_DRIVER_CONNECT_FAILURE` path. - `sqlite-driver-fallback.ts`, `packages/types/**`, `packages/drivers/**` and `packages/spec/**` are not touched. - No new env var, flag or gate. ## Premise re-check (the ruling's P), measured before any edit P: in this monorepo, once `pnpm install` has run, a missing or unbuilt optional driver surfaces as a PATH, not as a bare `Cannot find package` name. Setup: a fresh worktree at `8cfe1f94cb`, `pnpm install --frozen-lockfile`, no build. All three packages are linked into `service-datasource/node_modules/@objectstack/`. - **Resolver leg** (no build at all): Node 22, from `packages/services/service-datasource`. For each package, ESM and CJS both answer a path: - ESM: `ERR_MODULE_NOT_FOUND | Cannot find module '…/service-datasource/node_modules/@objectstack/driver-turso/dist/index.mjs' imported from …` - CJS: `MODULE_NOT_FOUND | Cannot find module '…/driver-turso/dist/index.js'` - `driver-mongodb` and `driver-sqlite-wasm` answer the same shape. - **Boot leg**: the service's closure built EXCEPT the three drivers, then a datasource bound to `my_app_note` booted per driver through the real factory and the real `DatasourceConnectionService` (built `dist/`). Every arm's first unresolved specifier is the `…/driver-X/dist/index.mjs` PATH. P holds, so B ships. A contributor with an unbuilt checkout is still told to build. That is unchanged by this PR, and the pins for it are now path-shaped. ## Measured on the card's path, before and after The published `@objectstack/service-datasource@18.0.0` was installed alone in an empty directory, with no driver packages (`npm install --ignore-scripts`). A declared datasource bound to one object was booted per optional driver through the published factory and `DatasourceConnectionService`, under both `dist/index.js` (ESM) and `dist/index.cjs` (CJS). - **Before (18.0.0):** - the first unresolved specifier is the bare `@objectstack/driver-turso` (`-mongodb`, `-sqlite-wasm`); - the refusal names `npm install` once AND ends on "… Run `pnpm install && pnpm build`, then start again." - **After:** the same directory with this branch's packed build installed over the published one (`pnpm pack` of `service-datasource`, marker grep: positive control 1 hit, the 18.0.0 copy 0 hits): - all three arms, ESM and CJS, end on "The fix is the install of `@objectstack/driver-X` named above, then start again: …"; - `npm install` appears exactly once in the whole refusal; - `pnpm install` and `pnpm build` are absent. - **NOT MEASURED:** the `os dev` scaffold path itself (`create-objectstack@18.0.0`, a turso datasource). Reason: the CLI reaches this refusal through the same factory and `DatasourceConnectionService` measured above. The card measured the scaffold's before-state, and a scaffold after-state needs this unpublished build overridden into a full CLI install. ## Tests (head `87b717e5a0`) - `pnpm --filter @objectstack/service-datasource typecheck`: `tsc --noEmit`, exit 0. `--listFiles` includes both edited test files. - `pnpm --filter @objectstack/service-datasource test`: `Test Files 49 passed (49)`, `Tests 899 passed (899)`. - New pins (`datasource-connection-service.test.ts`): - Each optional package × {ESM `Cannot find package 'X' imported from …` with `ERR_MODULE_NOT_FOUND`, CJS `Cannot find module 'X'` plus `Require stack` with `MODULE_NOT_FOUND`} goes through the factory's real message builders (the turso one as `MissingDriverPackageError`). Each ends on the deferring sentence. The whole refusal holds exactly one `npm install`, no `pnpm build`, no `pnpm install`, no unbuilt remedy and no generic advice. - A path into each package keeps the unbuilt remedy, and so does a subpath. - The remedy is one sentence with no command, `OS_*` variable or `--database`. - The escape hatch still boots degraded. - The list equals the manifest's optional peers. - Classifier units: bare name only, module-not-found only, `@objectstack/driver-sql` is not on the list, a code-only error names nothing. - The existing #24933 client pins and the `ECONNREFUSED` / `28P01` generic pins are unchanged and green. - Revised factory classifier pins (`default-datasource-driver-factory.test.ts`). The card says three; there are FOUR sites asserting `isUnbuiltWorkspaceFailure(...)` true on a bare-package cause: the turso, sqlite-wasm and mongodb "keeps the import error verbatim" cases, and the three-arm parity case. - Each now asserts the unbuilt reading on a PATH-shaped cause (`unbuiltCause(pkg)`, the measured shape). - Each asserts the bare name is that package's missing install. - The parity block gains one case for the bare reading. - No real-path pin was added, because none is cheap here. vitest wraps every mock-factory throw in its own "[vitest] There was an error when mocking a module" error (`@vitest/mocker` `createHelpfulError`, measured in the installed 4.1.11). A staged absence therefore cannot carry Node's text to the classifier. The factory has no import seam, and its file is off this PR's surface. The before/after run above is the real-path evidence instead. ## Ablation (one leg per changed behaviour; fix committed first; `node scripts/ablation-replace.mjs`, WRAP mode) Each leg ran the three suites `datasource-connection-service`, `default-datasource-driver-factory` and `missing-driver-client-boot-refusal`. The subjects resolve through relative `src` imports, so no `dist/` leg applies. 1. **Delete the `connectFailureRemedy` branch** (`if (driverPackage !== undefined) return missingOptionalDriverPackageRemedy(driverPackage);`). - On disk: anchor 1 → 0, blob `e6403dd54e18` → `bf36838df528`. - Predicted red. Result: `Tests 6 failed | 150 passed`, exactly the six ESM/CJS deferring-remedy pins. - Restored by `git checkout HEAD --`: blob `e6403dd54e18` == HEAD, `git diff HEAD` empty, `git status` clean. 2. **Drop the `isUnbuiltWorkspaceFailure` exclusion** (`&& missingOptionalDriverPackage(cause) === undefined`). - The first attempt was a no-op. The tool refused it on its own replacement-count check (newline replacement), restored, and never ran the suites. - Re-run with a distinctive replacement. On disk: anchor 1 → 0, replacement 0 → 1, blob `e6403dd54e18` → `ebf06e9d96f4`. - Predicted: the classifier pins go red, and the service-level remedy pins stay green because the branch runs first. Result: `Tests 5 failed | 151 passed`, exactly the five classifier pins. The four factory sites plus the unit failed; the remedy pins stayed green. - Restored: blob == HEAD, `git diff HEAD` empty. ## Gates (local subset, head `87b717e5a0`) - Derivation: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 66 commands, 4 changed paths vs merge base `8cfe1f94c`. - Each command was run once, exit captured before any pipe. - Result: 65 exit 0, and 1 NOT MEASURED, `pnpm check:dual-build-cjs-loads`. It exited 3 with `PREREQUISITE NOT MET`: it reads every package's `dist/`, and this worktree builds only the `service-datasource` closure. CI's `Lint & Repo Gates` runs it. - `--ran` reconciliation: `✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 65 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`, exit 0. - The repo-wide scans and the CI-only families (`Test Core`, `Build Core`, the type-check lanes) are CI's. ## Acceptance notes - **The #24909 changeset goes partly false when this lands.** Its "Unchanged" bullet in `.changeset/24909-missing-driver-client-remedy.md` is still unreleased. It reads "an `@objectstack/…` driver package that is not installed, still ends on `pnpm install && pnpm build`". That stops being true for the three optional driver packages. - The file is outside this PR's declared surface, so it is not edited here. - Suggested one-word narrowing before the next release consumes it: "or a required `@objectstack/…` driver package that is not installed". - This PR's own changeset states the new behaviour. - **One factory comment drifts slightly.** In `default-datasource-driver-factory.ts`, the `missingTursoDriverMessage` doc says the classifier "can only recognise an unbuilt/uninstalled workspace" from the interpolated text. With this change an uninstalled optional package is read as an install, not as unbuilt. The file is off-surface by the hard stop, and nothing else in it is made false. Carrier: none. - **The workspace mirror is unchanged.** In this monorepo with an UNBUILT optional driver, the factory's text says "is not installed … `npm install @objectstack/driver-X`", and the refusal then correctly ends on the build advice. The boot-leg measurement above shows `npm install` and `pnpm build` both present. That is the factory message's wording, which is out of scope here by the hard stop, and ruling B accepted the workspace reading. It was measured at the service seam only, with no public door. Carrier: none. > **Seat's append** (`domain:services` seat 1, `session_011TdZ8GjixNZFLz1xJD9zzK`): the dev's round-2 markdown from os-dev report `6300109209` on #24937, verbatim. The dev writes the body once and never PATCHes it. The seat's written confirmation of the DELIBERATE CORRECTION is in its ACCEPT on this PR. ## Round 2: the client-remedy changeset's "Unchanged" bullet (seat ruling A on open question 1) `.changeset/24909-missing-driver-client-remedy.md` is still unreleased. Its "Unchanged" bullet said "an `@objectstack/…` driver package that is not installed, still ends on `pnpm install && pnpm build`", which this PR makes false for the three optional driver packages. That one bullet now reads: > A driver package whose build output is missing, or a required `@objectstack/…` driver package that is not installed (any but the optional `@objectstack/driver-turso`, `@objectstack/driver-mongodb` and `@objectstack/driver-sqlite-wasm`), still ends on `pnpm install && pnpm build`. - It is a one-line edit (`1 insertion(+), 1 deletion(-)`). No other line of that file changed, and no other file changed in this round. - It matches the code. A path into any driver package's `dist/` is still the unbuilt remedy, and so is a bare `@objectstack/…` name off `OPTIONAL_DRIVER_PACKAGES` (e.g. `@objectstack/driver-sql`). A bare name on that list ends on `missingOptionalDriverPackageRemedy`. - `origin/main` `693352e4c2` was merged first. It brings PR #24955 on `sqlite-driver-fallback.ts`, disjoint from this diff. - Head `e759c6c8e9`: - `pnpm --filter @objectstack/service-datasource typecheck`: exit 0. - `pnpm --filter @objectstack/service-datasource test`: `Test Files 50 passed (50)`, `Tests 908 passed (908)`. That includes #24955's new file. - Gates: re-derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`. That gives 5 paths vs merge base `693352e4c` and 66 commands, the same set as round 1 (the new path adds no family). All 66 were re-run at this head under the lock, `node scripts/check-empty-changeset.mjs --base origin/main` among them. Results: 64 exit 0; 1 NOT MEASURED, `pnpm check:dual-build-cjs-loads` (exit 3, `PREREQUISITE NOT MET`, it reads every package's `dist/`); and 1 red BY DESIGN, `node scripts/check-empty-changeset.mjs --base origin/main` (exit 1). `--ran` reconciliation: `✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 65 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`, exit 0. ### `Check Changeset` stays red, and that is the gate's DELIBERATE CORRECTION class `check-empty-changeset` refuses any PR that changes a changeset it did not add. Here that is `.changeset/24909-missing-driver-client-remedy.md`, `M` against merge base `693352e4c`, beside this PR's own `A` `.changeset/24937-optional-driver-package-remedy.md`. The gate names two classes with opposite remedies. This is the second one, not a filename collision: this PR's own code change made that PENDING release note false, and this round rewrites only the sentence it falsified. Per the gate's own remedy text, which `pr-automation.yml`'s route 0 repeats for the same class: - The note is NOT restored from the base. That would republish the false sentence. - `skip-changeset` is NOT applied. Ruled on #18375 (ruling D): never on a PR that edits an existing changeset. - The check is left red. `Check Changeset` is not one of the five required contexts, so it blocks no merge. The note is `.changeset/24909-missing-driver-client-remedy.md`. What changed under it: this PR routes a bare `@objectstack/driver-turso` / `-mongodb` / `-sqlite-wasm` module-not-found to the factory's install instead of the build advice. Only its "Unchanged" bullet's second clause is narrowed to match. The correction was ruled by the `domain:services` seat 1 PM (ruling A on open question 1 of the os-dev report on #24937). The seat records that confirmation on this PR in writing. --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a7a60a commit b811c73

5 files changed

Lines changed: 350 additions & 22 deletions

File tree

‎.changeset/24909-missing-driver-client-remedy.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ Clause-②: no
88

99
- **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.
1010
- **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.
11+
- **Unchanged.** A driver package whose build output is missing, or a required `@objectstack/…` driver package that is not installed (any but the optional `@objectstack/driver-turso`, `@objectstack/driver-mongodb` and `@objectstack/driver-sqlite-wasm`), 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.
1212

1313
What to do: install the client the refusal names next to the server that opens the datasource, then start again.
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 optional driver package is not installed (`@objectstack/driver-turso`, `@objectstack/driver-mongodb`, `@objectstack/driver-sqlite-wasm`) is refused with that package's install alone, no longer followed by "Run `pnpm install && pnpm build`"
6+
7+
Clause-②: no
8+
9+
- **What was wrong.** A `turso`, `mongodb` or `sqlite-wasm` datasource whose driver package is not installed is refused at boot, and the refusal already says so and names the install (`npm install @objectstack/driver-turso`, with the `pnpm add` and `yarn add` spellings beside it). It then went on to end on the unbuilt-workspace remedy anyway: "… That is a build precondition … Run `pnpm install && pnpm build`, then start again." Those are two fixes that contradict each other, and the second does nothing for a project, which adds these optional packages itself. Measured on the published release with a declared `turso` datasource and no `@objectstack/driver-turso` installed.
10+
- **Now.** When the module that could not be resolved is one of those three packages, by its bare name (`Cannot find package '@objectstack/driver-turso'` under ESM, `Cannot find module '@objectstack/driver-turso'` under CJS), the refusal ends on one sentence that defers to the install named above it. It names no second command, no build step and no environment variable.
11+
- **Unchanged.** A driver package whose build output is missing (the error names a path into its `dist/`, which is what a monorepo checkout that has been installed but not built reports), or a required `@objectstack/…` package that is not installed, still ends on `pnpm install && pnpm build`. A missing `pg`, `mysql2` or `better-sqlite3` client still ends on the install of that client. 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, and the driver factory's own messages are unchanged: only the closing sentence of the refusal is different.
12+
13+
What to do: install the driver package the refusal names next to the server that opens the datasource, then start again.

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

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,27 @@ import {
1010
import type { IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js';
1111
import type { IDataDriver } from '@objectstack/spec/contracts';
1212
import type { DatasourceConnectPolicy } from '../contracts/connect-policy.js';
13+
import { readFileSync } from 'node:fs';
1314
import {
1415
GENERIC_CONNECT_FAILURE_REMEDY,
16+
OPTIONAL_DRIVER_PACKAGES,
1517
UNBUILT_WORKSPACE_REMEDY,
1618
isUnbuiltWorkspaceFailure,
1719
missingDriverClient,
1820
missingDriverClientRemedy,
21+
missingOptionalDriverPackage,
22+
missingOptionalDriverPackageRemedy,
1923
} from '../connect-failure-remedy.js';
20-
import { missingSqliteWasmDriverMessage } from '../default-datasource-driver-factory.js';
24+
import {
25+
MONGODB_DRIVER_PACKAGE,
26+
SQLITE_WASM_DRIVER_PACKAGE,
27+
TURSO_DRIVER_INSTALL_COMMAND,
28+
TURSO_DRIVER_PACKAGE,
29+
missingMongodbDriverMessage,
30+
missingSqliteWasmDriverMessage,
31+
missingTursoDriverMessage,
32+
} from '../default-datasource-driver-factory.js';
33+
import { MissingDriverPackageError } from '../missing-driver-package-error.js';
2134

2235
/** One `markDatasourceUnavailable` call, as the engine would receive it. */
2336
type UnavailableCall = { name: string; kind: 'blocked' | 'failed'; publicDetail?: string };
@@ -673,6 +686,112 @@ describe('fail-fast remedy is chosen by CAUSE (#5794)', () => {
673686
});
674687
});
675688

689+
// #24937: the shared factory's `turso`, `mongodb` and `sqlite-wasm` arms load
690+
// a package this service declares only as an OPTIONAL peer. When it is
691+
// absent the arm already names its install, and the refusal used to append
692+
// "a build precondition … Run `pnpm install && pnpm build`" after it — two
693+
// fixes that contradict each other. Measured on the published release with a
694+
// declared `turso` datasource and no `@objectstack/driver-turso`.
695+
describe("a missing optional driver package defers to the factory's install (#24937)", () => {
696+
/**
697+
* Each arm's import failure, wrapped exactly as that arm wraps it: the turso
698+
* arm raises the typed `MissingDriverPackageError`, the other two a plain
699+
* `Error`, and all three interpolate the import error at the END of their
700+
* own message (the factory's real builders, not a copy of their wording).
701+
*/
702+
const arms: Array<[pkg: string, wrap: (cause: Error) => Error]> = [
703+
[
704+
TURSO_DRIVER_PACKAGE,
705+
(cause) =>
706+
new MissingDriverPackageError({
707+
driverType: 'turso',
708+
packageName: TURSO_DRIVER_PACKAGE,
709+
installCommand: TURSO_DRIVER_INSTALL_COMMAND,
710+
message: missingTursoDriverMessage({ datasource: 'analytics', cause }),
711+
}),
712+
],
713+
[MONGODB_DRIVER_PACKAGE, (cause) => new Error(missingMongodbDriverMessage({ datasource: 'analytics', cause }))],
714+
[
715+
SQLITE_WASM_DRIVER_PACKAGE,
716+
(cause) => new Error(missingSqliteWasmDriverMessage({ datasource: 'analytics', cause })),
717+
],
718+
];
719+
720+
/** The two module systems' answers for a package that is not installed, as the published build raises them. */
721+
const bareImportErrors: Array<[system: string, error: (pkg: string) => Error]> = [
722+
[
723+
'ESM',
724+
(pkg) =>
725+
withCode(
726+
new Error(`Cannot find package '${pkg}' imported from /app/node_modules/@objectstack/service-datasource/dist/index.js`),
727+
'ERR_MODULE_NOT_FOUND',
728+
),
729+
],
730+
[
731+
'CJS',
732+
(pkg) =>
733+
withCode(
734+
new Error(
735+
`Cannot find module '${pkg}'\nRequire stack:\n- /app/node_modules/@objectstack/service-datasource/dist/index.cjs`,
736+
),
737+
'MODULE_NOT_FOUND',
738+
),
739+
],
740+
];
741+
742+
const cases = arms.flatMap(([pkg, wrap]) =>
743+
bareImportErrors.map(([system, error]) => [pkg, system, wrap, error] as const),
744+
);
745+
746+
it.each(cases)('%s absent (%s): the refusal ends on one sentence deferring to the install', async (pkg, _system, wrap, error) => {
747+
const err = await failFast(factoryThrowing(wrap(error(pkg))));
748+
expect(err.message.endsWith(missingOptionalDriverPackageRemedy(pkg))).toBe(true);
749+
// ONE fix in the whole refusal: the factory's install command, once. The
750+
// remedy adds no second spelling and no build.
751+
expect(err.message.match(/\bnpm install\b/g)).toHaveLength(1);
752+
expect(err.message).toContain(`npm install ${pkg}`);
753+
expect(err.message).not.toContain('pnpm build');
754+
expect(err.message).not.toContain('pnpm install');
755+
expect(err.message).not.toContain(UNBUILT_WORKSPACE_REMEDY);
756+
expectNoHarmfulAdvice(err.message);
757+
});
758+
759+
it.each(arms)('%s unbuilt — a PATH into the package — keeps the unbuilt-workspace remedy', async (pkg, wrap) => {
760+
const unbuilt = withCode(
761+
new Error(`Cannot find module '/w/node_modules/${pkg}/dist/index.mjs' imported from /w/x.js`),
762+
'ERR_MODULE_NOT_FOUND',
763+
);
764+
const err = await failFast(factoryThrowing(wrap(unbuilt)));
765+
expect(err.message.endsWith(UNBUILT_WORKSPACE_REMEDY)).toBe(true);
766+
});
767+
768+
it('a subpath of an optional driver package (the package IS installed) keeps the unbuilt-workspace remedy', async () => {
769+
const err = await failFast(
770+
factoryThrowing(new Error(`Cannot find module '${TURSO_DRIVER_PACKAGE}/dist/index.js'`)),
771+
);
772+
expect(err.message.endsWith(UNBUILT_WORKSPACE_REMEDY)).toBe(true);
773+
});
774+
775+
it('the remedy is one sentence naming no command, environment variable or configuration key', () => {
776+
for (const pkg of OPTIONAL_DRIVER_PACKAGES) {
777+
const remedy = missingOptionalDriverPackageRemedy(pkg);
778+
expect(remedy).toContain(pkg);
779+
expect(remedy.match(/[.!?](?=\s|$)/g)).toHaveLength(1);
780+
expect(remedy).not.toMatch(/\bnpm install\b/);
781+
expect(remedy).not.toMatch(/\b(?:pnpm|yarn) (?:add|install|build)\b/);
782+
expect(remedy).not.toMatch(/\bOS_[A-Z_]+/);
783+
expect(remedy).not.toContain('--database');
784+
}
785+
});
786+
787+
it('the escape hatch still boots degraded instead of refusing', async () => {
788+
process.env[ENV] = '1';
789+
const { service } = svc({ factory: factoryThrowing(arms[0][1](bareImportErrors[0][1](TURSO_DRIVER_PACKAGE))) });
790+
const result = await service.connect(analytics, { objects: ['visit'], context: { trigger: 'declared-auto' } });
791+
expect(result.status).toBe('failed-degraded');
792+
});
793+
});
794+
676795
// "Diagnostic classification, zero behaviour change" is the whole claim of
677796
// #5794 — so the things that did NOT move are pinned next to the thing that did.
678797
describe('zero behaviour change', () => {
@@ -791,6 +910,46 @@ describe('missingDriverClient (#24909)', () => {
791910
});
792911
});
793912

913+
describe('missingOptionalDriverPackage (#24937)', () => {
914+
it('is exactly the optional peers this package declares', () => {
915+
// The list promises a remedy that defers to the factory's install, so it
916+
// must name the packages this service really declares optional — read from
917+
// its own manifest, not restated here.
918+
const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as {
919+
peerDependencies?: Record<string, string>;
920+
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
921+
};
922+
const declaredOptional = Object.keys(manifest.peerDependencies ?? {}).filter(
923+
(name) => manifest.peerDependenciesMeta?.[name]?.optional === true,
924+
);
925+
expect([...OPTIONAL_DRIVER_PACKAGES].sort()).toEqual(declaredOptional.sort());
926+
});
927+
928+
it('names the package only for a module-resolution failure on its bare name', () => {
929+
expect(missingOptionalDriverPackage(new Error(`Cannot find package '${MONGODB_DRIVER_PACKAGE}'`))).toBe(
930+
MONGODB_DRIVER_PACKAGE,
931+
);
932+
// The same name in a message that is NOT a module-not-found is not this case.
933+
expect(missingOptionalDriverPackage(new Error(`${MONGODB_DRIVER_PACKAGE}: connect ECONNREFUSED`))).toBeUndefined();
934+
// A path into the package, or a subpath of it, is not a missing install.
935+
expect(
936+
missingOptionalDriverPackage(new Error(`Cannot find module '/w/node_modules/${TURSO_DRIVER_PACKAGE}/dist/index.mjs'`)),
937+
).toBeUndefined();
938+
expect(missingOptionalDriverPackage(new Error(`Cannot find module '${TURSO_DRIVER_PACKAGE}/dist/x.js'`))).toBeUndefined();
939+
// A required driver package is not on the list: it stays the unbuilt workspace.
940+
expect(missingOptionalDriverPackage(new Error("Cannot find package '@objectstack/driver-sql'"))).toBeUndefined();
941+
expect(missingOptionalDriverPackage(withCodeOnly('ERR_MODULE_NOT_FOUND'))).toBeUndefined();
942+
expect(missingOptionalDriverPackage(undefined)).toBeUndefined();
943+
});
944+
945+
it('leaves the unbuilt-workspace classifier off for that case only', () => {
946+
expect(isUnbuiltWorkspaceFailure(new Error(`Cannot find package '${SQLITE_WASM_DRIVER_PACKAGE}'`))).toBe(false);
947+
expect(
948+
isUnbuiltWorkspaceFailure(new Error(`Cannot find module '/w/node_modules/${SQLITE_WASM_DRIVER_PACKAGE}/dist/index.js'`)),
949+
).toBe(true);
950+
});
951+
});
952+
794953
function withCodeOnly(code: string): Error {
795954
return Object.assign(new Error('the entry point is not on disk'), { code });
796955
}

0 commit comments

Comments
 (0)