@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
@@ -6730,6 +6730,15 @@ var init_userOutcome = __esm({
6730
6730
  }
6731
6731
  });
6732
6732
 
6733
+ // src/core/types/hydration.ts
6734
+ var HYDRATION_MARKER_ID;
6735
+ var init_hydration = __esm({
6736
+ "src/core/types/hydration.ts"() {
6737
+ "use strict";
6738
+ HYDRATION_MARKER_ID = "_local/hydration";
6739
+ }
6740
+ });
6741
+
6733
6742
  // src/core/util/index.ts
6734
6743
  function getCardHistoryID(courseID, cardID) {
6735
6744
  return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
@@ -7225,6 +7234,7 @@ var init_core = __esm({
7225
7234
  init_user();
7226
7235
  init_strategyState();
7227
7236
  init_userOutcome();
7237
+ init_hydration();
7228
7238
  init_Loggable();
7229
7239
  init_util();
7230
7240
  init_navigators();
@@ -7300,6 +7310,19 @@ var init_user_course_relDB = __esm({
7300
7310
  // src/impl/common/BaseUserDB.ts
7301
7311
  import { Status as Status3 } from "@vue-skuilder/common";
7302
7312
  import moment5 from "moment";
7313
+ function withTimeout(p, ms, label) {
7314
+ let timer;
7315
+ return Promise.race([
7316
+ p,
7317
+ new Promise((_resolve, reject) => {
7318
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
7319
+ })
7320
+ ]).finally(() => {
7321
+ if (timer !== void 0) {
7322
+ clearTimeout(timer);
7323
+ }
7324
+ });
7325
+ }
7303
7326
  function accomodateGuest() {
7304
7327
  logger.log("[funnel] accomodateGuest() called");
7305
7328
  if (typeof localStorage === "undefined") {
@@ -7473,7 +7496,7 @@ async function dropUserFromClassroom(user, classID) {
7473
7496
  async function getUserClassrooms(user) {
7474
7497
  return getOrCreateClassroomRegistrationsDoc(user);
7475
7498
  }
7476
- var log3, BaseUser, userCoursesDoc, userClassroomsDoc;
7499
+ var log3, HYDRATION_TIMEOUT_MS, BaseUser, userCoursesDoc, userClassroomsDoc;
7477
7500
  var init_BaseUserDB = __esm({
7478
7501
  "src/impl/common/BaseUserDB.ts"() {
7479
7502
  "use strict";
@@ -7488,6 +7511,7 @@ var init_BaseUserDB = __esm({
7488
7511
  log3 = (s) => {
7489
7512
  logger.info(s);
7490
7513
  };
7514
+ HYDRATION_TIMEOUT_MS = 15e3;
7491
7515
  BaseUser = class _BaseUser {
7492
7516
  static _instance;
7493
7517
  static _initialized = false;
@@ -7516,6 +7540,49 @@ var init_BaseUserDB = __esm({
7516
7540
  writeDB;
7517
7541
  // Database to use for write operations (local-first approach)
7518
7542
  updateQueue;
7543
+ _hydration = { state: "not-required" };
7544
+ /** In-flight hydration, so concurrent callers can wait for a real answer. */
7545
+ _hydrationPromise = null;
7546
+ /**
7547
+ * How far the local mirror can be trusted, RIGHT NOW. See
7548
+ * {@link UserHydrationStatus}.
7549
+ *
7550
+ * This is a snapshot, and during login it can legitimately read
7551
+ * `hydrating` — prefer awaitHydration() anywhere a decision hangs on the
7552
+ * answer.
7553
+ */
7554
+ hydrationStatus() {
7555
+ return { ...this._hydration };
7556
+ }
7557
+ /**
7558
+ * The hydration status once it has settled — never `hydrating`.
7559
+ *
7560
+ * Resolves immediately unless a pull is in flight. Exists for callers that
7561
+ * must decide something (a route guard, a first-run branch) and would
7562
+ * otherwise sample `hydrating` and guess. Since init() is awaited by both
7563
+ * app startup and login(), that only happens when something navigates
7564
+ * concurrently with a login still in progress — a window that stretches to
7565
+ * HYDRATION_TIMEOUT_MS on a slow connection.
7566
+ */
7567
+ async awaitHydration() {
7568
+ try {
7569
+ await this._hydrationPromise;
7570
+ } catch {
7571
+ }
7572
+ return this.hydrationStatus();
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
+ }
7519
7586
  async createAccount(username, password) {
7520
7587
  if (!this.syncStrategy.canCreateAccount()) {
7521
7588
  throw new Error("Account creation not supported by current sync strategy");
@@ -7633,6 +7700,11 @@ Currently logged-in as ${this._username}.`
7633
7700
  } catch (e) {
7634
7701
  const err = e;
7635
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
+ }
7636
7708
  await this.localDB.put({
7637
7709
  _id: _BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
7638
7710
  courses: [],
@@ -7875,6 +7947,11 @@ Currently logged-in as ${this._username}.`
7875
7947
  } catch (e) {
7876
7948
  const err = e;
7877
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
+ }
7878
7955
  await this.localDB.put(defaultConfig);
7879
7956
  return this.getConfig();
7880
7957
  } else {
@@ -7948,7 +8025,10 @@ Currently logged-in as ${this._username}.`
7948
8025
  _BaseUser._initialized = true;
7949
8026
  return;
7950
8027
  }
8028
+ this.syncStrategy.stopSync?.();
7951
8029
  this.setDBandQ();
8030
+ this._hydrationPromise = this.hydrateLocalMirror();
8031
+ await this._hydrationPromise;
7952
8032
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
7953
8033
  this.applyDesignDocs().catch((error) => {
7954
8034
  log3(`Error in applyDesignDocs background task: ${error}`);
@@ -7964,6 +8044,85 @@ Currently logged-in as ${this._username}.`
7964
8044
  });
7965
8045
  _BaseUser._initialized = true;
7966
8046
  }
8047
+ /**
8048
+ * Pull this account's documents into the local mirror, if that hasn't
8049
+ * happened on this device before.
8050
+ *
8051
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
8052
+ * anything can observe an empty local DB or replicate one upward.
8053
+ *
8054
+ * Never throws: a failure is recorded as `failed` state and reported through
8055
+ * hydrationStatus(), because throwing here would abort init() and leave
8056
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
8057
+ * ceiling). Callers decide what a failure means for them.
8058
+ */
8059
+ async hydrateLocalMirror() {
8060
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
8061
+ this._hydration = { state: "not-required" };
8062
+ return;
8063
+ }
8064
+ if (await this.hasHydrationMarker()) {
8065
+ this._hydration = { state: "stale" };
8066
+ return;
8067
+ }
8068
+ this._hydration = { state: "hydrating" };
8069
+ const start = Date.now();
8070
+ try {
8071
+ const { docsWritten } = await withTimeout(
8072
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
8073
+ HYDRATION_TIMEOUT_MS,
8074
+ `Hydration of local mirror for ${this._username}`
8075
+ );
8076
+ await this.writeHydrationMarker();
8077
+ this._hydration = {
8078
+ state: "hydrated",
8079
+ docsWritten,
8080
+ durationMs: Date.now() - start
8081
+ };
8082
+ log3(
8083
+ `Hydrated local mirror for ${this._username}: ${docsWritten} docs in ${this._hydration.durationMs}ms`
8084
+ );
8085
+ } catch (e) {
8086
+ this.syncStrategy.stopSync?.();
8087
+ this._hydration = {
8088
+ state: "failed",
8089
+ durationMs: Date.now() - start,
8090
+ error: e instanceof Error ? e.message : String(e)
8091
+ };
8092
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
8093
+ }
8094
+ }
8095
+ /**
8096
+ * Has a full pull completed on this device for the CURRENT account?
8097
+ *
8098
+ * The marker is a `_local/` document, which never replicates — so its
8099
+ * presence describes this device's mirror and cannot arrive from elsewhere.
8100
+ */
8101
+ async hasHydrationMarker() {
8102
+ try {
8103
+ const marker = await this.localDB.get(HYDRATION_MARKER_ID);
8104
+ return marker.username === this._username;
8105
+ } catch {
8106
+ return false;
8107
+ }
8108
+ }
8109
+ async writeHydrationMarker() {
8110
+ try {
8111
+ let existingRev;
8112
+ try {
8113
+ existingRev = (await this.localDB.get(HYDRATION_MARKER_ID))._rev;
8114
+ } catch {
8115
+ }
8116
+ await this.localDB.put({
8117
+ _id: HYDRATION_MARKER_ID,
8118
+ _rev: existingRev,
8119
+ username: this._username,
8120
+ hydratedAt: (/* @__PURE__ */ new Date()).toISOString()
8121
+ });
8122
+ } catch (e) {
8123
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
8124
+ }
8125
+ }
7967
8126
  static designDocs = [
7968
8127
  {
7969
8128
  _id: "_design/reviewCards",
@@ -8300,6 +8459,11 @@ Currently logged-in as ${this._username}.`
8300
8459
  } catch (e) {
8301
8460
  const err = e;
8302
8461
  if (err.status === 404) {
8462
+ if (!this.canMaterializeDefaults()) {
8463
+ throw new Error(
8464
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}').`
8465
+ );
8466
+ }
8303
8467
  return null;
8304
8468
  }
8305
8469
  throw e;
@@ -8823,6 +8987,8 @@ var init_CouchDBSyncStrategy = __esm({
8823
8987
  CouchDBSyncStrategy = class {
8824
8988
  syncHandle;
8825
8989
  // Handle to cancel sync if needed
8990
+ /** In-flight one-shot hydration pull, so a timed-out pull can be abandoned. */
8991
+ hydrationHandle;
8826
8992
  setupRemoteDB(username) {
8827
8993
  if (username === GuestUsername || username.startsWith(GuestUsername)) {
8828
8994
  return getLocalUserDB(username);
@@ -8837,8 +9003,26 @@ var init_CouchDBSyncStrategy = __esm({
8837
9003
  return this.getUserDB(username);
8838
9004
  }
8839
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
+ }
8840
9024
  startSync(localDB, remoteDB) {
8841
- if (localDB !== remoteDB) {
9025
+ if (localDB.name !== remoteDB.name) {
8842
9026
  this.syncHandle = pouchdb_setup_default.sync(localDB, remoteDB, {
8843
9027
  live: true,
8844
9028
  retry: true
@@ -8846,8 +9030,20 @@ var init_CouchDBSyncStrategy = __esm({
8846
9030
  }
8847
9031
  }
8848
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
+ }
8849
9041
  if (this.syncHandle) {
8850
- this.syncHandle.cancel();
9042
+ try {
9043
+ this.syncHandle.cancel();
9044
+ } catch (e) {
9045
+ logger.warn(`Failed to cancel user sync (continuing anyway): ${e}`);
9046
+ }
8851
9047
  this.syncHandle = void 0;
8852
9048
  }
8853
9049
  }