@vue-skuilder/db 0.2.17 → 0.2.19

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-B1p-vdz7.d.ts → contentSource-BDRX_YYJ.d.ts} +97 -1
  4. package/dist/{contentSource-Brz42x7n.d.cts → contentSource-D-LQYFyx.d.cts} +97 -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 +167 -1
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/index.mjs +166 -1
  10. package/dist/core/index.mjs.map +1 -1
  11. package/dist/{dataLayerProvider-CpwpT1rM.d.cts → dataLayerProvider-Dd2UcCif.d.cts} +1 -1
  12. package/dist/{dataLayerProvider-BWayUIoK.d.ts → dataLayerProvider-Dpshuql4.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 +199 -3
  16. package/dist/impl/couch/index.js.map +1 -1
  17. package/dist/impl/couch/index.mjs +199 -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 +165 -1
  22. package/dist/impl/static/index.js.map +1 -1
  23. package/dist/impl/static/index.mjs +165 -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 +201 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +200 -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 +24 -0
  34. package/src/core/types/hydration.ts +78 -0
  35. package/src/impl/common/BaseUserDB.ts +233 -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 +330 -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,27 @@ 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
+ * as of right now.
519
+ *
520
+ * Reads answer from the local mirror, so on a device that has never synced
521
+ * this account "document not found" does not mean "no such data". Anything
522
+ * that would otherwise interpret an empty read as a new user should check
523
+ * for `failed` first. See {@link UserHydrationStatus}.
524
+ *
525
+ * Can return `hydrating`; use {@link awaitHydration} to decide on a settled
526
+ * answer instead of a snapshot.
527
+ */
528
+ hydrationStatus(): UserHydrationStatus;
529
+ /**
530
+ * The hydration status once settled — never `hydrating`.
531
+ *
532
+ * Resolves immediately unless a pull is in flight. Prefer this over
533
+ * hydrationStatus() wherever the answer gates behaviour, such as a route
534
+ * guard that may run while a login is still completing.
535
+ */
536
+ awaitHydration(): Promise<UserHydrationStatus>;
441
537
  /**
442
538
  * Get user configuration
443
539
  */
@@ -1248,4 +1344,4 @@ interface StudyContentSource {
1248
1344
  }
1249
1345
  declare function getStudySource(source: ContentSourceID, user: UserDBInterface): Promise<StudyContentSource>;
1250
1346
 
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 };
1347
+ 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,27 @@ 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
+ * as of right now.
519
+ *
520
+ * Reads answer from the local mirror, so on a device that has never synced
521
+ * this account "document not found" does not mean "no such data". Anything
522
+ * that would otherwise interpret an empty read as a new user should check
523
+ * for `failed` first. See {@link UserHydrationStatus}.
524
+ *
525
+ * Can return `hydrating`; use {@link awaitHydration} to decide on a settled
526
+ * answer instead of a snapshot.
527
+ */
528
+ hydrationStatus(): UserHydrationStatus;
529
+ /**
530
+ * The hydration status once settled — never `hydrating`.
531
+ *
532
+ * Resolves immediately unless a pull is in flight. Prefer this over
533
+ * hydrationStatus() wherever the answer gates behaviour, such as a route
534
+ * guard that may run while a login is still completing.
535
+ */
536
+ awaitHydration(): Promise<UserHydrationStatus>;
441
537
  /**
442
538
  * Get user configuration
443
539
  */
@@ -1248,4 +1344,4 @@ interface StudyContentSource {
1248
1344
  }
1249
1345
  declare function getStudySource(source: ContentSourceID, user: UserDBInterface): Promise<StudyContentSource>;
