@vue-skuilder/db 0.2.17 → 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 (38) 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 +3 -3
  6. package/dist/core/index.d.ts +3 -3
  7. package/dist/core/index.js +146 -1
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/index.mjs +145 -1
  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 +178 -3
  16. package/dist/impl/couch/index.js.map +1 -1
  17. package/dist/impl/couch/index.mjs +178 -3
  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 +144 -1
  22. package/dist/impl/static/index.js.map +1 -1
  23. package/dist/impl/static/index.mjs +144 -1
  24. package/dist/impl/static/index.mjs.map +1 -1
  25. package/dist/index.d.cts +3 -3
  26. package/dist/index.d.ts +3 -3
  27. package/dist/index.js +180 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +179 -3
  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/types/hydration.ts +78 -0
  35. package/src/impl/common/BaseUserDB.ts +202 -2
  36. package/src/impl/common/SyncStrategy.ts +19 -0
  37. package/src/impl/couch/CouchDBSyncStrategy.ts +54 -4
  38. package/tests/impl/hydration.test.ts +281 -0
@@ -36,6 +36,23 @@ interface SyncStrategy {
36
36
  * @returns PouchDB database instance for write operations
37
37
  */
38
38
  getWriteDB?(username: string): PouchDB.Database;
39
+ /**
40
+ * Pull the remote database into the local one and RESOLVE WHEN DONE.
41
+ *
42
+ * Distinct from startSync(): this is a one-shot, awaitable catch-up used to
43
+ * populate an empty local mirror before the user object is handed out. A
44
+ * strategy that omits it declares that it has no remote worth waiting for.
45
+ *
46
+ * Mechanism only — BaseUser owns the policy (whether to run it, timeout,
47
+ * marker bookkeeping, resulting hydration state).
48
+ *
49
+ * @param localDB The local PouchDB instance
50
+ * @param remoteDB The remote PouchDB instance
51
+ * @returns Count of documents written locally
52
+ */
53
+ hydrate?(localDB: PouchDB.Database, remoteDB: PouchDB.Database): Promise<{
54
+ docsWritten: number;
55
+ }>;
39
56
  /**
40
57
  * Start synchronization between local and remote databases
41
58
  * @param localDB The local PouchDB instance
@@ -36,6 +36,23 @@ interface SyncStrategy {
36
36
  * @returns PouchDB database instance for write operations
37
37
  */
38
38
  getWriteDB?(username: string): PouchDB.Database;
39
+ /**
40
+ * Pull the remote database into the local one and RESOLVE WHEN DONE.
41
+ *
42
+ * Distinct from startSync(): this is a one-shot, awaitable catch-up used to
43
+ * populate an empty local mirror before the user object is handed out. A
44
+ * strategy that omits it declares that it has no remote worth waiting for.
45
+ *
46
+ * Mechanism only — BaseUser owns the policy (whether to run it, timeout,
47
+ * marker bookkeeping, resulting hydration state).
48
+ *
49
+ * @param localDB The local PouchDB instance
50
+ * @param remoteDB The remote PouchDB instance
51
+ * @returns Count of documents written locally
52
+ */
53
+ hydrate?(localDB: PouchDB.Database, remoteDB: PouchDB.Database): Promise<{
54
+ docsWritten: number;
55
+ }>;
39
56
  /**
40
57
  * Start synchronization between local and remote databases
41
58
  * @param localDB The local PouchDB instance
@@ -418,6 +418,81 @@ interface UserOutcomeRecord {
418
418
  };
419
419
  }
420
420
 
421
+ /**
422
+ * User database hydration.
423
+ *
424
+ * A logged-in user's data lives in a remote CouchDB `userdb-<hex>` and is
425
+ * mirrored into a browser-local PouchDB. On a device that has never synced
426
+ * that account, the local mirror starts EMPTY — and every local read
427
+ * (`getStrategyState`, `getConfig`, `getCourseRegistrationsDoc`, ...) answers
428
+ * "no such document", which is indistinguishable from "brand new user".
429
+ *
430
+ * Consumers acting on that answer render a new-user experience to an existing
431
+ * user, and — worse — compute writes from empty state that then lose to the
432
+ * real remote document at replication time. Hydration closes that window by
433
+ * pulling the account's documents down BEFORE the user object is handed out.
434
+ */
435
+ /**
436
+ * Where a user's local database stands relative to its remote counterpart.
437
+ *
438
+ * Only `failed` is a blocking condition. `stale` explicitly is not: it means a
439
+ * full pull completed on this device previously, so local data is real and
440
+ * merely possibly-behind — live sync closes the gap in the background. That is
441
+ * the ordinary offline case and it must keep working.
442
+ */
443
+ type UserHydrationState =
444
+ /** Guest, or a data layer with no remote. There is nothing to hydrate from. */
445
+ 'not-required'
446
+ /** Initial pull in flight. */
447
+ | 'hydrating'
448
+ /** Initial pull completed this session. Local is a faithful mirror. */
449
+ | 'hydrated'
450
+ /**
451
+ * A previous session completed a full pull on this device (recorded by the
452
+ * `_local/hydration` marker). Local data is real; live sync is catching up.
453
+ */
454
+ | 'stale'
455
+ /**
456
+ * No pull has ever completed on this device and one could not be completed
457
+ * now. Local reads CANNOT be trusted to mean "no data" — callers must not
458
+ * present a new-user experience or compute writes from what they read.
459
+ */
460
+ | 'failed';
461
+ /**
462
+ * Hydration state plus observability detail.
463
+ *
464
+ * Shaped to mirror `CourseSyncStatus` in CourseSyncService, which solves the
465
+ * same problem for course databases.
466
+ */
467
+ interface UserHydrationStatus {
468
+ state: UserHydrationState;
469
+ /** Documents pulled by the initial replication (set when state is `hydrated`). */
470
+ docsWritten?: number;
471
+ /** Wall-clock duration of the initial pull attempt, successful or not. */
472
+ durationMs?: number;
473
+ /** Failure detail, set when state is `failed`. */
474
+ error?: string;
475
+ }
476
+ /**
477
+ * ID of the per-device marker recording that a full pull once completed.
478
+ *
479
+ * `_local/*` documents are deliberately excluded from replication by CouchDB
480
+ * and PouchDB alike, which is exactly the semantics wanted here: the marker
481
+ * describes THIS device's mirror, and must never travel to another one.
482
+ */
483
+ declare const HYDRATION_MARKER_ID = "_local/hydration";
484
+ /**
485
+ * Payload of {@link HYDRATION_MARKER_ID}.
486
+ */
487
+ interface HydrationMarker {
488
+ _id: typeof HYDRATION_MARKER_ID;
489
+ _rev?: string;
490
+ /** Account the mirror was hydrated for — guards against a stale marker after an account switch. */
491
+ username: string;
492
+ /** ISO timestamp of the most recent successful full pull. */
493
+ hydratedAt: string;
494
+ }
495
+
421
496
  type Update<T> = Partial<T> | ((x: T) => T);
422
497
 
423
498
  interface DocumentUpdater {
@@ -438,6 +513,15 @@ interface UserDBReader {
438
513
  get<T>(id: string): Promise<T & PouchDB.Core.RevisionIdMeta>;
439
514
  getUsername(): string;
440
515
  isLoggedIn(): boolean;
516
+ /**
517
+ * How far this device's local mirror of the user's data can be trusted.
518
+ *
519
+ * Reads answer from the local mirror, so on a device that has never synced
520
+ * this account "document not found" does not mean "no such data". Anything
521
+ * that would otherwise interpret an empty read as a new user should check
522
+ * for `failed` first. See {@link UserHydrationStatus}.
523
+ */
524
+ hydrationStatus(): UserHydrationStatus;
441
525
  /**
442
526
  * Get user configuration
443
527
  */
@@ -1248,4 +1332,4 @@ interface StudyContentSource {
1248
1332
  }
1249
1333
  declare function getStudySource(source: ContentSourceID, user: UserDBInterface): Promise<StudyContentSource>;
1250
1334
 
1251
- export { Navigators as $, type AdminDBInterface as A, type UsrCrsDataInterface as B, type CourseDBInterface as C, type DataLayerResult as D, type ClassroomRegistrationDesignation as E, type ClassroomRegistration as F, type GeneratorResult as G, type ClassroomRegistrationDoc as H, type SessionTrackingData as I, type UserConfig as J, type ActivityRecord as K, type CourseRegistration as L, type UserOutcomeRecord as M, type NavigatorConstructor as N, registerNavigator as O, getRegisteredNavigator as P, hasRegisteredNavigator as Q, type ReplanHints as R, type StudySessionItem as S, type TeacherClassroomDBInterface as T, type UserDBInterface as U, getRegisteredNavigatorRole as V, type WeightedCard as W, getRegisteredNavigatorNames as X, initializeNavigatorRegistry as Y, type StrategyContribution as Z, getCardOrigin as _, type UserDBReader as a, NavigatorRole as a0, NavigatorRoles as a1, isGenerator as a2, isFilter as a3, type CardGenerator as a4, type GeneratorContext as a5, type CardGeneratorFactory as a6, type LearnableWeight as a7, type OrchestrationContext as a8, computeDeviation as a9, computeSpread as aa, computeEffectiveWeight as ab, createOrchestrationContext as ac, type DocumentUpdater as ad, newInterval as ae, type CoursesDBInterface as b, type ClassroomDBInterface as c, type CourseInfo as d, type ContentNavigationStrategyData as e, ContentNavigator as f, type AssignedContent as g, type StudyContentSource as h, type StudentClassroomDBInterface as i, type ScheduledCard as j, type StudySessionFailedItem as k, type StudySessionFailedNewItem as l, type StudySessionFailedReviewItem as m, type StudySessionNewItem as n, type StudySessionReviewItem as o, isReview as p, type ContentSourceID as q, getStudySource as r, type CourseRegistrationDoc as s, type AssignedTag as t, type AssignedCourse as u, type AssignedCard as v, type UserDBWriter as w, type UserDBAuthenticator as x, type UserCourseSettings as y, type UserCourseSetting as z };
1335
+ export { getRegisteredNavigatorNames as $, type AdminDBInterface as A, type UsrCrsDataInterface as B, type CourseDBInterface as C, type DataLayerResult as D, type ClassroomRegistrationDesignation as E, type ClassroomRegistration as F, type GeneratorResult as G, type ClassroomRegistrationDoc as H, type SessionTrackingData as I, type UserConfig as J, type ActivityRecord as K, type CourseRegistration as L, type UserOutcomeRecord as M, type UserHydrationState as N, type UserHydrationStatus as O, HYDRATION_MARKER_ID as P, type HydrationMarker as Q, type ReplanHints as R, type StudySessionItem as S, type TeacherClassroomDBInterface as T, type UserDBInterface as U, type NavigatorConstructor as V, type WeightedCard as W, registerNavigator as X, getRegisteredNavigator as Y, hasRegisteredNavigator as Z, getRegisteredNavigatorRole as _, type UserDBReader as a, initializeNavigatorRegistry as a0, type StrategyContribution as a1, getCardOrigin as a2, Navigators as a3, NavigatorRole as a4, NavigatorRoles as a5, isGenerator as a6, isFilter as a7, type CardGenerator as a8, type GeneratorContext as a9, type CardGeneratorFactory as aa, type LearnableWeight as ab, type OrchestrationContext as ac, computeDeviation as ad, computeSpread as ae, computeEffectiveWeight as af, createOrchestrationContext as ag, type DocumentUpdater as ah, newInterval as ai, type CoursesDBInterface as b, type ClassroomDBInterface as c, type CourseInfo as d, type ContentNavigationStrategyData as e, ContentNavigator as f, type AssignedContent as g, type StudyContentSource as h, type StudentClassroomDBInterface as i, type ScheduledCard as j, type StudySessionFailedItem as k, type StudySessionFailedNewItem as l, type StudySessionFailedReviewItem as m, type StudySessionNewItem as n, type StudySessionReviewItem as o, isReview as p, type ContentSourceID as q, getStudySource as r, type CourseRegistrationDoc as s, type AssignedTag as t, type AssignedCourse as u, type AssignedCard as v, type UserDBWriter as w, type UserDBAuthenticator as x, type UserCourseSettings as y, type UserCourseSetting as z };
@@ -418,6 +418,81 @@ interface UserOutcomeRecord {
418
418
  };
419
419
  }
420
420
 
421
+ /**
422
+ * User database hydration.
423
+ *
424
+ * A logged-in user's data lives in a remote CouchDB `userdb-<hex>` and is
425
+ * mirrored into a browser-local PouchDB. On a device that has never synced
426
+ * that account, the local mirror starts EMPTY — and every local read
427
+ * (`getStrategyState`, `getConfig`, `getCourseRegistrationsDoc`, ...) answers
428
+ * "no such document", which is indistinguishable from "brand new user".
429
+ *
430
+ * Consumers acting on that answer render a new-user experience to an existing
431
+ * user, and — worse — compute writes from empty state that then lose to the
432
+ * real remote document at replication time. Hydration closes that window by
433
+ * pulling the account's documents down BEFORE the user object is handed out.
434
+ */
435
+ /**
436
+ * Where a user's local database stands relative to its remote counterpart.
437
+ *
438
+ * Only `failed` is a blocking condition. `stale` explicitly is not: it means a
439
+ * full pull completed on this device previously, so local data is real and
440
+ * merely possibly-behind — live sync closes the gap in the background. That is
441
+ * the ordinary offline case and it must keep working.
442
+ */
443
+ type UserHydrationState =
444
+ /** Guest, or a data layer with no remote. There is nothing to hydrate from. */
445
+ 'not-required'
446
+ /** Initial pull in flight. */
447
+ | 'hydrating'
448
+ /** Initial pull completed this session. Local is a faithful mirror. */
449
+ | 'hydrated'
450
+ /**
451
+ * A previous session completed a full pull on this device (recorded by the
452
+ * `_local/hydration` marker). Local data is real; live sync is catching up.
453
+ */
454
+ | 'stale'
455
+ /**
456
+ * No pull has ever completed on this device and one could not be completed
457
+ * now. Local reads CANNOT be trusted to mean "no data" — callers must not
458
+ * present a new-user experience or compute writes from what they read.
459
+ */
460
+ | 'failed';
461
+ /**
462
+ * Hydration state plus observability detail.
463
+ *
464
+ * Shaped to mirror `CourseSyncStatus` in CourseSyncService, which solves the
465
+ * same problem for course databases.
466
+ */
467
+ interface UserHydrationStatus {
468
+ state: UserHydrationState;
469
+ /** Documents pulled by the initial replication (set when state is `hydrated`). */
470
+ docsWritten?: number;
471
+ /** Wall-clock duration of the initial pull attempt, successful or not. */
472
+ durationMs?: number;
473
+ /** Failure detail, set when state is `failed`. */
474
+ error?: string;
475
+ }
476
+ /**
477
+ * ID of the per-device marker recording that a full pull once completed.
478
+ *
479
+ * `_local/*` documents are deliberately excluded from replication by CouchDB
480
+ * and PouchDB alike, which is exactly the semantics wanted here: the marker
481
+ * describes THIS device's mirror, and must never travel to another one.
482
+ */
483
+ declare const HYDRATION_MARKER_ID = "_local/hydration";
484
+ /**
485
+ * Payload of {@link HYDRATION_MARKER_ID}.
486
+ */
487
+ interface HydrationMarker {
488
+ _id: typeof HYDRATION_MARKER_ID;
489
+ _rev?: string;
490
+ /** Account the mirror was hydrated for — guards against a stale marker after an account switch. */
491
+ username: string;
492
+ /** ISO timestamp of the most recent successful full pull. */
493
+ hydratedAt: string;
494
+ }
495
+
421
496
  type Update<T> = Partial<T> | ((x: T) => T);
422
497
 
423
498
  interface DocumentUpdater {
@@ -438,6 +513,15 @@ interface UserDBReader {
438
513
  get<T>(id: string): Promise<T & PouchDB.Core.RevisionIdMeta>;
439
514
  getUsername(): string;
440
515
  isLoggedIn(): boolean;
516
+ /**
517
+ * How far this device's local mirror of the user's data can be trusted.
518
+ *
519
+ * Reads answer from the local mirror, so on a device that has never synced
520
+ * this account "document not found" does not mean "no such data". Anything
521
+ * that would otherwise interpret an empty read as a new user should check
522
+ * for `failed` first. See {@link UserHydrationStatus}.
523
+ */
524
+ hydrationStatus(): UserHydrationStatus;
441
525
  /**
442
526
  * Get user configuration
443
527
  */
@@ -1248,4 +1332,4 @@ interface StudyContentSource {
1248
1332
  }
1249
1333
  declare function getStudySource(source: ContentSourceID, user: UserDBInterface): Promise<StudyContentSource>;
1250
1334
 
1251
- export { Navigators as $, type AdminDBInterface as A, type UsrCrsDataInterface as B, type CourseDBInterface as C, type DataLayerResult as D, type ClassroomRegistrationDesignation as E, type ClassroomRegistration as F, type GeneratorResult as G, type ClassroomRegistrationDoc as H, type SessionTrackingData as I, type UserConfig as J, type ActivityRecord as K, type CourseRegistration as L, type UserOutcomeRecord as M, type NavigatorConstructor as N, registerNavigator as O, getRegisteredNavigator as P, hasRegisteredNavigator as Q, type ReplanHints as R, type StudySessionItem as S, type TeacherClassroomDBInterface as T, type UserDBInterface as U, getRegisteredNavigatorRole as V, type WeightedCard as W, getRegisteredNavigatorNames as X, initializeNavigatorRegistry as Y, type StrategyContribution as Z, getCardOrigin as _, type UserDBReader as a, NavigatorRole as a0, NavigatorRoles as a1, isGenerator as a2, isFilter as a3, type CardGenerator as a4, type GeneratorContext as a5, type CardGeneratorFactory as a6, type LearnableWeight as a7, type OrchestrationContext as a8, computeDeviation as a9, computeSpread as aa, computeEffectiveWeight as ab, createOrchestrationContext as ac, type DocumentUpdater as ad, newInterval as ae, type CoursesDBInterface as b, type ClassroomDBInterface as c, type CourseInfo as d, type ContentNavigationStrategyData as e, ContentNavigator as f, type AssignedContent as g, type StudyContentSource as h, type StudentClassroomDBInterface as i, type ScheduledCard as j, type StudySessionFailedItem as k, type StudySessionFailedNewItem as l, type StudySessionFailedReviewItem as m, type StudySessionNewItem as n, type StudySessionReviewItem as o, isReview as p, type ContentSourceID as q, getStudySource as r, type CourseRegistrationDoc as s, type AssignedTag as t, type AssignedCourse as u, type AssignedCard as v, type UserDBWriter as w, type UserDBAuthenticator as x, type UserCourseSettings as y, type UserCourseSetting as z };
1335
+ export { getRegisteredNavigatorNames as $, type AdminDBInterface as A, type UsrCrsDataInterface as B, type CourseDBInterface as C, type DataLayerResult as D, type ClassroomRegistrationDesignation as E, type ClassroomRegistration as F, type GeneratorResult as G, type ClassroomRegistrationDoc as H, type SessionTrackingData as I, type UserConfig as J, type ActivityRecord as K, type CourseRegistration as L, type UserOutcomeRecord as M, type UserHydrationState as N, type UserHydrationStatus as O, HYDRATION_MARKER_ID as P, type HydrationMarker as Q, type ReplanHints as R, type StudySessionItem as S, type TeacherClassroomDBInterface as T, type UserDBInterface as U, type NavigatorConstructor as V, type WeightedCard as W, registerNavigator as X, getRegisteredNavigator as Y, hasRegisteredNavigator as Z, getRegisteredNavigatorRole as _, type UserDBReader as a, initializeNavigatorRegistry as a0, type StrategyContribution as a1, getCardOrigin as a2, Navigators as a3, NavigatorRole as a4, NavigatorRoles as a5, isGenerator as a6, isFilter as a7, type CardGenerator as a8, type GeneratorContext as a9, type CardGeneratorFactory as aa, type LearnableWeight as ab, type OrchestrationContext as ac, computeDeviation as ad, computeSpread as ae, computeEffectiveWeight as af, createOrchestrationContext as ag, type DocumentUpdater as ah, newInterval as ai, type CoursesDBInterface as b, type ClassroomDBInterface as c, type CourseInfo as d, type ContentNavigationStrategyData as e, ContentNavigator as f, type AssignedContent as g, type StudyContentSource as h, type StudentClassroomDBInterface as i, type ScheduledCard as j, type StudySessionFailedItem as k, type StudySessionFailedNewItem as l, type StudySessionFailedReviewItem as m, type StudySessionNewItem as n, type StudySessionReviewItem as o, isReview as p, type ContentSourceID as q, getStudySource as r, type CourseRegistrationDoc as s, type AssignedTag as t, type AssignedCourse as u, type AssignedCard as v, type UserDBWriter as w, type UserDBAuthenticator as x, type UserCourseSettings as y, type UserCourseSetting as z };
@@ -1,6 +1,6 @@
1
- import { a7 as LearnableWeight, M as UserOutcomeRecord, a8 as OrchestrationContext, W as WeightedCard, U as UserDBInterface, C as CourseDBInterface, R as ReplanHints, Z as StrategyContribution } from '../contentSource-Brz42x7n.cjs';
2
- export { K as ActivityRecord, A as AdminDBInterface, v as AssignedCard, g as AssignedContent, u as AssignedCourse, t as AssignedTag, a4 as CardGenerator, a6 as CardGeneratorFactory, c as ClassroomDBInterface, F as ClassroomRegistration, E as ClassroomRegistrationDesignation, H as ClassroomRegistrationDoc, e as ContentNavigationStrategyData, f as ContentNavigator, q as ContentSourceID, d as CourseInfo, L as CourseRegistration, s as CourseRegistrationDoc, b as CoursesDBInterface, a5 as GeneratorContext, G as GeneratorResult, N as NavigatorConstructor, a0 as NavigatorRole, a1 as NavigatorRoles, $ as Navigators, j as ScheduledCard, I as SessionTrackingData, i as StudentClassroomDBInterface, h as StudyContentSource, k as StudySessionFailedItem, l as StudySessionFailedNewItem, m as StudySessionFailedReviewItem, S as StudySessionItem, n as StudySessionNewItem, o as StudySessionReviewItem, T as TeacherClassroomDBInterface, J as UserConfig, z as UserCourseSetting, y as UserCourseSettings, x as UserDBAuthenticator, a as UserDBReader, w as UserDBWriter, B as UsrCrsDataInterface, a9 as computeDeviation, ab as computeEffectiveWeight, aa as computeSpread, ac as createOrchestrationContext, _ as getCardOrigin, P as getRegisteredNavigator, X as getRegisteredNavigatorNames, V as getRegisteredNavigatorRole, r as getStudySource, Q as hasRegisteredNavigator, Y as initializeNavigatorRegistry, a3 as isFilter, a2 as isGenerator, p as isReview, O as registerNavigator } from '../contentSource-Brz42x7n.cjs';
3
- export { D as DataLayerProvider } from '../dataLayerProvider-CpwpT1rM.cjs';
1
+ import { ab as LearnableWeight, M as UserOutcomeRecord, ac as OrchestrationContext, W as WeightedCard, U as UserDBInterface, C as CourseDBInterface, R as ReplanHints, a1 as StrategyContribution } from '../contentSource-CBqZCoGU.cjs';
2
+ export { K as ActivityRecord, A as AdminDBInterface, v as AssignedCard, g as AssignedContent, u as AssignedCourse, t as AssignedTag, a8 as CardGenerator, aa as CardGeneratorFactory, c as ClassroomDBInterface, F as ClassroomRegistration, E as ClassroomRegistrationDesignation, H as ClassroomRegistrationDoc, e as ContentNavigationStrategyData, f as ContentNavigator, q as ContentSourceID, d as CourseInfo, L as CourseRegistration, s as CourseRegistrationDoc, b as CoursesDBInterface, a9 as GeneratorContext, G as GeneratorResult, P as HYDRATION_MARKER_ID, Q as HydrationMarker, V as NavigatorConstructor, a4 as NavigatorRole, a5 as NavigatorRoles, a3 as Navigators, j as ScheduledCard, I as SessionTrackingData, i as StudentClassroomDBInterface, h as StudyContentSource, k as StudySessionFailedItem, l as StudySessionFailedNewItem, m as StudySessionFailedReviewItem, S as StudySessionItem, n as StudySessionNewItem, o as StudySessionReviewItem, T as TeacherClassroomDBInterface, J as UserConfig, z as UserCourseSetting, y as UserCourseSettings, x as UserDBAuthenticator, a as UserDBReader, w as UserDBWriter, N as UserHydrationState, O as UserHydrationStatus, B as UsrCrsDataInterface, ad as computeDeviation, af as computeEffectiveWeight, ae as computeSpread, ag as createOrchestrationContext, a2 as getCardOrigin, Y as getRegisteredNavigator, $ as getRegisteredNavigatorNames, _ as getRegisteredNavigatorRole, r as getStudySource, Z as hasRegisteredNavigator, a0 as initializeNavigatorRegistry, a7 as isFilter, a6 as isGenerator, p as isReview, X as registerNavigator } from '../contentSource-CBqZCoGU.cjs';
3
+ export { D as DataLayerProvider } from '../dataLayerProvider-BBA8tJNx.cjs';
4
4
  import { D as DocType, d as QuestionRecord, b as DocTypePrefixes, C as CardHistory, c as CardRecord } from '../types-legacy-4tlwHnXo.cjs';
5
5
  export { e as CardData, f as CourseListData, h as DataShapeData, g as DisplayableData, F as Field, G as GuestUsername, Q as QualifiedCardID, i as QuestionData, S as SkuilderCourseData, a as Tag, T as TagStub, l as log } from '../types-legacy-4tlwHnXo.cjs';
6
6
  import { DataShape, ParsedCard } from '@vue-skuilder/common';
@@ -1,6 +1,6 @@
1
- import { a7 as LearnableWeight, M as UserOutcomeRecord, a8 as OrchestrationContext, W as WeightedCard, U as UserDBInterface, C as CourseDBInterface, R as ReplanHints, Z as StrategyContribution } from '../contentSource-B1p-vdz7.js';
2
- export { K as ActivityRecord, A as AdminDBInterface, v as AssignedCard, g as AssignedContent, u as AssignedCourse, t as AssignedTag, a4 as CardGenerator, a6 as CardGeneratorFactory, c as ClassroomDBInterface, F as ClassroomRegistration, E as ClassroomRegistrationDesignation, H as ClassroomRegistrationDoc, e as ContentNavigationStrategyData, f as ContentNavigator, q as ContentSourceID, d as CourseInfo, L as CourseRegistration, s as CourseRegistrationDoc, b as CoursesDBInterface, a5 as GeneratorContext, G as GeneratorResult, N as NavigatorConstructor, a0 as NavigatorRole, a1 as NavigatorRoles, $ as Navigators, j as ScheduledCard, I as SessionTrackingData, i as StudentClassroomDBInterface, h as StudyContentSource, k as StudySessionFailedItem, l as StudySessionFailedNewItem, m as StudySessionFailedReviewItem, S as StudySessionItem, n as StudySessionNewItem, o as StudySessionReviewItem, T as TeacherClassroomDBInterface, J as UserConfig, z as UserCourseSetting, y as UserCourseSettings, x as UserDBAuthenticator, a as UserDBReader, w as UserDBWriter, B as UsrCrsDataInterface, a9 as computeDeviation, ab as computeEffectiveWeight, aa as computeSpread, ac as createOrchestrationContext, _ as getCardOrigin, P as getRegisteredNavigator, X as getRegisteredNavigatorNames, V as getRegisteredNavigatorRole, r as getStudySource, Q as hasRegisteredNavigator, Y as initializeNavigatorRegistry, a3 as isFilter, a2 as isGenerator, p as isReview, O as registerNavigator } from '../contentSource-B1p-vdz7.js';
3
- export { D as DataLayerProvider } from '../dataLayerProvider-BWayUIoK.js';
1
+ import { ab as LearnableWeight, M as UserOutcomeRecord, ac as OrchestrationContext, W as WeightedCard, U as UserDBInterface, C as CourseDBInterface, R as ReplanHints, a1 as StrategyContribution } from '../contentSource-CudEz5Tm.js';
2
+ export { K as ActivityRecord, A as AdminDBInterface, v as AssignedCard, g as AssignedContent, u as AssignedCourse, t as AssignedTag, a8 as CardGenerator, aa as CardGeneratorFactory, c as ClassroomDBInterface, F as ClassroomRegistration, E as ClassroomRegistrationDesignation, H as ClassroomRegistrationDoc, e as ContentNavigationStrategyData, f as ContentNavigator, q as ContentSourceID, d as CourseInfo, L as CourseRegistration, s as CourseRegistrationDoc, b as CoursesDBInterface, a9 as GeneratorContext, G as GeneratorResult, P as HYDRATION_MARKER_ID, Q as HydrationMarker, V as NavigatorConstructor, a4 as NavigatorRole, a5 as NavigatorRoles, a3 as Navigators, j as ScheduledCard, I as SessionTrackingData, i as StudentClassroomDBInterface, h as StudyContentSource, k as StudySessionFailedItem, l as StudySessionFailedNewItem, m as StudySessionFailedReviewItem, S as StudySessionItem, n as StudySessionNewItem, o as StudySessionReviewItem, T as TeacherClassroomDBInterface, J as UserConfig, z as UserCourseSetting, y as UserCourseSettings, x as UserDBAuthenticator, a as UserDBReader, w as UserDBWriter, N as UserHydrationState, O as UserHydrationStatus, B as UsrCrsDataInterface, ad as computeDeviation, af as computeEffectiveWeight, ae as computeSpread, ag as createOrchestrationContext, a2 as getCardOrigin, Y as getRegisteredNavigator, $ as getRegisteredNavigatorNames, _ as getRegisteredNavigatorRole, r as getStudySource, Z as hasRegisteredNavigator, a0 as initializeNavigatorRegistry, a7 as isFilter, a6 as isGenerator, p as isReview, X as registerNavigator } from '../contentSource-CudEz5Tm.js';
3
+ export { D as DataLayerProvider } from '../dataLayerProvider-C9WgkBzR.js';
4
4
  import { D as DocType, d as QuestionRecord, b as DocTypePrefixes, C as CardHistory, c as CardRecord } from '../types-legacy-4tlwHnXo.js';
5
5
  export { e as CardData, f as CourseListData, h as DataShapeData, g as DisplayableData, F as Field, G as GuestUsername, Q as QualifiedCardID, i as QuestionData, S as SkuilderCourseData, a as Tag, T as TagStub, l as log } from '../types-legacy-4tlwHnXo.js';
6
6
  import { DataShape, ParsedCard } from '@vue-skuilder/common';
@@ -6767,6 +6767,19 @@ var init_couch = __esm({
6767
6767
  });
6768
6768
 
6769
6769
  // src/impl/common/BaseUserDB.ts
6770
+ function withTimeout(p, ms, label) {
6771
+ let timer;
6772
+ return Promise.race([
6773
+ p,
6774
+ new Promise((_resolve, reject) => {
6775
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
6776
+ })
6777
+ ]).finally(() => {
6778
+ if (timer !== void 0) {
6779
+ clearTimeout(timer);
6780
+ }
6781
+ });
6782
+ }
6770
6783
  async function getOrCreateClassroomRegistrationsDoc(user) {
6771
6784
  let ret;
6772
6785
  try {
@@ -6859,7 +6872,7 @@ async function dropUserFromClassroom(user, classID) {
6859
6872
  async function getUserClassrooms(user) {
6860
6873
  return getOrCreateClassroomRegistrationsDoc(user);
6861
6874
  }
6862
- var import_common12, import_moment6, log3, BaseUser, userCoursesDoc, userClassroomsDoc;
6875
+ var import_common12, import_moment6, log3, HYDRATION_TIMEOUT_MS, BaseUser, userCoursesDoc, userClassroomsDoc;
6863
6876
  var init_BaseUserDB = __esm({
6864
6877
  "src/impl/common/BaseUserDB.ts"() {
6865
6878
  "use strict";
@@ -6876,6 +6889,7 @@ var init_BaseUserDB = __esm({
6876
6889
  log3 = (s) => {
6877
6890
  logger.info(s);
6878
6891
  };
6892
+ HYDRATION_TIMEOUT_MS = 15e3;
6879
6893
  BaseUser = class _BaseUser {
6880
6894
  static _instance;
6881
6895
  static _initialized = false;
@@ -6904,6 +6918,29 @@ var init_BaseUserDB = __esm({
6904
6918
  writeDB;
6905
6919
  // Database to use for write operations (local-first approach)
6906
6920
  updateQueue;
6921
+ _hydration = { state: "not-required" };
6922
+ /**
6923
+ * How far the local mirror can be trusted. See {@link UserHydrationStatus}.
6924
+ *
6925
+ * Consumers that would otherwise read a 404 as "new user" — onboarding
6926
+ * gates, first-run experiences, progress dashboards — should check for
6927
+ * `failed` and present a retry affordance rather than an empty state.
6928
+ */
6929
+ hydrationStatus() {
6930
+ return { ...this._hydration };
6931
+ }
6932
+ /**
6933
+ * Whether a missing document may be treated as genuinely absent, allowing
6934
+ * callers to create it with defaults.
6935
+ *
6936
+ * False only when hydration failed or is still running: a 404 then may just
6937
+ * mean "not pulled down yet", and materializing a default would both lie to
6938
+ * the user and, once live sync starts, push a rev-1 document that loses to
6939
+ * the real one — silently discarding it.
6940
+ */
6941
+ canMaterializeDefaults() {
6942
+ return this._hydration.state !== "failed" && this._hydration.state !== "hydrating";
6943
+ }
6907
6944
  async createAccount(username, password) {
6908
6945
  if (!this.syncStrategy.canCreateAccount()) {
6909
6946
  throw new Error("Account creation not supported by current sync strategy");
@@ -7021,6 +7058,11 @@ Currently logged-in as ${this._username}.`
7021
7058
  } catch (e) {
7022
7059
  const err = e;
7023
7060
  if (err.status === 404) {
7061
+ if (!this.canMaterializeDefaults()) {
7062
+ throw new Error(
7063
+ `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.`
7064
+ );
7065
+ }
7024
7066
  await this.localDB.put({
7025
7067
  _id: _BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
7026
7068
  courses: [],
@@ -7263,6 +7305,11 @@ Currently logged-in as ${this._username}.`
7263
7305
  } catch (e) {
7264
7306
  const err = e;
7265
7307
  if (err.name && err.name === "not_found") {
7308
+ if (!this.canMaterializeDefaults()) {
7309
+ throw new Error(
7310
+ `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.`
7311
+ );
7312
+ }
7266
7313
  await this.localDB.put(defaultConfig);
7267
7314
  return this.getConfig();
7268
7315
  } else {
@@ -7336,7 +7383,9 @@ Currently logged-in as ${this._username}.`
7336
7383
  _BaseUser._initialized = true;
7337
7384
  return;
7338
7385
  }
7386
+ this.syncStrategy.stopSync?.();
7339
7387
  this.setDBandQ();
7388
+ await this.hydrateLocalMirror();
7340
7389
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
7341
7390
  this.applyDesignDocs().catch((error) => {
7342
7391
  log3(`Error in applyDesignDocs background task: ${error}`);
@@ -7352,6 +7401,85 @@ Currently logged-in as ${this._username}.`
7352
7401
  });
7353
7402
  _BaseUser._initialized = true;
7354
7403
  }
7404
+ /**
7405
+ * Pull this account's documents into the local mirror, if that hasn't
7406
+ * happened on this device before.
7407
+ *
7408
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
7409
+ * anything can observe an empty local DB or replicate one upward.
7410
+ *
7411
+ * Never throws: a failure is recorded as `failed` state and reported through
7412
+ * hydrationStatus(), because throwing here would abort init() and leave
7413
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
7414
+ * ceiling). Callers decide what a failure means for them.
7415
+ */
7416
+ async hydrateLocalMirror() {
7417
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
7418
+ this._hydration = { state: "not-required" };
7419
+ return;
7420
+ }
7421
+ if (await this.hasHydrationMarker()) {
7422
+ this._hydration = { state: "stale" };
7423
+ return;
7424
+ }
7425
+ this._hydration = { state: "hydrating" };
7426
+ const start = Date.now();
7427
+ try {
7428
+ const { docsWritten } = await withTimeout(
7429
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
7430
+ HYDRATION_TIMEOUT_MS,
7431
+ `Hydration of local mirror for ${this._username}`
7432
+ );
7433
+ await this.writeHydrationMarker();
7434
+ this._hydration = {
7435
+ state: "hydrated",
7436
+ docsWritten,
7437
+ durationMs: Date.now() - start
7438
+ };
7439
+ log3(
7440
+ `Hydrated local mirror for ${this._username}: ${docsWritten} docs in ${this._hydration.durationMs}ms`
7441
+ );
7442
+ } catch (e) {
7443
+ this.syncStrategy.stopSync?.();
7444
+ this._hydration = {
7445
+ state: "failed",
7446
+ durationMs: Date.now() - start,
7447
+ error: e instanceof Error ? e.message : String(e)
7448
+ };
7449
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
7450
+ }
7451
+ }
7452
+ /**
7453
+ * Has a full pull completed on this device for the CURRENT account?
7454
+ *
7455
+ * The marker is a `_local/` document, which never replicates — so its
7456
+ * presence describes this device's mirror and cannot arrive from elsewhere.
7457
+ */
7458
+ async hasHydrationMarker() {
7459
+ try {
7460
+ const marker = await this.localDB.get(HYDRATION_MARKER_ID);
7461
+ return marker.username === this._username;
7462
+ } catch {
7463
+ return false;
7464
+ }
7465
+ }
7466
+ async writeHydrationMarker() {
7467
+ try {
7468
+ let existingRev;
7469
+ try {
7470
+ existingRev = (await this.localDB.get(HYDRATION_MARKER_ID))._rev;
7471
+ } catch {
7472
+ }
7473
+ await this.localDB.put({
7474
+ _id: HYDRATION_MARKER_ID,
7475
+ _rev: existingRev,
7476
+ username: this._username,
7477
+ hydratedAt: (/* @__PURE__ */ new Date()).toISOString()
7478
+ });
7479
+ } catch (e) {
7480
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
7481
+ }
7482
+ }
7355
7483
  static designDocs = [
7356
7484
  {
7357
7485
  _id: "_design/reviewCards",
@@ -7688,6 +7816,11 @@ Currently logged-in as ${this._username}.`
7688
7816
  } catch (e) {
7689
7817
  const err = e;
7690
7818
  if (err.status === 404) {
7819
+ if (!this.canMaterializeDefaults()) {
7820
+ throw new Error(
7821
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}').`
7822
+ );
7823
+ }
7691
7824
  return null;
7692
7825
  }
7693
7826
  throw e;
@@ -8036,6 +8169,15 @@ var init_userOutcome = __esm({
8036
8169
  }
8037
8170
  });
8038
8171
 
8172
+ // src/core/types/hydration.ts
8173
+ var HYDRATION_MARKER_ID;
8174
+ var init_hydration = __esm({
8175
+ "src/core/types/hydration.ts"() {
8176
+ "use strict";
8177
+ HYDRATION_MARKER_ID = "_local/hydration";
8178
+ }
8179
+ });
8180
+
8039
8181
  // src/core/bulkImport/cardProcessor.ts
8040
8182
  async function importParsedCards(parsedCards, courseDB, config) {
8041
8183
  const results = [];
@@ -8516,6 +8658,7 @@ __export(core_exports, {
8516
8658
  DocType: () => DocType,
8517
8659
  DocTypePrefixes: () => DocTypePrefixes,
8518
8660
  GuestUsername: () => GuestUsername,
8661
+ HYDRATION_MARKER_ID: () => HYDRATION_MARKER_ID,
8519
8662
  Loggable: () => Loggable,
8520
8663
  NavigatorRole: () => NavigatorRole,
8521
8664
  NavigatorRoles: () => NavigatorRoles,
@@ -8573,6 +8716,7 @@ var init_core = __esm({
8573
8716
  init_user();
8574
8717
  init_strategyState();
8575
8718
  init_userOutcome();
8719
+ init_hydration();
8576
8720
  init_Loggable();
8577
8721
  init_util();
8578
8722
  init_navigators();
@@ -8590,6 +8734,7 @@ init_core();
8590
8734
  DocType,
8591
8735
  DocTypePrefixes,
8592
8736
  GuestUsername,
8737
+ HYDRATION_MARKER_ID,
8593
8738
  Loggable,
8594
8739
  NavigatorRole,
8595
8740
  NavigatorRoles,