@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
@@ -1,4 +1,4 @@
1
- import { U as UserDBInterface, a as UserDBReader, C as CourseDBInterface, b as CoursesDBInterface, c as ClassroomDBInterface, A as AdminDBInterface } from './contentSource-Brz42x7n.cjs';
1
+ import { U as UserDBInterface, a as UserDBReader, C as CourseDBInterface, b as CoursesDBInterface, c as ClassroomDBInterface, A as AdminDBInterface } from './contentSource-CBqZCoGU.cjs';
2
2
 
3
3
  /**
4
4
  * Main factory interface for data access
@@ -1,4 +1,4 @@
1
- import { U as UserDBInterface, a as UserDBReader, C as CourseDBInterface, b as CoursesDBInterface, c as ClassroomDBInterface, A as AdminDBInterface } from './contentSource-B1p-vdz7.js';
1
+ import { U as UserDBInterface, a as UserDBReader, C as CourseDBInterface, b as CoursesDBInterface, c as ClassroomDBInterface, A as AdminDBInterface } from './contentSource-CudEz5Tm.js';
2
2
 
3
3
  /**
4
4
  * Main factory interface for data access
@@ -1,10 +1,10 @@
1
1
  import { T as TagStub, a as Tag, S as SkuilderCourseData, Q as QualifiedCardID } from '../../types-legacy-4tlwHnXo.cjs';
2
2
  import { Moment } from 'moment';
3
- import { A as AdminDBInterface, g as AssignedContent, h as StudyContentSource, i as StudentClassroomDBInterface, U as UserDBInterface, G as GeneratorResult, T as TeacherClassroomDBInterface, b as CoursesDBInterface, C as CourseDBInterface, d as CourseInfo, D as DataLayerResult, e as ContentNavigationStrategyData, f as ContentNavigator, R as ReplanHints, S as StudySessionItem, j as ScheduledCard } from '../../contentSource-Brz42x7n.cjs';
4
- export { q as ContentSourceID, k as StudySessionFailedItem, l as StudySessionFailedNewItem, m as StudySessionFailedReviewItem, n as StudySessionNewItem, o as StudySessionReviewItem, r as getStudySource, p as isReview } from '../../contentSource-Brz42x7n.cjs';
3
+ import { A as AdminDBInterface, g as AssignedContent, h as StudyContentSource, i as StudentClassroomDBInterface, U as UserDBInterface, G as GeneratorResult, T as TeacherClassroomDBInterface, b as CoursesDBInterface, C as CourseDBInterface, d as CourseInfo, D as DataLayerResult, e as ContentNavigationStrategyData, f as ContentNavigator, R as ReplanHints, S as StudySessionItem, j as ScheduledCard } from '../../contentSource-CBqZCoGU.cjs';
4
+ export { q as ContentSourceID, k as StudySessionFailedItem, l as StudySessionFailedNewItem, m as StudySessionFailedReviewItem, n as StudySessionNewItem, o as StudySessionReviewItem, r as getStudySource, p as isReview } from '../../contentSource-CBqZCoGU.cjs';
5
5
  import * as _vue_skuilder_common from '@vue-skuilder/common';
6
6
  import { ClassroomConfig, DataShape, CourseElo, CourseConfig as CourseConfig$1 } from '@vue-skuilder/common';
7
- import { S as SyncStrategy, A as AccountCreationResult, a as AuthenticationResult } from '../../SyncStrategy-CyATpyLQ.cjs';
7
+ import { S as SyncStrategy, A as AccountCreationResult, a as AuthenticationResult } from '../../SyncStrategy-BJMZq3WO.cjs';
8
8
 
9
9
  type NamespacedDatashape = string;
10
10
  interface DataShape55 {
@@ -497,8 +497,21 @@ declare class CourseSyncService {
497
497
  */
