@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
@@ -6749,6 +6749,19 @@ var init_couch = __esm({
6749
6749
  // src/impl/common/BaseUserDB.ts
6750
6750
  import { Status as Status3 } from "@vue-skuilder/common";
6751
6751
  import moment6 from "moment";
6752
+ function withTimeout(p, ms, label) {
6753
+ let timer;
6754
+ return Promise.race([
6755
+ p,
6756
+ new Promise((_resolve, reject) => {
6757
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
6758
+ })
6759
+ ]).finally(() => {
6760
+ if (timer !== void 0) {
6761
+ clearTimeout(timer);
6762
+ }
6763
+ });
6764
+ }
6752
6765
  async function getOrCreateClassroomRegistrationsDoc(user) {
6753
6766
  let ret;
6754
6767
  try {
@@ -6841,7 +6854,7 @@ async function dropUserFromClassroom(user, classID) {
6841
6854
  async function getUserClassrooms(user) {
6842
6855
  return getOrCreateClassroomRegistrationsDoc(user);
6843
6856
  }
6844
- var log3, BaseUser, userCoursesDoc, userClassroomsDoc;
6857
+ var log3, HYDRATION_TIMEOUT_MS, BaseUser, userCoursesDoc, userClassroomsDoc;
6845
6858
  var init_BaseUserDB = __esm({
6846
6859
  "src/impl/common/BaseUserDB.ts"() {
6847
6860
  "use strict";
@@ -6856,6 +6869,7 @@ var init_BaseUserDB = __esm({
6856
6869
  log3 = (s) => {
6857
6870
  logger.info(s);
6858
6871
  };
6872
+ HYDRATION_TIMEOUT_MS = 15e3;
6859
6873
  BaseUser = class _BaseUser {
6860
6874
  static _instance;
6861
6875
  static _initialized = false;
@@ -6884,6 +6898,29 @@ var init_BaseUserDB = __esm({
6884
6898
  writeDB;
6885
6899
  // Database to use for write operations (local-first approach)
6886
6900
  updateQueue;
6901
+ _hydration = { state: "not-required" };
6902
+ /**
6903
+ * How far the local mirror can be trusted. See {@link UserHydrationStatus}.
6904
+ *
6905
+ * Consumers that would otherwise read a 404 as "new user" — onboarding
6906
+ * gates, first-run experiences, progress dashboards — should check for
6907
+ * `failed` and present a retry affordance rather than an empty state.
6908
+ */
6909
+ hydrationStatus() {
6910
+ return { ...this._hydration };
6911
+ }
6912
+ /**
6913
+ * Whether a missing document may be treated as genuinely absent, allowing
6914
+ * callers to create it with defaults.
6915
+ *
6916
+ * False only when hydration failed or is still running: a 404 then may just
6917
+ * mean "not pulled down yet", and materializing a default would both lie to
6918
+ * the user and, once live sync starts, push a rev-1 document that loses to
6919
+ * the real one — silently discarding it.
6920
+ */
6921
+ canMaterializeDefaults() {
6922
+ return this._hydration.state !== "failed" && this._hydration.state !== "hydrating";
6923
+ }
6887
6924
  async createAccount(username, password) {
6888
6925
  if (!this.syncStrategy.canCreateAccount()) {
6889
6926
  throw new Error("Account creation not supported by current sync strategy");
@@ -7001,6 +7038,11 @@ Currently logged-in as ${this._username}.`
7001
7038
  } catch (e) {
7002
7039
  const err = e;
7003
7040
  if (err.status === 404) {
7041
+ if (!this.canMaterializeDefaults()) {
7042
+ throw new Error(
7043
+ `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.`
7044
+ );
7045
+ }
7004
7046
  await this.localDB.put({
7005
7047
  _id: _BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
7006
7048
  courses: [],
@@ -7243,6 +7285,11 @@ Currently logged-in as ${this._username}.`
7243
7285
  } catch (e) {
7244
7286
  const err = e;
7245
7287
  if (err.name && err.name === "not_found") {
7288
+ if (!this.canMaterializeDefaults()) {
7289
+ throw new Error(
7290
+ `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.`
7291
+ );
7292
+ }
7246
7293
  await this.localDB.put(defaultConfig);
7247
7294
  return this.getConfig();
7248
7295
  } else {
@@ -7316,7 +7363,9 @@ Currently logged-in as ${this._username}.`
7316
7363
  _BaseUser._initialized = true;
7317
7364
  return;
7318
7365
  }
7366
+ this.syncStrategy.stopSync?.();
7319
7367
  this.setDBandQ();
7368
+ await this.hydrateLocalMirror();
7320
7369
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
7321
7370
  this.applyDesignDocs().catch((error) => {
7322
7371
  log3(`Error in applyDesignDocs background task: ${error}`);
@@ -7332,6 +7381,85 @@ Currently logged-in as ${this._username}.`
7332
7381
  });
7333
7382
  _BaseUser._initialized = true;
7334
7383
  }
7384
+ /**
7385
+ * Pull this account's documents into the local mirror, if that hasn't
7386
+ * happened on this device before.
7387
+ *
7388
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
7389
+ * anything can observe an empty local DB or replicate one upward.
7390
+ *
7391
+ * Never throws: a failure is recorded as `failed` state and reported through
7392
+ * hydrationStatus(), because throwing here would abort init() and leave
7393
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
7394
+ * ceiling). Callers decide what a failure means for them.
7395
+ */
7396
+ async hydrateLocalMirror() {
7397
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
7398
+ this._hydration = { state: "not-required" };
7399
+ return;
7400
+ }
7401
+ if (await this.hasHydrationMarker()) {
7402
+ this._hydration = { state: "stale" };
7403
+ return;
7404
+ }
7405
+ this._hydration = { state: "hydrating" };
7406
+ const start = Date.now();
7407
+ try {
7408
+ const { docsWritten } = await withTimeout(
7409
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
7410
+ HYDRATION_TIMEOUT_MS,
7411
+ `Hydration of local mirror for ${this._username}`
7412
+ );
7413
+ await this.writeHydrationMarker();
7414
+ this._hydration = {
7415
+ state: "hydrated",
7416
+ docsWritten,
7417
+ durationMs: Date.now() - start
7418
+ };
7419
+ log3(
7420
+ `Hydrated local mirror for ${this._username}: ${docsWritten} docs in ${this._hydration.durationMs}ms`
7421
+ );
7422
+ } catch (e) {
7423
+ this.syncStrategy.stopSync?.();
7424
+ this._hydration = {
7425
+ state: "failed",
7426
+ durationMs: Date.now() - start,
7427
+ error: e instanceof Error ? e.message : String(e)
7428
+ };
7429
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
7430
+ }
7431
+ }
7432
+ /**
7433
+ * Has a full pull completed on this device for the CURRENT account?
7434
+ *
7435
+ * The marker is a `_local/` document, which never replicates — so its
7436
+ * presence describes this device's mirror and cannot arrive from elsewhere.
7437
+ */
7438
+ async hasHydrationMarker() {
7439
+ try {
7440
+ const marker = await this.localDB.get(HYDRATION_MARKER_ID);
7441
+ return marker.username === this._username;
7442
+ } catch {
7443
+ return false;
7444
+ }
7445
+ }
7446
+ async writeHydrationMarker() {
7447
+ try {
7448
+ let existingRev;
7449
+ try {
7450
+ existingRev = (await this.localDB.get(HYDRATION_MARKER_ID))._rev;
7451
+ } catch {
7452
+ }
7453
+ await this.localDB.put({
7454
+ _id: HYDRATION_MARKER_ID,
7455
+ _rev: existingRev,
7456
+ username: this._username,
7457
+ hydratedAt: (/* @__PURE__ */ new Date()).toISOString()
7458
+ });
7459
+ } catch (e) {
7460
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
7461
+ }
7462
+ }
7335
7463
  static designDocs = [
7336
7464
  {
7337
7465
  _id: "_design/reviewCards",
@@ -7668,6 +7796,11 @@ Currently logged-in as ${this._username}.`
7668
7796
  } catch (e) {
7669
7797
  const err = e;
7670
7798
  if (err.status === 404) {
7799
+ if (!this.canMaterializeDefaults()) {
7800
+ throw new Error(
7801
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}').`
7802
+ );
7803
+ }
7671
7804
  return null;
7672
7805
  }
7673
7806
  throw e;
@@ -8015,6 +8148,15 @@ var init_userOutcome = __esm({
8015
8148
  }
8016
8149
  });
8017
8150
 
8151
+ // src/core/types/hydration.ts
8152
+ var HYDRATION_MARKER_ID;
8153
+ var init_hydration = __esm({
8154
+ "src/core/types/hydration.ts"() {
8155
+ "use strict";
8156
+ HYDRATION_MARKER_ID = "_local/hydration";
8157
+ }
8158
+ });
8159
+
8018
8160
  // src/core/bulkImport/cardProcessor.ts
8019
8161
  import { Status as Status4 } from "@vue-skuilder/common";
8020
8162
  async function importParsedCards(parsedCards, courseDB, config) {
@@ -8493,6 +8635,7 @@ var init_core = __esm({
8493
8635
  init_user();
8494
8636
  init_strategyState();
8495
8637
  init_userOutcome();
8638
+ init_hydration();
8496
8639
  init_Loggable();
8497
8640
  init_util();
8498
8641
  init_navigators();
@@ -8509,6 +8652,7 @@ export {
8509
8652
  DocType,
8510
8653
  DocTypePrefixes,
8511
8654
  GuestUsername,
8655
+ HYDRATION_MARKER_ID,
8512
8656
  Loggable,
8513
8657
  NavigatorRole,
8514
8658
  NavigatorRoles,