Skip to content

Commit d92ed03

Browse files
hotlongclaude
andauthored
fix(service-datasource): datasource fail-fast 认得「工作区未构建」这个成因 (#5794) (#6302)
connect 失败的 fail-fast 报错原本只有一句收尾建议,不分成因: "Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway"。对「数据库真连不上」它是对的;对「驱动包没构建」两半都有害 —— 配置本来就是好的,而设开关只会让半个工作区宣称启动成功、然后对每个请求 报错。唯一有效的修法 pnpm build 一个字都没提。 现在按成因选收尾句:底层错误是模块解析失败时(ESM import() 的 err.code === 'ERR_MODULE_NOT_FOUND'、CJS require() 的 'MODULE_NOT_FOUND'; code 被 re-throw 丢掉时退回 Cannot find module / Cannot find package 文本), 消息只给一个正确修法 `pnpm install && pnpm build`,并且不提那个逃生开关。 判据复用 @objectstack/types 的 isModuleNotFoundError(framework#3265 起的 唯一所有者),不另起一份;结构化的 err.code 原本在 handleFailure 只收 reason: string 时被丢弃,所以抛出值本身现在也一并传入。 纯诊断分类,零行为变化:fail-fast 的判定与时机、抛出的错误类型、保留的连接 状态、以及 OS_ALLOW_DRIVER_CONNECT_FAILURE 的降级启动路径全部不变;其它成因 的消息逐字未动。 Claude-Session: https://claude.ai/code/session_015a5qkLzpGXhLL2F5gvJ7dD Co-authored-by: Claude <noreply@anthropic.com>
1 parent a80302a commit d92ed03

4 files changed

Lines changed: 475 additions & 6 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
---
4+
5+
fix(service-datasource): 未构建的工作区不再被当成「配置写错了」(#5794)
6+
7+
datasource 的 fail-fast 报错原本只有一句收尾建议,不分成因:
8+
9+
```
10+
✗ datasource 'default': connect failed — Cannot find module
11+
'…/@objectstack/driver-sql/dist/index.mjs' imported from …
12+
Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1
13+
to boot anyway and serve errors until it is reachable.
14+
```
15+
16+
对「数据库真连不上」——错的 DSN、轮换掉的密码、断掉的网络——这句话是对的。
17+
但对**驱动包没构建**这一个成因,两半都是有害建议:
18+
19+
- **「Fix the datasource configuration」** 把读者支去改一份本来就正确的配置。
20+
在那里写什么都变不出一个 `dist/` 目录。
21+
- **「set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway」** 比没用更糟:
22+
它不是绕过问题,而是**藏起**问题。半个工作区会宣称自己启动成功,然后对每个
23+
请求回 `ERR_DATASOURCE_UNAVAILABLE`——比诚实地拒绝启动难查得多。那个开关是
24+
为「数据库暂时不可达」准备的(一个关于世界的事实,可能自己好起来);缺构建产物
25+
是关于这份 checkout 的事实,不该有任何环境变量能启动越过它。
26+
27+
而唯一有效的修法(`pnpm build`)一个字都没提。
28+
29+
现在 connect 失败会按**成因**选收尾句。底层错误是模块解析失败时(ESM `import()`
30+
`err.code === 'ERR_MODULE_NOT_FOUND'`,CJS `require()``MODULE_NOT_FOUND`;
31+
`code` 被 re-throw 丢掉时退回 `Cannot find module` / `Cannot find package` 文本),
32+
消息改成:
33+
34+
```
35+
The driver package could not be LOADED at all — it is not installed, or its build
36+
output is missing. That is a build precondition, not a datasource fault: the
37+
configuration is fine, and no boot-time override can make a driver that does not
38+
exist answer a query. Run `pnpm install && pnpm build`, then start again.
39+
```
40+
41+
一个正确修法,只说一次,**不提**那个逃生开关——连「别用它」都不提:一个已经卡住的
42+
读者会去找最短的那行看起来能让他继续的话。这与 `datasource-pool-support.ts`
43+
(#5714 / #5931)和 `check:dev-prereqs`(#5795)是同一条消息纪律。
44+
45+
判据复用 `@objectstack/types``isModuleNotFoundError`(framework#3265 起的唯一
46+
所有者),不另起一份;它先看结构化的 `err.code`、再退回文本,而这个结构化信号原本
47+
`handleFailure` 只收 `reason: string` 时被丢弃了,所以抛出值本身现在也一并传入。
48+
49+
**纯诊断分类,零行为变化。** fail-fast 的判定、触发时机、抛出的错误类型、保留的
50+
连接状态,以及设了 `OS_ALLOW_DRIVER_CONNECT_FAILURE` 时的降级启动路径全部不变;
51+
其它成因(真连接失败、驱动不受支持、凭据解析不出)的消息逐字未动。

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

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import {
99
} from '../datasource-connection-service.js';
1010
import type { IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js';
1111
import type { DatasourceConnectPolicy } from '../contracts/connect-policy.js';
12+
import {
13+
GENERIC_CONNECT_FAILURE_REMEDY,
14+
isUnbuiltWorkspaceFailure,
15+
} from '../connect-failure-remedy.js';
1216

1317
/** One `markDatasourceUnavailable` call, as the engine would receive it. */
1418
type UnavailableCall = { name: string; kind: 'blocked' | 'failed'; publicDetail?: string };
@@ -385,6 +389,274 @@ describe('DatasourceConnectionService.connect', () => {
385389
});
386390
});
387391

392+
// #5794: the fail-fast throw used to end on ONE sentence for every cause —
393+
// "Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1".
394+
// For a driver package whose `dist/` was never built, BOTH halves are harmful:
395+
// the configuration is already correct, and the flag boots a half-built
396+
// workspace that then fails every request. The one fix that works (`pnpm build`)
397+
// was never named. These pin the split — and pin that nothing else moved.
398+
describe('fail-fast remedy is chosen by CAUSE (#5794)', () => {
399+
const ENV = 'OS_ALLOW_DRIVER_CONNECT_FAILURE';
400+
let saved: string | undefined;
401+
beforeEach(() => { saved = process.env[ENV]; delete process.env[ENV]; });
402+
afterEach(() => {
403+
if (saved === undefined) delete process.env[ENV];
404+
else process.env[ENV] = saved;
405+
});
406+
407+
/** A bound, managed datasource — the plainest route to a D5 fail-fast verdict. */
408+
const analytics: ConnectableDatasource = {
409+
name: 'analytics',
410+
driver: 'sqlite',
411+
schemaMode: 'managed',
412+
config: {},
413+
};
414+
415+
/**
416+
* A factory whose `create()` throws exactly what the real one does when the
417+
* driver package cannot be resolved. `create()` is the true failure site:
418+
* every arm of `DefaultDatasourceDriverFactory.create()` reaches its driver
419+
* through `await import('@objectstack/driver-…')`, so an unbuilt `dist/`
420+
* rejects there, before any connection is attempted.
421+
*/
422+
function factoryThrowing(err: unknown): IDatasourceDriverFactory {
423+
return {
424+
supports: () => true,
425+
create: vi.fn(async () => {
426+
throw err;
427+
}),
428+
};
429+
}
430+
431+
/** Attach a Node error `code` without widening the declared Error type. */
432+
function withCode(err: Error, code: string): Error {
433+
(err as Error & { code?: string }).code = code;
434+
return err;
435+
}
436+
437+
/** Boot `analytics` with one bound object and return the thrown error. */
438+
async function failFast(factory: IDatasourceDriverFactory): Promise<Error> {
439+
const { service } = svc({ factory });
440+
return service
441+
.connect(analytics, { objects: ['visit'], context: { trigger: 'declared-auto' } })
442+
.then(
443+
() => { throw new Error('connect() resolved but should have thrown'); },
444+
(e: unknown) => e as Error,
445+
);
446+
}
447+
448+
/** The two sentences that must never be said about an unbuilt workspace. */
449+
function expectNoHarmfulAdvice(message: string): void {
450+
expect(message).not.toContain('Fix the datasource configuration');
451+
expect(message).not.toContain('OS_ALLOW_DRIVER_CONNECT_FAILURE');
452+
}
453+
454+
describe('the unbuilt-workspace cause is recognised on BOTH criteria', () => {
455+
// Criterion 1 — the STRUCTURED signal, which is what the classifier reads
456+
// first. The message here says nothing a substring match could find, so
457+
// only `err.code` can classify it.
458+
it('by err.code alone: ERR_MODULE_NOT_FOUND with an unrecognisable message', async () => {
459+
const err = await failFast(
460+
factoryThrowing(withCode(new Error('the driver entry point is not on disk'), 'ERR_MODULE_NOT_FOUND')),
461+
);
462+
expect(err.message).toContain('pnpm install && pnpm build');
463+
expectNoHarmfulAdvice(err.message);
464+
});
465+
466+
it('by err.code alone: CJS require() reports MODULE_NOT_FOUND', async () => {
467+
const err = await failFast(
468+
factoryThrowing(withCode(new Error('the driver entry point is not on disk'), 'MODULE_NOT_FOUND')),
469+
);
470+
expect(err.message).toContain('pnpm install && pnpm build');
471+
expectNoHarmfulAdvice(err.message);
472+
});
473+
474+
// Criterion 2 — the MESSAGE text, with no `code` to read. Not a hypothetical
475+
// fallback on this path: the factory's `sqlite-wasm` and `mongo` arms catch
476+
// the import failure and re-throw a `new Error(...)` that interpolates the
477+
// original message and drops its `code`.
478+
it("by message alone: ESM's `Cannot find package`, no code", async () => {
479+
const err = await failFast(
480+
factoryThrowing(new Error("Cannot find package '@objectstack/driver-sql' imported from /w/factory.js")),
481+
);
482+
expect(err.message).toContain('pnpm install && pnpm build');
483+
expectNoHarmfulAdvice(err.message);
484+
});
485+
486+
it('by message alone: the factory-wrapped optional-driver form, no code', async () => {
487+
const err = await failFast(
488+
factoryThrowing(
489+
new Error(
490+
'sqlite-wasm driver requested but @objectstack/driver-sqlite-wasm is not installed ' +
491+
"(Cannot find module '/w/node_modules/@objectstack/driver-sqlite-wasm/dist/index.mjs').",
492+
),
493+
),
494+
);
495+
expect(err.message).toContain('pnpm install && pnpm build');
496+
expectNoHarmfulAdvice(err.message);
497+
});
498+
499+
// The measured shape from an actually-unbuilt worktree: both signals present.
500+
it('the real unbuilt-worktree shape names the build fix and nothing else', async () => {
501+
const err = await failFast(
502+
factoryThrowing(
503+
withCode(
504+
new Error(
505+
"Cannot find module '/w/node_modules/@objectstack/driver-sql/dist/index.mjs' " +
506+
'imported from /w/packages/services/service-datasource/dist/index.js',
507+
),
508+
'ERR_MODULE_NOT_FOUND',
509+
),
510+
),
511+
);
512+
expect(err.message).toContain('pnpm install && pnpm build');
513+
expectNoHarmfulAdvice(err.message);
514+
// ONE fix, stated once — the same discipline check:dev-prereqs pins.
515+
expect(err.message.match(/pnpm build/g)).toHaveLength(1);
516+
});
517+
});
518+
519+
it('keeps everything ABOVE the remedy — only the closing sentence differs', async () => {
520+
const err = await failFast(
521+
factoryThrowing(withCode(new Error("Cannot find package '@objectstack/driver-sql'"), 'ERR_MODULE_NOT_FOUND')),
522+
);
523+
expect(err.message).toContain("datasource 'analytics'");
524+
expect(err.message).toContain('connect failed');
525+
expect(err.message).toContain("Cannot find package '@objectstack/driver-sql'"); // the underlying cause
526+
expect(err.message).toContain('1 object(s) bind to it explicitly');
527+
expect(err.message).toContain('visit');
528+
expect(err.message).toContain('fail-fast per ADR-0062 D5');
529+
});
530+
531+
describe('every OTHER cause keeps its message, verbatim', () => {
532+
// The exact sentence this error has always ended on. Spelled out here as a
533+
// literal rather than as `GENERIC_CONNECT_FAILURE_REMEDY` so that renaming
534+
// or "improving" the constant cannot quietly rewrite the pin with itself.
535+
const GENERIC =
536+
'Fix the datasource configuration, or set OS_ALLOW_DRIVER_CONNECT_FAILURE=1 to boot anyway ' +
537+
'and serve errors until it is reachable.';
538+
539+
it('the exported constant IS that sentence (no drift between pin and source)', () => {
540+
expect(GENERIC_CONNECT_FAILURE_REMEDY).toBe(GENERIC);
541+
});
542+
543+
it('a genuine connection refusal still ends on it, byte for byte', async () => {
544+
const err = await failFast(factoryThrowing(new Error('connection refused')));
545+
expect(err.message.endsWith(GENERIC)).toBe(true);
546+
expect(err.message).not.toContain('pnpm build');
547+
});
548+
549+
it('a driver the factory cannot build still ends on it (no thrown value to read)', async () => {
550+
const { service } = svc({ factory: fakeFactory({ supports: () => false }) });
551+
const err = await service
552+
.connect(analytics, { objects: ['visit'], context: { trigger: 'declared-auto' } })
553+
.then(() => undefined, (e: Error) => e);
554+
expect(err!.message).toContain('no driver factory supports');
555+
expect(err!.message.endsWith(GENERIC)).toBe(true);
556+
});
557+
558+
it('an unresolvable credential still ends on it', async () => {
559+
const { service } = svc({ secrets: { resolve: async () => undefined } });
560+
const err = await service
561+
.connect(
562+
{ ...analytics, external: { credentialsRef: 'sys_secret:abc' } },
563+
{ objects: ['visit'], context: { trigger: 'declared-auto' } },
564+
)
565+
.then(() => undefined, (e: Error) => e);
566+
expect(err!.message.endsWith(GENERIC)).toBe(true);
567+
});
568+
});
569+
570+
// "Diagnostic classification, zero behaviour change" is the whole claim of
571+
// #5794 — so the things that did NOT move are pinned next to the thing that did.
572+
describe('zero behaviour change', () => {
573+
const unbuilt = () =>
574+
factoryThrowing(withCode(new Error("Cannot find package '@objectstack/driver-sql'"), 'ERR_MODULE_NOT_FOUND'));
575+
576+
it('still fails fast, and still with a plain Error — not a new type', async () => {
577+
const err = await failFast(unbuilt());
578+
expect(err).toBeInstanceOf(Error);
579+
expect(err.name).toBe('Error');
580+
});
581+
582+
it('still degrades (no throw) when nothing binds to the datasource', async () => {
583+
const { service } = svc({ factory: unbuilt() });
584+
const result = await service.connect(
585+
{ ...analytics, autoConnect: true },
586+
{ context: { trigger: 'declared-auto' } },
587+
);
588+
expect(result.status).toBe('failed-degraded');
589+
});
590+
591+
it('still degrades for a runtime-admin connect, never bricking a running server', async () => {
592+
const { service } = svc({ factory: unbuilt() });
593+
const result = await service.connect(analytics, {
594+
objects: ['visit'],
595+
context: { trigger: 'runtime-admin' },
596+
});
597+
expect(result.status).toBe('failed-degraded');
598+
});
599+
600+
it('still boots degraded under the escape hatch, with the banner unchanged', async () => {
601+
process.env[ENV] = '1';
602+
const { service, warnings } = svc({ factory: unbuilt() });
603+
const result = await service.connect(analytics, {
604+
objects: ['visit'],
605+
context: { trigger: 'declared-auto' },
606+
});
607+
expect(result.status).toBe('failed-degraded');
608+
const warned = warnings.join('\n');
609+
expect(warned).toContain('DEGRADED BOOT');
610+
expect(warned).toContain('OS_ALLOW_DRIVER_CONNECT_FAILURE is set');
611+
});
612+
613+
it('still retains the verdict for the admin surface', async () => {
614+
const { service, engine } = svc({ factory: unbuilt() });
615+
await service
616+
.connect(analytics, { objects: ['visit'], context: { trigger: 'declared-auto' } })
617+
.catch(() => undefined);
618+
const state = service.getConnectionState('analytics');
619+
expect(state?.status).toBe('failed-degraded');
620+
expect(state?.availability).toBe('failed');
621+
expect(engine!.unavailable.get('analytics')?.kind).toBe('failed');
622+
});
623+
});
624+
});
625+
626+
// The classifier on its own, at the boundary it is responsible for.
627+
describe('isUnbuiltWorkspaceFailure (#5794)', () => {
628+
it('reads the structured code first, in both module systems', () => {
629+
const esm = Object.assign(new Error('nothing recognisable here'), { code: 'ERR_MODULE_NOT_FOUND' });
630+
const cjs = Object.assign(new Error('nothing recognisable here'), { code: 'MODULE_NOT_FOUND' });
631+
expect(isUnbuiltWorkspaceFailure(esm)).toBe(true);
632+
expect(isUnbuiltWorkspaceFailure(cjs)).toBe(true);
633+
});
634+
635+
it('falls back to the message when the code was dropped by a re-throw', () => {
636+
expect(isUnbuiltWorkspaceFailure(new Error("Cannot find package '@objectstack/driver-sql'"))).toBe(true);
637+
expect(isUnbuiltWorkspaceFailure(new Error("Cannot find module '/w/dist/index.mjs'"))).toBe(true);
638+
});
639+
640+
it('leaves a real connect failure alone', () => {
641+
expect(isUnbuiltWorkspaceFailure(new Error('connection refused'))).toBe(false);
642+
expect(
643+
isUnbuiltWorkspaceFailure(Object.assign(new Error('password authentication failed'), { code: '28P01' })),
644+
).toBe(false);
645+
});
646+
647+
it('leaves a native-addon ABI mismatch alone — a rebuild, not an unbuilt workspace', () => {
648+
const abi = Object.assign(
649+
new Error('better_sqlite3.node was compiled against a different Node.js version'),
650+
{ code: 'ERR_DLOPEN_FAILED' },
651+
);
652+
expect(isUnbuiltWorkspaceFailure(abi)).toBe(false);
653+
});
654+
655+
it('classifies nothing when there was no thrown value to read', () => {
656+
expect(isUnbuiltWorkspaceFailure(undefined)).toBe(false);
657+
});
658+
});
659+
388660
describe('D3 credential resolution — fail-closed', () => {
389661
const credExternal: ConnectableDatasource = {
390662
name: 'warehouse',

0 commit comments

Comments
 (0)