@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.
- package/dist/{SyncStrategy-CyATpyLQ.d.cts → SyncStrategy-BJMZq3WO.d.cts} +17 -0
- package/dist/{SyncStrategy-CyATpyLQ.d.ts → SyncStrategy-BJMZq3WO.d.ts} +17 -0
- package/dist/{contentSource-B1p-vdz7.d.ts → contentSource-BDRX_YYJ.d.ts} +97 -1
- package/dist/{contentSource-Brz42x7n.d.cts → contentSource-D-LQYFyx.d.cts} +97 -1
- package/dist/core/index.d.cts +3 -3
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +167 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +166 -1
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CpwpT1rM.d.cts → dataLayerProvider-Dd2UcCif.d.cts} +1 -1
- package/dist/{dataLayerProvider-BWayUIoK.d.ts → dataLayerProvider-Dpshuql4.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +16 -3
- package/dist/impl/couch/index.d.ts +16 -3
- package/dist/impl/couch/index.js +199 -3
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +199 -3
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -3
- package/dist/impl/static/index.d.ts +3 -3
- package/dist/impl/static/index.js +165 -1
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +165 -1
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +201 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +200 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/userDB.ts +24 -0
- package/src/core/types/hydration.ts +78 -0
- package/src/impl/common/BaseUserDB.ts +233 -2
- package/src/impl/common/SyncStrategy.ts +19 -0
- package/src/impl/couch/CouchDBSyncStrategy.ts +54 -4
- 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
|
-
|
|
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,
|