@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
package/dist/index.mjs CHANGED
@@ -1797,6 +1797,30 @@ var init_SrsDebugger = __esm({
1797
1797
  }
1798
1798
  });
1799
1799
 
1800
+ // src/core/navigators/StrategyPressureDebugger.ts
1801
+ var StrategyPressureDebugger_exports = {};
1802
+ __export(StrategyPressureDebugger_exports, {
1803
+ captureStrategyPressure: () => captureStrategyPressure,
1804
+ clearStrategyPressureDebug: () => clearStrategyPressureDebug,
1805
+ getStrategyPressureDebug: () => getStrategyPressureDebug
1806
+ });
1807
+ function captureStrategyPressure(snapshot) {
1808
+ snapshots2.set(`${snapshot.source}:${snapshot.courseId}`, snapshot);
1809
+ }
1810
+ function getStrategyPressureDebug() {
1811
+ return [...snapshots2.values()].sort((a, b) => b.timestamp - a.timestamp);
1812
+ }
1813
+ function clearStrategyPressureDebug() {
1814
+ snapshots2.clear();
1815
+ }
1816
+ var snapshots2;
1817
+ var init_StrategyPressureDebugger = __esm({
1818
+ "src/core/navigators/StrategyPressureDebugger.ts"() {
1819
+ "use strict";
1820
+ snapshots2 = /* @__PURE__ */ new Map();
1821
+ }
1822
+ });
1823
+
1800
1824
  // src/core/navigators/generators/CompositeGenerator.ts
1801
1825
  var CompositeGenerator_exports = {};
