From 2d74bf30b3e91e27e60fb70d5f3a922d32bb999e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:54:13 +0000 Subject: [PATCH 1/4] fix(service-datasource): a missing optional driver package ends its refusal on the factory's install, not the build advice A bare @objectstack/driver-turso / -mongodb / -sqlite-wasm specifier is a missing install the project makes itself; the refusal now ends on one sentence deferring to the install the factory text already names. A path into one of those packages stays on the unbuilt-workspace remedy. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../src/connect-failure-remedy.ts | 109 +++++++++++++++++- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/packages/services/service-datasource/src/connect-failure-remedy.ts b/packages/services/service-datasource/src/connect-failure-remedy.ts index 78e1c14f40..e6403dd54e 100644 --- a/packages/services/service-datasource/src/connect-failure-remedy.ts +++ b/packages/services/service-datasource/src/connect-failure-remedy.ts @@ -91,9 +91,10 @@ * Only the closed list in {@link OPTIONAL_DRIVER_CLIENT_PACKAGES} is re-routed: * it is exactly the clients the factory's knex arms load, so it names a fix * that is known to be right. Every other module-not-found — any path, any - * `@objectstack/…` package, any other bare name (a hard dependency such as - * `knex` missing is a broken install, for which `pnpm install` is the fix) — - * keeps the unbuilt remedy unchanged. + * `@objectstack/…` package other than the optional driver packages below, any + * other bare name (a hard dependency such as `knex` missing is a broken + * install, for which `pnpm install` is the fix) — keeps the unbuilt remedy + * unchanged. * * The client remedy keeps this module's discipline: it names the install of * THAT module once and stops — no build, no environment variable, no @@ -102,6 +103,41 @@ * optional-driver messages already do: `npm install `, with the pnpm * and yarn spellings of the same command beside it. * + * ## A missing OPTIONAL driver package is an install too (#24937) + * + * Three of the shared factory's arms load their driver from a package this + * service declares only as an OPTIONAL peer — `@objectstack/driver-turso`, + * `@objectstack/driver-mongodb` and `@objectstack/driver-sqlite-wasm` + * ({@link OPTIONAL_DRIVER_PACKAGES}). A project installs those itself, and when + * one is absent the arm already says so and names the install + * (`missingTursoDriverMessage` and its two siblings in + * `default-datasource-driver-factory.ts`). The refusal used to append the + * unbuilt remedy after that text anyway, so it named two fixes that contradict + * each other: the install, then "a build precondition … Run `pnpm install && + * pnpm build`". Measured on the published release, outside any workspace, with + * a declared `turso` datasource and no `@objectstack/driver-turso`: + * + * ```text + * ✗ datasource 'notes_store': connect failed — … the driver package + * @objectstack/driver-turso is not installed. … npm install @objectstack/driver-turso + * … Import error: Cannot find package '@objectstack/driver-turso' imported from … + * … That is a build precondition … Run `pnpm install && pnpm build`, then start again. + * ``` + * + * The split is the same specifier read as the client one, and it falls the same + * way for every arm: a BARE optional driver package name is a missing install, + * so the refusal ends on {@link missingOptionalDriverPackageRemedy}, ONE + * sentence that defers to the install the factory's text has already named — + * no second command, no build, no environment variable. A PATH into one of + * those packages (`…/@objectstack/driver-turso/dist/index.mjs`) is still an + * unbuilt workspace and keeps the unbuilt remedy, and so does a subpath of one + * (the package IS installed). Measured in this monorepo on a fresh worktree + * after `pnpm install` and before any build: all three packages are linked, so + * each arm's refusal names the PATH of the missing `dist/` entry, never the bare + * name — a contributor with an unbuilt checkout is still told to build. The + * accepted cost is a contributor whose install is stale enough to lack the link + * itself: they are pointed at the package's install, not at `pnpm install`. + * * ## Scope: a message branch, nothing else * * This changes no behaviour. The fail-fast verdict, when it fires, which error @@ -111,6 +147,11 @@ */ import { isModuleNotFoundError } from '@objectstack/types'; +import { + MONGODB_DRIVER_PACKAGE, + SQLITE_WASM_DRIVER_PACKAGE, + TURSO_DRIVER_PACKAGE, +} from './default-datasource-driver-factory.js'; /** * The remedy for every cause except the unbuilt workspace — a database that is @@ -168,6 +209,42 @@ export function missingDriverClientRemedy(client: string): string { ); } +/** + * The driver packages this service declares as OPTIONAL peers and the shared + * factory loads lazily — one per arm (`turso`, `mongodb`, `sqlite-wasm`), named + * through the factory's own constants so the two cannot drift. A + * module-not-found naming one of these, as a bare specifier, is a missing + * install and not an unbuilt workspace. + * + * Closed on purpose, like {@link OPTIONAL_DRIVER_CLIENT_PACKAGES}: a name on + * this list is promised a remedy that defers to the factory's install text, so + * it holds only the packages whose arm states that install. The pins hold it + * equal to the optional peers `package.json` declares. + */ +export const OPTIONAL_DRIVER_PACKAGES: readonly string[] = [ + TURSO_DRIVER_PACKAGE, + MONGODB_DRIVER_PACKAGE, + SQLITE_WASM_DRIVER_PACKAGE, +]; + +/** + * The remedy when an optional driver package is not installed. + * + * ONE sentence, and it names no command: the factory's own text, which the + * refusal carries above this sentence, already names the install of that + * package (`npm install `, with the pnpm and yarn spellings beside + * it), so a second spelling here would only be a second fix to choose between. + * No build, no environment variable, no configuration key — see the module + * note. + */ +export function missingOptionalDriverPackageRemedy(packageName: string): string { + return ( + `The fix is the install of \`${packageName}\` 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.` + ); +} + /** `Cannot find module 'x'` (CJS `require`) and `Cannot find package 'x'` (ESM `import`). */ const UNRESOLVED_SPECIFIER = /Cannot find (?:module|package) '([^']+)'/; @@ -202,10 +279,26 @@ export function missingDriverClient(cause: unknown): string | undefined { : undefined; } +/** + * The optional driver package this connect failed to load, when that is the + * cause — one of {@link OPTIONAL_DRIVER_PACKAGES}, named exactly as a bare + * specifier. A path into the package (`…/driver-turso/dist/index.mjs`) is an + * unbuilt workspace and a subpath means the package IS installed; neither is + * this case. + */ +export function missingOptionalDriverPackage(cause: unknown): string | undefined { + const specifier = unresolvedSpecifier(cause); + return specifier !== undefined && OPTIONAL_DRIVER_PACKAGES.includes(specifier) + ? specifier + : undefined; +} + /** * Did this connect fail because the driver's module could not be RESOLVED, as * opposed to a connection that was attempted and refused — and was it not - * merely a missing database client ({@link missingDriverClient})? + * merely a missing install the project makes itself: a database client + * ({@link missingDriverClient}) or an optional driver package + * ({@link missingOptionalDriverPackage})? * * Delegates to `@objectstack/types`' shared classifier — the single owner of * this judgement since framework#3265, already relied on by the CLI's @@ -226,7 +319,11 @@ export function missingDriverClient(cause: unknown): string | undefined { */ export function isUnbuiltWorkspaceFailure(cause: unknown): boolean { if (cause === undefined) return false; - return isModuleNotFoundError(cause) && missingDriverClient(cause) === undefined; + return ( + isModuleNotFoundError(cause) && + missingDriverClient(cause) === undefined && + missingOptionalDriverPackage(cause) === undefined + ); } /** @@ -237,5 +334,7 @@ export function isUnbuiltWorkspaceFailure(cause: unknown): boolean { export function connectFailureRemedy(cause: unknown): string { const client = missingDriverClient(cause); if (client !== undefined) return missingDriverClientRemedy(client); + const driverPackage = missingOptionalDriverPackage(cause); + if (driverPackage !== undefined) return missingOptionalDriverPackageRemedy(driverPackage); return isUnbuiltWorkspaceFailure(cause) ? UNBUILT_WORKSPACE_REMEDY : GENERIC_CONNECT_FAILURE_REMEDY; } From bc8a2c4de2d3c2ede48feee72fb84919ea89de05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 18:56:57 +0000 Subject: [PATCH 2/4] test(service-datasource): pin the optional driver package remedy; factory classifier pins read a path for the unbuilt case Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../datasource-connection-service.test.ts | 161 +++++++++++++++++- .../default-datasource-driver-factory.test.ts | 87 ++++++++-- 2 files changed, 232 insertions(+), 16 deletions(-) diff --git a/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts b/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts index 2e5f0f6ca1..1b9117f968 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-connection-service.test.ts @@ -10,14 +10,27 @@ import { import type { IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js'; import type { IDataDriver } from '@objectstack/spec/contracts'; import type { DatasourceConnectPolicy } from '../contracts/connect-policy.js'; +import { readFileSync } from 'node:fs'; import { GENERIC_CONNECT_FAILURE_REMEDY, + OPTIONAL_DRIVER_PACKAGES, UNBUILT_WORKSPACE_REMEDY, isUnbuiltWorkspaceFailure, missingDriverClient, missingDriverClientRemedy, + missingOptionalDriverPackage, + missingOptionalDriverPackageRemedy, } from '../connect-failure-remedy.js'; -import { missingSqliteWasmDriverMessage } from '../default-datasource-driver-factory.js'; +import { + MONGODB_DRIVER_PACKAGE, + SQLITE_WASM_DRIVER_PACKAGE, + TURSO_DRIVER_INSTALL_COMMAND, + TURSO_DRIVER_PACKAGE, + missingMongodbDriverMessage, + missingSqliteWasmDriverMessage, + missingTursoDriverMessage, +} from '../default-datasource-driver-factory.js'; +import { MissingDriverPackageError } from '../missing-driver-package-error.js'; /** One `markDatasourceUnavailable` call, as the engine would receive it. */ type UnavailableCall = { name: string; kind: 'blocked' | 'failed'; publicDetail?: string }; @@ -673,6 +686,112 @@ describe('fail-fast remedy is chosen by CAUSE (#5794)', () => { }); }); + // #24937: the shared factory's `turso`, `mongodb` and `sqlite-wasm` arms load + // a package this service declares only as an OPTIONAL peer. When it is + // absent the arm already names its install, and the refusal used to append + // "a build precondition … Run `pnpm install && pnpm build`" after it — two + // fixes that contradict each other. Measured on the published release with a + // declared `turso` datasource and no `@objectstack/driver-turso`. + describe("a missing optional driver package defers to the factory's install (#24937)", () => { + /** + * Each arm's import failure, wrapped exactly as that arm wraps it: the turso + * arm raises the typed `MissingDriverPackageError`, the other two a plain + * `Error`, and all three interpolate the import error at the END of their + * own message (the factory's real builders, not a copy of their wording). + */ + const arms: Array<[pkg: string, wrap: (cause: Error) => Error]> = [ + [ + TURSO_DRIVER_PACKAGE, + (cause) => + new MissingDriverPackageError({ + driverType: 'turso', + packageName: TURSO_DRIVER_PACKAGE, + installCommand: TURSO_DRIVER_INSTALL_COMMAND, + message: missingTursoDriverMessage({ datasource: 'analytics', cause }), + }), + ], + [MONGODB_DRIVER_PACKAGE, (cause) => new Error(missingMongodbDriverMessage({ datasource: 'analytics', cause }))], + [ + SQLITE_WASM_DRIVER_PACKAGE, + (cause) => new Error(missingSqliteWasmDriverMessage({ datasource: 'analytics', cause })), + ], + ]; + + /** The two module systems' answers for a package that is not installed, as the published build raises them. */ + const bareImportErrors: Array<[system: string, error: (pkg: string) => Error]> = [ + [ + 'ESM', + (pkg) => + withCode( + new Error(`Cannot find package '${pkg}' imported from /app/node_modules/@objectstack/service-datasource/dist/index.js`), + 'ERR_MODULE_NOT_FOUND', + ), + ], + [ + 'CJS', + (pkg) => + withCode( + new Error( + `Cannot find module '${pkg}'\nRequire stack:\n- /app/node_modules/@objectstack/service-datasource/dist/index.cjs`, + ), + 'MODULE_NOT_FOUND', + ), + ], + ]; + + const cases = arms.flatMap(([pkg, wrap]) => + bareImportErrors.map(([system, error]) => [pkg, system, wrap, error] as const), + ); + + it.each(cases)('%s absent (%s): the refusal ends on one sentence deferring to the install', async (pkg, _system, wrap, error) => { + const err = await failFast(factoryThrowing(wrap(error(pkg)))); + expect(err.message.endsWith(missingOptionalDriverPackageRemedy(pkg))).toBe(true); + // ONE fix in the whole refusal: the factory's install command, once. The + // remedy adds no second spelling and no build. + expect(err.message.match(/\bnpm install\b/g)).toHaveLength(1); + expect(err.message).toContain(`npm install ${pkg}`); + expect(err.message).not.toContain('pnpm build'); + expect(err.message).not.toContain('pnpm install'); + expect(err.message).not.toContain(UNBUILT_WORKSPACE_REMEDY); + expectNoHarmfulAdvice(err.message); + }); + + it.each(arms)('%s unbuilt — a PATH into the package — keeps the unbuilt-workspace remedy', async (pkg, wrap) => { + const unbuilt = withCode( + new Error(`Cannot find module '/w/node_modules/${pkg}/dist/index.mjs' imported from /w/x.js`), + 'ERR_MODULE_NOT_FOUND', + ); + const err = await failFast(factoryThrowing(wrap(unbuilt))); + expect(err.message.endsWith(UNBUILT_WORKSPACE_REMEDY)).toBe(true); + }); + + it('a subpath of an optional driver package (the package IS installed) keeps the unbuilt-workspace remedy', async () => { + const err = await failFast( + factoryThrowing(new Error(`Cannot find module '${TURSO_DRIVER_PACKAGE}/dist/index.js'`)), + ); + expect(err.message.endsWith(UNBUILT_WORKSPACE_REMEDY)).toBe(true); + }); + + it('the remedy is one sentence naming no command, environment variable or configuration key', () => { + for (const pkg of OPTIONAL_DRIVER_PACKAGES) { + const remedy = missingOptionalDriverPackageRemedy(pkg); + expect(remedy).toContain(pkg); + expect(remedy.match(/[.!?](?=\s|$)/g)).toHaveLength(1); + expect(remedy).not.toMatch(/\bnpm install\b/); + expect(remedy).not.toMatch(/\b(?:pnpm|yarn) (?:add|install|build)\b/); + expect(remedy).not.toMatch(/\bOS_[A-Z_]+/); + expect(remedy).not.toContain('--database'); + } + }); + + it('the escape hatch still boots degraded instead of refusing', async () => { + process.env[ENV] = '1'; + const { service } = svc({ factory: factoryThrowing(arms[0][1](bareImportErrors[0][1](TURSO_DRIVER_PACKAGE))) }); + const result = await service.connect(analytics, { objects: ['visit'], context: { trigger: 'declared-auto' } }); + expect(result.status).toBe('failed-degraded'); + }); + }); + // "Diagnostic classification, zero behaviour change" is the whole claim of // #5794 — so the things that did NOT move are pinned next to the thing that did. describe('zero behaviour change', () => { @@ -791,6 +910,46 @@ describe('missingDriverClient (#24909)', () => { }); }); +describe('missingOptionalDriverPackage (#24937)', () => { + it('is exactly the optional peers this package declares', () => { + // The list promises a remedy that defers to the factory's install, so it + // must name the packages this service really declares optional — read from + // its own manifest, not restated here. + const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { + peerDependencies?: Record; + peerDependenciesMeta?: Record; + }; + const declaredOptional = Object.keys(manifest.peerDependencies ?? {}).filter( + (name) => manifest.peerDependenciesMeta?.[name]?.optional === true, + ); + expect([...OPTIONAL_DRIVER_PACKAGES].sort()).toEqual(declaredOptional.sort()); + }); + + it('names the package only for a module-resolution failure on its bare name', () => { + expect(missingOptionalDriverPackage(new Error(`Cannot find package '${MONGODB_DRIVER_PACKAGE}'`))).toBe( + MONGODB_DRIVER_PACKAGE, + ); + // The same name in a message that is NOT a module-not-found is not this case. + expect(missingOptionalDriverPackage(new Error(`${MONGODB_DRIVER_PACKAGE}: connect ECONNREFUSED`))).toBeUndefined(); + // A path into the package, or a subpath of it, is not a missing install. + expect( + missingOptionalDriverPackage(new Error(`Cannot find module '/w/node_modules/${TURSO_DRIVER_PACKAGE}/dist/index.mjs'`)), + ).toBeUndefined(); + expect(missingOptionalDriverPackage(new Error(`Cannot find module '${TURSO_DRIVER_PACKAGE}/dist/x.js'`))).toBeUndefined(); + // A required driver package is not on the list: it stays the unbuilt workspace. + expect(missingOptionalDriverPackage(new Error("Cannot find package '@objectstack/driver-sql'"))).toBeUndefined(); + expect(missingOptionalDriverPackage(withCodeOnly('ERR_MODULE_NOT_FOUND'))).toBeUndefined(); + expect(missingOptionalDriverPackage(undefined)).toBeUndefined(); + }); + + it('leaves the unbuilt-workspace classifier off for that case only', () => { + expect(isUnbuiltWorkspaceFailure(new Error(`Cannot find package '${SQLITE_WASM_DRIVER_PACKAGE}'`))).toBe(false); + expect( + isUnbuiltWorkspaceFailure(new Error(`Cannot find module '/w/node_modules/${SQLITE_WASM_DRIVER_PACKAGE}/dist/index.js'`)), + ).toBe(true); + }); +}); + function withCodeOnly(code: string): Error { return Object.assign(new Error('the entry point is not on disk'), { code }); } 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 dcd3a6fa13..7eb5ba3a5b 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 @@ -21,7 +21,7 @@ import { MONGODB_DRIVER_INSTALL_COMMAND, MONGODB_DRIVER_PACKAGE, } from '../default-datasource-driver-factory.js'; -import { isUnbuiltWorkspaceFailure } from '../connect-failure-remedy.js'; +import { isUnbuiltWorkspaceFailure, missingOptionalDriverPackage } from '../connect-failure-remedy.js'; import { MissingDriverPackageError } from '../missing-driver-package-error.js'; // [#23184] The first load of what the drivers stand on is paid HERE, at module // load, and not inside the first clocked case: clocked windows measure @@ -51,6 +51,26 @@ import '@objectstack/driver-sqlite-wasm'; const factory = () => createDefaultDatasourceDriverFactory({ dev: false }); +/** + * The import error an UNBUILT optional driver package raises in this monorepo: + * `pnpm install` links all three packages (they are optional peers AND, for + * two of them, `devDependencies` of this service), so a missing build names the + * PATH of the absent `dist/` entry, never the bare package name. Measured on a + * fresh worktree after `pnpm install` and before any build, for each of the + * three packages and both module systems (#24937). The bare name — `Cannot find + * package '@objectstack/driver-…'` — is what a project WITHOUT the package + * sees, and that is a missing install, not an unbuilt workspace. + */ +function unbuiltCause(pkg: string): Error { + return Object.assign( + new Error( + `Cannot find module '/w/packages/services/service-datasource/node_modules/${pkg}/dist/index.mjs' ` + + 'imported from /w/packages/services/service-datasource/dist/index.js', + ), + { code: 'ERR_MODULE_NOT_FOUND' }, + ); +} + describe('createDefaultDatasourceDriverFactory — driver id surface', () => { it('supports the sqlite-wasm id and its alias', () => { expect(factory().supports('sqlite-wasm')).toBe(true); @@ -439,15 +459,23 @@ describe('createDefaultDatasourceDriverFactory — the missing libSQL package is expect(message(notInstalled)).toContain("datasource 'default'"); }); - it('keeps the import error verbatim, so the unbuilt-workspace classifier still fires', () => { + it('keeps the import error verbatim, so the classifier can tell an unbuilt workspace from a missing install', () => { // 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 + // original `code`, so the connect-failure classifier can only read the + // cause from the `Cannot find module` / `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); + // A half-built worktree names the PATH of the missing `dist/` entry, and + // that is still the unbuilt workspace… + const unbuilt = message(unbuiltCause(TURSO_DRIVER_PACKAGE)); + expect(unbuilt).toContain(unbuiltCause(TURSO_DRIVER_PACKAGE).message); + expect(isUnbuiltWorkspaceFailure(new Error(unbuilt))).toBe(true); + // …while the bare package name is the missing install this message already + // names, so no build advice may follow it (#24937). + expect(isUnbuiltWorkspaceFailure(new Error(text))).toBe(false); + expect(missingOptionalDriverPackage(new Error(text))).toBe(TURSO_DRIVER_PACKAGE); }); it('names no escape hatch and no host-boot knob — one fix, stated once', () => { @@ -621,16 +649,23 @@ describe('createDefaultDatasourceDriverFactory — the missing WASM SQLite packa expect(message(cause)).toContain("datasource 'default'"); }); - it('keeps the import error verbatim, so the unbuilt-workspace classifier still fires', () => { + it('keeps the import error verbatim, so the classifier can tell an unbuilt workspace from a missing install', () => { // 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. + // `code`, so the classifier can only read the cause from the TEXT carried + // here. The unbuilt 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. That reader's import + // error names the PATH of the missing `dist/` entry (measured, #24937)… + const unbuilt = message(unbuiltCause(SQLITE_WASM_DRIVER_PACKAGE)); + expect(unbuilt).toContain(unbuiltCause(SQLITE_WASM_DRIVER_PACKAGE).message); + expect(isUnbuiltWorkspaceFailure(new Error(unbuilt))).toBe(true); + // …and the bare package name is a missing install, which this message + // already names (#24937). const text = message(cause); expect(text).toContain(cause.message); - expect(isUnbuiltWorkspaceFailure(new Error(text))).toBe(true); + expect(isUnbuiltWorkspaceFailure(new Error(text))).toBe(false); + expect(missingOptionalDriverPackage(new Error(text))).toBe(SQLITE_WASM_DRIVER_PACKAGE); }); it('names no escape hatch and no host-boot knob — one fix, stated once', () => { @@ -686,10 +721,17 @@ describe('createDefaultDatasourceDriverFactory — the missing MongoDB package i expect(message(cause)).toContain("datasource 'default'"); }); - it('keeps the import error verbatim, so the unbuilt-workspace classifier still fires', () => { + it('keeps the import error verbatim, so the classifier can tell an unbuilt workspace from a missing install', () => { + const unbuilt = message(unbuiltCause(MONGODB_DRIVER_PACKAGE)); + expect(unbuilt).toContain(unbuiltCause(MONGODB_DRIVER_PACKAGE).message); + expect(isUnbuiltWorkspaceFailure(new Error(unbuilt))).toBe(true); + // The bare name is reachable from a project installed with + // `--omit=optional`, where `@objectstack/runtime` skips this package: a + // missing install, which this message already names (#24937). const text = message(cause); expect(text).toContain(cause.message); - expect(isUnbuiltWorkspaceFailure(new Error(text))).toBe(true); + expect(isUnbuiltWorkspaceFailure(new Error(text))).toBe(false); + expect(missingOptionalDriverPackage(new Error(text))).toBe(MONGODB_DRIVER_PACKAGE); }); it('names no escape hatch and no host-boot knob — one fix, stated once', () => { @@ -738,6 +780,21 @@ describe('createDefaultDatasourceDriverFactory — all three optional-driver arm }); 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); + // The real cause in a half-built worktree is the PATH of the missing + // `dist/` entry (#24937), whichever arm raised it. + const unbuilt = [ + missingTursoDriverMessage({ datasource: 'd', cause: unbuiltCause(TURSO_DRIVER_PACKAGE) }), + missingSqliteWasmDriverMessage({ datasource: 'd', cause: unbuiltCause(SQLITE_WASM_DRIVER_PACKAGE) }), + missingMongodbDriverMessage({ datasource: 'd', cause: unbuiltCause(MONGODB_DRIVER_PACKAGE) }), + ]; + for (const text of unbuilt) expect(isUnbuiltWorkspaceFailure(new Error(text))).toBe(true); + }); + + it("every arm's bare package name is classified as that package's missing install (#24937)", () => { + const packages = [TURSO_DRIVER_PACKAGE, SQLITE_WASM_DRIVER_PACKAGE, MONGODB_DRIVER_PACKAGE]; + messages.forEach((text, i) => { + expect(isUnbuiltWorkspaceFailure(new Error(text))).toBe(false); + expect(missingOptionalDriverPackage(new Error(text))).toBe(packages[i]); + }); }); }); From a2d86082449cfc7d45794e20a04889e701bd5939 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:07:00 +0000 Subject: [PATCH 3/4] chore(changeset): service-datasource patch for the optional driver package remedy Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .changeset/24937-optional-driver-package-remedy.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/24937-optional-driver-package-remedy.md diff --git a/.changeset/24937-optional-driver-package-remedy.md b/.changeset/24937-optional-driver-package-remedy.md new file mode 100644 index 0000000000..05f934eeba --- /dev/null +++ b/.changeset/24937-optional-driver-package-remedy.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-datasource': patch +--- + +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`" + +Clause-②: no + +- **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. +- **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. +- **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. + +What to do: install the driver package the refusal names next to the server that opens the datasource, then start again. From e759c6c8e976be2c01593ab191fe7fa9bca6ab21 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:36:23 +0000 Subject: [PATCH 4/4] chore(changeset): the client-remedy entry's Unchanged bullet excludes the three optional driver packages A required @objectstack/ driver package that is not installed still ends on the build advice; the optional driver-turso, driver-mongodb and driver-sqlite-wasm now end on their own install. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .changeset/24909-missing-driver-client-remedy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24909-missing-driver-client-remedy.md b/.changeset/24909-missing-driver-client-remedy.md index 24147a3314..6a25bc3b9f 100644 --- a/.changeset/24909-missing-driver-client-remedy.md +++ b/.changeset/24909-missing-driver-client-remedy.md @@ -8,6 +8,6 @@ Clause-②: no - **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. - **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. -- **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. +- **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. What to do: install the client the refusal names next to the server that opens the datasource, then start again.