@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
@@ -1446,6 +1446,30 @@ var init_SrsDebugger = __esm({
1446
1446
  }
1447
1447
  });
1448
1448
 
1449
+ // src/core/navigators/StrategyPressureDebugger.ts
1450
+ var StrategyPressureDebugger_exports = {};
1451
+ __export(StrategyPressureDebugger_exports, {
1452
+ captureStrategyPressure: () => captureStrategyPressure,
1453
+ clearStrategyPressureDebug: () => clearStrategyPressureDebug,
1454
+ getStrategyPressureDebug: () => getStrategyPressureDebug
1455
+ });
1456
+ function captureStrategyPressure(snapshot) {
1457
+ snapshots2.set(`${snapshot.source}:${snapshot.courseId}`, snapshot);
1458
+ }
1459
+ function getStrategyPressureDebug() {
1460
+ return [...snapshots2.values()].sort((a, b) => b.timestamp - a.timestamp);
1461
+ }
1462
+ function clearStrategyPressureDebug() {
1463
+ snapshots2.clear();
1464
+ }
1465
+ var snapshots2;
1466
+ var init_StrategyPressureDebugger = __esm({
1467
+ "src/core/navigators/StrategyPressureDebugger.ts"() {
1468
+ "use strict";
1469
+ snapshots2 = /* @__PURE__ */ new Map();
1470
+ }
1471
+ });
1472
+
1449
1473
  // src/core/navigators/generators/CompositeGenerator.ts
1450
1474
  var CompositeGenerator_exports = {};
