@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
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.18",
|
|
8
8
|
"description": "Database layer for vue-skuilder",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"module": "dist/index.mjs",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@nilock2/pouchdb-authentication": "^1.0.2",
|
|
51
|
-
"@vue-skuilder/common": "0.2.
|
|
51
|
+
"@vue-skuilder/common": "0.2.18",
|
|
52
52
|
"cross-fetch": "^4.1.0",
|
|
53
53
|
"moment": "^2.29.4",
|
|
54
54
|
"pouchdb": "^9.0.0",
|
|
@@ -57,10 +57,11 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/uuid": "^10.0.0",
|
|
60
|
+
"pouchdb-adapter-memory": "^9.0.0",
|
|
60
61
|
"tsup": "^8.0.2",
|
|
61
62
|
"typescript": "~5.9.3",
|
|
62
63
|
"vite": "^8.0.0",
|
|
63
64
|
"vitest": "^4.1.0"
|
|
64
65
|
},
|
|
65
|
-
"stableVersion": "0.2.
|
|
66
|
+
"stableVersion": "0.2.18"
|
|
66
67
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export * from './types/types-legacy';
|
|
|
5
5
|
export * from './types/user';
|
|
6
6
|
export * from './types/strategyState';
|
|
7
7
|
export * from './types/userOutcome';
|
|
8
|
+
export * from './types/hydration';
|
|
8
9
|
export * from '../util/Loggable';
|
|
9
10
|
export * from './util';
|
|
10
11
|
export * from './navigators';
|
|
@@ -9,6 +9,7 @@ import { Moment } from 'moment';
|
|
|
9
9
|
import { CardHistory, CardRecord, QualifiedCardID } from '../types/types-legacy';
|
|
10
10
|
import { UserOutcomeRecord } from '../types/userOutcome';
|
|
11
11
|
import { UserConfig } from '../types/user';
|
|
12
|
+
import { UserHydrationStatus } from '../types/hydration';
|
|
12
13
|
import { DocumentUpdater } from '@db/study';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -19,6 +20,16 @@ export interface UserDBReader {
|
|
|
19
20
|
getUsername(): string;
|
|
20
21
|
isLoggedIn(): boolean;
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* How far this device's local mirror of the user's data can be trusted.
|
|
25
|
+
*
|
|
26
|
+
* Reads answer from the local mirror, so on a device that has never synced
|
|
27
|
+
* this account "document not found" does not mean "no such data". Anything
|
|
28
|
+
* that would otherwise interpret an empty read as a new user should check
|
|
29
|
+
* for `failed` first. See {@link UserHydrationStatus}.
|
|
30
|
+
*/
|
|
31
|
+
hydrationStatus(): UserHydrationStatus;
|
|
32
|
+
|
|
22
33
|
/**
|
|
23
34
|
* Get user configuration
|
|
24
35
|
*/
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// STRATEGY PRESSURE DEBUGGER
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// A generic, semi-structured capture channel for the backpressures that
|
|
6
|
+
// navigation strategies exert on the pipeline — the general-purpose sibling
|
|
7
|
+
// of SrsDebugger's bespoke review-backlog capture.
|
|
8
|
+
//
|
|
9
|
+
// Any strategy (built-in or consumer-registered via `registerNavigator`) may
|
|
10
|
+
// push a snapshot of its current pressure state once per run. The live
|
|
11
|
+
// session overlay renders every captured source in a "strategy backpressure"
|
|
12
|
+
// section without knowing producer semantics: each snapshot is a list of
|
|
13
|
+
// *gauges* — named multipliers, optionally capped, with human-readable detail
|
|
14
|
+
// and expandable item lists (blocked targets, per-tag debt ages, ...).
|
|
15
|
+
//
|
|
16
|
+
// Mirrors SrsDebugger's lifecycle: latest snapshot per (source, course) wins;
|
|
17
|
+
// the controller reads via getDebugSnapshot() with no DB load; cleared on
|
|
18
|
+
// session start alongside the pipeline run history.
|
|
19
|
+
//
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single named pressure reading. The common shape across strategies: a
|
|
24
|
+
* multiplier that climbs under some accumulated condition (staleness, debt,
|
|
25
|
+
* blockage), optionally clamped at a cap.
|
|
26
|
+
*/
|
|
27
|
+
export interface PressureGaugeDebug {
|
|
28
|
+
/** Stable identifier within the source (e.g. 'group:intro-core:target'). */
|
|
29
|
+
id: string;
|
|
30
|
+
/** Short display label (e.g. 'intro-core targets'). */
|
|
31
|
+
label: string;
|
|
32
|
+
/** Current pressure multiplier (×1.0 = no pressure). */
|
|
33
|
+
multiplier: number;
|
|
34
|
+
/** Clamp ceiling, if the multiplier is capped. Omit for unbounded. */
|
|
35
|
+
max?: number;
|
|
36
|
+
/** One-line context for the reading (counts, mode, staleness). */
|
|
37
|
+
detail?: string;
|
|
38
|
+
/** Expandable rows (blocked target ids, per-tag debt ages, ...). */
|
|
39
|
+
items?: Array<{ label: string; value?: string }>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One strategy's pressure snapshot for one course, captured once per run. */
|
|
43
|
+
export interface StrategyPressureDebug {
|
|
44
|
+
/** Producer identity — implementingClass name (e.g. 'prescribed'). */
|
|
45
|
+
source: string;
|
|
46
|
+
courseId: string;
|
|
47
|
+
gauges: PressureGaugeDebug[];
|
|
48
|
+
/**
|
|
49
|
+
* Highest score this strategy emitted into the candidate pool this run;
|
|
50
|
+
* null if it emitted nothing. Compare against the supplyQ head score to
|
|
51
|
+
* read how the pressure is competing for slots (same crossover read as the
|
|
52
|
+
* SRS panel's `top review`).
|
|
53
|
+
*/
|
|
54
|
+
topScore?: number | null;
|
|
55
|
+
/** Label of any one-shot hints emitted alongside the cards this run. */
|
|
56
|
+
hintsLabel?: string;
|
|
57
|
+
/** Epoch ms of capture. */
|
|
58
|
+
timestamp: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const snapshots = new Map<string, StrategyPressureDebug>();
|
|
62
|
+
|
|
63
|
+
/** Called by a strategy once per run. Latest snapshot per (source, course) wins. */
|
|
64
|
+
export function captureStrategyPressure(snapshot: StrategyPressureDebug): void {
|
|
65
|
+
snapshots.set(`${snapshot.source}:${snapshot.courseId}`, snapshot);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Current pressure snapshot for every source seen, newest-first. */
|
|
69
|
+
export function getStrategyPressureDebug(): StrategyPressureDebug[] {
|
|
70
|
+
return [...snapshots.values()].sort((a, b) => b.timestamp - a.timestamp);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Drop all captured snapshots (called on session start, alongside pipeline history). */
|
|
74
|
+
export function clearStrategyPressureDebug(): void {
|
|
75
|
+
snapshots.clear();
|
|
76
|
+
}
|
|
@@ -4,6 +4,10 @@ import { ContentNavigator } from '../index';
|
|
|
4
4
|
import type { WeightedCard } from '../index';
|
|
5
5
|
import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
|
|
6
6
|
import type { CardGenerator, GeneratorContext, GeneratorResult, ReplanHints } from './types';
|
|
7
|
+
import {
|
|
8
|
+
captureStrategyPressure,
|
|
9
|
+
type PressureGaugeDebug,
|
|
10
|
+
} from '../StrategyPressureDebugger';
|
|
7
11
|
import { logger } from '@db/util/logger';
|
|
8
12
|
|
|
9
13
|
// ============================================================================
|
|
@@ -115,9 +119,19 @@ interface GroupRuntimeState {
|
|
|
115
119
|
supportTags: string[];
|
|
116
120
|
pressureMultiplier: number;
|
|
117
121
|
supportMultiplier: number;
|
|
122
|
+
/** Carried-forward staleness count driving the pressure multipliers. */
|
|
123
|
+
sessionsSinceSurfaced: number;
|
|
118
124
|
debugVersion: string;
|
|
119
125
|
}
|
|
120
126
|
|
|
127
|
+
/** One open practice debt, surfaced for the strategy-pressure debug channel. */
|
|
128
|
+
interface PracticeDebtDebug {
|
|
129
|
+
tag: string;
|
|
130
|
+
multiplier: number;
|
|
131
|
+
/** ISO timestamp when the skill first appeared unlocked-but-under-practiced. */
|
|
132
|
+
firstOwedAt: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
121
135
|
interface HintEmissionSummary {
|
|
122
136
|
boostTags: Record<string, number>;
|
|
123
137
|
blockedTargetIds: string[];
|
|
@@ -199,6 +213,15 @@ function shuffleInPlace<T>(arr: T[]): void {
|
|
|
199
213
|
}
|
|
200
214
|
}
|
|
201
215
|
|
|
216
|
+
/** Compact age string for debt-open durations: 42m / 7h / 3d. */
|
|
217
|
+
function formatAge(ms: number): string {
|
|
218
|
+
const minutes = Math.max(0, Math.round(ms / 60000));
|
|
219
|
+
if (minutes < 60) return `${minutes}m`;
|
|
220
|
+
const hours = Math.round(minutes / 60);
|
|
221
|
+
if (hours < 24) return `${hours}h`;
|
|
222
|
+
return `${Math.round(hours / 24)}d`;
|
|
223
|
+
}
|
|
224
|
+
|
|
202
225
|
function pickTopByScore(cards: WeightedCard[], limit: number): WeightedCard[] {
|
|
203
226
|
return [...cards]
|
|
204
227
|
.sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId))
|
|
@@ -293,6 +316,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
293
316
|
// under-practiced, dropped once it's discharged (see buildPracticeCards).
|
|
294
317
|
const priorPracticeDebt = progress.practiceDebt ?? {};
|
|
295
318
|
const nextPracticeDebt: Record<string, string> = {};
|
|
319
|
+
const practiceDebtsByGroup = new Map<string, PracticeDebtDebug[]>();
|
|
296
320
|
|
|
297
321
|
for (const group of this.config.groups) {
|
|
298
322
|
const runtime = this.buildGroupRuntimeState({
|
|
@@ -358,7 +382,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
358
382
|
courseId,
|
|
359
383
|
emittedIds
|
|
360
384
|
);
|
|
361
|
-
const
|
|
385
|
+
const practice = this.buildPracticeCards({
|
|
362
386
|
group,
|
|
363
387
|
courseId,
|
|
364
388
|
emittedIds,
|
|
@@ -371,8 +395,9 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
371
395
|
priorPracticeDebt,
|
|
372
396
|
nextPracticeDebt,
|
|
373
397
|
});
|
|
398
|
+
practiceDebtsByGroup.set(group.id, practice.debts);
|
|
374
399
|
|
|
375
|
-
emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...
|
|
400
|
+
emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practice.cards);
|
|
376
401
|
}
|
|
377
402
|
|
|
378
403
|
// Persist the carried-forward practice debt (self-pruned: discharged skills
|
|
@@ -401,6 +426,11 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
401
426
|
logger.info('[Prescribed] No hints to emit (no blocked targets or no support tags)');
|
|
402
427
|
}
|
|
403
428
|
|
|
429
|
+
// Push this run's pressure state to the live-overlay debug channel. Done
|
|
430
|
+
// before the empty-emission return — an all-blocked run is exactly the
|
|
431
|
+
// state the overlay most needs to show.
|
|
432
|
+
this.capturePressureSnapshot(courseId, groupRuntimes, practiceDebtsByGroup, emitted, hints);
|
|
433
|
+
|
|
404
434
|
if (emitted.length === 0) {
|
|
405
435
|
logger.info(
|
|
406
436
|
'[Prescribed] 0 cards emitted (all targets blocked, authored/discovered support candidates exhausted)' +
|
|
@@ -481,6 +511,80 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
481
511
|
};
|
|
482
512
|
}
|
|
483
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Translate this run's per-group runtimes and practice debts into gauges on
|
|
516
|
+
* the generic strategy-pressure debug channel (rendered by the live session
|
|
517
|
+
* overlay's "strategy backpressure" section).
|
|
518
|
+
*/
|
|
519
|
+
private capturePressureSnapshot(
|
|
520
|
+
courseId: string,
|
|
521
|
+
groupRuntimes: GroupRuntimeState[],
|
|
522
|
+
practiceDebtsByGroup: Map<string, PracticeDebtDebug[]>,
|
|
523
|
+
emitted: WeightedCard[],
|
|
524
|
+
hints: ReplanHints | undefined
|
|
525
|
+
): void {
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
const gauges: PressureGaugeDebug[] = [];
|
|
528
|
+
|
|
529
|
+
for (const runtime of groupRuntimes) {
|
|
530
|
+
const group = runtime.group;
|
|
531
|
+
const window = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
|
|
532
|
+
|
|
533
|
+
gauges.push({
|
|
534
|
+
id: `group:${group.id}:target`,
|
|
535
|
+
label: `${group.id} targets`,
|
|
536
|
+
multiplier: runtime.pressureMultiplier,
|
|
537
|
+
max: MAX_TARGET_MULTIPLIER,
|
|
538
|
+
detail:
|
|
539
|
+
`${runtime.pendingTargets.length} pending ` +
|
|
540
|
+
`(${runtime.surfaceableTargets.length} surfaceable, ${runtime.blockedTargets.length} blocked) · ` +
|
|
541
|
+
`sinceSurfaced ${runtime.sessionsSinceSurfaced}/${window}`,
|
|
542
|
+
items: runtime.blockedTargets.map((cardId) => ({ label: cardId, value: 'blocked' })),
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
if (runtime.blockedTargets.length > 0) {
|
|
546
|
+
const mode =
|
|
547
|
+
runtime.supportCandidates.length > 0
|
|
548
|
+
? 'direct-support'
|
|
549
|
+
: runtime.discoveredSupportCandidates.length > 0
|
|
550
|
+
? 'inserted-support'
|
|
551
|
+
: 'boost-only';
|
|
552
|
+
gauges.push({
|
|
553
|
+
id: `group:${group.id}:support`,
|
|
554
|
+
label: `${group.id} support`,
|
|
555
|
+
multiplier: runtime.supportMultiplier,
|
|
556
|
+
max: MAX_SUPPORT_MULTIPLIER,
|
|
557
|
+
detail: `mode=${mode} · ${runtime.supportTags.length} support tag(s)`,
|
|
558
|
+
items: runtime.supportTags.map((tag) => ({ label: tag })),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const debts = practiceDebtsByGroup.get(group.id) ?? [];
|
|
563
|
+
if (debts.length > 0) {
|
|
564
|
+
gauges.push({
|
|
565
|
+
id: `group:${group.id}:practice-debt`,
|
|
566
|
+
label: `${group.id} practice debt`,
|
|
567
|
+
multiplier: Math.max(...debts.map((d) => d.multiplier)),
|
|
568
|
+
max: MAX_PRACTICE_MULTIPLIER,
|
|
569
|
+
detail: `${debts.length} under-practiced skill(s)`,
|
|
570
|
+
items: debts.map((d) => ({
|
|
571
|
+
label: d.tag,
|
|
572
|
+
value: `×${d.multiplier.toFixed(2)} · open ${formatAge(now - new Date(d.firstOwedAt).getTime())}`,
|
|
573
|
+
})),
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
captureStrategyPressure({
|
|
579
|
+
source: 'prescribed',
|
|
580
|
+
courseId,
|
|
581
|
+
gauges,
|
|
582
|
+
topScore: emitted.length > 0 ? Math.max(...emitted.map((c) => c.score)) : null,
|
|
583
|
+
hintsLabel: hints?._label,
|
|
584
|
+
timestamp: now,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
484
588
|
private parseConfig(serializedData: string): PrescribedConfig {
|
|
485
589
|
try {
|
|
486
590
|
const parsed = JSON.parse(serializedData) as { groups?: unknown[] };
|
|
@@ -693,6 +797,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
693
797
|
supportTags: [...supportTags],
|
|
694
798
|
pressureMultiplier,
|
|
695
799
|
supportMultiplier,
|
|
800
|
+
sessionsSinceSurfaced,
|
|
696
801
|
debugVersion: PRESCRIBED_DEBUG_VERSION,
|
|
697
802
|
};
|
|
698
803
|
}
|
|
@@ -875,7 +980,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
875
980
|
seenIds: Set<string>;
|
|
876
981
|
priorPracticeDebt: Record<string, string>;
|
|
877
982
|
nextPracticeDebt: Record<string, string>;
|
|
878
|
-
}): WeightedCard[] {
|
|
983
|
+
}): { cards: WeightedCard[]; debts: PracticeDebtDebug[] } {
|
|
879
984
|
const {
|
|
880
985
|
group,
|
|
881
986
|
courseId,
|
|
@@ -891,7 +996,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
891
996
|
} = args;
|
|
892
997
|
|
|
893
998
|
const patterns = group.practiceTagPatterns ?? [];
|
|
894
|
-
if (patterns.length === 0) return [];
|
|
999
|
+
if (patterns.length === 0) return { cards: [], debts: [] };
|
|
895
1000
|
|
|
896
1001
|
const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
|
|
897
1002
|
const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
|
|
@@ -903,7 +1008,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
903
1008
|
(userTagElo[tag]?.count ?? 0) < practiceMinCount
|
|
904
1009
|
);
|
|
905
1010
|
|
|
906
|
-
if (practiceTags.length === 0) return [];
|
|
1011
|
+
if (practiceTags.length === 0) return { cards: [], debts: [] };
|
|
907
1012
|
|
|
908
1013
|
// Carry forward (or open) each under-practiced skill's debt age, and derive
|
|
909
1014
|
// its practice multiplier: base + staleness-since-first-owed, clamped. Done
|
|
@@ -924,6 +1029,16 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
924
1029
|
tagMultiplier.set(tag, mult);
|
|
925
1030
|
}
|
|
926
1031
|
|
|
1032
|
+
// Debug view of every open debt (built before the emission early-return so
|
|
1033
|
+
// the pressure channel sees debts even on runs that emit no drill cards).
|
|
1034
|
+
const debts: PracticeDebtDebug[] = practiceTags
|
|
1035
|
+
.map((tag) => ({
|
|
1036
|
+
tag,
|
|
1037
|
+
multiplier: tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT,
|
|
1038
|
+
firstOwedAt: nextPracticeDebt[tag],
|
|
1039
|
+
}))
|
|
1040
|
+
.sort((a, b) => b.multiplier - a.multiplier || a.tag.localeCompare(b.tag));
|
|
1041
|
+
|
|
927
1042
|
// Reuse the diversity-aware tag→cards collector (stem-dedup + shuffle).
|
|
928
1043
|
const practiceCardIds = this.findDiscoveredSupportCards({
|
|
929
1044
|
supportTags: practiceTags,
|
|
@@ -934,7 +1049,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
934
1049
|
limit: maxPractice,
|
|
935
1050
|
});
|
|
936
1051
|
|
|
937
|
-
if (practiceCardIds.length === 0) return [];
|
|
1052
|
+
if (practiceCardIds.length === 0) return { cards: [], debts };
|
|
938
1053
|
|
|
939
1054
|
logger.info(
|
|
940
1055
|
`[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced ` +
|
|
@@ -974,7 +1089,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
|
|
|
974
1089
|
});
|
|
975
1090
|
}
|
|
976
1091
|
|
|
977
|
-
return cards;
|
|
1092
|
+
return { cards, debts };
|
|
978
1093
|
}
|
|
979
1094
|
|
|
980
1095
|
/**
|
|
@@ -33,6 +33,18 @@ export {
|
|
|
33
33
|
type SrsBacklogDebug,
|
|
34
34
|
} from './SrsDebugger';
|
|
35
35
|
|
|
36
|
+
// Re-export the generic strategy-pressure debug channel. `captureStrategyPressure`
|
|
37
|
+
// is part of the consumer surface: client-defined strategies (registered via
|
|
38
|
+
// registerNavigator) push their backpressure state through it for the live
|
|
39
|
+
// session overlay.
|
|
40
|
+
export {
|
|
41
|
+
captureStrategyPressure,
|
|
42
|
+
getStrategyPressureDebug,
|
|
43
|
+
clearStrategyPressureDebug,
|
|
44
|
+
type StrategyPressureDebug,
|
|
45
|
+
type PressureGaugeDebug,
|
|
46
|
+
} from './StrategyPressureDebugger';
|
|
47
|
+
|
|
36
48
|
import { LearnableWeight } from '../types/contentNavigationStrategy';
|
|
37
49
|
export type { ContentNavigationStrategyData, LearnableWeight } from '../types/contentNavigationStrategy';
|
|
38
50
|
import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User database hydration.
|
|
3
|
+
*
|
|
4
|
+
* A logged-in user's data lives in a remote CouchDB `userdb-<hex>` and is
|
|
5
|
+
* mirrored into a browser-local PouchDB. On a device that has never synced
|
|
6
|
+
* that account, the local mirror starts EMPTY — and every local read
|
|
7
|
+
* (`getStrategyState`, `getConfig`, `getCourseRegistrationsDoc`, ...) answers
|
|
8
|
+
* "no such document", which is indistinguishable from "brand new user".
|
|
9
|
+
*
|
|
10
|
+
* Consumers acting on that answer render a new-user experience to an existing
|
|
11
|
+
* user, and — worse — compute writes from empty state that then lose to the
|
|
12
|
+
* real remote document at replication time. Hydration closes that window by
|
|
13
|
+
* pulling the account's documents down BEFORE the user object is handed out.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a user's local database stands relative to its remote counterpart.
|
|
18
|
+
*
|
|
19
|
+
* Only `failed` is a blocking condition. `stale` explicitly is not: it means a
|
|
20
|
+
* full pull completed on this device previously, so local data is real and
|
|
21
|
+
* merely possibly-behind — live sync closes the gap in the background. That is
|
|
22
|
+
* the ordinary offline case and it must keep working.
|
|
23
|
+
*/
|
|
24
|
+
export type UserHydrationState =
|
|
25
|
+
/** Guest, or a data layer with no remote. There is nothing to hydrate from. */
|
|
26
|
+
| 'not-required'
|
|
27
|
+
/** Initial pull in flight. */
|
|
28
|
+
| 'hydrating'
|
|
29
|
+
/** Initial pull completed this session. Local is a faithful mirror. */
|
|
30
|
+
| 'hydrated'
|
|
31
|
+
/**
|
|
32
|
+
* A previous session completed a full pull on this device (recorded by the
|
|
33
|
+
* `_local/hydration` marker). Local data is real; live sync is catching up.
|
|
34
|
+
*/
|
|
35
|
+
| 'stale'
|
|
36
|
+
/**
|
|
37
|
+
* No pull has ever completed on this device and one could not be completed
|
|
38
|
+
* now. Local reads CANNOT be trusted to mean "no data" — callers must not
|
|
39
|
+
* present a new-user experience or compute writes from what they read.
|
|
40
|
+
*/
|
|
41
|
+
| 'failed';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hydration state plus observability detail.
|
|
45
|
+
*
|
|
46
|
+
* Shaped to mirror `CourseSyncStatus` in CourseSyncService, which solves the
|
|
47
|
+
* same problem for course databases.
|
|
48
|
+
*/
|
|
49
|
+
export interface UserHydrationStatus {
|
|
50
|
+
state: UserHydrationState;
|
|
51
|
+
/** Documents pulled by the initial replication (set when state is `hydrated`). */
|
|
52
|
+
docsWritten?: number;
|
|
53
|
+
/** Wall-clock duration of the initial pull attempt, successful or not. */
|
|
54
|
+
durationMs?: number;
|
|
55
|
+
/** Failure detail, set when state is `failed`. */
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ID of the per-device marker recording that a full pull once completed.
|
|
61
|
+
*
|
|
62
|
+
* `_local/*` documents are deliberately excluded from replication by CouchDB
|
|
63
|
+
* and PouchDB alike, which is exactly the semantics wanted here: the marker
|
|
64
|
+
* describes THIS device's mirror, and must never travel to another one.
|
|
65
|
+
*/
|
|
66
|
+
export const HYDRATION_MARKER_ID = '_local/hydration';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Payload of {@link HYDRATION_MARKER_ID}.
|
|
70
|
+
*/
|
|
71
|
+
export interface HydrationMarker {
|
|
72
|
+
_id: typeof HYDRATION_MARKER_ID;
|
|
73
|
+
_rev?: string;
|
|
74
|
+
/** Account the mirror was hydrated for — guards against a stale marker after an account switch. */
|
|
75
|
+
username: string;
|
|
76
|
+
/** ISO timestamp of the most recent successful full pull. */
|
|
77
|
+
hydratedAt: string;
|
|
78
|
+
}
|