@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.
- package/dist/{SyncStrategy-CyATpyLQ.d.cts → SyncStrategy-BJMZq3WO.d.cts} +17 -0
- package/dist/{SyncStrategy-CyATpyLQ.d.ts → SyncStrategy-BJMZq3WO.d.ts} +17 -0
- package/dist/{contentSource-Brz42x7n.d.cts → contentSource-CBqZCoGU.d.cts} +85 -1
- package/dist/{contentSource-B1p-vdz7.d.ts → contentSource-CudEz5Tm.d.ts} +85 -1
- package/dist/core/index.d.cts +51 -4
- package/dist/core/index.d.ts +51 -4
- package/dist/core/index.js +258 -7
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +254 -7
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CpwpT1rM.d.cts → dataLayerProvider-BBA8tJNx.d.cts} +1 -1
- package/dist/{dataLayerProvider-BWayUIoK.d.ts → dataLayerProvider-C9WgkBzR.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +16 -3
- package/dist/impl/couch/index.d.ts +16 -3
- package/dist/impl/couch/index.js +284 -9
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +284 -9
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -3
- package/dist/impl/static/index.d.ts +3 -3
- package/dist/impl/static/index.js +250 -7
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +250 -7
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +8 -6
- package/dist/index.d.ts +8 -6
- package/dist/index.js +354 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +350 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/userDB.ts +11 -0
- package/src/core/navigators/StrategyPressureDebugger.ts +76 -0
- package/src/core/navigators/generators/prescribed.ts +122 -7
- package/src/core/navigators/index.ts +12 -0
- package/src/core/types/hydration.ts +78 -0
- package/src/impl/common/BaseUserDB.ts +202 -2
- package/src/impl/common/SyncStrategy.ts +19 -0
- package/src/impl/couch/CouchDBSyncStrategy.ts +54 -4
- package/src/study/SessionController.ts +7 -1
- package/src/study/SessionDebugger.ts +2 -0
- package/src/study/SessionOverlay.ts +113 -0
- package/tests/impl/hydration.test.ts +281 -0
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
47
|
-
|
|
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
|
-
|
|
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 {
|
|
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})`);
|