@vue-skuilder/db 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/{SyncStrategy-CyATpyLQ.d.cts → SyncStrategy-BJMZq3WO.d.cts} +17 -0
  2. package/dist/{SyncStrategy-CyATpyLQ.d.ts → SyncStrategy-BJMZq3WO.d.ts} +17 -0
  3. package/dist/{contentSource-Brz42x7n.d.cts → contentSource-CBqZCoGU.d.cts} +85 -1
  4. package/dist/{contentSource-B1p-vdz7.d.ts → contentSource-CudEz5Tm.d.ts} +85 -1
  5. package/dist/core/index.d.cts +51 -4
  6. package/dist/core/index.d.ts +51 -4
  7. package/dist/core/index.js +258 -7
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/index.mjs +254 -7
  10. package/dist/core/index.mjs.map +1 -1
  11. package/dist/{dataLayerProvider-CpwpT1rM.d.cts → dataLayerProvider-BBA8tJNx.d.cts} +1 -1
  12. package/dist/{dataLayerProvider-BWayUIoK.d.ts → dataLayerProvider-C9WgkBzR.d.ts} +1 -1
  13. package/dist/impl/couch/index.d.cts +16 -3
  14. package/dist/impl/couch/index.d.ts +16 -3
  15. package/dist/impl/couch/index.js +284 -9
  16. package/dist/impl/couch/index.js.map +1 -1
  17. package/dist/impl/couch/index.mjs +284 -9
  18. package/dist/impl/couch/index.mjs.map +1 -1
  19. package/dist/impl/static/index.d.cts +3 -3
  20. package/dist/impl/static/index.d.ts +3 -3
  21. package/dist/impl/static/index.js +250 -7
  22. package/dist/impl/static/index.js.map +1 -1
  23. package/dist/impl/static/index.mjs +250 -7
  24. package/dist/impl/static/index.mjs.map +1 -1
  25. package/dist/index.d.cts +8 -6
  26. package/dist/index.d.ts +8 -6
  27. package/dist/index.js +354 -10
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +350 -10
  30. package/dist/index.mjs.map +1 -1
  31. package/package.json +4 -3
  32. package/src/core/index.ts +1 -0
  33. package/src/core/interfaces/userDB.ts +11 -0
  34. package/src/core/navigators/StrategyPressureDebugger.ts +76 -0
  35. package/src/core/navigators/generators/prescribed.ts +122 -7
  36. package/src/core/navigators/index.ts +12 -0
  37. package/src/core/types/hydration.ts +78 -0
  38. package/src/impl/common/BaseUserDB.ts +202 -2
  39. package/src/impl/common/SyncStrategy.ts +19 -0
  40. package/src/impl/couch/CouchDBSyncStrategy.ts +54 -4
  41. package/src/study/SessionController.ts +7 -1
  42. package/src/study/SessionDebugger.ts +2 -0
  43. package/src/study/SessionOverlay.ts +113 -0
  44. package/tests/impl/hydration.test.ts +281 -0
@@ -1,4 +1,12 @@
1
- import { DocType, DocTypePrefixes, StrategyStateDoc, buildStrategyStateId } from '@db/core';
1
+ import {
2
+ DocType,
3
+ DocTypePrefixes,
4
+ StrategyStateDoc,
5
+ buildStrategyStateId,
6
+ HYDRATION_MARKER_ID,
7
+ type HydrationMarker,
8
+ type UserHydrationStatus,
9
+ } from '@db/core';
2
10
  import { getCardHistoryID } from '@db/core/util';
3
11
  import { CourseElo, Status } from '@vue-skuilder/common';
4
12
  import moment, { Moment } from 'moment';
@@ -50,6 +58,35 @@ interface DesignDoc {
50
58
  };
51
59
  }
52
60
 
