@@ -9,6 +9,10 @@ import {
99} from '../datasource-connection-service.js' ;
1010import type { IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js' ;
1111import 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. */
1418type 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 ( / p n p m b u i l d / 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+
388660describe ( 'D3 credential resolution — fail-closed' , ( ) => {
389661 const credExternal : ConnectableDatasource = {
390662 name : 'warehouse' ,
0 commit comments