1802
1826
  __export(CompositeGenerator_exports, {
@@ -2161,6 +2185,13 @@ function shuffleInPlace(arr) {
2161
2185
  [arr[i], arr[j]] = [arr[j], arr[i]];
2162
2186
  }
2163
2187
  }
2188
+ function formatAge(ms) {
2189
+ const minutes = Math.max(0, Math.round(ms / 6e4));
2190
+ if (minutes < 60) return `${minutes}m`;
2191
+ const hours = Math.round(minutes / 60);
2192
+ if (hours < 24) return `${hours}h`;
2193
+ return `${Math.round(hours / 24)}d`;
2194
+ }
2164
2195
  function pickTopByScore(cards, limit) {
2165
2196
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
2166
2197
  }
@@ -2169,6 +2200,7 @@ var init_prescribed = __esm({
2169
2200
  "src/core/navigators/generators/prescribed.ts"() {
2170
2201
  "use strict";
2171
2202
  init_navigators();
2203
+ init_StrategyPressureDebugger();
2172
2204
  init_logger();
2173
2205
  DEFAULT_FRESHNESS_WINDOW = 3;
2174
2206
  DEFAULT_MAX_DIRECT_PER_RUN = 3;
@@ -2245,6 +2277,7 @@ var init_prescribed = __esm({
2245
2277
  const groupRuntimes = [];
2246
2278
  const priorPracticeDebt = progress.practiceDebt ?? {};
2247
2279
  const nextPracticeDebt = {};
2280
+ const practiceDebtsByGroup = /* @__PURE__ */ new Map();
2248
2281
  for (const group of this.config.groups) {
2249
2282
  const runtime = this.buildGroupRuntimeState({
2250
2283
  group,
@@ -2293,7 +2326,7 @@ var init_prescribed = __esm({
2293
2326
  courseId,
2294
2327
  emittedIds
2295
2328
  );
2296
- const practiceCards = this.buildPracticeCards({
2329
+ const practice = this.buildPracticeCards({
2297
2330
  group,
2298
2331
  courseId,
2299
2332
  emittedIds,
@@ -2306,7 +2339,8 @@ var init_prescribed = __esm({
2306
2339
  priorPracticeDebt,
2307
2340
  nextPracticeDebt
2308
2341
  });
2309
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
2342
+ practiceDebtsByGroup.set(group.id, practice.debts);
2343
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practice.cards);
2310
2344
  }
2311
2345
  nextState.practiceDebt = nextPracticeDebt;
2312
2346
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
@@ -2322,6 +2356,7 @@ var init_prescribed = __esm({
2322
2356
  } else {
2323
2357
  logger.info("[Prescribed] No hints to emit (no blocked targets or no support tags)");
2324
2358
  }
2359
+ this.capturePressureSnapshot(courseId, groupRuntimes, practiceDebtsByGroup, emitted, hints);
2325
2360
  if (emitted.length === 0) {
2326
2361
  logger.info(
2327
2362
  "[Prescribed] 0 cards emitted (all targets blocked, authored/discovered support candidates exhausted)" + (hints ? " \u2014 boost hints emitted but may not survive filters" : "")
@@ -2383,6 +2418,60 @@ var init_prescribed = __esm({
2383
2418
  supportTags: [...supportTags].sort()
2384
2419
  };
2385
2420
  }
2421
+ /**
2422
+ * Translate this run's per-group runtimes and practice debts into gauges on
2423
+ * the generic strategy-pressure debug channel (rendered by the live session
2424
+ * overlay's "strategy backpressure" section).
2425
+ */
2426
+ capturePressureSnapshot(courseId, groupRuntimes, practiceDebtsByGroup, emitted, hints) {
2427
+ const now = Date.now();
2428
+ const gauges = [];
2429
+ for (const runtime of groupRuntimes) {
2430
+ const group = runtime.group;
2431
+ const window2 = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
2432
+ gauges.push({
2433
+ id: `group:${group.id}:target`,
2434
+ label: `${group.id} targets`,
2435
+ multiplier: runtime.pressureMultiplier,
2436
+ max: MAX_TARGET_MULTIPLIER,
2437
+ detail: `${runtime.pendingTargets.length} pending (${runtime.surfaceableTargets.length} surfaceable, ${runtime.blockedTargets.length} blocked) \xB7 sinceSurfaced ${runtime.sessionsSinceSurfaced}/${window2}`,
2438
+ items: runtime.blockedTargets.map((cardId) => ({ label: cardId, value: "blocked" }))
2439
+ });
2440
+ if (runtime.blockedTargets.length > 0) {
2441
+ const mode = runtime.supportCandidates.length > 0 ? "direct-support" : runtime.discoveredSupportCandidates.length > 0 ? "inserted-support" : "boost-only";
2442
+ gauges.push({
2443
+ id: `group:${group.id}:support`,
2444
+ label: `${group.id} support`,
2445
+ multiplier: runtime.supportMultiplier,
2446
+ max: MAX_SUPPORT_MULTIPLIER,
2447
+ detail: `mode=${mode} \xB7 ${runtime.supportTags.length} support tag(s)`,
2448
+ items: runtime.supportTags.map((tag) => ({ label: tag }))
2449
+ });
2450
+ }
2451
+ const debts = practiceDebtsByGroup.get(group.id) ?? [];
2452
+ if (debts.length > 0) {
2453
+ gauges.push({
2454
+ id: `group:${group.id}:practice-debt`,
2455
+ label: `${group.id} practice debt`,
2456
+ multiplier: Math.max(...debts.map((d) => d.multiplier)),
2457
+ max: MAX_PRACTICE_MULTIPLIER,
2458
+ detail: `${debts.length} under-practiced skill(s)`,
2459
+ items: debts.map((d) => ({
2460
+ label: d.tag,
2461
+ value: `\xD7${d.multiplier.toFixed(2)} \xB7 open ${formatAge(now - new Date(d.firstOwedAt).getTime())}`
2462
+ }))
2463
+ });
2464
+ }
2465
+ }
2466
+ captureStrategyPressure({
2467
+ source: "prescribed",
2468
+ courseId,
2469
+ gauges,
2470
+ topScore: emitted.length > 0 ? Math.max(...emitted.map((c) => c.score)) : null,
2471
+ hintsLabel: hints?._label,
2472
+ timestamp: now
2473
+ });
2474
+ }
2386
2475
  parseConfig(serializedData) {
2387
2476
  try {
2388
2477
  const parsed = JSON.parse(serializedData);
@@ -2539,6 +2628,7 @@ var init_prescribed = __esm({
2539
2628
  supportTags: [...supportTags],
2540
2629
  pressureMultiplier,
2541
2630
  supportMultiplier,
2631
+ sessionsSinceSurfaced,
2542
2632
  debugVersion: PRESCRIBED_DEBUG_VERSION
2543
2633
  };
2544
2634
  }
@@ -2671,13 +2761,13 @@ var init_prescribed = __esm({
2671
2761
  nextPracticeDebt
2672
2762
  } = args;
2673
2763
  const patterns = group.practiceTagPatterns ?? [];
2674
- if (patterns.length === 0) return [];
2764
+ if (patterns.length === 0) return { cards: [], debts: [] };
2675
2765
  const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2676
2766
  const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2677
2767
  const practiceTags = [...cardsByTag.keys()].filter(
2678
2768
  (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2679
2769
  );
2680
- if (practiceTags.length === 0) return [];
2770
+ if (practiceTags.length === 0) return { cards: [], debts: [] };
2681
2771
  const now = Date.now();
2682
2772
  const DAY_MS = 24 * 60 * 60 * 1e3;
2683
2773
  const tagMultiplier = /* @__PURE__ */ new Map();
@@ -2692,6 +2782,11 @@ var init_prescribed = __esm({
2692
2782
  );
2693
2783
  tagMultiplier.set(tag, mult);
2694
2784
  }
2785
+ const debts = practiceTags.map((tag) => ({
2786
+ tag,
2787
+ multiplier: tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT,
2788
+ firstOwedAt: nextPracticeDebt[tag]
2789
+ })).sort((a, b) => b.multiplier - a.multiplier || a.tag.localeCompare(b.tag));
2695
2790
  const practiceCardIds = this.findDiscoveredSupportCards({
2696
2791
  supportTags: practiceTags,
2697
2792
  cardsByTag,
@@ -2700,7 +2795,7 @@ var init_prescribed = __esm({
2700
2795
  excludedIds: emittedIds,
2701
2796
  limit: maxPractice
2702
2797
  });
2703
- if (practiceCardIds.length === 0) return [];
2798
+ if (practiceCardIds.length === 0) return { cards: [], debts };
2704
2799
  logger.info(
2705
2800
  `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2706
2801
  );
@@ -2730,7 +2825,7 @@ var init_prescribed = __esm({
2730
2825
  ]
2731
2826
  });
2732
2827
  }
2733
- return cards;
2828
+ return { cards, debts };
2734
2829
  }
2735
2830
  /**
2736
2831
  * True for a skill that was *gated and is now reached*: it has at least one
@@ -5407,6 +5502,7 @@ var init_3 = __esm({
5407
5502
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
5408
5503
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
5409
5504
  "./SrsDebugger.ts": () => Promise.resolve().then(() => (init_SrsDebugger(), SrsDebugger_exports)),
5505
+ "./StrategyPressureDebugger.ts": () => Promise.resolve().then(() => (init_StrategyPressureDebugger(), StrategyPressureDebugger_exports)),
5410
5506
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
5411
5507
  "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
5412
5508
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
@@ -5439,7 +5535,9 @@ __export(navigators_exports, {
5439
5535
  NavigatorRole: () => NavigatorRole,
5440
5536
  NavigatorRoles: () => NavigatorRoles,
5441
5537
  Navigators: () => Navigators,
5538
+ captureStrategyPressure: () => captureStrategyPressure,
5442
5539
  clearSrsBacklogDebug: () => clearSrsBacklogDebug,
5540
+ clearStrategyPressureDebug: () => clearStrategyPressureDebug,
5443
5541
  diversityRerank: () => diversityRerank,
5444
5542
  getActivePipeline: () => getActivePipeline,
5445
5543
  getCardOrigin: () => getCardOrigin,
@@ -5447,6 +5545,7 @@ __export(navigators_exports, {
5447
5545
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
5448
5546
  getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
5449
5547
  getSrsBacklogDebug: () => getSrsBacklogDebug,
5548
+ getStrategyPressureDebug: () => getStrategyPressureDebug,
5450
5549
  hasRegisteredNavigator: () => hasRegisteredNavigator,
5451
5550
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
5452
5551
  isFilter: () => isFilter,
@@ -5529,6 +5628,7 @@ var init_navigators = __esm({
5529
5628
  init_diversityRerank();
5530
5629
  init_PipelineDebugger();
5531
5630
  init_SrsDebugger();
5631
+ init_StrategyPressureDebugger();
5532
5632
  init_logger();
5533
5633
  init_();
5534
5634
  init_2();
@@ -7238,6 +7338,8 @@ var init_CouchDBSyncStrategy = __esm({
7238
7338
  CouchDBSyncStrategy = class {
7239
7339
  syncHandle;
7240
7340
  // Handle to cancel sync if needed
7341
+ /** In-flight one-shot hydration pull, so a timed-out pull can be abandoned. */
7342
+ hydrationHandle;
7241
7343
  setupRemoteDB(username) {
7242
7344
  if (username === GuestUsername || username.startsWith(GuestUsername)) {
7243
7345
  return getLocalUserDB(username);
@@ -7252,8 +7354,26 @@ var init_CouchDBSyncStrategy = __esm({
7252
7354
  return this.getUserDB(username);
7253
7355
  }
7254
7356
  }
7357
+ /**
7358
+ * One-shot remote → local pull. Resolves once the local mirror is caught up.
7359
+ *
7360
+ * Pull only: pushing here would upload whatever the local DB happens to hold
7361
+ * before we know what the remote already has, which is the conflict-leaf
7362
+ * problem hydration exists to avoid. Local changes go up when startSync()
7363
+ * takes over.
7364
+ */
7365
+ async hydrate(localDB, remoteDB) {
7366
+ const replication = pouchdb_setup_default.replicate(remoteDB, localDB, {});
7367
+ this.hydrationHandle = replication;
7368
+ try {
7369
+ const info = await replication;
7370
+ return { docsWritten: info.docs_written };
7371
+ } finally {
7372
+ this.hydrationHandle = void 0;
7373
+ }
7374
+ }
7255
7375
  startSync(localDB, remoteDB) {
7256
- if (localDB !== remoteDB) {
7376
+ if (localDB.name !== remoteDB.name) {
7257
7377
  this.syncHandle = pouchdb_setup_default.sync(localDB, remoteDB, {
7258
7378
  live: true,
7259
7379
  retry: true
@@ -7261,8 +7381,20 @@ var init_CouchDBSyncStrategy = __esm({
7261
7381
  }
7262
7382
  }
7263
7383
  stopSync() {
7384
+ if (this.hydrationHandle) {
7385
+ try {
7386
+ this.hydrationHandle.cancel();
7387
+ } catch (e) {
7388
+ logger.warn(`Failed to cancel hydration pull (continuing anyway): ${e}`);
7389
+ }
7390
+ this.hydrationHandle = void 0;
7391
+ }
7264
7392
  if (this.syncHandle) {
7265
- this.syncHandle.cancel();
7393
+ try {
7394
+ this.syncHandle.cancel();
7395
+ } catch (e) {
7396
+ logger.warn(`Failed to cancel user sync (continuing anyway): ${e}`);
7397
+ }
7266
7398
  this.syncHandle = void 0;
7267
7399
  }
7268
7400
  }
@@ -7533,6 +7665,19 @@ var init_couch = __esm({
7533
7665
  // src/impl/common/BaseUserDB.ts
7534
7666
  import { Status as Status3 } from "@vue-skuilder/common";
7535
7667
  import moment6 from "moment";
7668
+ function withTimeout(p, ms, label) {
7669
+ let timer;
7670
+ return Promise.race([
7671
+ p,
7672
+ new Promise((_resolve, reject) => {
7673
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
7674
+ })
7675
+ ]).finally(() => {
7676
+ if (timer !== void 0) {
7677
+ clearTimeout(timer);
7678
+ }
7679
+ });
7680
+ }
7536
7681
  function accomodateGuest() {
7537
7682
  logger.log("[funnel] accomodateGuest() called");
7538
7683
  if (typeof localStorage === "undefined") {
@@ -7706,7 +7851,7 @@ async function dropUserFromClassroom(user, classID) {
7706
7851
  async function getUserClassrooms(user) {
7707
7852
  return getOrCreateClassroomRegistrationsDoc(user);
7708
7853
  }
7709
- var log4, BaseUser, userCoursesDoc, userClassroomsDoc;
7854
+ var log4, HYDRATION_TIMEOUT_MS, BaseUser, userCoursesDoc, userClassroomsDoc;
7710
7855
  var init_BaseUserDB = __esm({
7711
7856
  "src/impl/common/BaseUserDB.ts"() {
7712
7857
  "use strict";
@@ -7721,6 +7866,7 @@ var init_BaseUserDB = __esm({
7721
7866
  log4 = (s) => {
7722
7867
  logger.info(s);
7723
7868
  };
7869
+ HYDRATION_TIMEOUT_MS = 15e3;
7724
7870
  BaseUser = class _BaseUser {
7725
7871
  static _instance;
7726
7872
  static _initialized = false;
@@ -7749,6 +7895,29 @@ var init_BaseUserDB = __esm({
7749
7895
  writeDB;
7750
7896
  // Database to use for write operations (local-first approach)
7751
7897
  updateQueue;
7898
+ _hydration = { state: "not-required" };
7899
+ /**
7900
+ * How far the local mirror can be trusted. See {@link UserHydrationStatus}.
7901
+ *
7902
+ * Consumers that would otherwise read a 404 as "new user" — onboarding
7903
+ * gates, first-run experiences, progress dashboards — should check for
7904
+ * `failed` and present a retry affordance rather than an empty state.
7905
+ */
7906
+ hydrationStatus() {
7907
+ return { ...this._hydration };
7908
+ }
7909
+ /**
7910
+ * Whether a missing document may be treated as genuinely absent, allowing
7911
+ * callers to create it with defaults.
7912
+ *
7913
+ * False only when hydration failed or is still running: a 404 then may just
7914
+ * mean "not pulled down yet", and materializing a default would both lie to
7915
+ * the user and, once live sync starts, push a rev-1 document that loses to
7916
+ * the real one — silently discarding it.
7917
+ */
7918
+ canMaterializeDefaults() {
7919
+ return this._hydration.state !== "failed" && this._hydration.state !== "hydrating";
7920
+ }
7752
7921
  async createAccount(username, password) {
7753
7922
  if (!this.syncStrategy.canCreateAccount()) {
7754
7923
  throw new Error("Account creation not supported by current sync strategy");
@@ -7866,6 +8035,11 @@ Currently logged-in as ${this._username}.`
7866
8035
  } catch (e) {
7867
8036
  const err = e;
7868
8037
  if (err.status === 404) {
8038
+ if (!this.canMaterializeDefaults()) {
8039
+ throw new Error(
8040
+ `Cannot read course registrations for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}'). Refusing to create an empty registration doc \u2014 that would report an existing user as unregistered, and then lose to their real document once sync resumes.`
8041
+ );
8042
+ }
7869
8043
  await this.localDB.put({
7870
8044
  _id: _BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
7871
8045
  courses: [],
@@ -8108,6 +8282,11 @@ Currently logged-in as ${this._username}.`
8108
8282
  } catch (e) {
8109
8283
  const err = e;
8110
8284
  if (err.name && err.name === "not_found") {
8285
+ if (!this.canMaterializeDefaults()) {
8286
+ throw new Error(
8287
+ `Cannot read config for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}'). Refusing to write a default config over what may be an existing one.`
8288
+ );
8289
+ }
8111
8290
  await this.localDB.put(defaultConfig);
8112
8291
  return this.getConfig();
8113
8292
  } else {
@@ -8181,7 +8360,9 @@ Currently logged-in as ${this._username}.`
8181
8360
  _BaseUser._initialized = true;
8182
8361
  return;
8183
8362
  }
8363
+ this.syncStrategy.stopSync?.();
8184
8364
  this.setDBandQ();
8365
+ await this.hydrateLocalMirror();
8185
8366
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
8186
8367
  this.applyDesignDocs().catch((error) => {
8187
8368
  log4(`Error in applyDesignDocs background task: ${error}`);
@@ -8197,6 +8378,85 @@ Currently logged-in as ${this._username}.`
8197
8378
  });
8198
8379
  _BaseUser._initialized = true;
8199
8380
  }
8381
+ /**
8382
+ * Pull this account's documents into the local mirror, if that hasn't
8383
+ * happened on this device before.
8384
+ *
8385
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
8386
+ * anything can observe an empty local DB or replicate one upward.
8387
+ *
8388
+ * Never throws: a failure is recorded as `failed` state and reported through
8389
+ * hydrationStatus(), because throwing here would abort init() and leave
8390
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
8391
+ * ceiling). Callers decide what a failure means for them.
8392
+ */
8393
+ async hydrateLocalMirror() {
8394
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
8395
+ this._hydration = { state: "not-required" };
8396
+ return;
8397
+ }
8398
+ if (await this.hasHydrationMarker()) {
8399
+ this._hydration = { state: "stale" };
8400
+ return;
8401
+ }
8402
+ this._hydration = { state: "hydrating" };
8403
+ const start = Date.now();
8404
+ try {
8405
+ const { docsWritten } = await withTimeout(
8406
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
8407
+ HYDRATION_TIMEOUT_MS,
8408
+ `Hydration of local mirror for ${this._username}`
8409
+ );
8410
+ await this.writeHydrationMarker();
8411
+ this._hydration = {
8412
+ state: "hydrated",
8413
+ docsWritten,
8414
+ durationMs: Date.now() - start
8415
+ };
8416
+ log4(
8417
+ `Hydrated local mirror for ${this._username}: ${docsWritten} docs in ${this._hydration.durationMs}ms`
8418
+ );
8419
+ } catch (e) {
8420
+ this.syncStrategy.stopSync?.();
8421
+ this._hydration = {
8422
+ state: "failed",
8423
+ durationMs: Date.now() - start,
8424
+ error: e instanceof Error ? e.message : String(e)
8425
+ };
8426
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
8427
+ }
8428
+ }
8429
+ /**
8430
+ * Has a full pull completed on this device for the CURRENT account?
8431
+ *
8432
+ * The marker is a `_local/` document, which never replicates — so its
8433
+ * presence describes this device's mirror and cannot arrive from elsewhere.
8434
+ */
8435
+ async hasHydrationMarker() {
8436
+ try {
8437
+ const marker = await this.localDB.get(HYDRATION_MARKER_ID);
8438
+ return marker.username === this._username;
8439
+ } catch {
8440
+ return false;
8441
+ }
8442
+ }
8443
+ async writeHydrationMarker() {
8444
+ try {
8445
+ let existingRev;
8446
+ try {
8447
+ existingRev = (await this.localDB.get(HYDRATION_MARKER_ID))._rev;
8448
+ } catch {
8449
+ }
8450
+ await this.localDB.put({
8451
+ _id: HYDRATION_MARKER_ID,
8452
+ _rev: existingRev,
8453
+ username: this._username,
8454
+ hydratedAt: (/* @__PURE__ */ new Date()).toISOString()
8455
+ });
8456
+ } catch (e) {
8457
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
8458
+ }
8459
+ }
8200
8460
  static designDocs = [
8201
8461
  {
8202
8462
  _id: "_design/reviewCards",
@@ -8533,6 +8793,11 @@ Currently logged-in as ${this._username}.`
8533
8793
  } catch (e) {
8534
8794
  const err = e;
8535
8795
  if (err.status === 404) {
8796
+ if (!this.canMaterializeDefaults()) {
8797
+ throw new Error(
8798
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}').`
8799
+ );
8800
+ }
8536
8801
  return null;
8537
8802
  }
8538
8803
  throw e;
@@ -10052,6 +10317,15 @@ var init_userOutcome = __esm({
10052
10317
  }
10053
10318
  });
10054
10319
 
10320
+ // src/core/types/hydration.ts
10321
+ var HYDRATION_MARKER_ID;
10322
+ var init_hydration = __esm({
10323
+ "src/core/types/hydration.ts"() {
10324
+ "use strict";
10325
+ HYDRATION_MARKER_ID = "_local/hydration";
10326
+ }
10327
+ });
10328
+
10055
10329
  // src/core/bulkImport/cardProcessor.ts
10056
10330
  import { Status as Status5 } from "@vue-skuilder/common";
10057
10331
  async function importParsedCards(parsedCards, courseDB, config) {
@@ -10531,6 +10805,7 @@ var init_core = __esm({
10531
10805
  init_user();
10532
10806
  init_strategyState();
10533
10807
  init_userOutcome();
10808
+ init_hydration();
10534
10809
  init_Loggable();
10535
10810
  init_util();
10536
10811
  init_navigators();
@@ -13471,6 +13746,7 @@ mountMixerDebugger();
13471
13746
  init_logger();
13472
13747
  init_PipelineDebugger();
13473
13748
  init_SrsDebugger();
13749
+ init_StrategyPressureDebugger();
13474
13750
 
13475
13751
  // src/study/SessionOverlay.ts
13476
13752
  init_logger();
@@ -13561,7 +13837,7 @@ function render() {
13561
13837
  attachHandlers();
13562
13838
  return;
13563
13839
  }
13564
- overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + backlogHtml(s.reviewBacklog) + queueHtml("supplyQ", "supplyQ", s.supplyQ) + queueHtml("failedQ", "failedQ", s.failedQ) + drawnHtml("drawn", s.drawnCards);
13840
+ overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + backlogHtml(s.reviewBacklog) + strategyPressureHtml(s.strategyPressure) + queueHtml("supplyQ", "supplyQ", s.supplyQ) + queueHtml("failedQ", "failedQ", s.failedQ) + drawnHtml("drawn", s.drawnCards);
13565
13841
  attachHandlers();
13566
13842
  }
13567
13843
  function attachHandlers() {
@@ -13665,6 +13941,46 @@ function backlogHtml(backlog) {
13665
13941
  }).join("");
13666
13942
  return `<div style="margin-bottom:6px"><div style="color:#93c5fd">review backpressure</div>${rows}</div>`;
13667
13943
  }
13944
+ function pressureColor(multiplier, max) {
13945
+ if (multiplier <= 1.001) return "#86efac";
13946
+ if (max !== void 0 && max > 1) {
13947
+ return (multiplier - 1) / (max - 1) >= 0.75 ? "#fca5a5" : "#fcd34d";
13948
+ }
13949
+ return multiplier >= 3 ? "#fca5a5" : "#fcd34d";
13950
+ }
13951
+ function toggleKey(raw) {
13952
+ return raw.replace(/[^\w:.|-]/g, "_");
13953
+ }
13954
+ function gaugeHtml(sectionKey, g) {
13955
+ const cap = g.max !== void 0 ? `<span style="opacity:.5">/${g.max}</span>` : "";
13956
+ const detail = g.detail ? ` <span style="opacity:.6">${esc(g.detail)}</span>` : "";
13957
+ let html = `<div style="margin-left:6px">${esc(g.label)} <span style="color:${pressureColor(g.multiplier, g.max)}">\xD7${g.multiplier.toFixed(2)}</span>${cap}${detail}`;
13958
+ const items = g.items ?? [];
13959
+ if (items.length > 0) {
13960
+ const key = toggleKey(`sp|${sectionKey}|${g.id}`);
13961
+ const isOpen = !!expanded[key];
13962
+ const shown = isOpen ? items : items.slice(0, INLINE_THRESHOLD);
13963
+ html += `<div style="margin-left:6px">` + shown.map(
13964
+ (it) => `<div style="white-space:nowrap;opacity:.75">${esc(it.label)}${it.value ? ` <span style="opacity:.6">${esc(it.value)}</span>` : ""}</div>`
13965
+ ).join("");
13966
+ if (items.length > INLINE_THRESHOLD) {
13967
+ const footer = isOpen ? "\u25BE show less" : `\u2026 +${items.length - shown.length} more`;
13968
+ html += `<div data-q="${key}" style="cursor:pointer;opacity:.6">${footer}</div>`;
13969
+ }
13970
+ html += `</div>`;
13971
+ }
13972
+ return html + `</div>`;
13973
+ }
13974
+ function strategyPressureHtml(list) {
13975
+ if (!list.length) return "";
13976
+ const sections = list.map((s) => {
13977
+ const sectionKey = `${s.source}:${s.courseId.slice(0, 8)}`;
13978
+ const top = s.topScore !== null && s.topScore !== void 0 ? s.topScore.toFixed(2) : "\u2014";
13979
+ const hints = s.hintsLabel ? `<div style="margin-left:6px;opacity:.6">hints: ${esc(s.hintsLabel)}</div>` : "";
13980
+ return `<div style="margin-left:6px"><span style="opacity:.7">${esc(s.source)} \xB7 ${esc(s.courseId.slice(0, 8))}</span> <span style="opacity:.6">top score ${top}</span>` + s.gauges.map((g) => gaugeHtml(sectionKey, g)).join("") + hints + `</div>`;
13981
+ }).join("");
13982
+ return `<div style="margin-bottom:6px"><div style="color:#fdba74">strategy backpressure</div>${sections}</div>`;
13983
+ }
13668
13984
  function fmtScore(score) {
13669
13985
  if (score === void 0) return "";
13670
13986
  if (!Number.isFinite(score)) return "REQ";
@@ -13765,6 +14081,24 @@ function snapshotToText(s) {
13765
14081
  );
13766
14082
  }
13767
14083
  }
14084
+ if (s.strategyPressure.length) {
14085
+ lines.push("");
14086
+ lines.push("strategy backpressure:");
14087
+ for (const sp of s.strategyPressure) {
14088
+ const top = sp.topScore !== null && sp.topScore !== void 0 ? sp.topScore.toFixed(2) : "\u2014";
14089
+ lines.push(` ${sp.source} (${sp.courseId.slice(0, 8)}): top score ${top}`);
14090
+ for (const g of sp.gauges) {
14091
+ const cap = g.max !== void 0 ? `/${g.max}` : "";
14092
+ lines.push(
14093
+ ` ${g.label} \xD7${g.multiplier.toFixed(2)}${cap}${g.detail ? ` \u2014 ${g.detail}` : ""}`
14094
+ );
14095
+ for (const it of g.items ?? []) {
14096
+ lines.push(` ${it.label}${it.value ? ` (${it.value})` : ""}`);
14097
+ }
14098
+ }
14099
+ if (sp.hintsLabel) lines.push(` hints: ${sp.hintsLabel}`);
14100
+ }
14101
+ }
13768
14102
  const queueText = (label, q) => {
13769
14103
  lines.push("");
13770
14104
  lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
@@ -13802,6 +14136,7 @@ var MAX_HISTORY = 5;
13802
14136
  function clearStaleSessionDebugState() {
13803
14137
  clearRunHistory();
13804
14138
  clearSrsBacklogDebug();
14139
+ clearStrategyPressureDebug();
13805
14140
  }
13806
14141
  function startSessionTracking(supplyQLength, failedQLength) {
13807
14142
  const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -14490,6 +14825,7 @@ var SessionController = class _SessionController extends Loggable {
14490
14825
  supplyQ: describe(this.supplyQ),
14491
14826
  failedQ: describe(this.failedQ),
14492
14827
  reviewBacklog: getSrsBacklogDebug(),
14828
+ strategyPressure: getStrategyPressureDebug(),
14493
14829
  drawnCards
14494
14830
  };
14495
14831
  }
@@ -15175,6 +15511,7 @@ export {
15175
15511
  ENV,
15176
15512
  FileSystemError,
15177
15513
  GuestUsername,
15514
+ HYDRATION_MARKER_ID,
15178
15515
  Loggable,
15179
15516
  NOT_SET,
15180
15517
  NavigatorRole,
@@ -15190,8 +15527,10 @@ export {
15190
15527
  areQuestionRecords,
15191
15528
  buildStrategyStateId,
15192
15529
  captureMixerRun,
15530
+ captureStrategyPressure,
15193
15531
  clearSrsBacklogDebug,
15194
15532
  clearStaleSessionDebugState,
15533
+ clearStrategyPressureDebug,
15195
15534
  computeDeviation,
15196
15535
  computeEffectiveWeight,
15197
15536
  computeOutcomeSignal,
@@ -15214,6 +15553,7 @@ export {
15214
15553
  getRegisteredNavigatorNames,
15215
15554
  getRegisteredNavigatorRole,
15216
15555
  getSrsBacklogDebug,
15556
+ getStrategyPressureDebug,
15217
15557
  getStudySource,
15218
15558
  hasRegisteredNavigator,
15219
15559
  importParsedCards,