From 1647758a1a4d0108599ed81733429de4d5adf458 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:50:38 +0000 Subject: [PATCH 1/4] fix(service-datasource): a missing database client is refused with its install, not the unbuilt-workspace build advice connectFailureRemedy() chose the unbuilt-workspace remedy for every module-not-found. knex reports a missing pg / mysql2 / better-sqlite3 as a bare specifier (and drops the code), which is a missing install in the project, not a build fault; those three now end on the install of that client. Paths and @objectstack packages keep the unbuilt remedy. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../src/connect-failure-remedy.ts | 118 +++++++++++++++++- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/packages/services/service-datasource/src/connect-failure-remedy.ts b/packages/services/service-datasource/src/connect-failure-remedy.ts index f183bf5b6ec..78e1c14f40f 100644 --- a/packages/services/service-datasource/src/connect-failure-remedy.ts +++ b/packages/services/service-datasource/src/connect-failure-remedy.ts @@ -59,12 +59,55 @@ * "never installed", and the combined command is correct for both — the same * choice `check-dev-prereqs` makes for a workspace with no `node_modules`. * + * ## A missing database CLIENT is not an unbuilt workspace (#24909) + * + * The unbuilt remedy was chosen for every module-not-found, and one of those is + * not a build fault at all: `@objectstack/driver-sql` speaks to PostgreSQL, + * MySQL and native SQLite through knex, and knex loads the database client + * (`pg`, `mysql2`, `better-sqlite3`) that the PROJECT installs — `pg` and + * `mysql2` are optional peers, `better-sqlite3` an optional dependency. A + * scaffolded app pointed at PostgreSQL without `pg` installed was refused with + * "a build precondition … Run `pnpm install && pnpm build`", which it followed + * and was refused again: neither command adds a package the project never + * declared. Measured on the published release, outside any workspace: + * + * ```text + * ✗ datasource 'default': connect failed — Knex: run $ npm install pg --save + * Cannot find module 'pg' … The driver package could not be LOADED at all … + * Run `pnpm install && pnpm build`, then start again. + * ``` + * + * The two causes are told apart by WHICH module could not be resolved, read + * from the error text — measured on every arm of the shared factory, ESM and + * CJS builds alike: + * + * - knex re-throws a NEW `Error` for a missing client and drops its `code`, + * keeping only `Cannot find module 'pg'` (`'mysql2'`, `'better-sqlite3'`) in + * the text — a BARE third-party specifier; + * - an unbuilt driver package is a PATH (`Cannot find module + * '…/@objectstack/driver-sql/dist/index.mjs'`), and an uninstalled one is + * its `@objectstack/…` name (`Cannot find package '@objectstack/driver-sql'`). + * + * 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. + * + * The client remedy keeps this module's discipline: it names the install of + * THAT module once and stops — no build, no environment variable, no + * configuration key. The server does not know which package manager the + * project uses, so it spells the one install the way the factory's + * optional-driver messages already do: `npm install `, with the pnpm + * and yarn spellings of the same command beside it. + * * ## Scope: a message branch, nothing else * * This changes no behaviour. The fail-fast verdict, when it fires, which error * type is thrown, the retained connection state, and the degraded-boot path * under `OS_ALLOW_DRIVER_CONNECT_FAILURE` are all untouched — only the closing - * sentence differs, and only for this one cause. + * sentence differs, and only for these causes. */ import { isModuleNotFoundError } from '@objectstack/types'; @@ -93,9 +136,76 @@ export const UNBUILT_WORKSPACE_REMEDY = `no boot-time override can make a driver that does not exist answer a query. Run ` + `\`pnpm install && pnpm build\`, then start again.`; +/** + * The database clients `@objectstack/driver-sql` loads through knex and that a + * project installs itself — the client each SQL arm of the shared factory names + * (`client: 'pg'` / `'mysql2'` / `'better-sqlite3'`; knex requires the package + * of the same name). A module-not-found naming one of these, as a bare + * specifier, is a missing install and not an unbuilt workspace. + * + * Closed on purpose: a name on this list is promised the install remedy, so it + * holds only the clients whose install is known to be the fix. The pins build + * each one's real failure through the real factory arm, so a renamed client + * shows up there rather than as a silent return to the build advice. + */ +export const OPTIONAL_DRIVER_CLIENT_PACKAGES: readonly string[] = ['pg', 'mysql2', 'better-sqlite3']; + +/** + * The remedy when the database client the driver loads is not installed. + * + * One fix, stated once: the install of that package, in the three package + * managers' spellings of the same command (the server cannot know which one the + * project uses — the factory's optional-driver messages make the same choice). + * No build, no environment variable, no configuration key — see the module note. + */ +export function missingDriverClientRemedy(client: string): string { + return ( + `The database client package \`${client}\` is not installed, so no connection was attempted. ` + + `It is an OPTIONAL install of @objectstack/driver-sql, loaded only for the database this ` + + `datasource uses: a build cannot produce it, and no boot-time override can stand in for it. ` + + `Install it next to the server that opens this datasource — \`npm install ${client}\` ` + + `(pnpm add ${client} / yarn add ${client}) — then start again.` + ); +} + +/** `Cannot find module 'x'` (CJS `require`) and `Cannot find package 'x'` (ESM `import`). */ +const UNRESOLVED_SPECIFIER = /Cannot find (?:module|package) '([^']+)'/; + +/** + * The specifier a module-not-found failed to resolve, as the first + * `Cannot find module|package '…'` in its text says — `undefined` when the + * value is not a module-not-found at all, or carries only the `code`. + * + * Read from the TEXT because that is the only place it exists on this path: + * Node's errors for a bare specifier carry no field naming it (`code` and, for + * CJS, `requireStack` — only ESM's missing-FILE shape adds a `url`), and knex's + * re-throw for a missing client keeps neither the `code` nor the original + * error — only the message (measured; see the module note). The first match is the one that + * failed: every wrapper on this path puts its own words first and the + * interpolated import error after them. + */ +function unresolvedSpecifier(cause: unknown): string | undefined { + if (cause === undefined || !isModuleNotFoundError(cause)) return undefined; + const message = cause instanceof Error ? cause.message : String(cause); + return UNRESOLVED_SPECIFIER.exec(message)?.[1]; +} + +/** + * The database client this connect failed to load, when that is the cause — + * one of {@link OPTIONAL_DRIVER_CLIENT_PACKAGES}, named exactly (a subpath such + * as `pg/lib/x` means the client IS installed and is not this case). + */ +export function missingDriverClient(cause: unknown): string | undefined { + const specifier = unresolvedSpecifier(cause); + return specifier !== undefined && OPTIONAL_DRIVER_CLIENT_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? + * opposed to a connection that was attempted and refused — and was it not + * merely a missing database client ({@link missingDriverClient})? * * Delegates to `@objectstack/types`' shared classifier — the single owner of * this judgement since framework#3265, already relied on by the CLI's @@ -116,7 +226,7 @@ export const UNBUILT_WORKSPACE_REMEDY = */ export function isUnbuiltWorkspaceFailure(cause: unknown): boolean { if (cause === undefined) return false; - return isModuleNotFoundError(cause); + return isModuleNotFoundError(cause) && missingDriverClient(cause) === undefined; } /** @@ -125,5 +235,7 @@ export function isUnbuiltWorkspaceFailure(cause: unknown): boolean { * @param cause the value `connect()`/the factory threw, when there is one. */ export function connectFailureRemedy(cause: unknown): string { + const client = missingDriverClient(cause); + if (client !== undefined) return missingDriverClientRemedy(client); return isUnbuiltWorkspaceFailure(cause) ? UNBUILT_WORKSPACE_REMEDY : GENERIC_CONNECT_FAILURE_REMEDY; } From 955b9238d233edc1d7117271f9d5371ddd13c75e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:57:21 +0000 Subject: [PATCH 2/4] test(service-datasource): pin the missing-client remedy at unit level and on the real boot-refusal path Unit: each measured knex client shape (pg, mysql2, better-sqlite3) ends on the install of that client; paths, @objectstack packages, subpaths and hard dependencies keep the unbuilt remedy; ECONNREFUSED and 28P01 keep the generic sentence. Real path: the real factory and connection service refuse the boot-critical default with the client made unresolvable through Node's own resolver, with a client-present control. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../datasource-connection-service.test.ts | 114 ++++++++++ ...missing-driver-client-boot-refusal.test.ts | 201 ++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 packages/services/service-datasource/src/__tests__/missing-driver-client-boot-refusal.test.ts 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 ba0a17c6666..2e5f0f6ca19 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 @@ -12,7 +12,10 @@ import type { IDataDriver } from '@objectstack/spec/contracts'; import type { DatasourceConnectPolicy } from '../contracts/connect-policy.js'; import { GENERIC_CONNECT_FAILURE_REMEDY, + UNBUILT_WORKSPACE_REMEDY, isUnbuiltWorkspaceFailure, + missingDriverClient, + missingDriverClientRemedy, } from '../connect-failure-remedy.js'; import { missingSqliteWasmDriverMessage } from '../default-datasource-driver-factory.js'; @@ -589,6 +592,85 @@ describe('fail-fast remedy is chosen by CAUSE (#5794)', () => { .then(() => undefined, (e: Error) => e); expect(err!.message.endsWith(GENERIC)).toBe(true); }); + + // The driver-level codes a real refused or rejected connection carries: + // neither is a module-resolution failure, so neither may reach the + // missing-client branch (#24909) any more than the unbuilt one. + it.each([ + ['ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:5432'], + ['28P01', 'password authentication failed for user "app"'], + ])('a driver error with code %s still ends on it, byte for byte', async (code, text) => { + const err = await failFast(factoryThrowing(withCode(new Error(text), code))); + expect(err.message.endsWith(GENERIC)).toBe(true); + // `\b`: "pnpm install" contains "npm install" as a substring. + expect(err.message).not.toMatch(/\bnpm install\b/); + expect(err.message).not.toContain('pnpm build'); + }); + }); + + // #24909: `@objectstack/driver-sql` reaches PostgreSQL, MySQL and native + // SQLite through knex, and knex loads a database client the PROJECT + // installs. A missing one is a missing install, not an unbuilt workspace: + // `pnpm install && pnpm build` adds no package the project never declared, so + // a scaffolded app that followed the build advice was refused again. + describe('a missing database client is answered with ITS install (#24909)', () => { + /** + * knex's own wrapper (`Client.initializeDriver`), in the shape measured on + * the real factory arms: a NEW Error that keeps the text and drops `code`. + * The genuine article is pinned on the real path in + * `missing-driver-client-boot-refusal.test.ts`. + */ + const knexMissingClient = (client: string, dialect: string) => + new Error( + `Knex: run\n$ npm install ${client} --save\nCannot find module '${client}'\n` + + `Require stack:\n- /app/node_modules/knex/lib/dialects/${dialect}/index.js`, + ); + + it.each([ + ['pg', 'postgres'], + ['mysql2', 'mysql2'], + ['better-sqlite3', 'better-sqlite3'], + ])('%s: the refusal ends on the install of that client and names no build', async (client, dialect) => { + const err = await failFast(factoryThrowing(knexMissingClient(client, dialect))); + const remedy = missingDriverClientRemedy(client); + expect(err.message.endsWith(remedy)).toBe(true); + // The install of THAT module, named once in the remedy (the earlier + // `npm install … --save` belongs to knex's interpolated text). + expect(remedy.split(`npm install ${client}`)).toHaveLength(2); + expect(err.message).not.toContain('pnpm build'); + expect(err.message).not.toContain(UNBUILT_WORKSPACE_REMEDY); + expectNoHarmfulAdvice(err.message); + }); + + it("ESM's `Cannot find package 'pg'`, code intact, is the same missing install", async () => { + const err = await failFast( + factoryThrowing(withCode(new Error("Cannot find package 'pg' imported from /app/x.js"), 'ERR_MODULE_NOT_FOUND')), + ); + expect(err.message.endsWith(missingDriverClientRemedy('pg'))).toBe(true); + }); + + it('names no environment variable and no configuration key', () => { + for (const client of ['pg', 'mysql2', 'better-sqlite3']) { + const remedy = missingDriverClientRemedy(client); + expect(remedy).not.toMatch(/\bOS_[A-Z_]+/); + expect(remedy).not.toContain('--database'); + expect(remedy).not.toContain('pnpm build'); + } + }); + + // The other side of the split: only a BARE specifier on the closed list is + // a missing client. Each of these is still the unbuilt/broken-install case. + it.each([ + ['the driver package itself, not installed', "Cannot find package '@objectstack/driver-sql' imported from /w/x.js"], + ['the driver package, unbuilt', "Cannot find module '/w/node_modules/@objectstack/driver-sql/dist/index.mjs'"], + ['a file inside an installed client', "Cannot find module '/w/node_modules/pg/lib/index.js'"], + ['a subpath of an installed client', "Cannot find module 'pg/lib/native'"], + ['a hard dependency of the driver (a broken install)', "Cannot find module 'knex'"], + ])('%s keeps the unbuilt-workspace remedy', async (_label, text) => { + const err = await failFast(factoryThrowing(new Error(text))); + expect(err.message.endsWith(UNBUILT_WORKSPACE_REMEDY)).toBe(true); + expect(err.message).not.toMatch(/\bnpm install\b/); + }); }); // "Diagnostic classification, zero behaviour change" is the whole claim of @@ -679,8 +761,40 @@ describe('isUnbuiltWorkspaceFailure (#5794)', () => { it('classifies nothing when there was no thrown value to read', () => { expect(isUnbuiltWorkspaceFailure(undefined)).toBe(false); }); + + it('leaves a missing database client alone — an install, not an unbuilt workspace (#24909)', () => { + const knexPg = new Error("Knex: run\n$ npm install pg --save\nCannot find module 'pg'\nRequire stack:\n- /a/knex.js"); + expect(isUnbuiltWorkspaceFailure(knexPg)).toBe(false); + expect(missingDriverClient(knexPg)).toBe('pg'); + }); +}); + +describe('missingDriverClient (#24909)', () => { + it('names the client only for a module-resolution failure', () => { + expect(missingDriverClient(new Error("Cannot find module 'mysql2'"))).toBe('mysql2'); + // The same name in a message that is NOT a module-not-found is not this case. + expect(missingDriverClient(new Error('mysql2: connect ECONNREFUSED'))).toBeUndefined(); + expect(missingDriverClient(undefined)).toBeUndefined(); + }); + + it('reads the FIRST unresolved specifier — the wrapper words come first, the import error after', () => { + expect( + missingDriverClient(new Error("Cannot find module 'pg'\nRequire stack:\n- /w/node_modules/knex/lib/x.js")), + ).toBe('pg'); + expect( + missingDriverClient(new Error("Cannot find package '@objectstack/driver-sql' (… 'pg' …)")), + ).toBeUndefined(); + }); + + it('a module-not-found carrying only its code names no client', () => { + expect(missingDriverClient(withCodeOnly('MODULE_NOT_FOUND'))).toBeUndefined(); + }); }); +function withCodeOnly(code: string): Error { + return Object.assign(new Error('the entry point is not on disk'), { code }); +} + describe('D3 credential resolution — fail-closed', () => { const credExternal: ConnectableDatasource = { name: 'warehouse', diff --git a/packages/services/service-datasource/src/__tests__/missing-driver-client-boot-refusal.test.ts b/packages/services/service-datasource/src/__tests__/missing-driver-client-boot-refusal.test.ts new file mode 100644 index 00000000000..557e088a591 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/missing-driver-client-boot-refusal.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A missing database client on the REAL boot-refusal path (#24909). + * + * The unit pins in `datasource-connection-service.test.ts` hand the service a + * knex-shaped error. This file builds the genuine one: the real shared factory + * constructs the real `SqlDriver`, knex fails to load a client that cannot be + * resolved, and the real `DatasourceConnectionService` refuses the host's + * boot-critical `default` datasource — the path a scaffolded app takes when it + * is pointed at PostgreSQL without `pg` installed. + * + * ## How a client is made unresolvable + * + * knex loads the client with a CJS `require()`. For that one specifier the test + * has Node's own resolver (`Module._resolveFilename`) look in an EMPTY directory + * and nowhere else, so Node raises its genuine `MODULE_NOT_FOUND` and knex + * wraps it exactly as it does in a project without the package: nothing in the + * error text is written here. Two details make "nowhere else" true: + * + * - The hook sits on `Module._load`, the entry every `require()` passes + * through, rather than on the resolver: `_load` answers a request it has + * already resolved for the same parent from a cache without asking the + * resolver again, so a client an earlier case loaded would slip past. + * - The resolver always appends the global folders, and those include + * `NODE_PATH` — which pnpm points at its hoisted store when it runs a + * script, so every client in the store would still be found there. The + * hook resolves with `NODE_PATH` set aside (`Module._initPaths()` re-reads + * it) and puts it back before the real load runs. + * + * If the narrowing ever stopped taking effect, the client would load, the + * driver would try to connect, and the `Cannot find module` assertion below + * would fail loudly — measured: it does, against port 1, with ECONNREFUSED. + * + * What must NOT move is pinned beside what did: the boot is still refused, the + * error is still a plain `Error`, the verdict is still retained as failed, and + * a refused connection with the client present still ends on the generic + * sentence. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Module from 'node:module'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +// The factory loads the SQL driver (and knex with it) through a dynamic import +// inside `create()`. Paid here, at collection, so no case's clock measures a +// cold load of the driver's whole module graph — clocked windows measure +// behaviour, never loading. +import '@objectstack/driver-sql'; +import { DatasourceConnectionService, type ConnectionEngineLike } from '../datasource-connection-service.js'; +import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js'; +import { + GENERIC_CONNECT_FAILURE_REMEDY, + UNBUILT_WORKSPACE_REMEDY, + missingDriverClientRemedy, +} from '../connect-failure-remedy.js'; + +type ResolveFilename = ( + request: string, + parent: unknown, + isMain: boolean, + options?: { paths?: string[] }, +) => string; +type Load = (request: string, parent: unknown, isMain: boolean) => unknown; +const loader = Module as unknown as { _resolveFilename: ResolveFilename; _load: Load; _initPaths: () => void }; + +/** Resolve `request` from `dir` alone — no `NODE_PATH` — and throw Node's own error when absent. */ +function resolveFromOnly(dir: string, request: string, parent: unknown, isMain: boolean): void { + const nodePath = process.env.NODE_PATH; + delete process.env.NODE_PATH; + loader._initPaths(); + try { + loader._resolveFilename(request, parent, isMain, { paths: [dir] }); + } finally { + if (nodePath !== undefined) process.env.NODE_PATH = nodePath; + loader._initPaths(); + } +} + +/** Run `fn` with `specifier` resolvable from nowhere but an empty directory. */ +async function withUnresolvable(specifier: string, fn: () => Promise): Promise { + const empty = mkdtempSync(join(tmpdir(), 'os-unresolvable-')); + const original = loader._load; + loader._load = function (this: unknown, request, parent, isMain) { + // Throws Node's own MODULE_NOT_FOUND; should it ever resolve instead, the + // real load runs and the assertions below catch it. + if (request === specifier) resolveFromOnly(empty, request, parent, isMain); + return original.call(this, request, parent, isMain); + }; + // knex prints its own copy of the failure through `console.log`; the thrown + // error is what is under test, so keep the run's output readable. + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); + try { + return await fn(); + } finally { + loader._load = original; + log.mockRestore(); + rmSync(empty, { recursive: true, force: true }); + } +} + +/** The narrowest engine the connect path needs, recording the availability verdict. */ +function engine(): ConnectionEngineLike & { unavailable: Map } { + const unavailable = new Map(); + return { + unavailable, + registerDriver: () => undefined, + registerDatasourceDef: () => undefined, + getDriverByName: () => undefined, + markDatasourceUnavailable: (info) => { + unavailable.set(info.name, info.kind); + }, + clearDatasourceUnavailable: (name) => { + unavailable.delete(name); + }, + }; +} + +/** Connect the host's boot-critical `default`, as `DefaultDatasourcePlugin` does at boot. */ +async function bootDefault(driver: string, config: Record) { + const eng = engine(); + const service = new DatasourceConnectionService({ + engine: () => eng, + // `dev: false` — production, where the sqlite arm builds the native client + // unprobed instead of stepping down to WASM. + factory: () => createDefaultDatasourceDriverFactory({ dev: false }), + }); + const outcome = await service + .connect( + { name: 'default', driver, config, bootCritical: true }, + { asDefault: true, context: { trigger: 'declared-auto' } }, + ) + .then( + (result) => ({ refused: false as const, result }), + (error: unknown) => ({ refused: true as const, error: error as Error }), + ); + return { outcome, service, eng }; +} + +const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE'; +let saved: string | undefined; +let scratch: string; +beforeEach(() => { + saved = process.env[ENV]; + delete process.env[ENV]; + scratch = mkdtempSync(join(tmpdir(), 'os-client-refusal-')); +}); +afterEach(() => { + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + rmSync(scratch, { recursive: true, force: true }); +}); + +describe('a missing database client refuses the boot with ITS install (#24909)', () => { + // Port 1: nothing listens there, and the client never gets as far as trying. + const arms: Array<[client: string, driver: string, config: () => Record]> = [ + ['pg', 'postgres', () => ({ url: 'postgres://app@127.0.0.1:1/app' })], + ['mysql2', 'mysql', () => ({ host: '127.0.0.1', port: 1, database: 'app', user: 'app' })], + ['better-sqlite3', 'sqlite', () => ({ filename: join(scratch, 'app.db') })], + ]; + + it.each(arms)('%s absent: the %s default is refused, ending on the install of that client', async (client, driver, config) => { + const { outcome, service, eng } = await withUnresolvable(client, () => bootDefault(driver, config())); + + // The refusal still happens, as the same plain Error. + expect(outcome.refused).toBe(true); + if (!outcome.refused) return; + expect(outcome.error).toBeInstanceOf(Error); + expect(outcome.error.name).toBe('Error'); + // It is the genuine client failure that reached the remedy… + expect(outcome.error.message).toContain(`Cannot find module '${client}'`); + expect(outcome.error.message).toContain('fail-fast per ADR-0062 D5'); + // …and the remedy names that client's install, not a build. + expect(outcome.error.message.endsWith(missingDriverClientRemedy(client))).toBe(true); + expect(outcome.error.message).not.toContain('pnpm build'); + expect(outcome.error.message).not.toContain(UNBUILT_WORKSPACE_REMEDY); + + // The verdict is retained exactly as before. + expect(service.getConnectionState('default')?.status).toBe('failed-degraded'); + expect(eng.unavailable.get('default')).toBe('failed'); + }); + + it('the escape hatch still boots degraded instead of refusing', async () => { + process.env[ENV] = '1'; + const { outcome } = await withUnresolvable('pg', () => + bootDefault('postgres', { url: 'postgres://app@127.0.0.1:1/app' }), + ); + expect(outcome.refused).toBe(false); + if (outcome.refused) return; + expect(outcome.result.status).toBe('failed-degraded'); + }); + + it('control: with the client present, a refused connection keeps the generic sentence', async () => { + const { outcome } = await bootDefault('postgres', { url: 'postgres://app@127.0.0.1:1/app' }); + expect(outcome.refused).toBe(true); + if (!outcome.refused) return; + expect(outcome.error.message).toContain('ECONNREFUSED'); + expect(outcome.error.message.endsWith(GENERIC_CONNECT_FAILURE_REMEDY)).toBe(true); + }); +}); From c3c575f0184692332bd71222966298e3e1ec2f5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:57:50 +0000 Subject: [PATCH 3/4] chore(changeset): service-datasource patch for the missing-client remedy Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .changeset/24909-missing-driver-client-remedy.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/24909-missing-driver-client-remedy.md diff --git a/.changeset/24909-missing-driver-client-remedy.md b/.changeset/24909-missing-driver-client-remedy.md new file mode 100644 index 00000000000..24147a3314d --- /dev/null +++ b/.changeset/24909-missing-driver-client-remedy.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-datasource': patch +--- + +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`" + +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. + +What to do: install the client the refusal names next to the server that opens the datasource, then start again. From 907bd42fdbe04a4174fbe07a32d5acf9cc49b438 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:40:56 +0000 Subject: [PATCH 4/4] docs(getting-started): your-first-project says PostgreSQL and MySQL need their client installed The package table and the paragraph under it said the runtime brings the database drivers and that pointing OS_DATABASE_URL at Postgres, MySQL or MongoDB needs no code changes. SQLite and MongoDB do work as installed; pg and mysql2 are optional peers of driver-sql the project installs itself, and without them the boot is refused. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- content/docs/getting-started/your-first-project.mdx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/content/docs/getting-started/your-first-project.mdx b/content/docs/getting-started/your-first-project.mdx index e843f61fbac..839751e7dcf 100644 --- a/content/docs/getting-started/your-first-project.mdx +++ b/content/docs/getting-started/your-first-project.mdx @@ -242,16 +242,20 @@ framework surface you install: | Package | What it does | |:---|:---| | `@objectstack/spec` | The protocol: `defineStack`, `ObjectSchema`, `Field.*` builders, all Zod schemas. Your metadata imports only this. | -| `@objectstack/runtime` | The kernel that interprets metadata at runtime. Brings the database drivers with it — you do not install one separately. | +| `@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). | | `@objectstack/plugin-hono-server` | The HTTP server that mounts the generated REST API. | | `@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)). | | `@objectstack/cli` *(dev)* | The `os` / `objectstack` CLI: `dev`, `validate`, `build`, `start`. | | `@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. | No driver package is listed because none is installed directly: `@objectstack/runtime` -depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory`, and the CLI carries -them too. `objectstack dev` resolves **SQLite** by default; point `OS_DATABASE_URL` at -Postgres / MySQL / MongoDB for production — [no code changes](/docs/data-modeling/drivers). +depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory` and installs +`driver-mongodb` as an optional dependency, and the CLI carries them too. +`objectstack dev` resolves **SQLite** by default, which needs nothing more. For +production, MongoDB needs only `OS_DATABASE_URL` pointed at it. Postgres and MySQL need +one install first: their database client (`npm install pg`, or `npm install mysql2`) is +an optional peer of `driver-sql` that your project adds itself, and without it the boot +is refused. No code changes either way — see [Drivers](/docs/data-modeling/drivers). ## 3. Run it