1451
1475
  __export(CompositeGenerator_exports, {
@@ -1810,6 +1834,13 @@ function shuffleInPlace(arr) {
1810
1834
  [arr[i], arr[j]] = [arr[j], arr[i]];
1811
1835
  }
1812
1836
  }
1837
+ function formatAge(ms) {
1838
+ const minutes = Math.max(0, Math.round(ms / 6e4));
1839
+ if (minutes < 60) return `${minutes}m`;
1840
+ const hours = Math.round(minutes / 60);
1841
+ if (hours < 24) return `${hours}h`;
1842
+ return `${Math.round(hours / 24)}d`;
1843
+ }
1813
1844
  function pickTopByScore(cards, limit) {
1814
1845
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1815
1846
  }
@@ -1818,6 +1849,7 @@ var init_prescribed = __esm({
1818
1849
  "src/core/navigators/generators/prescribed.ts"() {
1819
1850
  "use strict";
1820
1851
  init_navigators();
1852
+ init_StrategyPressureDebugger();
1821
1853
  init_logger();
1822
1854
  DEFAULT_FRESHNESS_WINDOW = 3;
1823
1855
  DEFAULT_MAX_DIRECT_PER_RUN = 3;
@@ -1894,6 +1926,7 @@ var init_prescribed = __esm({
1894
1926
  const groupRuntimes = [];
1895
1927
  const priorPracticeDebt = progress.practiceDebt ?? {};
1896
1928
  const nextPracticeDebt = {};
1929
+ const practiceDebtsByGroup = /* @__PURE__ */ new Map();
1897
1930
  for (const group of this.config.groups) {
1898
1931
  const runtime = this.buildGroupRuntimeState({
1899
1932
  group,
@@ -1942,7 +1975,7 @@ var init_prescribed = __esm({
1942
1975
  courseId,
1943
1976
  emittedIds
1944
1977
  );
1945
- const practiceCards = this.buildPracticeCards({
1978
+ const practice = this.buildPracticeCards({
1946
1979
  group,
1947
1980
  courseId,
1948
1981
  emittedIds,
@@ -1955,7 +1988,8 @@ var init_prescribed = __esm({
1955
1988
  priorPracticeDebt,
1956
1989
  nextPracticeDebt
1957
1990
  });
1958
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
1991
+ practiceDebtsByGroup.set(group.id, practice.debts);
1992
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practice.cards);
1959
1993
  }
1960
1994
  nextState.practiceDebt = nextPracticeDebt;
1961
1995
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
@@ -1971,6 +2005,7 @@ var init_prescribed = __esm({
1971
2005
  } else {
1972
2006
  logger.info("[Prescribed] No hints to emit (no blocked targets or no support tags)");
1973
2007
  }
2008
+ this.capturePressureSnapshot(courseId, groupRuntimes, practiceDebtsByGroup, emitted, hints);
1974
2009
  if (emitted.length === 0) {
1975
2010
  logger.info(
1976
2011
  "[Prescribed] 0 cards emitted (all targets blocked, authored/discovered support candidates exhausted)" + (hints ? " \u2014 boost hints emitted but may not survive filters" : "")
@@ -2032,6 +2067,60 @@ var init_prescribed = __esm({
2032
2067
  supportTags: [...supportTags].sort()
2033
2068
  };
2034
2069
  }
2070
+ /**
2071
+ * Translate this run's per-group runtimes and practice debts into gauges on
2072
+ * the generic strategy-pressure debug channel (rendered by the live session
2073
+ * overlay's "strategy backpressure" section).
2074
+ */
2075
+ capturePressureSnapshot(courseId, groupRuntimes, practiceDebtsByGroup, emitted, hints) {
2076
+ const now = Date.now();
2077
+ const gauges = [];
2078
+ for (const runtime of groupRuntimes) {
2079
+ const group = runtime.group;
2080
+ const window2 = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
2081
+ gauges.push({
2082
+ id: `group:${group.id}:target`,
2083
+ label: `${group.id} targets`,
2084
+ multiplier: runtime.pressureMultiplier,
2085
+ max: MAX_TARGET_MULTIPLIER,
2086
+ detail: `${runtime.pendingTargets.length} pending (${runtime.surfaceableTargets.length} surfaceable, ${runtime.blockedTargets.length} blocked) \xB7 sinceSurfaced ${runtime.sessionsSinceSurfaced}/${window2}`,
2087
+ items: runtime.blockedTargets.map((cardId) => ({ label: cardId, value: "blocked" }))
2088
+ });
2089
+ if (runtime.blockedTargets.length > 0) {
2090
+ const mode = runtime.supportCandidates.length > 0 ? "direct-support" : runtime.discoveredSupportCandidates.length > 0 ? "inserted-support" : "boost-only";
2091
+ gauges.push({
2092
+ id: `group:${group.id}:support`,
2093
+ label: `${group.id} support`,
2094
+ multiplier: runtime.supportMultiplier,
2095
+ max: MAX_SUPPORT_MULTIPLIER,
2096
+ detail: `mode=${mode} \xB7 ${runtime.supportTags.length} support tag(s)`,
2097
+ items: runtime.supportTags.map((tag) => ({ label: tag }))
2098
+ });
2099
+ }
2100
+ const debts = practiceDebtsByGroup.get(group.id) ?? [];
2101
+ if (debts.length > 0) {
2102
+ gauges.push({
2103
+ id: `group:${group.id}:practice-debt`,
2104
+ label: `${group.id} practice debt`,
2105
+ multiplier: Math.max(...debts.map((d) => d.multiplier)),
2106
+ max: MAX_PRACTICE_MULTIPLIER,
2107
+ detail: `${debts.length} under-practiced skill(s)`,
2108
+ items: debts.map((d) => ({
2109
+ label: d.tag,
2110
+ value: `\xD7${d.multiplier.toFixed(2)} \xB7 open ${formatAge(now - new Date(d.firstOwedAt).getTime())}`
2111
+ }))
2112
+ });
2113
+ }
2114
+ }
2115
+ captureStrategyPressure({
2116
+ source: "prescribed",
2117
+ courseId,
2118
+ gauges,
2119
+ topScore: emitted.length > 0 ? Math.max(...emitted.map((c) => c.score)) : null,
2120
+ hintsLabel: hints?._label,
2121
+ timestamp: now
2122
+ });
2123
+ }
2035
2124
  parseConfig(serializedData) {
2036
2125
  try {
2037
2126
  const parsed = JSON.parse(serializedData);
@@ -2188,6 +2277,7 @@ var init_prescribed = __esm({
2188
2277
  supportTags: [...supportTags],
2189
2278
  pressureMultiplier,
2190
2279
  supportMultiplier,
2280
+ sessionsSinceSurfaced,
2191
2281
  debugVersion: PRESCRIBED_DEBUG_VERSION
2192
2282
  };
2193
2283
  }
@@ -2320,13 +2410,13 @@ var init_prescribed = __esm({
2320
2410
  nextPracticeDebt
2321
2411
  } = args;
2322
2412
  const patterns = group.practiceTagPatterns ?? [];
2323
- if (patterns.length === 0) return [];
2413
+ if (patterns.length === 0) return { cards: [], debts: [] };
2324
2414
  const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2325
2415
  const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2326
2416
  const practiceTags = [...cardsByTag.keys()].filter(
2327
2417
  (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2328
2418
  );
2329
- if (practiceTags.length === 0) return [];
2419
+ if (practiceTags.length === 0) return { cards: [], debts: [] };
2330
2420
  const now = Date.now();
2331
2421
  const DAY_MS = 24 * 60 * 60 * 1e3;
2332
2422
  const tagMultiplier = /* @__PURE__ */ new Map();
@@ -2341,6 +2431,11 @@ var init_prescribed = __esm({
2341
2431
  );
2342
2432
  tagMultiplier.set(tag, mult);
2343
2433
  }
2434
+ const debts = practiceTags.map((tag) => ({
2435
+ tag,
2436
+ multiplier: tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT,
2437
+ firstOwedAt: nextPracticeDebt[tag]
2438
+ })).sort((a, b) => b.multiplier - a.multiplier || a.tag.localeCompare(b.tag));
2344
2439
  const practiceCardIds = this.findDiscoveredSupportCards({
2345
2440
  supportTags: practiceTags,
2346
2441
  cardsByTag,
@@ -2349,7 +2444,7 @@ var init_prescribed = __esm({
2349
2444
  excludedIds: emittedIds,
2350
2445
  limit: maxPractice
2351
2446
  });
2352
- if (practiceCardIds.length === 0) return [];
2447
+ if (practiceCardIds.length === 0) return { cards: [], debts };
2353
2448
  logger.info(
2354
2449
  `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2355
2450
  );
@@ -2379,7 +2474,7 @@ var init_prescribed = __esm({
2379
2474
  ]
2380
2475
  });
2381
2476
  }
2382
- return cards;
2477
+ return { cards, debts };
2383
2478
  }
2384
2479
  /**
2385
2480
  * True for a skill that was *gated and is now reached*: it has at least one
@@ -4810,6 +4905,7 @@ var init_3 = __esm({
4810
4905
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4811
4906
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4812
4907
  "./SrsDebugger.ts": () => Promise.resolve().then(() => (init_SrsDebugger(), SrsDebugger_exports)),
4908
+ "./StrategyPressureDebugger.ts": () => Promise.resolve().then(() => (init_StrategyPressureDebugger(), StrategyPressureDebugger_exports)),
4813
4909
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
4814
4910
  "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4815
4911
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
@@ -4842,7 +4938,9 @@ __export(navigators_exports, {
4842
4938
  NavigatorRole: () => NavigatorRole,
4843
4939
  NavigatorRoles: () => NavigatorRoles,
4844
4940
  Navigators: () => Navigators,
4941
+ captureStrategyPressure: () => captureStrategyPressure,
4845
4942
  clearSrsBacklogDebug: () => clearSrsBacklogDebug,
4943
+ clearStrategyPressureDebug: () => clearStrategyPressureDebug,
4846
4944
  diversityRerank: () => diversityRerank,
4847
4945
  getActivePipeline: () => getActivePipeline,
4848
4946
  getCardOrigin: () => getCardOrigin,
@@ -4850,6 +4948,7 @@ __export(navigators_exports, {
4850
4948
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
4851
4949
  getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
4852
4950
  getSrsBacklogDebug: () => getSrsBacklogDebug,
4951
+ getStrategyPressureDebug: () => getStrategyPressureDebug,
4853
4952
  hasRegisteredNavigator: () => hasRegisteredNavigator,
4854
4953
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
4855
4954
  isFilter: () => isFilter,
@@ -4932,6 +5031,7 @@ var init_navigators = __esm({
4932
5031
  init_diversityRerank();
4933
5032
  init_PipelineDebugger();
4934
5033
  init_SrsDebugger();
5034
+ init_StrategyPressureDebugger();
4935
5035
  init_logger();
4936
5036
  init_();
4937
5037
  init_2();
@@ -5270,6 +5370,19 @@ var init_couch = __esm({
5270
5370
  // src/impl/common/BaseUserDB.ts
5271
5371
  import { Status as Status3 } from "@vue-skuilder/common";
5272
5372
  import moment6 from "moment";
5373
+ function withTimeout(p, ms, label) {
5374
+ let timer;
5375
+ return Promise.race([
5376
+ p,
5377
+ new Promise((_resolve, reject) => {
5378
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
5379
+ })
5380
+ ]).finally(() => {
5381
+ if (timer !== void 0) {
5382
+ clearTimeout(timer);
5383
+ }
5384
+ });
5385
+ }
5273
5386
  function accomodateGuest() {
5274
5387
  logger.log("[funnel] accomodateGuest() called");
5275
5388
  if (typeof localStorage === "undefined") {
@@ -5443,7 +5556,7 @@ async function dropUserFromClassroom(user, classID) {
5443
5556
  async function getUserClassrooms(user) {
5444
5557
  return getOrCreateClassroomRegistrationsDoc(user);
5445
5558
  }
5446
- var log3, BaseUser, userCoursesDoc, userClassroomsDoc;
5559
+ var log3, HYDRATION_TIMEOUT_MS, BaseUser, userCoursesDoc, userClassroomsDoc;
5447
5560
  var init_BaseUserDB = __esm({
5448
5561
  "src/impl/common/BaseUserDB.ts"() {
5449
5562
  "use strict";
@@ -5458,6 +5571,7 @@ var init_BaseUserDB = __esm({
5458
5571
  log3 = (s) => {
5459
5572
  logger.info(s);
5460
5573
  };
5574
+ HYDRATION_TIMEOUT_MS = 15e3;
5461
5575
  BaseUser = class _BaseUser {
5462
5576
  static _instance;
5463
5577
  static _initialized = false;
@@ -5486,6 +5600,29 @@ var init_BaseUserDB = __esm({
5486
5600
  writeDB;
5487
5601
  // Database to use for write operations (local-first approach)
5488
5602
  updateQueue;
5603
+ _hydration = { state: "not-required" };
5604
+ /**
5605
+ * How far the local mirror can be trusted. See {@link UserHydrationStatus}.
5606
+ *
5607
+ * Consumers that would otherwise read a 404 as "new user" — onboarding
5608
+ * gates, first-run experiences, progress dashboards — should check for
5609
+ * `failed` and present a retry affordance rather than an empty state.
5610
+ */
5611
+ hydrationStatus() {
5612
+ return { ...this._hydration };
5613
+ }
5614
+ /**
5615
+ * Whether a missing document may be treated as genuinely absent, allowing
5616
+ * callers to create it with defaults.
5617
+ *
5618
+ * False only when hydration failed or is still running: a 404 then may just
5619
+ * mean "not pulled down yet", and materializing a default would both lie to
5620
+ * the user and, once live sync starts, push a rev-1 document that loses to
5621
+ * the real one — silently discarding it.
5622
+ */
5623
+ canMaterializeDefaults() {
5624
+ return this._hydration.state !== "failed" && this._hydration.state !== "hydrating";
5625
+ }
5489
5626
  async createAccount(username, password) {
5490
5627
  if (!this.syncStrategy.canCreateAccount()) {
5491
5628
  throw new Error("Account creation not supported by current sync strategy");
@@ -5603,6 +5740,11 @@ Currently logged-in as ${this._username}.`
5603
5740
  } catch (e) {
5604
5741
  const err = e;
5605
5742
  if (err.status === 404) {
5743
+ if (!this.canMaterializeDefaults()) {
5744
+ throw new Error(
5745
+ `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.`
5746
+ );
5747
+ }
5606
5748
  await this.localDB.put({
5607
5749
  _id: _BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
5608
5750
  courses: [],
@@ -5845,6 +5987,11 @@ Currently logged-in as ${this._username}.`
5845
5987
  } catch (e) {
5846
5988
  const err = e;
5847
5989
  if (err.name && err.name === "not_found") {
5990
+ if (!this.canMaterializeDefaults()) {
5991
+ throw new Error(
5992
+ `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.`
5993
+ );
5994
+ }
5848
5995
  await this.localDB.put(defaultConfig);
5849
5996
  return this.getConfig();
5850
5997
  } else {
@@ -5918,7 +6065,9 @@ Currently logged-in as ${this._username}.`
5918
6065
  _BaseUser._initialized = true;
5919
6066
  return;
5920
6067
  }
6068
+ this.syncStrategy.stopSync?.();
5921
6069
  this.setDBandQ();
6070
+ await this.hydrateLocalMirror();
5922
6071
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
5923
6072
  this.applyDesignDocs().catch((error) => {
5924
6073
  log3(`Error in applyDesignDocs background task: ${error}`);
@@ -5934,6 +6083,85 @@ Currently logged-in as ${this._username}.`
5934
6083
  });
5935
6084
  _BaseUser._initialized = true;
5936
6085
  }
6086
+ /**
6087
+ * Pull this account's documents into the local mirror, if that hasn't
6088
+ * happened on this device before.
6089
+ *
6090
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
6091
+ * anything can observe an empty local DB or replicate one upward.
6092
+ *
6093
+ * Never throws: a failure is recorded as `failed` state and reported through
6094
+ * hydrationStatus(), because throwing here would abort init() and leave
6095
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
6096
+ * ceiling). Callers decide what a failure means for them.
6097
+ */
6098
+ async hydrateLocalMirror() {
6099
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
6100
+ this._hydration = { state: "not-required" };
6101
+ return;
6102
+ }
6103
+ if (await this.hasHydrationMarker()) {
6104
+ this._hydration = { state: "stale" };
6105
+ return;
6106
+ }
6107
+ this._hydration = { state: "hydrating" };
6108
+ const start = Date.now();
6109
+ try {
6110
+ const { docsWritten } = await withTimeout(
6111
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
6112
+ HYDRATION_TIMEOUT_MS,
6113
+ `Hydration of local mirror for ${this._username}`
6114
+ );
6115
+ await this.writeHydrationMarker();
6116
+ this._hydration = {
6117
+ state: "hydrated",
6118
+ docsWritten,
6119
+ durationMs: Date.now() - start
6120
+ };
6121
+ log3(
6122
+ `Hydrated local mirror for ${this._username}: ${docsWritten} docs in ${this._hydration.durationMs}ms`
6123
+ );
6124
+ } catch (e) {
6125
+ this.syncStrategy.stopSync?.();
6126
+ this._hydration = {
6127
+ state: "failed",
6128
+ durationMs: Date.now() - start,
6129
+ error: e instanceof Error ? e.message : String(e)
6130
+ };
6131
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
6132
+ }
6133
+ }
6134
+ /**
6135
+ * Has a full pull completed on this device for the CURRENT account?
6136
+ *
6137
+ * The marker is a `_local/` document, which never replicates — so its
6138
+ * presence describes this device's mirror and cannot arrive from elsewhere.
6139
+ */
6140
+ async hasHydrationMarker() {
6141
+ try {
6142
+ const marker = await this.localDB.get(HYDRATION_MARKER_ID);
6143
+ return marker.username === this._username;
6144
+ } catch {
6145
+ return false;
6146
+ }
6147
+ }
6148
+ async writeHydrationMarker() {
6149
+ try {
6150
+ let existingRev;
6151
+ try {
6152
+ existingRev = (await this.localDB.get(HYDRATION_MARKER_ID))._rev;
6153
+ } catch {
6154
+ }
6155
+ await this.localDB.put({
6156
+ _id: HYDRATION_MARKER_ID,
6157
+ _rev: existingRev,
6158
+ username: this._username,
6159
+ hydratedAt: (/* @__PURE__ */ new Date()).toISOString()
6160
+ });
6161
+ } catch (e) {
6162
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
6163
+ }
6164
+ }
5937
6165
  static designDocs = [
5938
6166
  {
5939
6167
  _id: "_design/reviewCards",
@@ -6270,6 +6498,11 @@ Currently logged-in as ${this._username}.`
6270
6498
  } catch (e) {
6271
6499
  const err = e;
6272
6500
  if (err.status === 404) {
6501
+ if (!this.canMaterializeDefaults()) {
6502
+ throw new Error(
6503
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}').`
6504
+ );
6505
+ }
6273
6506
  return null;
6274
6507
  }
6275
6508
  throw e;
@@ -6443,6 +6676,15 @@ var init_userOutcome = __esm({
6443
6676
  }
6444
6677
  });
6445
6678
 
6679
+ // src/core/types/hydration.ts
6680
+ var HYDRATION_MARKER_ID;
6681
+ var init_hydration = __esm({
6682
+ "src/core/types/hydration.ts"() {
6683
+ "use strict";
6684
+ HYDRATION_MARKER_ID = "_local/hydration";
6685
+ }
6686
+ });
6687
+
6446
6688
  // src/core/bulkImport/cardProcessor.ts
6447
6689
  import { Status as Status4 } from "@vue-skuilder/common";
6448
6690
  var init_cardProcessor = __esm({
@@ -6811,6 +7053,7 @@ var init_core = __esm({
6811
7053
  init_user();
6812
7054
  init_strategyState();
6813
7055
  init_userOutcome();
7056
+ init_hydration();
6814
7057
  init_Loggable();
6815
7058
  init_util();
6816
7059
  init_navigators();