498
498
  declare class CouchDBSyncStrategy implements SyncStrategy {
499
499
  private syncHandle?;
500
+ /** In-flight one-shot hydration pull, so a timed-out pull can be abandoned. */
501
+ private hydrationHandle?;
500
502
  setupRemoteDB(username: string): PouchDB.Database;
501
503
  getWriteDB(username: string): PouchDB.Database;
504
+ /**
505
+ * One-shot remote → local pull. Resolves once the local mirror is caught up.
506
+ *
507
+ * Pull only: pushing here would upload whatever the local DB happens to hold
508
+ * before we know what the remote already has, which is the conflict-leaf
509
+ * problem hydration exists to avoid. Local changes go up when startSync()
510
+ * takes over.
511
+ */
512
+ hydrate(localDB: PouchDB.Database, remoteDB: PouchDB.Database): Promise<{
513
+ docsWritten: number;
514
+ }>;
502
515
  startSync(localDB: PouchDB.Database, remoteDB: PouchDB.Database): void;
503
516
  stopSync?(): void;
504
517
  canCreateAccount(): boolean;
@@ -1,10 +1,10 @@
1
1
  import { T as TagStub, a as Tag, S as SkuilderCourseData, Q as QualifiedCardID } from '../../types-legacy-4tlwHnXo.js';
2
2
  import { Moment } from 'moment';
3
- import { A as AdminDBInterface, g as AssignedContent, h as StudyContentSource, i as StudentClassroomDBInterface, U as UserDBInterface, G as GeneratorResult, T as TeacherClassroomDBInterface, b as CoursesDBInterface, C as CourseDBInterface, d as CourseInfo, D as DataLayerResult, e as ContentNavigationStrategyData, f as ContentNavigator, R as ReplanHints, S as StudySessionItem, j as ScheduledCard } from '../../contentSource-B1p-vdz7.js';
4
- export { q as ContentSourceID, k as StudySessionFailedItem, l as StudySessionFailedNewItem, m as StudySessionFailedReviewItem, n as StudySessionNewItem, o as StudySessionReviewItem, r as getStudySource, p as isReview } from '../../contentSource-B1p-vdz7.js';
3
+ import { A as AdminDBInterface, g as AssignedContent, h as StudyContentSource, i as StudentClassroomDBInterface, U as UserDBInterface, G as GeneratorResult, T as TeacherClassroomDBInterface, b as CoursesDBInterface, C as CourseDBInterface, d as CourseInfo, D as DataLayerResult, e as ContentNavigationStrategyData, f as ContentNavigator, R as ReplanHints, S as StudySessionItem, j as ScheduledCard } from '../../contentSource-CudEz5Tm.js';
4
+ export { q as ContentSourceID, k as StudySessionFailedItem, l as StudySessionFailedNewItem, m as StudySessionFailedReviewItem, n as StudySessionNewItem, o as StudySessionReviewItem, r as getStudySource, p as isReview } from '../../contentSource-CudEz5Tm.js';
5
5
  import * as _vue_skuilder_common from '@vue-skuilder/common';
6
6
  import { ClassroomConfig, DataShape, CourseElo, CourseConfig as CourseConfig$1 } from '@vue-skuilder/common';
7
- import { S as SyncStrategy, A as AccountCreationResult, a as AuthenticationResult } from '../../SyncStrategy-CyATpyLQ.js';
7
+ import { S as SyncStrategy, A as AccountCreationResult, a as AuthenticationResult } from '../../SyncStrategy-BJMZq3WO.js';
8
8
 
9
9
  type NamespacedDatashape = string;
10
10
  interface DataShape55 {
@@ -497,8 +497,21 @@ declare class CourseSyncService {
497
497
  */
498
498
  declare class CouchDBSyncStrategy implements SyncStrategy {
499
499
  private syncHandle?;
500
+ /** In-flight one-shot hydration pull, so a timed-out pull can be abandoned. */
501
+ private hydrationHandle?;
500
502
  setupRemoteDB(username: string): PouchDB.Database;
501
503
  getWriteDB(username: string): PouchDB.Database;
504
+ /**
505
+ * One-shot remote → local pull. Resolves once the local mirror is caught up.
506
+ *
507
+ * Pull only: pushing here would upload whatever the local DB happens to hold
508
+ * before we know what the remote already has, which is the conflict-leaf
509
+ * problem hydration exists to avoid. Local changes go up when startSync()
510
+ * takes over.
511
+ */
512
+ hydrate(localDB: PouchDB.Database, remoteDB: PouchDB.Database): Promise<{
513
+ docsWritten: number;
514
+ }>;
502
515
  startSync(localDB: PouchDB.Database, remoteDB: PouchDB.Database): void;
503
516
  stopSync?(): void;
504
517
  canCreateAccount(): boolean;
@@ -6748,6 +6748,15 @@ var init_userOutcome = __esm({
6748
6748
  }
6749
6749
  });
6750
6750
 
6751
+ // src/core/types/hydration.ts
6752
+ var HYDRATION_MARKER_ID;
6753
+ var init_hydration = __esm({
6754
+ "src/core/types/hydration.ts"() {
6755
+ "use strict";
6756
+ HYDRATION_MARKER_ID = "_local/hydration";
6757
+ }
6758
+ });
6759
+
6751
6760
  // src/core/util/index.ts
6752
6761
  function getCardHistoryID(courseID, cardID) {
6753
6762
  return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
@@ -7245,6 +7254,7 @@ var init_core = __esm({
7245
7254
  init_user();
7246
7255
  init_strategyState();
7247
7256
  init_userOutcome();
7257
+ init_hydration();
7248
7258
  init_Loggable();
7249
7259
  init_util();
7250
7260
  init_navigators();
@@ -7318,6 +7328,19 @@ var init_user_course_relDB = __esm({
7318
7328
  });
7319
7329
 
7320
7330
  // src/impl/common/BaseUserDB.ts
7331
+ function withTimeout(p, ms, label) {
7332
+ let timer;
7333
+ return Promise.race([
7334
+ p,
7335
+ new Promise((_resolve, reject) => {
7336
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
7337
+ })
7338
+ ]).finally(() => {
7339
+ if (timer !== void 0) {
7340
+ clearTimeout(timer);
7341
+ }
7342
+ });
7343
+ }
7321
7344
  function accomodateGuest() {
7322
7345
  logger.log("[funnel] accomodateGuest() called");
7323
7346
  if (typeof localStorage === "undefined") {
@@ -7491,7 +7514,7 @@ async function dropUserFromClassroom(user, classID) {
7491
7514
  async function getUserClassrooms(user) {
7492
7515
  return getOrCreateClassroomRegistrationsDoc(user);
7493
7516
  }
7494
- var import_common13, import_moment5, log3, BaseUser, userCoursesDoc, userClassroomsDoc;
7517
+ var import_common13, import_moment5, log3, HYDRATION_TIMEOUT_MS, BaseUser, userCoursesDoc, userClassroomsDoc;
7495
7518
  var init_BaseUserDB = __esm({
7496
7519
  "src/impl/common/BaseUserDB.ts"() {
7497
7520
  "use strict";
@@ -7508,6 +7531,7 @@ var init_BaseUserDB = __esm({
7508
7531
  log3 = (s) => {
7509
7532
  logger.info(s);
7510
7533
  };
7534
+ HYDRATION_TIMEOUT_MS = 15e3;
7511
7535
  BaseUser = class _BaseUser {
7512
7536
  static _instance;
7513
7537
  static _initialized = false;
@@ -7536,6 +7560,29 @@ var init_BaseUserDB = __esm({
7536
7560
  writeDB;
7537
7561
  // Database to use for write operations (local-first approach)
7538
7562
  updateQueue;
7563
+ _hydration = { state: "not-required" };
7564
+ /**
7565
+ * How far the local mirror can be trusted. See {@link UserHydrationStatus}.
7566
+ *
7567
+ * Consumers that would otherwise read a 404 as "new user" — onboarding
7568
+ * gates, first-run experiences, progress dashboards — should check for
7569
+ * `failed` and present a retry affordance rather than an empty state.
7570
+ */
7571
+ hydrationStatus() {
7572
+ return { ...this._hydration };
7573
+ }
7574
+ /**
7575
+ * Whether a missing document may be treated as genuinely absent, allowing
7576
+ * callers to create it with defaults.
7577
+ *
7578
+ * False only when hydration failed or is still running: a 404 then may just
7579
+ * mean "not pulled down yet", and materializing a default would both lie to
7580
+ * the user and, once live sync starts, push a rev-1 document that loses to
7581
+ * the real one — silently discarding it.
7582
+ */
7583
+ canMaterializeDefaults() {
7584
+ return this._hydration.state !== "failed" && this._hydration.state !== "hydrating";
7585
+ }
7539
7586
  async createAccount(username, password) {
7540
7587
  if (!this.syncStrategy.canCreateAccount()) {
7541
7588
  throw new Error("Account creation not supported by current sync strategy");
@@ -7653,6 +7700,11 @@ Currently logged-in as ${this._username}.`
7653
7700
  } catch (e) {
7654
7701
  const err = e;
7655
7702
  if (err.status === 404) {
7703
+ if (!this.canMaterializeDefaults()) {
7704
+ throw new Error(
7705
+ `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.`
7706
+ );
7707
+ }
7656
7708
  await this.localDB.put({
7657
7709
  _id: _BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
7658
7710
  courses: [],
@@ -7895,6 +7947,11 @@ Currently logged-in as ${this._username}.`
7895
7947
  } catch (e) {
7896
7948
  const err = e;
7897
7949
  if (err.name && err.name === "not_found") {
7950
+ if (!this.canMaterializeDefaults()) {
7951
+ throw new Error(
7952
+ `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.`
7953
+ );
7954
+ }
7898
7955
  await this.localDB.put(defaultConfig);
7899
7956
  return this.getConfig();
7900
7957
  } else {
@@ -7968,7 +8025,9 @@ Currently logged-in as ${this._username}.`
7968
8025
  _BaseUser._initialized = true;
7969
8026
  return;
7970
8027
  }
8028
+ this.syncStrategy.stopSync?.();
7971
8029
  this.setDBandQ();
8030
+ await this.hydrateLocalMirror();
7972
8031
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
7973
8032
  this.applyDesignDocs().catch((error) => {
7974
8033
  log3(`Error in applyDesignDocs background task: ${error}`);
@@ -7984,6 +8043,85 @@ Currently logged-in as ${this._username}.`
7984
8043
  });
7985
8044
  _BaseUser._initialized = true;
7986
8045
  }
8046
+ /**
8047
+ * Pull this account's documents into the local mirror, if that hasn't
8048
+ * happened on this device before.
8049
+ *
8050
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
8051
+ * anything can observe an empty local DB or replicate one upward.
8052
+ *
8053
+ * Never throws: a failure is recorded as `failed` state and reported through
8054
+ * hydrationStatus(), because throwing here would abort init() and leave
8055
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
8056
+ * ceiling). Callers decide what a failure means for them.
8057
+ */
8058
+ async hydrateLocalMirror() {
8059
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
8060
+ this._hydration = { state: "not-required" };
8061
+ return;
8062
+ }
8063
+ if (await this.hasHydrationMarker()) {
8064
+ this._hydration = { state: "stale" };
8065
+ return;
8066
+ }
8067
+ this._hydration = { state: "hydrating" };
8068
+ const start = Date.now();
8069
+ try {
8070
+ const { docsWritten } = await withTimeout(
8071
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
8072
+ HYDRATION_TIMEOUT_MS,
8073
+ `Hydration of local mirror for ${this._username}`
8074
+ );
8075
+ await this.writeHydrationMarker();
8076
+ this._hydration = {
8077
+ state: "hydrated",
8078
+ docsWritten,
8079
+ durationMs: Date.now() - start
8080
+ };
8081
+ log3(
8082
+ `Hydrated local mirror for ${this._username}: ${docsWritten} docs in ${this._hydration.durationMs}ms`
8083
+ );
8084
+ } catch (e) {
8085
+ this.syncStrategy.stopSync?.();
8086
+ this._hydration = {
8087
+ state: "failed",
8088
+ durationMs: Date.now() - start,
8089
+ error: e instanceof Error ? e.message : String(e)
8090
+ };
8091
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
8092
+ }
8093
+ }
8094
+ /**
8095
+ * Has a full pull completed on this device for the CURRENT account?
8096
+ *
8097
+ * The marker is a `_local/` document, which never replicates — so its
8098
+ * presence describes this device's mirror and cannot arrive from elsewhere.
8099
+ */
8100
+ async hasHydrationMarker() {
8101
+ try {
8102
+ const marker = await this.localDB.get(HYDRATION_MARKER_ID);
8103
+ return marker.username === this._username;
8104
+ } catch {
8105
+ return false;
8106
+ }
8107
+ }
8108
+ async writeHydrationMarker() {
8109
+ try {
8110
+ let existingRev;
8111
+ try {
8112
+ existingRev = (await this.localDB.get(HYDRATION_MARKER_ID))._rev;
8113
+ } catch {
8114
+ }
8115
+ await this.localDB.put({
8116
+ _id: HYDRATION_MARKER_ID,
8117
+ _rev: existingRev,
8118
+ username: this._username,
8119
+ hydratedAt: (/* @__PURE__ */ new Date()).toISOString()
8120
+ });
8121
+ } catch (e) {
8122
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
8123
+ }
8124
+ }
7987
8125
  static designDocs = [
7988
8126
  {
7989
8127
  _id: "_design/reviewCards",
@@ -8320,6 +8458,11 @@ Currently logged-in as ${this._username}.`
8320
8458
  } catch (e) {
8321
8459
  const err = e;
8322
8460
  if (err.status === 404) {
8461
+ if (!this.canMaterializeDefaults()) {
8462
+ throw new Error(
8463
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}').`
8464
+ );
8465
+ }
8323
8466
  return null;
8324
8467
  }
8325
8468
  throw e;
@@ -8844,6 +8987,8 @@ var init_CouchDBSyncStrategy = __esm({
8844
8987
  CouchDBSyncStrategy = class {
8845
8988
  syncHandle;
8846
8989
  // Handle to cancel sync if needed
8990
+ /** In-flight one-shot hydration pull, so a timed-out pull can be abandoned. */
8991
+ hydrationHandle;
8847
8992
  setupRemoteDB(username) {
8848
8993
  if (username === GuestUsername || username.startsWith(GuestUsername)) {
8849
8994
  return getLocalUserDB(username);
@@ -8858,8 +9003,26 @@ var init_CouchDBSyncStrategy = __esm({
8858
9003
  return this.getUserDB(username);
8859
9004
  }
8860
9005
  }
9006
+ /**
9007
+ * One-shot remote → local pull. Resolves once the local mirror is caught up.
9008
+ *
9009
+ * Pull only: pushing here would upload whatever the local DB happens to hold
9010
+ * before we know what the remote already has, which is the conflict-leaf
9011
+ * problem hydration exists to avoid. Local changes go up when startSync()
9012
+ * takes over.
9013
+ */
9014
+ async hydrate(localDB, remoteDB) {
9015
+ const replication = pouchdb_setup_default.replicate(remoteDB, localDB, {});
9016
+ this.hydrationHandle = replication;
9017
+ try {
9018
+ const info = await replication;
9019
+ return { docsWritten: info.docs_written };
9020
+ } finally {
9021
+ this.hydrationHandle = void 0;
9022
+ }
9023
+ }
8861
9024
  startSync(localDB, remoteDB) {
8862
- if (localDB !== remoteDB) {
9025
+ if (localDB.name !== remoteDB.name) {
8863
9026
  this.syncHandle = pouchdb_setup_default.sync(localDB, remoteDB, {
8864
9027
  live: true,
8865
9028
  retry: true
@@ -8867,8 +9030,20 @@ var init_CouchDBSyncStrategy = __esm({
8867
9030
  }
8868
9031
  }
8869
9032
  stopSync() {
9033
+ if (this.hydrationHandle) {
9034
+ try {
9035
+ this.hydrationHandle.cancel();
9036
+ } catch (e) {
9037
+ logger.warn(`Failed to cancel hydration pull (continuing anyway): ${e}`);
9038
+ }
9039
+ this.hydrationHandle = void 0;
9040
+ }
8870
9041
  if (this.syncHandle) {
8871
- this.syncHandle.cancel();
9042
+ try {
9043
+ this.syncHandle.cancel();
9044
+ } catch (e) {
9045
+ logger.warn(`Failed to cancel user sync (continuing anyway): ${e}`);
9046
+ }
8872
9047
  this.syncHandle = void 0;
8873
9048
  }
8874
9049
  }