@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
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import PouchDB from 'pouchdb';
|
|
3
|
+
import memoryAdapter from 'pouchdb-adapter-memory';
|
|
4
|
+
import { buildStrategyStateId, DocType } from '@db/core';
|
|
5
|
+
import type { SyncStrategy } from '@db/impl/common/SyncStrategy';
|
|
6
|
+
|
|
7
|
+
PouchDB.plugin(memoryAdapter);
|
|
8
|
+
|
|
9
|
+
// Swap the local-DB factory for in-memory databases. Everything else — the
|
|
10
|
+
// replication, the marker read/write, BaseUser itself — stays real.
|
|
11
|
+
//
|
|
12
|
+
// The memory adapter keys stores by name, so repeated calls hand back distinct
|
|
13
|
+
// handles onto the same store. That matches production (getLocalUserDB does not
|
|
14
|
+
// cache) and keeps the localDB/remoteDB name comparison meaningful.
|
|
15
|
+
vi.mock('@db/impl/common/userDBHelpers', async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<typeof import('@db/impl/common/userDBHelpers')>();
|
|
17
|
+
const Pouch = (await import('pouchdb')).default;
|
|
18
|
+
const memory = (await import('pouchdb-adapter-memory')).default;
|
|
19
|
+
Pouch.plugin(memory);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
getLocalUserDB: (username: string) => new Pouch(`local-${username}`, { adapter: 'memory' }),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const { BaseUser } = await import('@db/impl/common/BaseUserDB');
|
|
28
|
+
|
|
29
|
+
const COURSE_ID = 'course-abc';
|
|
30
|
+
const STATE_KEY = 'letterProgression';
|
|
31
|
+
|
|
32
|
+
function localDB(username: string) {
|
|
33
|
+
return new PouchDB(`local-${username}`, { adapter: 'memory' });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function remoteDB(username: string) {
|
|
37
|
+
return new PouchDB(`remote-${username}`, { adapter: 'memory' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A strategy whose remote is a separate in-memory DB, i.e. a logged-in user.
|
|
42
|
+
* hydrate() performs a genuine one-shot replication.
|
|
43
|
+
*/
|
|
44
|
+
function authedStrategy(username: string, overrides: Partial<SyncStrategy> = {}): SyncStrategy {
|
|
45
|
+
return {
|
|
46
|
+
setupRemoteDB: () => remoteDB(username),
|
|
47
|
+
getWriteDB: () => remoteDB(username),
|
|
48
|
+
startSync: vi.fn(),
|
|
49
|
+
stopSync: vi.fn(),
|
|
50
|
+
canCreateAccount: () => true,
|
|
51
|
+
canAuthenticate: () => true,
|
|
52
|
+
getCurrentUsername: async () => username,
|
|
53
|
+
hydrate: vi.fn(async (local: PouchDB.Database, remote: PouchDB.Database) => {
|
|
54
|
+
const info = await new Promise<any>((resolve, reject) => {
|
|
55
|
+
void PouchDB.replicate(remote, local, {}).on('complete', resolve).on('error', reject);
|
|
56
|
+
});
|
|
57
|
+
return { docsWritten: info.docs_written };
|
|
58
|
+
}),
|
|
59
|
+
...overrides,
|
|
60
|
+
} as SyncStrategy;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Guest: setupRemoteDB hands back the local database itself. */
|
|
64
|
+
function guestStrategy(username: string): SyncStrategy {
|
|
65
|
+
return {
|
|
66
|
+
setupRemoteDB: (u: string) => localDB(u),
|
|
67
|
+
getWriteDB: (u: string) => localDB(u),
|
|
68
|
+
startSync: vi.fn(),
|
|
69
|
+
stopSync: vi.fn(),
|
|
70
|
+
canCreateAccount: () => false,
|
|
71
|
+
canAuthenticate: () => false,
|
|
72
|
+
getCurrentUsername: async () => username,
|
|
73
|
+
hydrate: vi.fn(),
|
|
74
|
+
} as unknown as SyncStrategy;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Seed a remote DB with the state an established account would have. */
|
|
78
|
+
async function seedRemote(username: string) {
|
|
79
|
+
const remote = remoteDB(username);
|
|
80
|
+
await remote.put({
|
|
81
|
+
_id: 'CONFIG',
|
|
82
|
+
darkMode: true,
|
|
83
|
+
likesConfetti: true,
|
|
84
|
+
sessionTimeLimit: 42,
|
|
85
|
+
});
|
|
86
|
+
await remote.put({
|
|
87
|
+
_id: 'CourseRegistrations',
|
|
88
|
+
courses: [
|
|
89
|
+
{
|
|
90
|
+
courseID: COURSE_ID,
|
|
91
|
+
status: 'active',
|
|
92
|
+
user: true,
|
|
93
|
+
admin: false,
|
|
94
|
+
moderator: false,
|
|
95
|
+
elo: { global: { score: 1600, count: 90 }, tags: {}, misc: {} },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
studyWeight: { [COURSE_ID]: 1 },
|
|
99
|
+
});
|
|
100
|
+
await remote.put({
|
|
101
|
+
_id: buildStrategyStateId(COURSE_ID, STATE_KEY),
|
|
102
|
+
docType: DocType.STRATEGY_STATE,
|
|
103
|
+
courseId: COURSE_ID,
|
|
104
|
+
strategyKey: STATE_KEY,
|
|
105
|
+
data: { unlockedLetters: ['a', 't', 's', 'm'] },
|
|
106
|
+
updatedAt: new Date().toISOString(),
|
|
107
|
+
});
|
|
108
|
+
return remote;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let counter = 0;
|
|
112
|
+
/** Unique username per test so each gets a pristine pair of stores. */
|
|
113
|
+
function freshUsername() {
|
|
114
|
+
return `hydration-user-${counter++}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('user DB hydration', () => {
|
|
118
|
+
let username: string;
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
username = freshUsername();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
vi.useRealTimers();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('pulls an existing account onto a fresh device before handing out the user', async () => {
|
|
129
|
+
// The reported bug: logging in on a new device showed the canned new-user
|
|
130
|
+
// state, because reads hit an empty local mirror.
|
|
131
|
+
await seedRemote(username);
|
|
132
|
+
const strategy = authedStrategy(username);
|
|
133
|
+
|
|
134
|
+
const user = await BaseUser.instance(strategy, username);
|
|
135
|
+
|
|
136
|
+
expect(user.hydrationStatus().state).toBe('hydrated');
|
|
137
|
+
expect(user.hydrationStatus().docsWritten).toBeGreaterThan(0);
|
|
138
|
+
|
|
139
|
+
// The state that drives the study hub must be the real one, not null.
|
|
140
|
+
const progression = await user.getStrategyState<{ unlockedLetters: string[] }>(
|
|
141
|
+
COURSE_ID,
|
|
142
|
+
STATE_KEY
|
|
143
|
+
);
|
|
144
|
+
expect(progression).toEqual({ unlockedLetters: ['a', 't', 's', 'm'] });
|
|
145
|
+
|
|
146
|
+
// And the registration doc must carry real ELO rather than a fresh default.
|
|
147
|
+
const reg = await user.getCourseRegDoc(COURSE_ID);
|
|
148
|
+
expect(reg.elo).toMatchObject({ global: { score: 1600, count: 90 } });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('does not re-pull on a device that has hydrated before', async () => {
|
|
152
|
+
await seedRemote(username);
|
|
153
|
+
|
|
154
|
+
const first = authedStrategy(username);
|
|
155
|
+
const firstUser = await BaseUser.instance(first, username);
|
|
156
|
+
expect(firstUser.hydrationStatus().state).toBe('hydrated');
|
|
157
|
+
expect(first.hydrate).toHaveBeenCalledTimes(1);
|
|
158
|
+
|
|
159
|
+
// Second startup on the same device: the _local marker survives, so this
|
|
160
|
+
// must proceed on local data rather than blocking on the network.
|
|
161
|
+
const second = authedStrategy(username);
|
|
162
|
+
const secondUser = await BaseUser.instance(second, username);
|
|
163
|
+
|
|
164
|
+
expect(secondUser.hydrationStatus().state).toBe('stale');
|
|
165
|
+
expect(second.hydrate).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('reports failure rather than presenting an empty account', async () => {
|
|
169
|
+
await seedRemote(username);
|
|
170
|
+
const strategy = authedStrategy(username, {
|
|
171
|
+
hydrate: vi.fn(async () => {
|
|
172
|
+
throw new Error('network down');
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const user = await BaseUser.instance(strategy, username);
|
|
177
|
+
|
|
178
|
+
expect(user.hydrationStatus().state).toBe('failed');
|
|
179
|
+
expect(user.hydrationStatus().error).toContain('network down');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('refuses to materialize defaults over data it could not read', async () => {
|
|
183
|
+
await seedRemote(username);
|
|
184
|
+
const strategy = authedStrategy(username, {
|
|
185
|
+
hydrate: vi.fn(async () => {
|
|
186
|
+
throw new Error('network down');
|
|
187
|
+
}),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const user = await BaseUser.instance(strategy, username);
|
|
191
|
+
|
|
192
|
+
// Both of these would otherwise 404 against the empty mirror and write a
|
|
193
|
+
// default doc — which then loses to the real remote doc at replication,
|
|
194
|
+
// silently discarding whatever the user did in the meantime.
|
|
195
|
+
await expect(user.getConfig()).rejects.toThrow(/hydration state 'failed'/);
|
|
196
|
+
await expect(user.getCourseRegistrationsDoc()).rejects.toThrow(/hydration state 'failed'/);
|
|
197
|
+
|
|
198
|
+
// And the read that caused the reported bug must not answer "no state",
|
|
199
|
+
// which consumers act on as "first run".
|
|
200
|
+
await expect(user.getStrategyState(COURSE_ID, STATE_KEY)).rejects.toThrow(
|
|
201
|
+
/hydration state 'failed'/
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('still materializes defaults for a genuinely new account', async () => {
|
|
206
|
+
// Nothing seeded: hydration succeeds with zero documents, so a 404 really
|
|
207
|
+
// does mean "no such data" and the defaults are correct.
|
|
208
|
+
const strategy = authedStrategy(username);
|
|
209
|
+
|
|
210
|
+
const user = await BaseUser.instance(strategy, username);
|
|
211
|
+
|
|
212
|
+
expect(user.hydrationStatus().state).toBe('hydrated');
|
|
213
|
+
const config = await user.getConfig();
|
|
214
|
+
expect(config.sessionTimeLimit).toBe(5);
|
|
215
|
+
const reg = await user.getCourseRegistrationsDoc();
|
|
216
|
+
expect(reg.courses).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('skips hydration for guests, who have no remote', async () => {
|
|
220
|
+
const strategy = guestStrategy(username);
|
|
221
|
+
|
|
222
|
+
const user = await BaseUser.instance(strategy, username);
|
|
223
|
+
|
|
224
|
+
expect(user.hydrationStatus().state).toBe('not-required');
|
|
225
|
+
expect(strategy.hydrate).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('hydrates before starting live sync', async () => {
|
|
229
|
+
await seedRemote(username);
|
|
230
|
+
const order: string[] = [];
|
|
231
|
+
const strategy = authedStrategy(username, {
|
|
232
|
+
startSync: vi.fn(() => {
|
|
233
|
+
order.push('startSync');
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
const originalHydrate = strategy.hydrate!;
|
|
237
|
+
strategy.hydrate = vi.fn(async (l, r) => {
|
|
238
|
+
order.push('hydrate');
|
|
239
|
+
return originalHydrate(l, r);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await BaseUser.instance(strategy, username);
|
|
243
|
+
|
|
244
|
+
// Live sync pushes local state upward; it must not run while the mirror is
|
|
245
|
+
// still empty.
|
|
246
|
+
expect(order).toEqual(['hydrate', 'startSync']);
|
|
247
|
+
});
|
|
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
|
+
|
|
298
|
+
it('gives up on a pull that hangs', async () => {
|
|
299
|
+
await seedRemote(username);
|
|
300
|
+
// Fake setTimeout only — PouchDB's internals rely on the other async
|
|
301
|
+
// primitives, and faking those wedges the marker read before hydration is
|
|
302
|
+
// even reached. runAllTimersAsync (rather than advancing a fixed window)
|
|
303
|
+
// because the timeout timer is scheduled partway through init, after the
|
|
304
|
+
// clock would already have moved past a fixed advance.
|
|
305
|
+
vi.useFakeTimers({ toFake: ['setTimeout'] });
|
|
306
|
+
|
|
307
|
+
const strategy = authedStrategy(username, {
|
|
308
|
+
hydrate: vi.fn(() => new Promise<{ docsWritten: number }>(() => {})), // never settles
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const pending = BaseUser.instance(strategy, username);
|
|
312
|
+
|
|
313
|
+
// init() first awaits the marker read (a real PouchDB op, not timer-driven),
|
|
314
|
+
// so the timeout is not armed yet — running timers now would find none
|
|
315
|
+
// pending and return immediately, leaving the test hanging. Yield real
|
|
316
|
+
// macrotasks until hydrate() has been entered; withTimeout arms the timer
|
|
317
|
+
// in that same synchronous step.
|
|
318
|
+
while ((strategy.hydrate as ReturnType<typeof vi.fn>).mock.calls.length === 0) {
|
|
319
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
await vi.runAllTimersAsync();
|
|
323
|
+
const user = await pending;
|
|
324
|
+
|
|
325
|
+
expect(user.hydrationStatus().state).toBe('failed');
|
|
326
|
+
expect(user.hydrationStatus().error).toMatch(/timed out/);
|
|
327
|
+
// The abandoned pull is cancelled so it cannot race the live sync.
|
|
328
|
+
expect(strategy.stopSync).toHaveBeenCalled();
|
|
329
|
+
});
|
|
330
|
+
});
|