@vue-skuilder/db 0.2.18 → 0.2.20
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-BJMZq3WO.d.cts → SyncStrategy-CCU1H81I.d.cts} +8 -2
- package/dist/{SyncStrategy-BJMZq3WO.d.ts → SyncStrategy-CCU1H81I.d.ts} +8 -2
- package/dist/{contentSource-CudEz5Tm.d.ts → contentSource-BDRX_YYJ.d.ts} +13 -1
- package/dist/{contentSource-CBqZCoGU.d.cts → contentSource-D-LQYFyx.d.cts} +13 -1
- package/dist/core/index.d.cts +3 -3
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +38 -7
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +38 -7
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BBA8tJNx.d.cts → dataLayerProvider-Dd2UcCif.d.cts} +1 -1
- package/dist/{dataLayerProvider-C9WgkBzR.d.ts → dataLayerProvider-Dpshuql4.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +20 -4
- package/dist/impl/couch/index.d.ts +20 -4
- package/dist/impl/couch/index.js +80 -11
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +80 -11
- 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 +38 -7
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +38 -7
- 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 +80 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +80 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/interfaces/userDB.ts +14 -1
- package/src/impl/common/BaseUserDB.ts +56 -8
- package/src/impl/common/SyncStrategy.ts +17 -4
- package/src/impl/couch/CouchDBSyncStrategy.ts +52 -4
- package/tests/impl/hydration.test.ts +49 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.20",
|
|
8
8
|
"description": "Database layer for vue-skuilder",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"module": "dist/index.mjs",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@nilock2/pouchdb-authentication": "^1.0.2",
|
|
51
|
-
"@vue-skuilder/common": "0.2.
|
|
51
|
+
"@vue-skuilder/common": "0.2.20",
|
|
52
52
|
"cross-fetch": "^4.1.0",
|
|
53
53
|
"moment": "^2.29.4",
|
|
54
54
|
"pouchdb": "^9.0.0",
|
|
@@ -63,5 +63,5 @@
|
|
|
63
63
|
"vite": "^8.0.0",
|
|
64
64
|
"vitest": "^4.1.0"
|
|
65
65
|
},
|
|
66
|
-
"stableVersion": "0.2.
|
|
66
|
+
"stableVersion": "0.2.20"
|
|
67
67
|
}
|
|
@@ -21,15 +21,28 @@ export interface UserDBReader {
|
|
|
21
21
|
isLoggedIn(): boolean;
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* How far this device's local mirror of the user's data can be trusted
|
|
24
|
+
* How far this device's local mirror of the user's data can be trusted,
|
|
25
|
+
* as of right now.
|
|
25
26
|
*
|
|
26
27
|
* Reads answer from the local mirror, so on a device that has never synced
|
|
27
28
|
* this account "document not found" does not mean "no such data". Anything
|
|
28
29
|
* that would otherwise interpret an empty read as a new user should check
|
|
29
30
|
* for `failed` first. See {@link UserHydrationStatus}.
|
|
31
|
+
*
|
|
32
|
+
* Can return `hydrating`; use {@link awaitHydration} to decide on a settled
|
|
33
|
+
* answer instead of a snapshot.
|
|
30
34
|
*/
|
|
31
35
|
hydrationStatus(): UserHydrationStatus;
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The hydration status once settled — never `hydrating`.
|
|
39
|
+
*
|
|
40
|
+
* Resolves immediately unless a pull is in flight. Prefer this over
|
|
41
|
+
* hydrationStatus() wherever the answer gates behaviour, such as a route
|
|
42
|
+
* guard that may run while a login is still completing.
|
|
43
|
+
*/
|
|
44
|
+
awaitHydration(): Promise<UserHydrationStatus>;
|
|
45
|
+
|
|
33
46
|
/**
|
|
34
47
|
* Get user configuration
|
|
35
48
|
*/
|
|
@@ -127,18 +127,51 @@ export class BaseUser implements UserDBInterface, DocumentUpdater {
|
|
|
127
127
|
private updateQueue!: UpdateQueue;
|
|
128
128
|
|
|
129
129
|
private _hydration: UserHydrationStatus = { state: 'not-required' };
|
|
130
|
+
/** In-flight hydration, so concurrent callers can wait for a real answer. */
|
|
131
|
+
private _hydrationPromise: Promise<void> | null = null;
|
|
132
|
+
/**
|
|
133
|
+
* Sequence the local mirror was filled to by a hydration that succeeded in
|
|
134
|
+
* THIS init(), handed to startSync() so the live pull can skip history it
|
|
135
|
+
* would otherwise re-walk. Undefined whenever there is no such guarantee —
|
|
136
|
+
* hydration skipped, not required, or failed. Never persisted; see the
|
|
137
|
+
* `since` note in CouchDBSyncStrategy.startSync().
|
|
138
|
+
*/
|
|
139
|
+
private _hydratedSeq?: string | number;
|
|
130
140
|
|
|
131
141
|
/**
|
|
132
|
-
* How far the local mirror can be trusted. See
|
|
142
|
+
* How far the local mirror can be trusted, RIGHT NOW. See
|
|
143
|
+
* {@link UserHydrationStatus}.
|
|
133
144
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
145
|
+
* This is a snapshot, and during login it can legitimately read
|
|
146
|
+
* `hydrating` — prefer awaitHydration() anywhere a decision hangs on the
|
|
147
|
+
* answer.
|
|
137
148
|
*/
|
|
138
149
|
public hydrationStatus(): UserHydrationStatus {
|
|
139
150
|
return { ...this._hydration };
|
|
140
151
|
}
|
|
141
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The hydration status once it has settled — never `hydrating`.
|
|
155
|
+
*
|
|
156
|
+
* Resolves immediately unless a pull is in flight. Exists for callers that
|
|
157
|
+
* must decide something (a route guard, a first-run branch) and would
|
|
158
|
+
* otherwise sample `hydrating` and guess. Since init() is awaited by both
|
|
159
|
+
* app startup and login(), that only happens when something navigates
|
|
160
|
+
* concurrently with a login still in progress — a window that stretches to
|
|
161
|
+
* HYDRATION_TIMEOUT_MS on a slow connection.
|
|
162
|
+
*/
|
|
163
|
+
public async awaitHydration(): Promise<UserHydrationStatus> {
|
|
164
|
+
// hydrateLocalMirror() records failures rather than throwing, so this
|
|
165
|
+
// cannot reject — but a stray rejection must not become the caller's
|
|
166
|
+
// problem when a status is available either way.
|
|
167
|
+
try {
|
|
168
|
+
await this._hydrationPromise;
|
|
169
|
+
} catch {
|
|
170
|
+
// fall through to the recorded status
|
|
171
|
+
}
|
|
172
|
+
return this.hydrationStatus();
|
|
173
|
+
}
|
|
174
|
+
|
|
142
175
|
/**
|
|
143
176
|
* Whether a missing document may be treated as genuinely absent, allowing
|
|
144
177
|
* callers to create it with defaults.
|
|
@@ -736,9 +769,18 @@ Currently logged-in as ${this._username}.`
|
|
|
736
769
|
|
|
737
770
|
// Populate the local mirror BEFORE anything can read it, and before live
|
|
738
771
|
// sync can push local state upward. See hydrateLocalMirror().
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
772
|
+
//
|
|
773
|
+
// Retained (not just awaited) so awaitHydration() can hand the same
|
|
774
|
+
// promise to anything that asks mid-flight. Assigned synchronously with
|
|
775
|
+
// respect to init()'s entry, so no caller can observe a previous
|
|
776
|
+
// identity's promise here.
|
|
777
|
+
this._hydrationPromise = this.hydrateLocalMirror();
|
|
778
|
+
await this._hydrationPromise;
|
|
779
|
+
|
|
780
|
+
// _hydratedSeq is set only by a hydration that just succeeded above, for
|
|
781
|
+
// this identity — see hydrateLocalMirror(). Anything else leaves it
|
|
782
|
+
// undefined and the live sync resumes from its own checkpoint.
|
|
783
|
+
this.syncStrategy.startSync(this.localDB, this.remoteDB, this._hydratedSeq);
|
|
742
784
|
this.applyDesignDocs().catch((error) => {
|
|
743
785
|
log(`Error in applyDesignDocs background task: ${error}`);
|
|
744
786
|
if (error && typeof error === 'object') {
|
|
@@ -767,6 +809,11 @@ Currently logged-in as ${this._username}.`
|
|
|
767
809
|
* ceiling). Callers decide what a failure means for them.
|
|
768
810
|
*/
|
|
769
811
|
private async hydrateLocalMirror(): Promise<void> {
|
|
812
|
+
// Clear first: init() reruns on every identity change, and a sequence from
|
|
813
|
+
// the departing account would be meaningless against the incoming one's
|
|
814
|
+
// database. Every path below either sets it or leaves it unset.
|
|
815
|
+
this._hydratedSeq = undefined;
|
|
816
|
+
|
|
770
817
|
// Guests and static-mode users have no remote — setupRemoteDB() hands back
|
|
771
818
|
// the local database itself, so there is nothing to pull from.
|
|
772
819
|
if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
|
|
@@ -786,11 +833,12 @@ Currently logged-in as ${this._username}.`
|
|
|
786
833
|
const start = Date.now();
|
|
787
834
|
|
|
788
835
|
try {
|
|
789
|
-
const { docsWritten } = await withTimeout(
|
|
836
|
+
const { docsWritten, lastSeq } = await withTimeout(
|
|
790
837
|
this.syncStrategy.hydrate(this.localDB, this.remoteDB),
|
|
791
838
|
HYDRATION_TIMEOUT_MS,
|
|
792
839
|
`Hydration of local mirror for ${this._username}`
|
|
793
840
|
);
|
|
841
|
+
this._hydratedSeq = lastSeq;
|
|
794
842
|
await this.writeHydrationMarker();
|
|
795
843
|
this._hydration = {
|
|
796
844
|
state: 'hydrated',
|
|
@@ -33,19 +33,28 @@ export interface SyncStrategy {
|
|
|
33
33
|
*
|
|
34
34
|
* @param localDB The local PouchDB instance
|
|
35
35
|
* @param remoteDB The remote PouchDB instance
|
|
36
|
-
* @returns Count of documents written locally
|
|
36
|
+
* @returns Count of documents written locally, and — only when the pull is
|
|
37
|
+
* known to have landed the remote's complete current state — the sequence
|
|
38
|
+
* it corresponds to, for startSync() to resume from. Omitted means "no
|
|
39
|
+
* safe shortcut, walk from the checkpoint".
|
|
37
40
|
*/
|
|
38
41
|
hydrate?(
|
|
39
42
|
localDB: PouchDB.Database,
|
|
40
43
|
remoteDB: PouchDB.Database
|
|
41
|
-
): Promise<{ docsWritten: number }>;
|
|
44
|
+
): Promise<{ docsWritten: number; lastSeq?: string | number }>;
|
|
42
45
|
|
|
43
46
|
/**
|
|
44
47
|
* Start synchronization between local and remote databases
|
|
45
48
|
* @param localDB The local PouchDB instance
|
|
46
49
|
* @param remoteDB The remote PouchDB instance
|
|
50
|
+
* @param since Sequence to start the pull from, as returned by a hydrate()
|
|
51
|
+
* in the same session. Omit to resume from the stored checkpoint.
|
|
47
52
|
*/
|
|
48
|
-
startSync(
|
|
53
|
+
startSync(
|
|
54
|
+
localDB: PouchDB.Database,
|
|
55
|
+
remoteDB: PouchDB.Database,
|
|
56
|
+
since?: string | number
|
|
57
|
+
): void;
|
|
49
58
|
|
|
50
59
|
/**
|
|
51
60
|
* Stop synchronization (optional - for cleanup)
|
|
@@ -93,7 +102,11 @@ export interface SyncStrategy {
|
|
|
93
102
|
*/
|
|
94
103
|
export abstract class BaseSyncStrategy implements SyncStrategy {
|
|
95
104
|
abstract setupRemoteDB(username: string): PouchDB.Database;
|
|
96
|
-
abstract startSync(
|
|
105
|
+
abstract startSync(
|
|
106
|
+
localDB: PouchDB.Database,
|
|
107
|
+
remoteDB: PouchDB.Database,
|
|
108
|
+
since?: string | number
|
|
109
|
+
): void;
|
|
97
110
|
abstract canCreateAccount(): boolean;
|
|
98
111
|
abstract canAuthenticate(): boolean;
|
|
99
112
|
abstract getCurrentUsername(): Promise<string>;
|
|
@@ -51,26 +51,61 @@ export class CouchDBSyncStrategy implements SyncStrategy {
|
|
|
51
51
|
* before we know what the remote already has, which is the conflict-leaf
|
|
52
52
|
* problem hydration exists to avoid. Local changes go up when startSync()
|
|
53
53
|
* takes over.
|
|
54
|
+
*
|
|
55
|
+
* Into an EMPTY local DB this skips deleted documents, because a mirror that
|
|
56
|
+
* never held a document does not need to be told it was removed. That
|
|
57
|
+
* matters more than it sounds: scheduled reviews (`card_review_*`) are
|
|
58
|
+
* created and deleted once per review completed, so the changes feed is
|
|
59
|
+
* dominated by tombstones and grows without bound, while the durable record
|
|
60
|
+
* lives in card history. One real account measured 385 live documents behind
|
|
61
|
+
* 8,370 deletions — an unfiltered pull moved 9,520 documents and took ~20s,
|
|
62
|
+
* blowing the hydration timeout on a phone; filtered, the same pull moves
|
|
63
|
+
* 404 and takes ~2s, reaching a byte-identical local state.
|
|
64
|
+
*
|
|
65
|
+
* The filter runs server-side, so `last_seq` still reports the source's true
|
|
66
|
+
* sequence. Returning it lets startSync() begin the live pull from there
|
|
67
|
+
* instead of re-walking the same history in the background — without that,
|
|
68
|
+
* the cost is merely deferred, not removed.
|
|
54
69
|
*/
|
|
55
70
|
async hydrate(
|
|
56
71
|
localDB: PouchDB.Database,
|
|
57
72
|
remoteDB: PouchDB.Database
|
|
58
|
-
): Promise<{ docsWritten: number }> {
|
|
73
|
+
): Promise<{ docsWritten: number; lastSeq?: string | number }> {
|
|
74
|
+
// Skipping tombstones is only sound when there is nothing local for a
|
|
75
|
+
// missed deletion to strand. A non-empty local DB here means a previous
|
|
76
|
+
// hydration failed partway (no marker was written, so we are retrying) and
|
|
77
|
+
// may hold documents the remote has since deleted — take the slow, honest
|
|
78
|
+
// path and let the full changes feed reconcile them.
|
|
79
|
+
const pristine = (await localDB.info()).doc_count === 0;
|
|
80
|
+
|
|
59
81
|
// A one-shot Replication is itself a promise of the completed result, so
|
|
60
82
|
// it can be awaited directly — the handle is retained only so a pull that
|
|
61
83
|
// outlives its timeout can be cancelled.
|
|
62
|
-
const replication = pouch.replicate(
|
|
84
|
+
const replication = pouch.replicate(
|
|
85
|
+
remoteDB,
|
|
86
|
+
localDB,
|
|
87
|
+
pristine ? { selector: { _deleted: { $exists: false } } } : {}
|
|
88
|
+
);
|
|
63
89
|
this.hydrationHandle = replication;
|
|
64
90
|
|
|
65
91
|
try {
|
|
66
92
|
const info = await replication;
|
|
67
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
docsWritten: info.docs_written,
|
|
95
|
+
// Only a pristine pull is a trustworthy starting point for the live
|
|
96
|
+
// sync; otherwise leave startSync() to its own checkpoint.
|
|
97
|
+
lastSeq: pristine ? info.last_seq : undefined,
|
|
98
|
+
};
|
|
68
99
|
} finally {
|
|
69
100
|
this.hydrationHandle = undefined;
|
|
70
101
|
}
|
|
71
102
|
}
|
|
72
103
|
|
|
73
|
-
startSync(
|
|
104
|
+
startSync(
|
|
105
|
+
localDB: PouchDB.Database,
|
|
106
|
+
remoteDB: PouchDB.Database,
|
|
107
|
+
since?: string | number
|
|
108
|
+
): void {
|
|
74
109
|
// Compare by NAME, not object identity. In guest mode setupRemoteDB()
|
|
75
110
|
// returns getLocalUserDB(username) — the same database as localDB — but
|
|
76
111
|
// getLocalUserDB() constructs a fresh PouchDB handle on every call rather
|
|
@@ -82,6 +117,19 @@ export class CouchDBSyncStrategy implements SyncStrategy {
|
|
|
82
117
|
this.syncHandle = pouch.sync(localDB, remoteDB, {
|
|
83
118
|
live: true,
|
|
84
119
|
retry: true,
|
|
120
|
+
// `since` applies to the pull only, and only when hydrate() just
|
|
121
|
+
// established that local matches remote at that sequence. Without it,
|
|
122
|
+
// a filtered hydration leaves the pull with no usable checkpoint (the
|
|
123
|
+
// filter changes the replication id), so it restarts from zero and
|
|
124
|
+
// re-walks in the background exactly the tombstone history hydration
|
|
125
|
+
// just skipped — same work, now competing with the study session.
|
|
126
|
+
//
|
|
127
|
+
// Deliberately NOT persisted across launches: on later boots hydration
|
|
128
|
+
// is skipped and the pull's own checkpoint is the correct resume
|
|
129
|
+
// point, so a stored sequence could only be stale. If the app dies
|
|
130
|
+
// before that first checkpoint is written, the next launch simply
|
|
131
|
+
// walks the full feed once.
|
|
132
|
+
...(since !== undefined ? { pull: { since } } : {}),
|
|
85
133
|
});
|
|
86
134
|
}
|
|
87
135
|
// If they're the same DB (guest mode), no sync needed
|
|
@@ -246,6 +246,55 @@ describe('user DB hydration', () => {
|
|
|
246
246
|
expect(order).toEqual(['hydrate', 'startSync']);
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
+
it('awaitHydration resolves to the settled status, never "hydrating"', async () => {
|
|
250
|
+
// The race this closes: a route guard sampling status while a login is
|
|
251
|
+
// still pulling. It would see 'hydrating', fall through, and render an
|
|
252
|
+
// established learner as brand new — the original bug, via a side door.
|
|
253
|
+
const realName = freshUsername();
|
|
254
|
+
await seedRemote(realName);
|
|
255
|
+
|
|
256
|
+
let releaseHydrate: (v: { docsWritten: number }) => void = () => {};
|
|
257
|
+
const hydrate = vi.fn(
|
|
258
|
+
() =>
|
|
259
|
+
new Promise<{ docsWritten: number }>((resolve) => {
|
|
260
|
+
releaseHydrate = resolve;
|
|
261
|
+
})
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const guestName = 'sk-guest-mid-login';
|
|
265
|
+
const strategy = {
|
|
266
|
+
...authedStrategy(realName, { hydrate }),
|
|
267
|
+
// Guest before login, real remote after — mirroring setupRemoteDB.
|
|
268
|
+
setupRemoteDB: (u: string) => (u.startsWith('sk-guest-') ? localDB(u) : remoteDB(realName)),
|
|
269
|
+
getWriteDB: (u: string) => (u.startsWith('sk-guest-') ? localDB(u) : remoteDB(realName)),
|
|
270
|
+
authenticate: async () => ({ ok: true }),
|
|
271
|
+
} as SyncStrategy;
|
|
272
|
+
|
|
273
|
+
// Starts as a guest: nothing to hydrate.
|
|
274
|
+
const user = await BaseUser.instance(strategy, guestName);
|
|
275
|
+
expect(user.hydrationStatus().state).toBe('not-required');
|
|
276
|
+
|
|
277
|
+
// Log in, but do NOT await it — this is the window a stray navigation
|
|
278
|
+
// lands in.
|
|
279
|
+
const loginPromise = user.login(realName, 'password');
|
|
280
|
+
|
|
281
|
+
while (hydrate.mock.calls.length === 0) {
|
|
282
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Mid-flight: the snapshot is honest about being unsettled...
|
|
286
|
+
expect(user.hydrationStatus().state).toBe('hydrating');
|
|
287
|
+
|
|
288
|
+
// ...and a caller that needs an answer waits for the real one.
|
|
289
|
+
const settled = user.awaitHydration();
|
|
290
|
+
releaseHydrate({ docsWritten: 3 });
|
|
291
|
+
|
|
292
|
+
expect((await settled).state).toBe('hydrated');
|
|
293
|
+
expect(user.hydrationStatus().state).toBe('hydrated');
|
|
294
|
+
|
|
295
|
+
await loginPromise;
|
|
296
|
+
});
|
|
297
|
+
|
|
249
298
|
it('gives up on a pull that hangs', async () => {
|
|
250
299
|
await seedRemote(username);
|
|
251
300
|
// Fake setTimeout only — PouchDB's internals rely on the other async
|