@@ -374,6 +374,22 @@ describe('resolveLocalizationContext — batched fallback read (#2409)', () => {
374374 } ) ;
375375} ) ;
376376
377+ // Simulates a fresh environment: `sys_setting` not migrated/written yet, so
378+ // every read rejects the way the real sql-driver's "no such table" does.
379+ // Shared by the #10221 cache block below and the #11877 leg-narrowing block
380+ // after it, which needs the same backend fault standing BEHIND a settings
381+ // refusal.
382+ function makeMissingTableQl ( ) {
383+ const counts = { sys_setting : 0 } ;
384+ return {
385+ counts,
386+ async find ( _object : string ) {
387+ counts . sys_setting += 1 ;
388+ throw new Error ( 'no such table: sys_setting' ) ;
389+ } ,
390+ } ;
391+ }
392+
377393// #10221: a fresh environment's `sys_setting` table doesn't exist yet, so
378394// EVERY request's read used to fail and the sql-driver's `[sql-driver]
379395// DATABASE_ERROR` warning repeated once per request, burying real errors.
@@ -395,19 +411,6 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
395411 vi . useRealTimers ( ) ;
396412 } ) ;
397413
398- // Simulates a fresh environment: `sys_setting` not migrated/written yet, so
399- // every read rejects the way the real sql-driver's "no such table" does.
400- function makeMissingTableQl ( ) {
401- const counts = { sys_setting : 0 } ;
402- return {
403- counts,
404- async find ( _object : string ) {
405- counts . sys_setting += 1 ;
406- throw new Error ( 'no such table: sys_setting' ) ;
407- } ,
408- } ;
409- }
410-
411414 it ( 'does not re-query on a second call within the TTL window when the read fails (same tenant)' , async ( ) => {
412415 const ql = makeMissingTableQl ( ) ;
413416 const first = await resolveLocalizationContext ( { ql, tenantId : 'o1' } ) ;
@@ -484,6 +487,193 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
484487 } ) ;
485488} ) ;
486489
490+ // ── #11877 — a SETTINGS-SERVICE refusal must not populate the failure memo ──
491+ //
492+ // The memo above exists for one thing (#10221): a `sys_setting` query that
493+ // actively FAILS must not re-run — and re-log the driver's line — on every
494+ // request. Its write condition was wider than that. `failed` was set by SIX
495+ // legs and only ONE of them is the backend fault the cache's own docblock
496+ // describes:
497+ //
498+ // settings.getMany(...) threw — the grouped read (settings leg)
499+ // settings.get(...) threw × 3 — the older per-key arm (settings)
500+ // the settings block threw — "service unavailable" (settings)
501+ // ql.find('sys_setting', ...) threw — THE backend fault
502+ //
503+ // The five settings legs are reachable INSIDE the settings engine's bind
504+ // window: `SettingsService.getMany` refuses all-or-nothing for a
505+ // `localization` namespace whose manifest is not (yet) registered. So a
506+ // caller that deliberately re-reads AFTER the bind — the #11580 repair does
507+ // exactly that, re-resolving at `kernel:bootstrapped` — could be answered
508+ // from the memo taken inside the window, within the 30s TTL, with nothing in
509+ // the output saying the correction did not happen.
510+ //
511+ // And directly against this cache's own docblock ("a successful read is NEVER
512+ // cached"): a settings refusal standing alongside a perfectly SUCCESSFUL
513+ // direct read memoized that successful value for 30s — the exact staleness
514+ // `analytics-timezone.dogfood.test.ts` (#1982/#2018) exists to forbid.
515+ //
516+ // So the memo is written only for the backend-fault leg now. #10221's
517+ // protection is untouched, and BOTH directions are pinned below: a genuine
518+ // backend fault still memoizes — including with a settings refusal standing
519+ // in front of it — while a settings refusal alone no longer does.
520+ describe ( 'resolveLocalizationContext — only a backend fault populates the memo (#11877)' , ( ) => {
521+ beforeEach ( ( ) => {
522+ vi . useFakeTimers ( ) ;
523+ } ) ;
524+ afterEach ( ( ) => {
525+ vi . useRealTimers ( ) ;
526+ } ) ;
527+
528+ /** What the workspace has PERSISTED — answerable only once the engine binds. */
529+ const CONFIGURED = { timezone : 'Asia/Shanghai' , locale : 'zh-CN' , currency : 'CNY' } ;
530+
531+ // The all-or-nothing refusal `SettingsService.getMany` gives for a namespace
532+ // whose manifest is not (yet) registered. It throws out of an in-memory
533+ // registry check — before any query, before any log line — which is why
534+ // memoizing THIS leg never suppressed a query or a log line to begin with.
535+ function makeBindWindowSettings ( ) {
536+ const state = { bound : false , getManyCalls : 0 } ;
537+ return {
538+ state,
539+ get : async ( ) => {
540+ throw new Error ( 'per-key get must not be called on the batched arm' ) ;
541+ } ,
542+ getMany : async ( ) => {
543+ state . getManyCalls += 1 ;
544+ if ( ! state . bound ) throw new Error ( "unknown settings namespace 'localization'" ) ;
545+ return {
546+ timezone : { value : CONFIGURED . timezone } ,
547+ locale : { value : CONFIGURED . locale } ,
548+ currency : { value : CONFIGURED . currency } ,
549+ } ;
550+ } ,
551+ } ;
552+ }
553+
554+ // ── the reproduction this card was filed without ────────────────────────
555+ //
556+ // Pre-bind read inside the window (settings refuses, `sys_setting` answers
557+ // an ordinary empty result) → deliberate post-bind re-read 1s later, well
558+ // inside the 30s TTL. The clock is FAKE and advanced explicitly; nothing
559+ // here sleeps on the wall clock.
560+ it ( 'a post-bind re-read inside the TTL is answered by the now-bound service, not by the pre-bind memo' , async ( ) => {
561+ const settings = makeBindWindowSettings ( ) ;
562+ const ql = makeCountingQl ( { sys_setting : [ ] } ) ;
563+
564+ const preBind = await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ;
565+ expect ( preBind ) . toEqual ( { timezone : 'UTC' , locale : 'en-US' , currency : undefined } ) ;
566+ expect ( settings . state . getManyCalls ) . toBe ( 1 ) ;
567+
568+ settings . state . bound = true ; // the settings engine binds
569+ await vi . advanceTimersByTimeAsync ( 1_000 ) ; // still deep inside the 30s window
570+
571+ const postBind = await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ;
572+ // The re-read must REACH the service (a memo hit would never call it) and
573+ // must carry the configured values, which exist only behind the bind.
574+ expect ( settings . state . getManyCalls ) . toBe ( 2 ) ;
575+ expect ( postBind ) . toEqual ( { timezone : 'Asia/Shanghai' , locale : 'zh-CN' , currency : 'CNY' } ) ;
576+ } ) ;
577+
578+ // The same refusal, but the direct read SUCCEEDS with rows. The memoized
579+ // value here was a correct, successful answer — frozen for 30s by a leg that
580+ // has nothing to do with the backend.
581+ it ( 'a settings refusal never freezes a SUCCESSFUL direct read: a row change is visible on the very next call' , async ( ) => {
582+ const settings = makeBindWindowSettings ( ) ; // stays unbound → refuses every call
583+ const rows = [ { namespace : 'localization' , key : 'timezone' , scope : 'tenant' , value : 'UTC' } ] ;
584+ const ql = makeCountingQl ( { sys_setting : rows } ) ;
585+
586+ const first = await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ;
587+ expect ( first . timezone ) . toBe ( 'UTC' ) ;
588+
589+ rows [ 0 ] . value = 'America/Los_Angeles' ; // a settings write lands, no TTL advance
590+ const second = await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ;
591+ expect ( second . timezone ) . toBe ( 'America/Los_Angeles' ) ;
592+ expect ( ql . counts . sys_setting ) . toBe ( 2 ) ;
593+ } ) ;
594+
595+ // The older per-key arm (a service with no `getMany`): three `get` legs,
596+ // same rule.
597+ it ( 'the per-key get legs do not populate the memo either' , async ( ) => {
598+ const state = { bound : false , gets : 0 } ;
599+ const settings = {
600+ get : async ( _ns : string , key : string ) => {
601+ state . gets += 1 ;
602+ if ( ! state . bound ) throw new Error ( "unknown settings namespace 'localization'" ) ;
603+ return { value : ( CONFIGURED as Record < string , string > ) [ key ] } ;
604+ } ,
605+ } ;
606+ const ql = makeCountingQl ( { sys_setting : [ ] } ) ;
607+
608+ expect ( await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ) . toEqual ( {
609+ timezone : 'UTC' ,
610+ locale : 'en-US' ,
611+ currency : undefined ,
612+ } ) ;
613+ expect ( state . gets ) . toBe ( 3 ) ;
614+
615+ state . bound = true ;
616+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
617+ expect ( await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ) . toEqual ( {
618+ timezone : 'Asia/Shanghai' ,
619+ locale : 'zh-CN' ,
620+ currency : 'CNY' ,
621+ } ) ;
622+ expect ( state . gets ) . toBe ( 6 ) ;
623+ } ) ;
624+
625+ // The whole-block leg: a `get` that throws SYNCHRONOUSLY never attaches its
626+ // `.catch`, so the throw escapes `Promise.all` into the outer
627+ // "settings service unavailable → direct read" handler. Same rule.
628+ it ( 'the outer "settings service unavailable" leg does not populate the memo either' , async ( ) => {
629+ const state = { bound : false , gets : 0 } ;
630+ const settings = {
631+ // Deliberately NOT async: this throws before a promise exists.
632+ get : ( _ns : string , key : string ) => {
633+ state . gets += 1 ;
634+ if ( ! state . bound ) throw new Error ( 'settings service unavailable' ) ;
635+ return Promise . resolve ( { value : ( CONFIGURED as Record < string , string > ) [ key ] } ) ;
636+ } ,
637+ } ;
638+ const ql = makeCountingQl ( { sys_setting : [ ] } ) ;
639+
640+ expect ( await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ) . toEqual ( {
641+ timezone : 'UTC' ,
642+ locale : 'en-US' ,
643+ currency : undefined ,
644+ } ) ;
645+
646+ state . bound = true ;
647+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
648+ expect ( ( await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ) . timezone ) . toBe ( 'Asia/Shanghai' ) ;
649+ } ) ;
650+
651+ // ── the half that must be PRESERVED (#10221) ────────────────────────────
652+ //
653+ // Narrowing the write condition must not narrow it to nothing. A settings
654+ // refusal standing in FRONT of a genuinely failing `sys_setting` read is the
655+ // real #10221 environment (fresh deployment: no manifest registered yet AND
656+ // no table yet) — the failing query must still be memoized there.
657+ it ( 'still memoizes when a settings refusal stands in front of a genuine backend fault' , async ( ) => {
658+ const settings = makeBindWindowSettings ( ) ; // unbound → refuses
659+ const ql = makeMissingTableQl ( ) ; // and the direct read throws
660+
661+ await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ;
662+ expect ( ql . counts . sys_setting ) . toBe ( 1 ) ;
663+
664+ await vi . advanceTimersByTimeAsync ( 1_000 ) ;
665+ await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ;
666+ // Memo hit: the failing query — and the driver's log line for it — did not
667+ // repeat. The settings refusal is re-attempted (it is free), but that is
668+ // not what #10221 was protecting.
669+ expect ( ql . counts . sys_setting ) . toBe ( 1 ) ;
670+
671+ await vi . advanceTimersByTimeAsync ( 30_001 ) ;
672+ await resolveLocalizationContext ( { ql, settings, tenantId : 'o1' } ) ;
673+ expect ( ql . counts . sys_setting ) . toBe ( 2 ) ; // and it still self-heals on expiry
674+ } ) ;
675+ } ) ;
676+
487677describe ( 'grant validity windows (ADR-0091 D1/D2)' , ( ) => {
488678 const NOW = Date . parse ( '2026-07-10T12:00:00Z' ) ;
489679 const PAST = '2026-07-01T00:00:00Z' ;
0 commit comments