diff --git a/.changeset/datasource-unbuilt-workspace-remedy.md b/.changeset/datasource-unbuilt-workspace-remedy.md new file mode 100644 index 0000000000..6b58a7c841 --- /dev/null +++ b/.changeset/datasource-unbuilt-workspace-remedy.md @@ -0,0 +1,51 @@ +--- +"@objectstack/service-datasource": patch +--- + +fix(service-datasource): 未构建的工作区不再被当成「配置写错了」(#5794) + +datasource 的 fail-fast 报错原本只有一句收尾建议,不分成因: + +``` +✗ datasource 'default': connect failed — Cannot find module + '…/@objectstack/driver-sql/dist/index.mjs' imported from … + Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 + to boot anyway and serve errors until it is reachable. +``` + +对「数据库真连不上」——错的 DSN、轮换掉的密码、断掉的网络——这句话是对的。 +但对**驱动包没构建**这一个成因,两半都是有害建议: + +- **「Fix the datasource configuration」** 把读者支去改一份本来就正确的配置。 + 在那里写什么都变不出一个 `dist/` 目录。 +- **「set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway」** 比没用更糟: + 它不是绕过问题,而是**藏起**问题。半个工作区会宣称自己启动成功,然后对每个 + 请求回 `ERR_DATASOURCE_UNAVAILABLE`——比诚实地拒绝启动难查得多。那个开关是 + 为「数据库暂时不可达」准备的(一个关于世界的事实,可能自己好起来);缺构建产物 + 是关于这份 checkout 的事实,不该有任何环境变量能启动越过它。 + +而唯一有效的修法(`pnpm build`)一个字都没提。 + +现在 connect 失败会按**成因**选收尾句。底层错误是模块解析失败时(ESM `import()` +报 `err.code === 'ERR_MODULE_NOT_FOUND'`,CJS `require()` 报 `MODULE_NOT_FOUND`; +`code` 被 re-throw 丢掉时退回 `Cannot find module` / `Cannot find package` 文本), +消息改成: + +``` +The driver package could not be LOADED at all — it is not installed, or its build +output is missing. That is a build precondition, not a datasource fault: the +configuration is fine, and no boot-time override can make a driver that does not +exist answer a query. Run `pnpm install && pnpm build`, then start again. +``` + +一个正确修法,只说一次,**不提**那个逃生开关——连「别用它」都不提:一个已经卡住的 +读者会去找最短的那行看起来能让他继续的话。这与 `datasource-pool-support.ts` +(#5714 / #5931)和 `check:dev-prereqs`(#5795)是同一条消息纪律。 + +判据复用 `@objectstack/types` 的 `isModuleNotFoundError`(framework#3265 起的唯一 +所有者),不另起一份;它先看结构化的 `err.code`、再退回文本,而这个结构化信号原本 +在 `handleFailure` 只收 `reason: string` 时被丢弃了,所以抛出值本身现在也一并传入。 + +**纯诊断分类,零行为变化。** fail-fast 的判定、触发时机、抛出的错误类型、保留的 +连接状态,以及设了 `OS_ALLOW_DRIVER_CONNECT_FAILURE` 时的降级启动路径全部不变; +其它成因(真连接失败、驱动不受支持、凭据解析不出)的消息逐字未动。 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 1687021a86..6ea80e534b 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 @@ -9,6 +9,10 @@ import { } from '../datasource-connection-service.js'; import type { IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js'; import type { DatasourceConnectPolicy } from '../contracts/connect-policy.js'; +import { + GENERIC_CONNECT_FAILURE_REMEDY, + isUnbuiltWorkspaceFailure, +} from '../connect-failure-remedy.js'; /** One `markDatasourceUnavailable` call, as the engine would receive it. */ type UnavailableCall = { name: string; kind: 'blocked' | 'failed'; publicDetail?: string }; @@ -385,6 +389,274 @@ describe('DatasourceConnectionService.connect', () => { }); }); +// #5794: the fail-fast throw used to end on ONE sentence for every cause — +// "Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1". +// For a driver package whose `dist/` was never built, BOTH halves are harmful: +// the configuration is already correct, and the flag boots a half-built +// workspace that then fails every request. The one fix that works (`pnpm build`) +// was never named. These pin the split — and pin that nothing else moved. +describe('fail-fast remedy is chosen by CAUSE (#5794)', () => { + const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE'; + let saved: string | undefined; + beforeEach(() => { saved = process.env[ENV]; delete process.env[ENV]; }); + afterEach(() => { + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + }); + + /** A bound, managed datasource — the plainest route to a D5 fail-fast verdict. */ + const analytics: ConnectableDatasource = { + name: 'analytics', + driver: 'sqlite', + schemaMode: 'managed', + config: {}, + }; + + /** + * A factory whose `create()` throws exactly what the real one does when the + * driver package cannot be resolved. `create()` is the true failure site: + * every arm of `DefaultDatasourceDriverFactory.create()` reaches its driver + * through `await import('@objectstack/driver-…')`, so an unbuilt `dist/` + * rejects there, before any connection is attempted. + */ + function factoryThrowing(err: unknown): IDatasourceDriverFactory { + return { + supports: () => true, + create: vi.fn(async () => { + throw err; + }), + }; + } + + /** Attach a Node error `code` without widening the declared Error type. */ + function withCode(err: Error, code: string): Error { + (err as Error & { code?: string }).code = code; + return err; + } + + /** Boot `analytics` with one bound object and return the thrown error. */ + async function failFast(factory: IDatasourceDriverFactory): Promise { + const { service } = svc({ factory }); + return service + .connect(analytics, { objects: ['visit'], context: { trigger: 'declared-auto' } }) + .then( + () => { throw new Error('connect() resolved but should have thrown'); }, + (e: unknown) => e as Error, + ); + } + + /** The two sentences that must never be said about an unbuilt workspace. */ + function expectNoHarmfulAdvice(message: string): void { + expect(message).not.toContain('Fix the datasource configuration'); + expect(message).not.toContain('OS_ALLOW_DRIVER_CONNECT_FAILURE'); + } + + describe('the unbuilt-workspace cause is recognised on BOTH criteria', () => { + // Criterion 1 — the STRUCTURED signal, which is what the classifier reads + // first. The message here says nothing a substring match could find, so + // only `err.code` can classify it. + it('by err.code alone: ERR_MODULE_NOT_FOUND with an unrecognisable message', async () => { + const err = await failFast( + factoryThrowing(withCode(new Error('the driver entry point is not on disk'), 'ERR_MODULE_NOT_FOUND')), + ); + expect(err.message).toContain('pnpm install && pnpm build'); + expectNoHarmfulAdvice(err.message); + }); + + it('by err.code alone: CJS require() reports MODULE_NOT_FOUND', async () => { + const err = await failFast( + factoryThrowing(withCode(new Error('the driver entry point is not on disk'), 'MODULE_NOT_FOUND')), + ); + expect(err.message).toContain('pnpm install && pnpm build'); + expectNoHarmfulAdvice(err.message); + }); + + // Criterion 2 — the MESSAGE text, with no `code` to read. Not a hypothetical + // fallback on this path: the factory's `sqlite-wasm` and `mongo` arms catch + // the import failure and re-throw a `new Error(...)` that interpolates the + // original message and drops its `code`. + it("by message alone: ESM's `Cannot find package`, no code", async () => { + const err = await failFast( + factoryThrowing(new Error("Cannot find package '@objectstack/driver-sql' imported from /w/factory.js")), + ); + expect(err.message).toContain('pnpm install && pnpm build'); + expectNoHarmfulAdvice(err.message); + }); + + it('by message alone: the factory-wrapped optional-driver form, no code', async () => { + const err = await failFast( + factoryThrowing( + new Error( + 'sqlite-wasm driver requested but @objectstack/driver-sqlite-wasm is not installed ' + + "(Cannot find module '/w/node_modules/@objectstack/driver-sqlite-wasm/dist/index.mjs').", + ), + ), + ); + expect(err.message).toContain('pnpm install && pnpm build'); + expectNoHarmfulAdvice(err.message); + }); + + // The measured shape from an actually-unbuilt worktree: both signals present. + it('the real unbuilt-worktree shape names the build fix and nothing else', async () => { + const err = await failFast( + factoryThrowing( + withCode( + new Error( + "Cannot find module '/w/node_modules/@objectstack/driver-sql/dist/index.mjs' " + + 'imported from /w/packages/services/service-datasource/dist/index.js', + ), + 'ERR_MODULE_NOT_FOUND', + ), + ), + ); + expect(err.message).toContain('pnpm install && pnpm build'); + expectNoHarmfulAdvice(err.message); + // ONE fix, stated once — the same discipline check:dev-prereqs pins. + expect(err.message.match(/pnpm build/g)).toHaveLength(1); + }); + }); + + it('keeps everything ABOVE the remedy — only the closing sentence differs', async () => { + const err = await failFast( + factoryThrowing(withCode(new Error("Cannot find package '@objectstack/driver-sql'"), 'ERR_MODULE_NOT_FOUND')), + ); + expect(err.message).toContain("datasource 'analytics'"); + expect(err.message).toContain('connect failed'); + expect(err.message).toContain("Cannot find package '@objectstack/driver-sql'"); // the underlying cause + expect(err.message).toContain('1 object(s) bind to it explicitly'); + expect(err.message).toContain('visit'); + expect(err.message).toContain('fail-fast per ADR-0062 D5'); + }); + + describe('every OTHER cause keeps its message, verbatim', () => { + // The exact sentence this error has always ended on. Spelled out here as a + // literal rather than as `GENERIC_CONNECT_FAILURE_REMEDY` so that renaming + // or "improving" the constant cannot quietly rewrite the pin with itself. + const GENERIC = + 'Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway ' + + 'and serve errors until it is reachable.'; + + it('the exported constant IS that sentence (no drift between pin and source)', () => { + expect(GENERIC_CONNECT_FAILURE_REMEDY).toBe(GENERIC); + }); + + it('a genuine connection refusal still ends on it, byte for byte', async () => { + const err = await failFast(factoryThrowing(new Error('connection refused'))); + expect(err.message.endsWith(GENERIC)).toBe(true); + expect(err.message).not.toContain('pnpm build'); + }); + + it('a driver the factory cannot build still ends on it (no thrown value to read)', async () => { + const { service } = svc({ factory: fakeFactory({ supports: () => false }) }); + const err = await service + .connect(analytics, { objects: ['visit'], context: { trigger: 'declared-auto' } }) + .then(() => undefined, (e: Error) => e); + expect(err!.message).toContain('no driver factory supports'); + expect(err!.message.endsWith(GENERIC)).toBe(true); + }); + + it('an unresolvable credential still ends on it', async () => { + const { service } = svc({ secrets: { resolve: async () => undefined } }); + const err = await service + .connect( + { ...analytics, external: { credentialsRef: 'sys_secret:abc' } }, + { objects: ['visit'], context: { trigger: 'declared-auto' } }, + ) + .then(() => undefined, (e: Error) => e); + expect(err!.message.endsWith(GENERIC)).toBe(true); + }); + }); + + // "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', () => { + const unbuilt = () => + factoryThrowing(withCode(new Error("Cannot find package '@objectstack/driver-sql'"), 'ERR_MODULE_NOT_FOUND')); + + it('still fails fast, and still with a plain Error — not a new type', async () => { + const err = await failFast(unbuilt()); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('Error'); + }); + + it('still degrades (no throw) when nothing binds to the datasource', async () => { + const { service } = svc({ factory: unbuilt() }); + const result = await service.connect( + { ...analytics, autoConnect: true }, + { context: { trigger: 'declared-auto' } }, + ); + expect(result.status).toBe('failed-degraded'); + }); + + it('still degrades for a runtime-admin connect, never bricking a running server', async () => { + const { service } = svc({ factory: unbuilt() }); + const result = await service.connect(analytics, { + objects: ['visit'], + context: { trigger: 'runtime-admin' }, + }); + expect(result.status).toBe('failed-degraded'); + }); + + it('still boots degraded under the escape hatch, with the banner unchanged', async () => { + process.env[ENV] = '1'; + const { service, warnings } = svc({ factory: unbuilt() }); + const result = await service.connect(analytics, { + objects: ['visit'], + context: { trigger: 'declared-auto' }, + }); + expect(result.status).toBe('failed-degraded'); + const warned = warnings.join('\n'); + expect(warned).toContain('DEGRADED BOOT'); + expect(warned).toContain('OS_ALLOW_DRIVER_CONNECT_FAILURE is set'); + }); + + it('still retains the verdict for the admin surface', async () => { + const { service, engine } = svc({ factory: unbuilt() }); + await service + .connect(analytics, { objects: ['visit'], context: { trigger: 'declared-auto' } }) + .catch(() => undefined); + const state = service.getConnectionState('analytics'); + expect(state?.status).toBe('failed-degraded'); + expect(state?.availability).toBe('failed'); + expect(engine!.unavailable.get('analytics')?.kind).toBe('failed'); + }); + }); +}); + +// The classifier on its own, at the boundary it is responsible for. +describe('isUnbuiltWorkspaceFailure (#5794)', () => { + it('reads the structured code first, in both module systems', () => { + const esm = Object.assign(new Error('nothing recognisable here'), { code: 'ERR_MODULE_NOT_FOUND' }); + const cjs = Object.assign(new Error('nothing recognisable here'), { code: 'MODULE_NOT_FOUND' }); + expect(isUnbuiltWorkspaceFailure(esm)).toBe(true); + expect(isUnbuiltWorkspaceFailure(cjs)).toBe(true); + }); + + it('falls back to the message when the code was dropped by a re-throw', () => { + expect(isUnbuiltWorkspaceFailure(new Error("Cannot find package '@objectstack/driver-sql'"))).toBe(true); + expect(isUnbuiltWorkspaceFailure(new Error("Cannot find module '/w/dist/index.mjs'"))).toBe(true); + }); + + it('leaves a real connect failure alone', () => { + expect(isUnbuiltWorkspaceFailure(new Error('connection refused'))).toBe(false); + expect( + isUnbuiltWorkspaceFailure(Object.assign(new Error('password authentication failed'), { code: '28P01' })), + ).toBe(false); + }); + + it('leaves a native-addon ABI mismatch alone — a rebuild, not an unbuilt workspace', () => { + const abi = Object.assign( + new Error('better_sqlite3.node was compiled against a different Node.js version'), + { code: 'ERR_DLOPEN_FAILED' }, + ); + expect(isUnbuiltWorkspaceFailure(abi)).toBe(false); + }); + + it('classifies nothing when there was no thrown value to read', () => { + expect(isUnbuiltWorkspaceFailure(undefined)).toBe(false); + }); +}); + describe('D3 credential resolution — fail-closed', () => { const credExternal: ConnectableDatasource = { name: 'warehouse', diff --git a/packages/services/service-datasource/src/connect-failure-remedy.ts b/packages/services/service-datasource/src/connect-failure-remedy.ts new file mode 100644 index 0000000000..f183bf5b6e --- /dev/null +++ b/packages/services/service-datasource/src/connect-failure-remedy.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The remedy clause of a fail-fast datasource connect failure — and the one + * cause that needs a DIFFERENT remedy (#5794). + * + * ## The failure this exists for + * + * `handleFailure`'s fail-fast throw used to end on exactly one sentence, for + * every cause: *"Fix the datasource configuration, or set + * OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway and serve errors until it is + * reachable."* That is correct advice for the cause it was written for — a + * database that is genuinely unreachable, a wrong DSN, a rotated password. + * + * It is **harmful, in both halves, for one specific cause**: booting an + * unbuilt workspace, where the driver's `dist/` does not exist and the failure + * is `ERR_MODULE_NOT_FOUND` rather than a refused connection. Measured on an + * unbuilt worktree (#5726, and re-measured for #5794): + * + * ```text + * ✗ datasource 'default': connect failed — Cannot find module + * '…/node_modules/@objectstack/driver-sql/dist/index.mjs' imported from … + * Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 + * to boot anyway and serve errors until it is reachable. + * ``` + * + * - **"Fix the datasource configuration"** sends the reader to edit a + * configuration that is *already correct*. Nothing they can write there + * creates a `dist/` directory. + * - **"set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway"** is worse than + * useless: it does not work around the problem, it *hides* it. A half-built + * workspace then reports itself started and fails every request with + * `ERR_DATASOURCE_UNAVAILABLE`, which is a strictly harder thing to diagnose + * than the honest refusal to boot. That flag exists for a database that is + * unreachable — a fact about the world, which may resolve itself. A missing + * build artifact is a fact about this checkout, and no env var should boot + * past it. (Same line #5714 drew for an unhonourable `pool` block.) + * + * And the one fix that *does* work — `pnpm build` — was never mentioned. + * + * This is #5217's invariant in a second place: **one unmet precondition is one + * precondition, not N content problems.** The local worktree is the first place + * anyone reproduces a CI red, so this message is handed to the reader at + * precisely their least-informed moment; CI never produces this shape at all, + * because a build always runs ahead of it there. + * + * ## Message discipline: ONE correct fix, and no escape hatch + * + * The unbuilt remedy names `pnpm install && pnpm build` once and stops. It does + * not mention `OS_ALLOW_DRIVER_CONNECT_FAILURE` — not even to warn against it. + * Naming an escape hatch is how it gets used: a reader who is already stuck + * skims for the shortest line that looks like it will let them continue. Same + * discipline as `datasource-pool-support.ts` (#5714 / #5931) and as + * `scripts/check-dev-prereqs.mjs` (#5795), whose own pin asserts its red text + * mentions its fix exactly once. + * + * `pnpm install && pnpm build` rather than a bare `pnpm build` is the complete + * form: `ERR_MODULE_NOT_FOUND` cannot distinguish "installed but unbuilt" from + * "never installed", and the combined command is correct for both — the same + * choice `check-dev-prereqs` makes for a workspace with no `node_modules`. + * + * ## 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. + */ + +import { isModuleNotFoundError } from '@objectstack/types'; + +/** + * The remedy for every cause except the unbuilt workspace — a database that is + * genuinely unreachable, a wrong DSN, a credential that will not resolve. + * + * **Byte-for-byte the sentence this message has always ended on.** Pinned as a + * constant rather than left inline so that "the other causes are unchanged" is + * a thing the tests can assert against one declaration instead of a copy. + */ +export const GENERIC_CONNECT_FAILURE_REMEDY = + `Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway and ` + + `serve errors until it is reachable.`; + +/** + * The remedy when the driver package could not be loaded at all. + * + * Deliberately mentions no environment variable and no configuration key — see + * the module note. One fix, stated once. + */ +export const UNBUILT_WORKSPACE_REMEDY = + `The driver package could not be LOADED at all — it is not installed, or its build output is ` + + `missing. That is a build precondition, not a datasource fault: the configuration is fine, and ` + + `no boot-time override can make a driver that does not exist answer a query. Run ` + + `\`pnpm install && pnpm build\`, then start again.`; + +/** + * Did this connect fail because the driver's module could not be RESOLVED, as + * opposed to a connection that was attempted and refused? + * + * Delegates to `@objectstack/types`' shared classifier — the single owner of + * this judgement since framework#3265, already relied on by the CLI's + * optional-plugin guards and its `requires` capability resolver. A second copy + * here would be free to drift from it, and the way it would drift is by + * mis-reading ESM's `Cannot find package` as a crash (the framework#1595 bug + * that classifier was written to end). + * + * That classifier checks the **structured** signal first (`err.code` is + * `ERR_MODULE_NOT_FOUND` under ESM `import()`, `MODULE_NOT_FOUND` under CJS + * `require()`) and only then falls back to the message text. The fallback earns + * its place on this path specifically: the factory's `sqlite-wasm` and `mongo` + * arms re-throw a `new Error(...)` that interpolates the original message but + * drops its `code`, so those two arms reach here with the text signal alone. + * + * `undefined` — the call sites that have no thrown value to classify, such as + * an unsupported driver id — is never a module-resolution failure. + */ +export function isUnbuiltWorkspaceFailure(cause: unknown): boolean { + if (cause === undefined) return false; + return isModuleNotFoundError(cause); +} + +/** + * The sentence a fail-fast connect failure ends on, chosen by cause. + * + * @param cause the value `connect()`/the factory threw, when there is one. + */ +export function connectFailureRemedy(cause: unknown): string { + return isUnbuiltWorkspaceFailure(cause) ? UNBUILT_WORKSPACE_REMEDY : GENERIC_CONNECT_FAILURE_REMEDY; +} diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts index 2500a6582d..e0a8016c97 100644 --- a/packages/services/service-datasource/src/datasource-connection-service.ts +++ b/packages/services/service-datasource/src/datasource-connection-service.ts @@ -43,6 +43,7 @@ import { assertDatasourcePoolSupported, unsupportedPoolIssue, } from './datasource-pool-support.js'; +import { connectFailureRemedy } from './connect-failure-remedy.js'; import type { Logger } from './logger.js'; /** A datasource definition this service can connect (code- or runtime-origin). */ @@ -563,7 +564,7 @@ export class DatasourceConnectionService { try { secret = await resolver(credentialsRef); } catch (err) { - return this.handleFailure(record, 'failed-credentials', `resolving credential '${credentialsRef}' threw: ${errMsg(err)}`, opts.context, opts.objects, opts.mappedObjects); + return this.handleFailure(record, 'failed-credentials', `resolving credential '${credentialsRef}' threw: ${errMsg(err)}`, opts.context, opts.objects, opts.mappedObjects, err); } if (secret == null || secret === '') { return this.handleFailure( @@ -615,7 +616,11 @@ export class DatasourceConnectionService { this.logger?.info?.(`datasource '${name}': connected (driver=${record.driver}, schemaMode=${record.schemaMode ?? 'managed'})`); return { name, status: 'connected', ...(handle.ownership ? { ownership: handle.ownership } : {}) }; } catch (err) { - return this.handleFailure(record, 'failed-degraded', errMsg(err), opts.context, opts.objects, opts.mappedObjects); + // `err` itself is handed on, not just its message: the driver package's + // build output being absent is reported as `err.code === + // 'ERR_MODULE_NOT_FOUND'`, and that structured signal is gone the moment + // the error is stringified (#5794). + return this.handleFailure(record, 'failed-degraded', errMsg(err), opts.context, opts.objects, opts.mappedObjects, err); } } @@ -708,6 +713,12 @@ export class DatasourceConnectionService { * * Either way the datasource is left unconnected with a clear message — never * a silent skip. + * + * The fail-fast throw's closing sentence is chosen by CAUSE (#5794): a driver + * package that could not be loaded at all gets `pnpm install && pnpm build` + * and nothing else, because for that cause both halves of the generic advice + * are actively harmful — see `connect-failure-remedy.ts`. Everything above + * that sentence, and every other cause's text, is unchanged. */ private handleFailure( record: ConnectableDatasource, @@ -716,6 +727,15 @@ export class DatasourceConnectionService { context?: DatasourceConnectContext, boundObjects: readonly string[] = [], mappedObjects: readonly string[] = [], + /** + * The value that was actually thrown, when this failure came from one. + * Carried alongside `reason` rather than folded into it because the signal + * that identifies an unbuilt workspace is STRUCTURED (`err.code`), and + * stringifying the error to a message drops it. Absent for the failures + * this service diagnoses itself (an unsupported driver id, an unresolvable + * credential) — those are never module-resolution failures. + */ + cause?: unknown, ): ConnectResult { const isExternal = record.schemaMode && record.schemaMode !== 'managed'; const msg = `datasource '${record.name}': connect failed — ${reason}`; @@ -752,10 +772,7 @@ export class DatasourceConnectionService { const why = causes.join('; '); if (!resolveAllowDriverConnectFailure()) { - throw new Error( - `${msg}. (${why} ⇒ fail-fast per ADR-0062 D5). Fix the datasource configuration, or set ` + - `OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway and serve errors until it is reachable.`, - ); + throw new Error(`${msg}. (${why} ⇒ fail-fast per ADR-0062 D5). ${connectFailureRemedy(cause)}`); } const banner = `⚠️ DEGRADED BOOT: ${msg} (${why}), but OS_ALLOW_DRIVER_CONNECT_FAILURE is set — starting ` +