1250
1346
 
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 };
1347
+ 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-D-LQYFyx.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-D-LQYFyx.cjs';
3
+ export { D as DataLayerProvider } from '../dataLayerProvider-Dd2UcCif.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-BDRX_YYJ.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-BDRX_YYJ.js';
3
+ export { D as DataLayerProvider } from '../dataLayerProvider-Dpshuql4.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,49 @@ 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
+ /** In-flight hydration, so concurrent callers can wait for a real answer. */
6923
+ _hydrationPromise = null;
6924
+ /**
6925
+ * How far the local mirror can be trusted, RIGHT NOW. See
6926
+ * {@link UserHydrationStatus}.
6927
+ *
6928
+ * This is a snapshot, and during login it can legitimately read
6929
+ * `hydrating` — prefer awaitHydration() anywhere a decision hangs on the
6930
+ * answer.
6931
+ */
6932
+ hydrationStatus() {
6933
+ return { ...this._hydration };
6934
+ }
6935
+ /**
6936
+ * The hydration status once it has settled — never `hydrating`.
6937
+ *
6938
+ * Resolves immediately unless a pull is in flight. Exists for callers that
6939
+ * must decide something (a route guard, a first-run branch) and would
6940
+ * otherwise sample `hydrating` and guess. Since init() is awaited by both
6941
+ * app startup and login(), that only happens when something navigates
6942
+ * concurrently with a login still in progress — a window that stretches to
6943
+ * HYDRATION_TIMEOUT_MS on a slow connection.
6944
+ */
6945
+ async awaitHydration() {
6946
+ try {
6947
+ await this._hydrationPromise;
6948
+ } catch {
6949
+ }
6950
+ return this.hydrationStatus();
6951
+ }
6952
+ /**
6953
+ * Whether a missing document may be treated as genuinely absent, allowing
6954
+ * callers to create it with defaults.
6955
+ *
6956
+ * False only when hydration failed or is still running: a 404 then may just
6957
+ * mean "not pulled down yet", and materializing a default would both lie to
6958
+ * the user and, once live sync starts, push a rev-1 document that loses to
6959
+ * the real one — silently discarding it.
6960
+ */
6961
+ canMaterializeDefaults() {
6962
+ return this._hydration.state !== "failed" && this._hydration.state !== "hydrating";
6963
+ }
6907
6964
  async createAccount(username, password) {
6908
6965
  if (!this.syncStrategy.canCreateAccount()) {
6909
6966
  throw new Error("Account creation not supported by current sync strategy");
@@ -7021,6 +7078,11 @@ Currently logged-in as ${this._username}.`
7021
7078
  } catch (e) {
7022
7079
  const err = e;
7023
7080
  if (err.status === 404) {
7081
+ if (!this.canMaterializeDefaults()) {
7082
+ throw new Error(
7083
+ `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.`
7084
+ );
7085
+ }
7024
7086
  await this.localDB.put({
7025
7087
  _id: _BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
7026
7088
  courses: [],
@@ -7263,6 +7325,11 @@ Currently logged-in as ${this._username}.`
7263
7325
  } catch (e) {
7264
7326
  const err = e;
7265
7327
  if (err.name && err.name === "not_found") {
7328
+ if (!this.canMaterializeDefaults()) {
7329
+ throw new Error(
7330
+ `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.`
7331
+ );
7332
+ }
7266
7333
  await this.localDB.put(defaultConfig);
7267
7334
  return this.getConfig();
7268
7335
  } else {
@@ -7336,7 +7403,10 @@ Currently logged-in as ${this._username}.`
7336
7403
  _BaseUser._initialized = true;
7337
7404
  return;
7338
7405
  }
7406
+ this.syncStrategy.stopSync?.();
7339
7407
  this.setDBandQ();
7408
+ this._hydrationPromise = this.hydrateLocalMirror();
7409
+ await this._hydrationPromise;
7340
7410
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
7341
7411
  this.applyDesignDocs().catch((error) => {
7342
7412
  log3(`Error in applyDesignDocs background task: ${error}`);
@@ -7352,6 +7422,85 @@ Currently logged-in as ${this._username}.`
7352
7422
  });
7353
7423
  _BaseUser._initialized = true;
7354
7424
  }