61
+ /**
62
+ * Ceiling on the initial pull of a user's data before we give up and report
63
+ * `failed`. Generous relative to the expected payload (a user database is a
64
+ * config doc, registrations, strategy state, and per-card history), so hitting
65
+ * it indicates a genuinely unusable connection rather than a large account.
66
+ */
67
+ const HYDRATION_TIMEOUT_MS = 15_000;
68
+
69
+ /**
70
+ * Reject if `p` has not settled within `ms`.
71
+ *
72
+ * Note the underlying work is not cancelled by this — callers that need the
73
+ * operation stopped must do so themselves.
74
+ */
75
+ function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
76
+ let timer: ReturnType<typeof setTimeout> | undefined;
77
+
78
+ return Promise.race([
79
+ p,
80
+ new Promise<never>((_resolve, reject) => {
81
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
82
+ }),
83
+ ]).finally(() => {
84
+ if (timer !== undefined) {
85
+ clearTimeout(timer);
86
+ }
87
+ }) as Promise<T>;
88
+ }
89
+
53
90
  /**
54
91
  * Base user database implementation that uses a pluggable sync strategy.
55
92
  * Handles local storage operations and delegates sync/remote operations to the strategy.
@@ -87,9 +124,34 @@ export class BaseUser implements UserDBInterface, DocumentUpdater {
87
124
  private localDB!: PouchDB.Database;
88
125
  private remoteDB!: PouchDB.Database;
89
126
  private writeDB!: PouchDB.Database; // Database to use for write operations (local-first approach)
90
-
91
127
  private updateQueue!: UpdateQueue;
92
128
 
129
+ private _hydration: UserHydrationStatus = { state: 'not-required' };
130
+
131
+ /**
132
+ * How far the local mirror can be trusted. See {@link UserHydrationStatus}.
133
+ *
134
+ * Consumers that would otherwise read a 404 as "new user" — onboarding
135
+ * gates, first-run experiences, progress dashboards — should check for
136
+ * `failed` and present a retry affordance rather than an empty state.
137
+ */
138
+ public hydrationStatus(): UserHydrationStatus {
139
+ return { ...this._hydration };
140
+ }
141
+
142
+ /**
143
+ * Whether a missing document may be treated as genuinely absent, allowing
144
+ * callers to create it with defaults.
145
+ *
146
+ * False only when hydration failed or is still running: a 404 then may just
147
+ * mean "not pulled down yet", and materializing a default would both lie to
148
+ * the user and, once live sync starts, push a rev-1 document that loses to
149
+ * the real one — silently discarding it.
150
+ */
151
+ private canMaterializeDefaults(): boolean {
152
+ return this._hydration.state !== 'failed' && this._hydration.state !== 'hydrating';
153
+ }
154
+
93
155
  public async createAccount(
94
156
  username: string,
95
157
  password: string
@@ -257,6 +319,14 @@ Currently logged-in as ${this._username}.`
257
319
  } catch (e) {
258
320
  const err = e as PouchError;
259
321
  if (err.status === 404) {
322
+ if (!this.canMaterializeDefaults()) {
323
+ throw new Error(
324
+ `Cannot read course registrations for ${this._username}: the local mirror is ` +
325
+ `unavailable (hydration state '${this._hydration.state}'). Refusing to create an ` +
326
+ `empty registration doc — that would report an existing user as unregistered, and ` +
327
+ `then lose to their real document once sync resumes.`
328
+ );
329
+ }
260
330
  await this.localDB.put<CourseRegistrationDoc>({
261
331
  _id: BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
262
332
  courses: [],
@@ -556,6 +626,13 @@ Currently logged-in as ${this._username}.`
556
626
  } catch (e) {
557
627
  const err = e as PouchError;
558
628
  if (err.name && err.name === 'not_found') {
629
+ if (!this.canMaterializeDefaults()) {
630
+ throw new Error(
631
+ `Cannot read config for ${this._username}: the local mirror is unavailable ` +
632
+ `(hydration state '${this._hydration.state}'). Refusing to write a default config ` +
633
+ `over what may be an existing one.`
634
+ );
635
+ }
559
636
  await this.localDB.put<UserConfig>(defaultConfig);
560
637
  return this.getConfig();
561
638
  } else {
@@ -643,8 +720,24 @@ Currently logged-in as ${this._username}.`
643
720
  return;
644
721
  }
645
722
 
723
+ // Cancel replication left over from the previous identity BEFORE rebinding
724
+ // the DB handles below. init() runs on every identity change (login,
725
+ // logout, account creation, data reset), and each one points localDB /
726
+ // remoteDB somewhere new — so without this, the prior sync keeps running
727
+ // against handles nobody holds a reference to anymore.
728
+ //
729
+ // Logout is the case that bites: the guest branch of startSync() never
730
+ // replaces the handle (it only ever assigns when a sync is actually
731
+ // started), so the departing user's live sync survived indefinitely. Its
732
+ // session cookie is gone by then, leaving it to 401-and-retry forever.
733
+ this.syncStrategy.stopSync?.();
734
+
646
735
  this.setDBandQ();
647
736
 
737
+ // Populate the local mirror BEFORE anything can read it, and before live
738
+ // sync can push local state upward. See hydrateLocalMirror().
739
+ await this.hydrateLocalMirror();
740
+
648
741
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
649
742
  this.applyDesignDocs().catch((error) => {
650
743
  log(`Error in applyDesignDocs background task: ${error}`);
@@ -661,6 +754,104 @@ Currently logged-in as ${this._username}.`
661
754
  BaseUser._initialized = true;
662
755
  }
663
756
 
757
+ /**
758
+ * Pull this account's documents into the local mirror, if that hasn't
759
+ * happened on this device before.
760
+ *
761
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
762
+ * anything can observe an empty local DB or replicate one upward.
763
+ *
764
+ * Never throws: a failure is recorded as `failed` state and reported through
765
+ * hydrationStatus(), because throwing here would abort init() and leave
766
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
767
+ * ceiling). Callers decide what a failure means for them.
768
+ */
769
+ private async hydrateLocalMirror(): Promise<void> {
770
+ // Guests and static-mode users have no remote — setupRemoteDB() hands back
771
+ // the local database itself, so there is nothing to pull from.
772
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
773
+ this._hydration = { state: 'not-required' };
774
+ return;
775
+ }
776
+
777
+ if (await this.hasHydrationMarker()) {
778
+ // A full pull already completed on this device, so local reads return
779
+ // real data and live sync will close any gap. Blocking here would break
780
+ // offline startup to re-fetch what we already have.
781
+ this._hydration = { state: 'stale' };
782
+ return;
783
+ }
784
+
785
+ this._hydration = { state: 'hydrating' };
786
+ const start = Date.now();
787
+
788
+ try {
789
+ const { docsWritten } = await withTimeout(
790
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
791
+ HYDRATION_TIMEOUT_MS,
792
+ `Hydration of local mirror for ${this._username}`
793
+ );
794
+ await this.writeHydrationMarker();
795
+ this._hydration = {
796
+ state: 'hydrated',
797
+ docsWritten,
798
+ durationMs: Date.now() - start,
799
+ };
800
+ log(
801
+ `Hydrated local mirror for ${this._username}: ` +
802
+ `${docsWritten} docs in ${this._hydration.durationMs}ms`
803
+ );
804
+ } catch (e) {
805
+ // Cancel the pull if it is merely slow rather than dead — otherwise it
806
+ // races the live sync we are about to start over the same documents.
807
+ this.syncStrategy.stopSync?.();
808
+ this._hydration = {
809
+ state: 'failed',
810
+ durationMs: Date.now() - start,
811
+ error: e instanceof Error ? e.message : String(e),
812
+ };
813
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Has a full pull completed on this device for the CURRENT account?
819
+ *
820
+ * The marker is a `_local/` document, which never replicates — so its
821
+ * presence describes this device's mirror and cannot arrive from elsewhere.
822
+ */
823
+ private async hasHydrationMarker(): Promise<boolean> {
824
+ try {
825
+ const marker = await this.localDB.get<HydrationMarker>(HYDRATION_MARKER_ID);
826
+ return marker.username === this._username;
827
+ } catch {
828
+ // Absent, or unreadable — either way, treat as never hydrated. The cost
829
+ // of a false negative is one redundant pull.
830
+ return false;
831
+ }
832
+ }
833
+
834
+ private async writeHydrationMarker(): Promise<void> {
835
+ try {
836
+ let existingRev: string | undefined;
837
+ try {
838
+ existingRev = (await this.localDB.get<HydrationMarker>(HYDRATION_MARKER_ID))._rev;
839
+ } catch {
840
+ // First hydration on this device.
841
+ }
842
+
843
+ await this.localDB.put<HydrationMarker>({
844
+ _id: HYDRATION_MARKER_ID,
845
+ _rev: existingRev,
846
+ username: this._username,
847
+ hydratedAt: new Date().toISOString(),
848
+ });
849
+ } catch (e) {
850
+ // Costs a redundant pull on next startup, not correctness.
851
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
852
+ }
853
+ }
854
+
664
855
  private static designDocs: DesignDoc[] = [
665
856
  {
666
857
  _id: '_design/reviewCards',
@@ -1083,6 +1274,15 @@ Currently logged-in as ${this._username}.`
1083
1274
  } catch (e) {
1084
1275
  const err = e as PouchError;
1085
1276
  if (err.status === 404) {
1277
+ // `null` here means "this user has no such state" — a first-run signal
1278
+ // consumers act on. When the mirror is untrusted we cannot honestly say
1279
+ // that, so fail instead of reporting an established user as new.
1280
+ if (!this.canMaterializeDefaults()) {
1281
+ throw new Error(
1282
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror ` +
1283
+ `is unavailable (hydration state '${this._hydration.state}').`
1284
+ );
1285
+ }
1086
1286
  return null;
1087
1287
  }
1088
1288
  throw e;
@@ -21,6 +21,25 @@ export interface SyncStrategy {
21
21
  */
22
22
  getWriteDB?(username: string): PouchDB.Database;
23
23
 
24
+ /**
25
+ * Pull the remote database into the local one and RESOLVE WHEN DONE.
26
+ *
27
+ * Distinct from startSync(): this is a one-shot, awaitable catch-up used to
28
+ * populate an empty local mirror before the user object is handed out. A
29
+ * strategy that omits it declares that it has no remote worth waiting for.
30
+ *
31
+ * Mechanism only — BaseUser owns the policy (whether to run it, timeout,
32
+ * marker bookkeeping, resulting hydration state).
33
+ *
34
+ * @param localDB The local PouchDB instance
35
+ * @param remoteDB The remote PouchDB instance
36
+ * @returns Count of documents written locally
37
+ */
38
+ hydrate?(
39
+ localDB: PouchDB.Database,
40
+ remoteDB: PouchDB.Database
41
+ ): Promise<{ docsWritten: number }>;
42
+
24
43
  /**
25
44
  * Start synchronization between local and remote databases
26
45
  * @param localDB The local PouchDB instance
@@ -21,6 +21,8 @@ const log = (s: any) => {
21
21
  */
22
22
  export class CouchDBSyncStrategy implements SyncStrategy {
23
23
  private syncHandle?: any; // Handle to cancel sync if needed
24
+ /** In-flight one-shot hydration pull, so a timed-out pull can be abandoned. */
25
+ private hydrationHandle?: PouchDB.Replication.Replication<object>;
24
26
 
25
27
  setupRemoteDB(username: string): PouchDB.Database {
26
28
  if (username === GuestUsername || username.startsWith(GuestUsername)) {
@@ -42,20 +44,68 @@ export class CouchDBSyncStrategy implements SyncStrategy {
42
44
  }
43
45
  }
44
46
 
47
+ /**
48
+ * One-shot remote → local pull. Resolves once the local mirror is caught up.
49
+ *
50
+ * Pull only: pushing here would upload whatever the local DB happens to hold
51
+ * before we know what the remote already has, which is the conflict-leaf
52
+ * problem hydration exists to avoid. Local changes go up when startSync()
53
+ * takes over.
54
+ */
55
+ async hydrate(
56
+ localDB: PouchDB.Database,
57
+ remoteDB: PouchDB.Database
58
+ ): Promise<{ docsWritten: number }> {
59
+ // A one-shot Replication is itself a promise of the completed result, so
60
+ // it can be awaited directly — the handle is retained only so a pull that
61
+ // outlives its timeout can be cancelled.
62
+ const replication = pouch.replicate(remoteDB, localDB, {});
63
+ this.hydrationHandle = replication;
64
+
65
+ try {
66
+ const info = await replication;
67
+ return { docsWritten: info.docs_written };
68
+ } finally {
69
+ this.hydrationHandle = undefined;
70
+ }
71
+ }
72
+
45
73
  startSync(localDB: PouchDB.Database, remoteDB: PouchDB.Database): void {
46
- // Only sync if local and remote are different instances
47
- if (localDB !== remoteDB) {
74
+ // Compare by NAME, not object identity. In guest mode setupRemoteDB()
75
+ // returns getLocalUserDB(username) — the same database as localDB but
76
+ // getLocalUserDB() constructs a fresh PouchDB handle on every call rather
77
+ // than caching, so `localDB !== remoteDB` was always true and guests ran a
78
+ // live sync of a database against itself. Names are unambiguous here:
79
+ // local DBs are named `userdb-<username>` (or a filesystem path in Node),
80
+ // remote ones are a full `protocol://host/userdb-<hex>` URL.
81
+ if (localDB.name !== remoteDB.name) {
48
82
  this.syncHandle = pouch.sync(localDB, remoteDB, {
49
83
  live: true,
50
84
  retry: true,
51
85
  });
52
86
  }
53
- // If they're the same (guest mode), no sync needed
87
+ // If they're the same DB (guest mode), no sync needed
54
88
  }
55
89
 
56
90
  stopSync?(): void {
91
+ if (this.hydrationHandle) {
92
+ try {
93
+ this.hydrationHandle.cancel();
94
+ } catch (e) {
95
+ logger.warn(`Failed to cancel hydration pull (continuing anyway): ${e}`);
96
+ }
97
+ this.hydrationHandle = undefined;
98
+ }
99
+
57
100
  if (this.syncHandle) {
58
- this.syncHandle.cancel();
101
+ // Called from BaseUser.init(), which is on the app boot path — a throw
102
+ // here would leave BaseUser._initialized false forever, and
103
+ // BaseUser.instance() polls that flag. Drop the handle regardless.
104
+ try {
105
+ this.syncHandle.cancel();
106
+ } catch (e) {
107
+ logger.warn(`Failed to cancel user sync (continuing anyway): ${e}`);
108
+ }
59
109
  this.syncHandle = undefined;
60
110
  }
61
111
  }
@@ -15,7 +15,12 @@ import {
15
15
  import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord, isQuestionRecord } from '@db/core';
16
16
  import { recordUserOutcome } from '@db/core/orchestration/recording';
17
17
  import { Loggable } from '@db/util';
18
- import { getCardOrigin, WeightedCard, getSrsBacklogDebug } from '@db/core/navigators';
18
+ import {
19
+ getCardOrigin,
20
+ WeightedCard,
21
+ getSrsBacklogDebug,
22
+ getStrategyPressureDebug,
23
+ } from '@db/core/navigators';
19
24
  import { ReplanHints } from '@db/core/navigators/generators/types';
20
25
  import { mergeHints } from '@db/core/navigators/Pipeline';
21
26
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
@@ -763,6 +768,7 @@ export class SessionController<TView = unknown> extends Loggable {
763
768
  supplyQ: describe(this.supplyQ),
764
769
  failedQ: describe(this.failedQ),
765
770
  reviewBacklog: getSrsBacklogDebug(),
771
+ strategyPressure: getStrategyPressureDebug(),
766
772
  drawnCards,
767
773
  };
768
774
  }
@@ -1,6 +1,7 @@
1
1
  import { logger } from '../util/logger';
2
2
  import { clearRunHistory as clearPipelineRunHistory } from '../core/navigators/PipelineDebugger';
3
3
  import { clearSrsBacklogDebug } from '../core/navigators/SrsDebugger';
4
+ import { clearStrategyPressureDebug } from '../core/navigators/StrategyPressureDebugger';
4
5
  import { toggleSessionOverlay } from './SessionOverlay';
5
6
 
6
7
  // ============================================================================
@@ -82,6 +83,7 @@ const MAX_HISTORY = 5;
82
83
  export function clearStaleSessionDebugState(): void {
83
84
  clearPipelineRunHistory();
84
85
  clearSrsBacklogDebug();
86
+ clearStrategyPressureDebug();
85
87
  }
86
88
 
87
89
  /**
@@ -1,6 +1,10 @@
1
1
  import { logger } from '../util/logger';
2
2
  import type { ReplanHints } from '@db/core/navigators/generators/types';
3
3
  import type { SrsBacklogDebug } from '@db/core/navigators/SrsDebugger';
4
+ import type {
5
+ StrategyPressureDebug,
6
+ PressureGaugeDebug,
7
+ } from '@db/core/navigators/StrategyPressureDebugger';
4
8
 
5
9
  // ============================================================================
6
10
  // SESSION OVERLAY
@@ -72,6 +76,8 @@ export interface SessionDebugSnapshot {
72
76
  failedQ: SessionQueueDebug;
73
77
  /** SRS backlog state per course (drives the "is review starvation permanent?" read). */
74
78
  reviewBacklog: SrsBacklogDebug[];
79
+ /** Per-strategy backpressure gauges (prescribed pressure, practice debt, ...). */
80
+ strategyPressure: StrategyPressureDebug[];
75
81
  /** Every card the learner has interacted with this session, draw order. */
76
82
  drawnCards: SessionDrawnCardDebug[];
77
83
  }
@@ -241,6 +247,7 @@ function render(): void {
241
247
  metaHtml(s) +
242
248
  hintsHtml(s.sessionHints) +
243
249
  backlogHtml(s.reviewBacklog) +
250
+ strategyPressureHtml(s.strategyPressure) +
244
251
  queueHtml('supplyQ', 'supplyQ', s.supplyQ) +
245
252
  queueHtml('failedQ', 'failedQ', s.failedQ) +
246
253
  drawnHtml('drawn', s.drawnCards);
@@ -416,6 +423,93 @@ function backlogHtml(backlog: SrsBacklogDebug[]): string {
416
423
  );
417
424
  }
418
425
 
426
+ /**
427
+ * Colour for a pressure multiplier. Green at ×1.0 (no pressure); when the
428
+ * gauge is capped, amber → red by fraction of headroom used; when unbounded,
429
+ * fall back to the SRS panel's absolute threshold (×3 = hot).
430
+ */
431
+ function pressureColor(multiplier: number, max?: number): string {
432
+ if (multiplier <= 1.001) return '#86efac';
433
+ if (max !== undefined && max > 1) {
434
+ return (multiplier - 1) / (max - 1) >= 0.75 ? '#fca5a5' : '#fcd34d';
435
+ }
436
+ return multiplier >= 3 ? '#fca5a5' : '#fcd34d';
437
+ }
438
+
439
+ /** data-q keys land in HTML attributes; strip anything outside a safe set. */
440
+ function toggleKey(raw: string): string {
441
+ return raw.replace(/[^\w:.|-]/g, '_');
442
+ }
443
+
444
+ /**
445
+ * One gauge row: `label ×2.50/8 — detail`, with an item list underneath that
446
+ * mirrors the queues' collapse behaviour (INLINE_THRESHOLD shown, "+N more"
447
+ * expands).
448
+ */
449
+ function gaugeHtml(sectionKey: string, g: PressureGaugeDebug): string {
450
+ const cap = g.max !== undefined ? `<span style="opacity:.5">/${g.max}</span>` : '';
451
+ const detail = g.detail ? ` <span style="opacity:.6">${esc(g.detail)}</span>` : '';
452
+ let html =
453
+ `<div style="margin-left:6px">${esc(g.label)} ` +
454
+ `<span style="color:${pressureColor(g.multiplier, g.max)}">×${g.multiplier.toFixed(2)}</span>` +
455
+ `${cap}${detail}`;
456
+
457
+ const items = g.items ?? [];
458
+ if (items.length > 0) {
459
+ const key = toggleKey(`sp|${sectionKey}|${g.id}`);
460
+ const isOpen = !!expanded[key];
461
+ const shown = isOpen ? items : items.slice(0, INLINE_THRESHOLD);
462
+ html +=
463
+ `<div style="margin-left:6px">` +
464
+ shown
465
+ .map(
466
+ (it) =>
467
+ `<div style="white-space:nowrap;opacity:.75">${esc(it.label)}` +
468
+ `${it.value ? ` <span style="opacity:.6">${esc(it.value)}</span>` : ''}</div>`
469
+ )
470
+ .join('');
471
+ if (items.length > INLINE_THRESHOLD) {
472
+ const footer = isOpen ? '▾ show less' : `… +${items.length - shown.length} more`;
473
+ html += `<div data-q="${key}" style="cursor:pointer;opacity:.6">${footer}</div>`;
474
+ }
475
+ html += `</div>`;
476
+ }
477
+
478
+ return html + `</div>`;
479
+ }
480
+
481
+ /**
482
+ * Strategy backpressure panel — the generic counterpart of the SRS panel
483
+ * above, fed by `captureStrategyPressure` pushes from navigation strategies
484
+ * (built-in prescribed, or consumer-registered). Per source: gauge rows with
485
+ * cap-aware colour, then a `top score` line that reads against the supplyQ
486
+ * head exactly like the SRS panel's `top review`.
487
+ */
488
+ function strategyPressureHtml(list: StrategyPressureDebug[]): string {
489
+ if (!list.length) return '';
490
+ const sections = list
491
+ .map((s) => {
492
+ const sectionKey = `${s.source}:${s.courseId.slice(0, 8)}`;
493
+ const top = s.topScore !== null && s.topScore !== undefined ? s.topScore.toFixed(2) : '—';
494
+ const hints = s.hintsLabel
495
+ ? `<div style="margin-left:6px;opacity:.6">hints: ${esc(s.hintsLabel)}</div>`
496
+ : '';
497
+ return (
498
+ `<div style="margin-left:6px">` +
499
+ `<span style="opacity:.7">${esc(s.source)} · ${esc(s.courseId.slice(0, 8))}</span>` +
500
+ ` <span style="opacity:.6">top score ${top}</span>` +
501
+ s.gauges.map((g) => gaugeHtml(sectionKey, g)).join('') +
502
+ hints +
503
+ `</div>`
504
+ );
505
+ })
506
+ .join('');
507
+ return (
508
+ `<div style="margin-bottom:6px">` +
509
+ `<div style="color:#fdba74">strategy backpressure</div>${sections}</div>`
510
+ );
511
+ }
512
+
419
513
  /** Format a rank score for display: finite → 2dp, `+INF` → REQ (required). */
420
514
  function fmtScore(score?: number): string {
421
515
  if (score === undefined) return '';
@@ -564,6 +658,25 @@ function snapshotToText(s: SessionDebugSnapshot | null): string {
564
658
  }
565
659
  }
566
660
 
661
+ if (s.strategyPressure.length) {
662
+ lines.push('');
663
+ lines.push('strategy backpressure:');
664
+ for (const sp of s.strategyPressure) {
665
+ const top = sp.topScore !== null && sp.topScore !== undefined ? sp.topScore.toFixed(2) : '—';
666
+ lines.push(` ${sp.source} (${sp.courseId.slice(0, 8)}): top score ${top}`);
667
+ for (const g of sp.gauges) {
668
+ const cap = g.max !== undefined ? `/${g.max}` : '';
669
+ lines.push(
670
+ ` ${g.label} ×${g.multiplier.toFixed(2)}${cap}${g.detail ? ` — ${g.detail}` : ''}`
671
+ );
672
+ for (const it of g.items ?? []) {
673
+ lines.push(` ${it.label}${it.value ? ` (${it.value})` : ''}`);
674
+ }
675
+ }
676
+ if (sp.hintsLabel) lines.push(` hints: ${sp.hintsLabel}`);
677
+ }
678
+ }
679
+
567
680
  const queueText = (label: string, q: SessionQueueDebug) => {
568
681
  lines.push('');
569
682
  lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);