@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
|
@@ -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
|
-
|
|
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
|
}
|