@vue-skuilder/db 0.2.18 → 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/{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 +26 -5
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +26 -5
- 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 +2 -2
- package/dist/impl/couch/index.d.ts +2 -2
- package/dist/impl/couch/index.js +26 -5
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +26 -5
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +2 -2
- package/dist/impl/static/index.d.ts +2 -2
- package/dist/impl/static/index.js +26 -5
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +26 -5
- 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 +26 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +26 -5
- 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 +36 -5
- 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.19",
|
|
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.19",
|
|
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.19"
|
|
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,43 @@ 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;
|
|
130
132
|
|
|
131
133
|
/**
|
|
132
|
-
* How far the local mirror can be trusted. See
|
|
134
|
+
* How far the local mirror can be trusted, RIGHT NOW. See
|
|
135
|
+
* {@link UserHydrationStatus}.
|
|
133
136
|
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
+
* This is a snapshot, and during login it can legitimately read
|
|
138
|
+
* `hydrating` — prefer awaitHydration() anywhere a decision hangs on the
|
|
139
|
+
* answer.
|
|
137
140
|
*/
|
|
138
141
|
public hydrationStatus(): UserHydrationStatus {
|
|
139
142
|
return { ...this._hydration };
|
|
140
143
|
}
|
|
141
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The hydration status once it has settled — never `hydrating`.
|
|
147
|
+
*
|
|
148
|
+
* Resolves immediately unless a pull is in flight. Exists for callers that
|
|
149
|
+
* must decide something (a route guard, a first-run branch) and would
|
|
150
|
+
* otherwise sample `hydrating` and guess. Since init() is awaited by both
|
|
151
|
+
* app startup and login(), that only happens when something navigates
|
|
152
|
+
* concurrently with a login still in progress — a window that stretches to
|
|
153
|
+
* HYDRATION_TIMEOUT_MS on a slow connection.
|
|
154
|
+
*/
|
|
155
|
+
public async awaitHydration(): Promise<UserHydrationStatus> {
|
|
156
|
+
// hydrateLocalMirror() records failures rather than throwing, so this
|
|
157
|
+
// cannot reject — but a stray rejection must not become the caller's
|
|
158
|
+
// problem when a status is available either way.
|
|
159
|
+
try {
|
|
160
|
+
await this._hydrationPromise;
|
|
161
|
+
} catch {
|
|
162
|
+
// fall through to the recorded status
|
|
163
|
+
}
|
|
164
|
+
return this.hydrationStatus();
|
|
165
|
+
}
|
|
166
|
+
|
|
142
167
|
/**
|
|
143
168
|
* Whether a missing document may be treated as genuinely absent, allowing
|
|
144
169
|
* callers to create it with defaults.
|
|
@@ -736,7 +761,13 @@ Currently logged-in as ${this._username}.`
|
|
|
736
761
|
|
|
737
762
|
// Populate the local mirror BEFORE anything can read it, and before live
|
|
738
763
|
// sync can push local state upward. See hydrateLocalMirror().
|
|
739
|
-
|
|
764
|
+
//
|
|
765
|
+
// Retained (not just awaited) so awaitHydration() can hand the same
|
|
766
|
+
// promise to anything that asks mid-flight. Assigned synchronously with
|
|
767
|
+
// respect to init()'s entry, so no caller can observe a previous
|
|
768
|
+
// identity's promise here.
|
|
769
|
+
this._hydrationPromise = this.hydrateLocalMirror();
|
|
770
|
+
await this._hydrationPromise;
|
|
740
771
|
|
|
741
772
|
this.syncStrategy.startSync(this.localDB, this.remoteDB);
|
|
742
773
|
this.applyDesignDocs().catch((error) => {
|
|
@@ -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
|