@vue-skuilder/db 0.2.17 → 0.2.18

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.
Files changed (38) hide show
  1. package/dist/{SyncStrategy-CyATpyLQ.d.cts → SyncStrategy-BJMZq3WO.d.cts} +17 -0
  2. package/dist/{SyncStrategy-CyATpyLQ.d.ts → SyncStrategy-BJMZq3WO.d.ts} +17 -0
  3. package/dist/{contentSource-Brz42x7n.d.cts → contentSource-CBqZCoGU.d.cts} +85 -1
  4. package/dist/{contentSource-B1p-vdz7.d.ts → contentSource-CudEz5Tm.d.ts} +85 -1
  5. package/dist/core/index.d.cts +3 -3
  6. package/dist/core/index.d.ts +3 -3
  7. package/dist/core/index.js +146 -1
  8. package/dist/core/index.js.map +1 -1
  9. package/dist/core/index.mjs +145 -1
  10. package/dist/core/index.mjs.map +1 -1
  11. package/dist/{dataLayerProvider-CpwpT1rM.d.cts → dataLayerProvider-BBA8tJNx.d.cts} +1 -1
  12. package/dist/{dataLayerProvider-BWayUIoK.d.ts → dataLayerProvider-C9WgkBzR.d.ts} +1 -1
  13. package/dist/impl/couch/index.d.cts +16 -3
  14. package/dist/impl/couch/index.d.ts +16 -3
  15. package/dist/impl/couch/index.js +178 -3
  16. package/dist/impl/couch/index.js.map +1 -1
  17. package/dist/impl/couch/index.mjs +178 -3
  18. package/dist/impl/couch/index.mjs.map +1 -1
  19. package/dist/impl/static/index.d.cts +3 -3
  20. package/dist/impl/static/index.d.ts +3 -3
  21. package/dist/impl/static/index.js +144 -1
  22. package/dist/impl/static/index.js.map +1 -1
  23. package/dist/impl/static/index.mjs +144 -1
  24. package/dist/impl/static/index.mjs.map +1 -1
  25. package/dist/index.d.cts +3 -3
  26. package/dist/index.d.ts +3 -3
  27. package/dist/index.js +180 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +179 -3
  30. package/dist/index.mjs.map +1 -1
  31. package/package.json +4 -3
  32. package/src/core/index.ts +1 -0
  33. package/src/core/interfaces/userDB.ts +11 -0
  34. package/src/core/types/hydration.ts +78 -0
  35. package/src/impl/common/BaseUserDB.ts +202 -2
  36. package/src/impl/common/SyncStrategy.ts +19 -0
  37. package/src/impl/couch/CouchDBSyncStrategy.ts +54 -4
  38. package/tests/impl/hydration.test.ts +281 -0
@@ -0,0 +1,281 @@
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('gives up on a pull that hangs', async () => {
250
+ await seedRemote(username);
251
+ // Fake setTimeout only — PouchDB's internals rely on the other async
252
+ // primitives, and faking those wedges the marker read before hydration is
253
+ // even reached. runAllTimersAsync (rather than advancing a fixed window)
254
+ // because the timeout timer is scheduled partway through init, after the
255
+ // clock would already have moved past a fixed advance.
256
+ vi.useFakeTimers({ toFake: ['setTimeout'] });
257
+
258
+ const strategy = authedStrategy(username, {
259
+ hydrate: vi.fn(() => new Promise<{ docsWritten: number }>(() => {})), // never settles
260
+ });
261
+
262
+ const pending = BaseUser.instance(strategy, username);
263
+
264
+ // init() first awaits the marker read (a real PouchDB op, not timer-driven),
265
+ // so the timeout is not armed yet — running timers now would find none
266
+ // pending and return immediately, leaving the test hanging. Yield real
267
+ // macrotasks until hydrate() has been entered; withTimeout arms the timer
268
+ // in that same synchronous step.
269
+ while ((strategy.hydrate as ReturnType<typeof vi.fn>).mock.calls.length === 0) {
270
+ await new Promise((resolve) => setImmediate(resolve));
271
+ }
272
+
273
+ await vi.runAllTimersAsync();
274
+ const user = await pending;
275
+
276
+ expect(user.hydrationStatus().state).toBe('failed');
277
+ expect(user.hydrationStatus().error).toMatch(/timed out/);
278
+ // The abandoned pull is cancelled so it cannot race the live sync.
279
+ expect(strategy.stopSync).toHaveBeenCalled();
280
+ });
281
+ });