@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
package/dist/index.mjs CHANGED
@@ -7338,6 +7338,8 @@ var init_CouchDBSyncStrategy = __esm({
7338
7338
  CouchDBSyncStrategy = class {
7339
7339
  syncHandle;
7340
7340
  // Handle to cancel sync if needed
7341
+ /** In-flight one-shot hydration pull, so a timed-out pull can be abandoned. */
7342
+ hydrationHandle;
7341
7343
  setupRemoteDB(username) {
7342
7344
  if (username === GuestUsername || username.startsWith(GuestUsername)) {
7343
7345
  return getLocalUserDB(username);
@@ -7352,8 +7354,26 @@ var init_CouchDBSyncStrategy = __esm({
7352
7354
  return this.getUserDB(username);
7353
7355
  }
7354
7356
  }
7357
+ /**
7358
+ * One-shot remote → local pull. Resolves once the local mirror is caught up.
7359
+ *
7360
+ * Pull only: pushing here would upload whatever the local DB happens to hold
7361
+ * before we know what the remote already has, which is the conflict-leaf
7362
+ * problem hydration exists to avoid. Local changes go up when startSync()
7363
+ * takes over.
7364
+ */
7365
+ async hydrate(localDB, remoteDB) {
7366
+ const replication = pouchdb_setup_default.replicate(remoteDB, localDB, {});
7367
+ this.hydrationHandle = replication;
7368
+ try {
7369
+ const info = await replication;
7370
+ return { docsWritten: info.docs_written };
7371
+ } finally {
7372
+ this.hydrationHandle = void 0;
7373
+ }
7374
+ }
7355
7375
  startSync(localDB, remoteDB) {
7356
- if (localDB !== remoteDB) {
7376
+ if (localDB.name !== remoteDB.name) {
7357
7377
  this.syncHandle = pouchdb_setup_default.sync(localDB, remoteDB, {
7358
7378
  live: true,
7359
7379
  retry: true
@@ -7361,8 +7381,20 @@ var init_CouchDBSyncStrategy = __esm({
7361
7381
  }
7362
7382
  }
7363
7383
  stopSync() {
7384
+ if (this.hydrationHandle) {
7385
+ try {
7386
+ this.hydrationHandle.cancel();
7387
+ } catch (e) {
7388
+ logger.warn(`Failed to cancel hydration pull (continuing anyway): ${e}`);
7389
+ }
7390
+ this.hydrationHandle = void 0;
7391
+ }
7364
7392
  if (this.syncHandle) {
7365
- this.syncHandle.cancel();
7393
+ try {
7394
+ this.syncHandle.cancel();
7395
+ } catch (e) {
7396
+ logger.warn(`Failed to cancel user sync (continuing anyway): ${e}`);
7397
+ }
7366
7398
  this.syncHandle = void 0;
7367
7399
  }
7368
7400
  }
@@ -7633,6 +7665,19 @@ var init_couch = __esm({
7633
7665
  // src/impl/common/BaseUserDB.ts
7634
7666
  import { Status as Status3 } from "@vue-skuilder/common";
7635
7667
  import moment6 from "moment";
7668
+ function withTimeout(p, ms, label) {
7669
+ let timer;
7670
+ return Promise.race([
7671
+ p,
7672
+ new Promise((_resolve, reject) => {
7673
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
7674
+ })
7675
+ ]).finally(() => {
7676
+ if (timer !== void 0) {
7677
+ clearTimeout(timer);
7678
+ }
7679
+ });
7680
+ }
7636
7681
  function accomodateGuest() {
7637
7682
  logger.log("[funnel] accomodateGuest() called");
7638
7683
  if (typeof localStorage === "undefined") {
@@ -7806,7 +7851,7 @@ async function dropUserFromClassroom(user, classID) {
7806
7851
  async function getUserClassrooms(user) {
7807
7852
  return getOrCreateClassroomRegistrationsDoc(user);
7808
7853
  }
7809
- var log4, BaseUser, userCoursesDoc, userClassroomsDoc;
7854
+ var log4, HYDRATION_TIMEOUT_MS, BaseUser, userCoursesDoc, userClassroomsDoc;
7810
7855
  var init_BaseUserDB = __esm({
7811
7856
  "src/impl/common/BaseUserDB.ts"() {
7812
7857
  "use strict";
@@ -7821,6 +7866,7 @@ var init_BaseUserDB = __esm({
7821
7866
  log4 = (s) => {
7822
7867
  logger.info(s);
7823
7868
  };
7869
+ HYDRATION_TIMEOUT_MS = 15e3;
7824
7870
  BaseUser = class _BaseUser {
7825
7871
  static _instance;
7826
7872
  static _initialized = false;
@@ -7849,6 +7895,49 @@ var init_BaseUserDB = __esm({
7849
7895
  writeDB;
7850
7896
  // Database to use for write operations (local-first approach)
7851
7897
  updateQueue;
7898
+ _hydration = { state: "not-required" };
7899
+ /** In-flight hydration, so concurrent callers can wait for a real answer. */
7900
+ _hydrationPromise = null;
7901
+ /**
7902
+ * How far the local mirror can be trusted, RIGHT NOW. See
7903
+ * {@link UserHydrationStatus}.
7904
+ *
7905
+ * This is a snapshot, and during login it can legitimately read
7906
+ * `hydrating` — prefer awaitHydration() anywhere a decision hangs on the
7907
+ * answer.
7908
+ */
7909
+ hydrationStatus() {
7910
+ return { ...this._hydration };
7911
+ }
7912
+ /**
7913
+ * The hydration status once it has settled — never `hydrating`.
7914
+ *
7915
+ * Resolves immediately unless a pull is in flight. Exists for callers that
7916
+ * must decide something (a route guard, a first-run branch) and would
7917
+ * otherwise sample `hydrating` and guess. Since init() is awaited by both
7918
+ * app startup and login(), that only happens when something navigates
7919
+ * concurrently with a login still in progress — a window that stretches to
7920
+ * HYDRATION_TIMEOUT_MS on a slow connection.
7921
+ */
7922
+ async awaitHydration() {
7923
+ try {
7924
+ await this._hydrationPromise;
7925
+ } catch {
7926
+ }
7927
+ return this.hydrationStatus();
7928
+ }
7929
+ /**
7930
+ * Whether a missing document may be treated as genuinely absent, allowing
7931
+ * callers to create it with defaults.
7932
+ *
7933
+ * False only when hydration failed or is still running: a 404 then may just
7934
+ * mean "not pulled down yet", and materializing a default would both lie to
7935
+ * the user and, once live sync starts, push a rev-1 document that loses to
7936
+ * the real one — silently discarding it.
7937
+ */
7938
+ canMaterializeDefaults() {
7939
+ return this._hydration.state !== "failed" && this._hydration.state !== "hydrating";
7940
+ }
7852
7941
  async createAccount(username, password) {
7853
7942
  if (!this.syncStrategy.canCreateAccount()) {
7854
7943
  throw new Error("Account creation not supported by current sync strategy");
@@ -7966,6 +8055,11 @@ Currently logged-in as ${this._username}.`
7966
8055
  } catch (e) {
7967
8056
  const err = e;
7968
8057
  if (err.status === 404) {
8058
+ if (!this.canMaterializeDefaults()) {
8059
+ throw new Error(
8060
+ `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.`
8061
+ );
8062
+ }
7969
8063
  await this.localDB.put({
7970
8064
  _id: _BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
7971
8065
  courses: [],
@@ -8208,6 +8302,11 @@ Currently logged-in as ${this._username}.`
8208
8302
  } catch (e) {
8209
8303
  const err = e;
8210
8304
  if (err.name && err.name === "not_found") {
8305
+ if (!this.canMaterializeDefaults()) {
8306
+ throw new Error(
8307
+ `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.`
8308
+ );
8309
+ }
8211
8310
  await this.localDB.put(defaultConfig);
8212
8311
  return this.getConfig();
8213
8312
  } else {
@@ -8281,7 +8380,10 @@ Currently logged-in as ${this._username}.`
8281
8380
  _BaseUser._initialized = true;
8282
8381
  return;
8283
8382
  }
8383
+ this.syncStrategy.stopSync?.();
8284
8384
  this.setDBandQ();
8385
+ this._hydrationPromise = this.hydrateLocalMirror();
8386
+ await this._hydrationPromise;
8285
8387
  this.syncStrategy.startSync(this.localDB, this.remoteDB);
8286
8388
  this.applyDesignDocs().catch((error) => {
8287
8389
  log4(`Error in applyDesignDocs background task: ${error}`);
@@ -8297,6 +8399,85 @@ Currently logged-in as ${this._username}.`
8297
8399
  });
8298
8400
  _BaseUser._initialized = true;
8299
8401
  }
8402
+ /**
8403
+ * Pull this account's documents into the local mirror, if that hasn't
8404
+ * happened on this device before.
8405
+ *
8406
+ * Runs between setDBandQ() and startSync() — after the handles exist, before
8407
+ * anything can observe an empty local DB or replicate one upward.
8408
+ *
8409
+ * Never throws: a failure is recorded as `failed` state and reported through
8410
+ * hydrationStatus(), because throwing here would abort init() and leave
8411
+ * BaseUser._initialized false forever (BaseUser.instance() polls it with no
8412
+ * ceiling). Callers decide what a failure means for them.
8413
+ */
8414
+ async hydrateLocalMirror() {
8415
+ if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
8416
+ this._hydration = { state: "not-required" };
8417
+ return;
8418
+ }
8419
+ if (await this.hasHydrationMarker()) {
8420
+ this._hydration = { state: "stale" };
8421
+ return;
8422
+ }
8423
+ this._hydration = { state: "hydrating" };
8424
+ const start = Date.now();
8425
+ try {
8426
+ const { docsWritten } = await withTimeout(
8427
+ this.syncStrategy.hydrate(this.localDB, this.remoteDB),
8428
+ HYDRATION_TIMEOUT_MS,
8429
+ `Hydration of local mirror for ${this._username}`
8430
+ );
8431
+ await this.writeHydrationMarker();
8432
+ this._hydration = {
8433
+ state: "hydrated",
8434
+ docsWritten,
8435
+ durationMs: Date.now() - start
8436
+ };
8437
+ log4(
8438
+ `Hydrated local mirror for ${this._username}: ${docsWritten} docs in ${this._hydration.durationMs}ms`
8439
+ );
8440
+ } catch (e) {
8441
+ this.syncStrategy.stopSync?.();
8442
+ this._hydration = {
8443
+ state: "failed",
8444
+ durationMs: Date.now() - start,
8445
+ error: e instanceof Error ? e.message : String(e)
8446
+ };
8447
+ logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
8448
+ }
8449
+ }
8450
+ /**
8451
+ * Has a full pull completed on this device for the CURRENT account?
8452
+ *
8453
+ * The marker is a `_local/` document, which never replicates — so its
8454
+ * presence describes this device's mirror and cannot arrive from elsewhere.
8455
+ */
8456
+ async hasHydrationMarker() {
8457
+ try {
8458
+ const marker = await this.localDB.get(HYDRATION_MARKER_ID);
8459
+ return marker.username === this._username;
8460
+ } catch {
8461
+ return false;
8462
+ }
8463
+ }
8464
+ async writeHydrationMarker() {
8465
+ try {
8466
+ let existingRev;
8467
+ try {
8468
+ existingRev = (await this.localDB.get(HYDRATION_MARKER_ID))._rev;
8469
+ } catch {
8470
+ }
8471
+ await this.localDB.put({
8472
+ _id: HYDRATION_MARKER_ID,
8473
+ _rev: existingRev,
8474
+ username: this._username,
8475
+ hydratedAt: (/* @__PURE__ */ new Date()).toISOString()
8476
+ });
8477
+ } catch (e) {
8478
+ logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
8479
+ }
8480
+ }
8300
8481
  static designDocs = [
8301
8482
  {
8302
8483
  _id: "_design/reviewCards",
@@ -8633,6 +8814,11 @@ Currently logged-in as ${this._username}.`
8633
8814
  } catch (e) {
8634
8815
  const err = e;
8635
8816
  if (err.status === 404) {
8817
+ if (!this.canMaterializeDefaults()) {
8818
+ throw new Error(
8819
+ `Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}').`
8820
+ );
8821
+ }
8636
8822
  return null;
8637
8823
  }
8638
8824
  throw e;
@@ -10152,6 +10338,15 @@ var init_userOutcome = __esm({
10152
10338
  }
10153
10339
  });
10154
10340
 
10341
+ // src/core/types/hydration.ts
10342
+ var HYDRATION_MARKER_ID;
10343
+ var init_hydration = __esm({
10344
+ "src/core/types/hydration.ts"() {
10345
+ "use strict";
10346
+ HYDRATION_MARKER_ID = "_local/hydration";
10347
+ }
10348
+ });
10349
+
10155
10350
  // src/core/bulkImport/cardProcessor.ts
10156
10351
  import { Status as Status5 } from "@vue-skuilder/common";
10157
10352
  async function importParsedCards(parsedCards, courseDB, config) {
@@ -10631,6 +10826,7 @@ var init_core = __esm({
10631
10826
  init_user();
10632
10827
  init_strategyState();
10633
10828
  init_userOutcome();
10829
+ init_hydration();
10634
10830
  init_Loggable();
10635
10831
  init_util();
10636
10832
  init_navigators();
@@ -15336,6 +15532,7 @@ export {
15336
15532
  ENV,
15337
15533
  FileSystemError,
15338
15534
  GuestUsername,
15535
+ HYDRATION_MARKER_ID,
15339
15536
  Loggable,
15340
15537
  NOT_SET,
15341
15538
  NavigatorRole,