@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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.18",
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.18",
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.18"
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 {@link UserHydrationStatus}.
134
+ * How far the local mirror can be trusted, RIGHT NOW. See
135
+ * {@link UserHydrationStatus}.
133
136
  *
134
- * Consumers that would otherwise read a 404 as "new user" — onboarding
135
- * gates, first-run experiences, progress dashboards should check for
136
- * `failed` and present a retry affordance rather than an empty state.
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
- await this.hydrateLocalMirror();
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