@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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.16",
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.16",
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.16"
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 practiceCards = this.buildPracticeCards({
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, ...practiceCards);
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
+ }