7425
+ /**
7426
+ * Pull this account's documents into the local mirror, if that hasn't
7427
+ * happened on this device before.
7428
+ *
7429
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
7430
+ * anything can observe an empty local DB or replicate one upward.
7431
+ *
7432
+ * Never throws: a failure is recorded as `failed` state and reported through
7433
+ * hydrationStatus(), because throwing here would abort init() and leave
7434
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
7435
+ * ceiling). Callers decide what a failure means for them.
7436
+ */
7437
+ async hydrateLocalMirror() {
7438
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
7439
+ this._hydration = { state: "not-required" };
7440
+ return;
7441
+ }
7442
+ if (await this.hasHydrationMarker()) {
7443
+ this._hydration = { state: "stale" };
7444
+ return;
7445
+ }
7446
+ this._hydration = { state: "hydrating" };
7447
+ const start = Date.now();
7448
+ try {
7449
+ const { docsWritten } = await withTimeout(
7450
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
7451
+ HYDRATION_TIMEOUT_MS,
7452
+ `Hydration of local mirror for ${this._username}`
7453
+ );
7454
+ await this.writeHydrationMarker();
7455
+ this._hydration = {
7456
+ state: "hydrated",
7457
+ docsWritten,
7458
+ durationMs: Date.now() - start
7459
+ };
7460
+ log3(
7461
+ `Hydrated local mirror for ${this._username}: ${docsWritten} docs in ${this._hydration.durationMs}ms`
7462
+ );
7463
+ } catch (e) {
7464
+ this.syncStrategy.stopSync?.();
7465
+ this._hydration = {
7466
+ state: "failed",
7467
+ durationMs: Date.now() - start,
7468
+ error: e instanceof Error ? e.message : String(e)
7469
+ };
7470
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
7471
+ }
7472
+ }
7473
+ /**
7474
+ * Has a full pull completed on this device for the CURRENT account?
7475
+ *
7476
+ * The marker is a `_local/` document, which never replicates — so its
7477
+ * presence describes this device's mirror and cannot arrive from elsewhere.
7478
+ */
7479
+ async hasHydrationMarker() {
7480
+ try {
7481
+ const marker = await this.localDB.get(HYDRATION_MARKER_ID);
7482
+ return marker.username === this._username;
7483
+ } catch {
7484
+ return false;
7485
+ }
7486
+ }
7487
+ async writeHydrationMarker() {
7488
+ try {
7489
+ let existingRev;
7490
+ try {
7491
+ existingRev = (await this.localDB.get(HYDRATION_MARKER_ID))._rev;
7492
+ } catch {
7493
+ }
7494
+ await this.localDB.put({
7495
+ _id: HYDRATION_MARKER_ID,
7496
+ _rev: existingRev,
7497
+ username: this._username,
7498
+ hydratedAt: (/* @__PURE__ */ new Date()).toISOString()
7499
+ });
7500
+ } catch (e) {
7501
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
7502
+ }
7503
+ }
7355
7504
  static designDocs = [
7356
7505
  {
7357
7506
  _id: "_design/reviewCards",
@@ -7688,6 +7837,11 @@ Currently logged-in as ${this._username}.`
7688
7837
  } catch (e) {
7689
7838
  const err = e;
7690
7839
  if (err.status === 404) {
7840
+ if (!this.canMaterializeDefaults()) {
7841
+ throw new Error(
7842
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}').`
7843
+ );
7844
+ }
7691
7845
  return null;
7692
7846
  }
7693
7847
  throw e;
@@ -8036,6 +8190,15 @@ var init_userOutcome = __esm({
8036
8190
  }
8037
8191
  });
8038
8192
 
8193
+ // src/core/types/hydration.ts
8194
+ var HYDRATION_MARKER_ID;
8195
+ var init_hydration = __esm({
8196
+ "src/core/types/hydration.ts"() {
8197
+ "use strict";
8198
+ HYDRATION_MARKER_ID = "_local/hydration";
8199
+ }
8200
+ });
8201
+
8039
8202
  // src/core/bulkImport/cardProcessor.ts
8040
8203
  async function importParsedCards(parsedCards, courseDB, config) {
8041
8204
  const results = [];
@@ -8516,6 +8679,7 @@ __export(core_exports, {
8516
8679
  DocType: () => DocType,
8517
8680
  DocTypePrefixes: () => DocTypePrefixes,
8518
8681
  GuestUsername: () => GuestUsername,
8682
+ HYDRATION_MARKER_ID: () => HYDRATION_MARKER_ID,
8519
8683
  Loggable: () => Loggable,
8520
8684
  NavigatorRole: () => NavigatorRole,
8521
8685
  NavigatorRoles: () => NavigatorRoles,
@@ -8573,6 +8737,7 @@ var init_core = __esm({
8573
8737
  init_user();
8574
8738
  init_strategyState();
8575
8739
  init_userOutcome();
8740
+ init_hydration();
8576
8741
  init_Loggable();
8577
8742
  init_util();
8578
8743
  init_navigators();
@@ -8590,6 +8755,7 @@ init_core();
8590
8755
  DocType,
8591
8756
  DocTypePrefixes,
8592
8757
  GuestUsername,
8758
+ HYDRATION_MARKER_ID,
8593
8759
  Loggable,
8594
8760
  NavigatorRole,
8595
8761
  NavigatorRoles,