@multiplatform.one/frappe 7.8.0 → 7.11.0
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/README.md +89 -1
- package/dist/cjs/frappeReact.cjs +54 -30
- package/dist/cjs/frappeReact.native.js +67 -32
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/sync/index.cjs +97 -13
- package/dist/cjs/sync/index.native.js +127 -13
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/subscriptionManager.cjs +94 -50
- package/dist/cjs/sync/subscriptionManager.native.js +133 -73
- package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
- package/dist/cjs/sync/types.native.js.map +1 -1
- package/dist/cjs/syncOptionsKey.cjs +59 -0
- package/dist/cjs/syncOptionsKey.native.js +82 -0
- package/dist/cjs/syncOptionsKey.native.js.map +1 -0
- package/dist/esm/frappeReact.mjs +55 -32
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +68 -34
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.mjs +2 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/sync/index.mjs +97 -13
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +127 -13
- package/dist/esm/sync/index.native.js.map +1 -1
- package/dist/esm/sync/subscriptionManager.mjs +94 -50
- package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
- package/dist/esm/sync/subscriptionManager.native.js +133 -73
- package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
- package/dist/esm/sync/types.mjs.map +1 -1
- package/dist/esm/sync/types.native.js.map +1 -1
- package/dist/esm/syncOptionsKey.mjs +34 -0
- package/dist/esm/syncOptionsKey.mjs.map +1 -0
- package/dist/esm/syncOptionsKey.native.js +54 -0
- package/dist/esm/syncOptionsKey.native.js.map +1 -0
- package/dist/jsx/frappeReact.mjs +55 -32
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +67 -32
- package/dist/jsx/frappeReact.native.js.map +1 -1
- package/dist/jsx/index.js +2 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +2 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +1 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/sync/index.mjs +97 -13
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +127 -13
- package/dist/jsx/sync/index.native.js.map +1 -1
- package/dist/jsx/sync/subscriptionManager.mjs +94 -50
- package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
- package/dist/jsx/sync/subscriptionManager.native.js +133 -73
- package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
- package/dist/jsx/sync/types.mjs.map +1 -1
- package/dist/jsx/sync/types.native.js.map +1 -1
- package/dist/jsx/syncOptionsKey.mjs +34 -0
- package/dist/jsx/syncOptionsKey.mjs.map +1 -0
- package/dist/jsx/syncOptionsKey.native.js +82 -0
- package/dist/jsx/syncOptionsKey.native.js.map +1 -0
- package/package.json +9 -9
- package/src/frappeReact.catchUp.spec.tsx +105 -0
- package/src/frappeReact.ts +62 -38
- package/src/index.ts +8 -1
- package/src/sync/catchUp.spec.ts +142 -0
- package/src/sync/index.ts +108 -8
- package/src/sync/refreshSubscriptions.lifecycle.spec.ts +268 -0
- package/src/sync/refreshSubscriptions.spec.ts +295 -0
- package/src/sync/subscriptionManager.ts +107 -44
- package/src/sync/types.ts +9 -1
- package/src/syncOptionsKey.spec.ts +46 -0
- package/src/syncOptionsKey.ts +45 -0
- package/types/frappeReact.d.ts +6 -1
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +4 -4
- package/types/index.d.ts.map +1 -1
- package/types/sync/index.d.ts +13 -1
- package/types/sync/index.d.ts.map +1 -1
- package/types/sync/subscriptionManager.d.ts +13 -10
- package/types/sync/subscriptionManager.d.ts.map +1 -1
- package/types/sync/types.d.ts +8 -1
- package/types/sync/types.d.ts.map +1 -1
- package/types/syncOptionsKey.d.ts +3 -0
- package/types/syncOptionsKey.d.ts.map +1 -0
package/src/sync/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { NormalizedCache } from "./normalizedCache";
|
|
|
8
8
|
import { PersistenceManager } from "./persistence";
|
|
9
9
|
import { RealtimeHandler } from "./realtime";
|
|
10
10
|
import { RehydrationManager } from "./rehydration";
|
|
11
|
+
import type { SubscriptionRefreshOptions, SubscriptionRefreshResult } from "./subscriptionManager";
|
|
12
|
+
export type { SubscriptionRefreshOptions, SubscriptionRefreshResult } from "./subscriptionManager";
|
|
11
13
|
import { SubscriptionManager } from "./subscriptionManager";
|
|
12
14
|
import { BackfillScope, Status } from "./types";
|
|
13
15
|
import type {
|
|
@@ -15,6 +17,7 @@ import type {
|
|
|
15
17
|
DebugMutationEntry,
|
|
16
18
|
DebugSubscription,
|
|
17
19
|
SyncOptions,
|
|
20
|
+
SyncCatchUpState,
|
|
18
21
|
FrappeDebugState,
|
|
19
22
|
Mutation,
|
|
20
23
|
RealtimeEventType,
|
|
@@ -40,6 +43,12 @@ function getBrowserJournalStorage(): Storage | undefined {
|
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
const idleCatchUpState: SyncCatchUpState = Object.freeze({
|
|
47
|
+
refreshing: false,
|
|
48
|
+
stale: false,
|
|
49
|
+
error: null,
|
|
50
|
+
});
|
|
51
|
+
|
|
43
52
|
/**
|
|
44
53
|
* Synchronization engine that provides Zero-style UX on top of Frappe's REST API.
|
|
45
54
|
*
|
|
@@ -111,6 +120,14 @@ export class SyncModule {
|
|
|
111
120
|
private readonly db: DbModule;
|
|
112
121
|
private readonly realtime?: RealtimeModule;
|
|
113
122
|
private readonly options: SyncOptions;
|
|
123
|
+
private catchUpState = idleCatchUpState;
|
|
124
|
+
private catchUpEpoch = 0;
|
|
125
|
+
private disposed = false;
|
|
126
|
+
private catchUpRun?: {
|
|
127
|
+
trailing: boolean;
|
|
128
|
+
attempt: number;
|
|
129
|
+
promise: Promise<SyncCatchUpState>;
|
|
130
|
+
};
|
|
114
131
|
|
|
115
132
|
constructor(db: DbModule, realtime?: RealtimeModule, options: SyncOptions = {}) {
|
|
116
133
|
this.db = db;
|
|
@@ -157,7 +174,9 @@ export class SyncModule {
|
|
|
157
174
|
const realtimeOptions: SyncOptions = {
|
|
158
175
|
...this.options,
|
|
159
176
|
onResetCache: () => this.resetCache(),
|
|
160
|
-
onBackfillAll: () =>
|
|
177
|
+
onBackfillAll: () => {
|
|
178
|
+
void this.catchUp();
|
|
179
|
+
},
|
|
161
180
|
// Realtime reconciliation mutates cache/membership outside the public
|
|
162
181
|
// mutate/backfill entry points — bridge those changes to store listeners.
|
|
163
182
|
onStoreChange: () => this.emit(),
|
|
@@ -192,7 +211,9 @@ export class SyncModule {
|
|
|
192
211
|
* Stores cleanup functions returned by each subscription.
|
|
193
212
|
*/
|
|
194
213
|
private setupWindowEvents() {
|
|
195
|
-
this.cleanupFocus = onAppFocus(() =>
|
|
214
|
+
this.cleanupFocus = onAppFocus(() => {
|
|
215
|
+
void this.catchUp();
|
|
216
|
+
});
|
|
196
217
|
// Journal replay on foreground: React Native never fires `online`
|
|
197
218
|
// (AppState is its only lifecycle signal), and on web this recovers
|
|
198
219
|
// server-restart cases where connectivity never flipped. Guarded so it
|
|
@@ -200,14 +221,15 @@ export class SyncModule {
|
|
|
200
221
|
this.cleanupFocusReplay = onAppFocus(() => {
|
|
201
222
|
if (this.mutationJournal.listPending().length > 0) void this.replayPending();
|
|
202
223
|
});
|
|
203
|
-
this.cleanupOnlineBackfill = onOnline(() =>
|
|
224
|
+
this.cleanupOnlineBackfill = onOnline(() => {
|
|
225
|
+
void this.catchUp();
|
|
226
|
+
});
|
|
204
227
|
this.cleanupOnlineReplay = onOnline(() => this.replayPending());
|
|
205
228
|
|
|
206
229
|
if (this.options.cdcInterval && this.options.cdcInterval > 0) {
|
|
207
|
-
this.cdcIntervalHandle = setInterval(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
);
|
|
230
|
+
this.cdcIntervalHandle = setInterval(() => {
|
|
231
|
+
void this.catchUp();
|
|
232
|
+
}, this.options.cdcInterval);
|
|
211
233
|
}
|
|
212
234
|
}
|
|
213
235
|
|
|
@@ -221,6 +243,9 @@ export class SyncModule {
|
|
|
221
243
|
* the subscription is marked stale until the next successful backfill.
|
|
222
244
|
*/
|
|
223
245
|
async subscribe<Data extends FrappeDocData>(query: SubscriptionQuery) {
|
|
246
|
+
if (this.options.catchUpStrategy === "snapshot" && query.progressive) {
|
|
247
|
+
throw new Error("Snapshot catch-up does not support progressive subscriptions");
|
|
248
|
+
}
|
|
224
249
|
// Wait for any persisted snapshot restore so a matching restored
|
|
225
250
|
// subscription can be adopted instead of racing the async load.
|
|
226
251
|
await this.persistenceManager.whenReady();
|
|
@@ -261,12 +286,83 @@ export class SyncModule {
|
|
|
261
286
|
this.emit();
|
|
262
287
|
}
|
|
263
288
|
|
|
289
|
+
/** Refresh active query snapshots in place. Failures retain the last committed data. */
|
|
290
|
+
async refreshSubscriptions(
|
|
291
|
+
options?: SubscriptionRefreshOptions,
|
|
292
|
+
): Promise<SubscriptionRefreshResult[]> {
|
|
293
|
+
const epoch = this.catchUpEpoch;
|
|
294
|
+
await this.persistenceManager.whenReady();
|
|
295
|
+
if (this.disposed || epoch !== this.catchUpEpoch) return [];
|
|
296
|
+
const results = await this.subscriptionManager.refreshSubscriptions(options);
|
|
297
|
+
if (!this.disposed && epoch === this.catchUpEpoch) this.emit();
|
|
298
|
+
return results;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
getCatchUpState(): SyncCatchUpState {
|
|
302
|
+
return this.catchUpState;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private publishCatchUp(state: SyncCatchUpState) {
|
|
306
|
+
this.catchUpState = Object.freeze(state);
|
|
307
|
+
for (const listener of this.storeListeners) listener();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Observes lifecycle/retry failures in state; explicit backfill still rejects. */
|
|
311
|
+
catchUp(): Promise<SyncCatchUpState> {
|
|
312
|
+
if (this.disposed) return Promise.resolve(this.catchUpState);
|
|
313
|
+
if (this.catchUpRun) {
|
|
314
|
+
if (this.catchUpRun.attempt === 0) this.catchUpRun.trailing = true;
|
|
315
|
+
return this.catchUpRun.promise;
|
|
316
|
+
}
|
|
317
|
+
const epoch = this.catchUpEpoch;
|
|
318
|
+
const run = { trailing: false, attempt: 0, promise: Promise.resolve(this.catchUpState) };
|
|
319
|
+
this.catchUpRun = run;
|
|
320
|
+
run.promise = Promise.resolve().then(async () => {
|
|
321
|
+
let error: unknown = null;
|
|
322
|
+
const active = () => !this.disposed && this.catchUpEpoch === epoch;
|
|
323
|
+
do {
|
|
324
|
+
if (!active()) return this.catchUpState;
|
|
325
|
+
run.trailing = false;
|
|
326
|
+
error = null;
|
|
327
|
+
try {
|
|
328
|
+
if (this.options.catchUpStrategy === "snapshot") {
|
|
329
|
+
const results = await this.refreshSubscriptions();
|
|
330
|
+
const failures = results.filter(
|
|
331
|
+
(result) =>
|
|
332
|
+
!result.ok &&
|
|
333
|
+
this.subscriptionManager.getSubscription(result.subscriptionId) &&
|
|
334
|
+
!this.subscriptionManager.isRestoredSubscription(result.subscriptionId),
|
|
335
|
+
);
|
|
336
|
+
if (failures.length)
|
|
337
|
+
error = new AggregateError(
|
|
338
|
+
failures.map((failure) => failure.error),
|
|
339
|
+
"Some subscriptions could not refresh. Showing previous data.",
|
|
340
|
+
);
|
|
341
|
+
} else {
|
|
342
|
+
await this.backfill({ scope: BackfillScope.All });
|
|
343
|
+
}
|
|
344
|
+
} catch (caught) {
|
|
345
|
+
error = caught;
|
|
346
|
+
}
|
|
347
|
+
// Bound a burst to one trailing read, including triggers during that read.
|
|
348
|
+
} while (active() && run.trailing && run.attempt++ === 0);
|
|
349
|
+
if (active()) {
|
|
350
|
+
this.catchUpRun = undefined;
|
|
351
|
+
this.publishCatchUp({ refreshing: false, stale: error !== null, error });
|
|
352
|
+
}
|
|
353
|
+
return this.catchUpState;
|
|
354
|
+
});
|
|
355
|
+
this.publishCatchUp({ ...this.catchUpState, refreshing: true });
|
|
356
|
+
return run.promise;
|
|
357
|
+
}
|
|
358
|
+
|
|
264
359
|
/**
|
|
265
360
|
* Reconciles local state by fetching changes since stored cursors.
|
|
266
361
|
*/
|
|
267
362
|
async backfill(args: BackfillArgs) {
|
|
363
|
+
const epoch = this.catchUpEpoch;
|
|
268
364
|
await this.subscriptionManager.backfill(args);
|
|
269
|
-
this.emit();
|
|
365
|
+
if (!this.disposed && epoch === this.catchUpEpoch) this.emit();
|
|
270
366
|
}
|
|
271
367
|
|
|
272
368
|
/**
|
|
@@ -438,6 +534,9 @@ export class SyncModule {
|
|
|
438
534
|
|
|
439
535
|
/** Clears subscriptions, cache, journal, and temp id mappings. */
|
|
440
536
|
resetCache() {
|
|
537
|
+
this.catchUpEpoch++;
|
|
538
|
+
this.catchUpRun = undefined;
|
|
539
|
+
this.catchUpState = idleCatchUpState;
|
|
441
540
|
// Leave all realtime doctype rooms alongside dropping the subscriptions
|
|
442
541
|
this.realtimeHandler.unwatchAll();
|
|
443
542
|
// Drop all live subs, in-memory cache, journal, and temp mappings
|
|
@@ -451,6 +550,7 @@ export class SyncModule {
|
|
|
451
550
|
|
|
452
551
|
/** Cleanup and dispose of resources */
|
|
453
552
|
async dispose() {
|
|
553
|
+
this.disposed = true;
|
|
454
554
|
// Clear all listeners
|
|
455
555
|
this.storeListeners.clear();
|
|
456
556
|
this.realtimeEventListeners.clear();
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { NormalizedCache } from "./normalizedCache";
|
|
3
|
+
import { SubscriptionManager } from "./subscriptionManager";
|
|
4
|
+
import type { SyncOptions } from "./types";
|
|
5
|
+
|
|
6
|
+
function deferred<T>() {
|
|
7
|
+
let resolve!: (value: T) => void;
|
|
8
|
+
let reject!: (error: unknown) => void;
|
|
9
|
+
const promise = new Promise<T>((yes, no) => {
|
|
10
|
+
resolve = yes;
|
|
11
|
+
reject = no;
|
|
12
|
+
});
|
|
13
|
+
return { promise, resolve, reject };
|
|
14
|
+
}
|
|
15
|
+
const row = (name: string, title = name) => ({
|
|
16
|
+
name,
|
|
17
|
+
title,
|
|
18
|
+
doctype: "Item",
|
|
19
|
+
modified: "2026-09-09T01:00:00Z",
|
|
20
|
+
});
|
|
21
|
+
const query = { doctype: "Item" };
|
|
22
|
+
async function setup(options: SyncOptions = {}) {
|
|
23
|
+
const db = { getDocList: vi.fn().mockResolvedValue([row("a")]) };
|
|
24
|
+
const cache = new NormalizedCache();
|
|
25
|
+
const manager = new SubscriptionManager(db as never, cache, options);
|
|
26
|
+
const { subscriptionId: id } = await manager.subscribe(query);
|
|
27
|
+
return { db, cache, manager, id };
|
|
28
|
+
}
|
|
29
|
+
function pending(db: Awaited<ReturnType<typeof setup>>["db"]) {
|
|
30
|
+
const response = deferred<ReturnType<typeof row>[]>();
|
|
31
|
+
const started = deferred<void>();
|
|
32
|
+
db.getDocList.mockImplementationOnce(() => {
|
|
33
|
+
started.resolve();
|
|
34
|
+
return response.promise;
|
|
35
|
+
});
|
|
36
|
+
return { ...response, started: started.promise };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("snapshot refresh lifecycle", () => {
|
|
40
|
+
it.each(["resolve", "reject"] as const)(
|
|
41
|
+
"invalidates pre-detach refresh that later %ss after re-adoption",
|
|
42
|
+
async (outcome) => {
|
|
43
|
+
const { manager, db, cache, id } = await setup({ persistKey: "lifecycle" });
|
|
44
|
+
const old = pending(db);
|
|
45
|
+
const oldResult = manager.refreshSubscriptions();
|
|
46
|
+
await old.started;
|
|
47
|
+
manager.unsubscribe(id);
|
|
48
|
+
const fresh = pending(db);
|
|
49
|
+
const adopted = await manager.subscribe(query);
|
|
50
|
+
expect(adopted.subscriptionId).toBe(id);
|
|
51
|
+
expect(adopted.rows).toEqual([row("a")]);
|
|
52
|
+
await fresh.started;
|
|
53
|
+
fresh.resolve([row("a", "new-adoption")]);
|
|
54
|
+
expect(await adopted.refresh).toMatchObject({ ok: true });
|
|
55
|
+
if (outcome === "resolve") old.resolve([row("a", "obsolete-before-detach"), row("obsolete")]);
|
|
56
|
+
else old.reject(new Error("old failure"));
|
|
57
|
+
expect((await oldResult)[0].ok).toBe(false);
|
|
58
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("a", "new-adoption")]);
|
|
59
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
60
|
+
expect(manager.getSubscription(id)?.stale).toBe(false);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
it("does not let an old finally erase the new adoption operation", async () => {
|
|
65
|
+
const { manager, db, cache, id } = await setup({ persistKey: "lifecycle" });
|
|
66
|
+
const old = pending(db);
|
|
67
|
+
const oldResult = manager.refreshSubscriptions();
|
|
68
|
+
await old.started;
|
|
69
|
+
manager.unsubscribe(id);
|
|
70
|
+
const adoption = pending(db);
|
|
71
|
+
const adopted = await manager.subscribe(query);
|
|
72
|
+
await adoption.started;
|
|
73
|
+
old.resolve([row("obsolete")]);
|
|
74
|
+
expect((await oldResult)[0].ok).toBe(false);
|
|
75
|
+
const trailing = pending(db);
|
|
76
|
+
const explicit = manager.refreshSubscriptions();
|
|
77
|
+
expect(db.getDocList).toHaveBeenCalledTimes(3);
|
|
78
|
+
adoption.resolve([row("discarded-adoption")]);
|
|
79
|
+
await trailing.started;
|
|
80
|
+
expect(cache.get({ doctype: "Item", name: "discarded-adoption" })).toBeUndefined();
|
|
81
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("a")]);
|
|
82
|
+
trailing.resolve([row("final")]);
|
|
83
|
+
expect(await adopted.refresh).toEqual((await explicit)[0]);
|
|
84
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
85
|
+
expect(db.getDocList).toHaveBeenCalledTimes(4);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it.each([false, true])(
|
|
89
|
+
"coalesces requests and retains the committed snapshot when trailing fails=%s",
|
|
90
|
+
async (fails) => {
|
|
91
|
+
const { manager, db, cache, id } = await setup();
|
|
92
|
+
const before = structuredClone(manager.getSubscription(id));
|
|
93
|
+
const first = pending(db);
|
|
94
|
+
const one = manager.refreshSubscriptions();
|
|
95
|
+
await first.started;
|
|
96
|
+
const last = pending(db);
|
|
97
|
+
const two = manager.refreshSubscriptions();
|
|
98
|
+
const three = manager.refreshSubscriptions();
|
|
99
|
+
first.resolve([row("a", "discarded"), row("obsolete")]);
|
|
100
|
+
await last.started;
|
|
101
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
102
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("a")]);
|
|
103
|
+
const error = new Error("trailing failed");
|
|
104
|
+
if (fails) last.reject(error);
|
|
105
|
+
else last.resolve([row("final")]);
|
|
106
|
+
const results = await Promise.all([one, two, three]);
|
|
107
|
+
expect(results).toEqual(
|
|
108
|
+
Array(3).fill([{ subscriptionId: id, ok: !fails, ...(fails ? { error } : {}) }]),
|
|
109
|
+
);
|
|
110
|
+
if (fails) for (const result of results) expect(result[0].error).toBe(error);
|
|
111
|
+
expect(db.getDocList).toHaveBeenCalledTimes(3);
|
|
112
|
+
if (fails) expect(manager.getSubscription(id)).toEqual({ ...before, stale: true });
|
|
113
|
+
else expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
114
|
+
await manager.refreshSubscriptions();
|
|
115
|
+
expect(db.getDocList).toHaveBeenCalledTimes(4);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
it("trails a failed attempt only when another caller requested it", async () => {
|
|
120
|
+
const { manager, db, id } = await setup();
|
|
121
|
+
const first = pending(db);
|
|
122
|
+
const one = manager.refreshSubscriptions();
|
|
123
|
+
await first.started;
|
|
124
|
+
const last = pending(db);
|
|
125
|
+
const two = manager.refreshSubscriptions();
|
|
126
|
+
first.reject(new Error("superseded failure"));
|
|
127
|
+
await last.started;
|
|
128
|
+
last.resolve([row("final")]);
|
|
129
|
+
expect(await one).toEqual(await two);
|
|
130
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
131
|
+
expect(db.getDocList).toHaveBeenCalledTimes(3);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("joins an adoption already fetching and discards its superseded rows", async () => {
|
|
135
|
+
const { manager, db, cache, id } = await setup({ persistKey: "lifecycle" });
|
|
136
|
+
manager.unsubscribe(id);
|
|
137
|
+
const adoption = pending(db);
|
|
138
|
+
const adopted = await manager.subscribe(query);
|
|
139
|
+
await adoption.started;
|
|
140
|
+
const last = pending(db);
|
|
141
|
+
const explicit = manager.refreshSubscriptions();
|
|
142
|
+
adoption.resolve([row("obsolete")]);
|
|
143
|
+
await last.started;
|
|
144
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
145
|
+
last.resolve([row("final")]);
|
|
146
|
+
expect(await adopted.refresh).toEqual((await explicit)[0]);
|
|
147
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("invalidates the last release without corrupting cached documents", async () => {
|
|
151
|
+
const { manager, db, cache, id } = await setup();
|
|
152
|
+
const read = pending(db);
|
|
153
|
+
const result = manager.refreshSubscriptions();
|
|
154
|
+
await read.started;
|
|
155
|
+
manager.unsubscribe(id);
|
|
156
|
+
read.resolve([row("a", "obsolete"), row("new")]);
|
|
157
|
+
expect((await result)[0].ok).toBe(false);
|
|
158
|
+
expect(manager.getSubscription(id)).toBeUndefined();
|
|
159
|
+
expect(cache.get({ doctype: "Item", name: "a" })).toEqual(row("a"));
|
|
160
|
+
expect(cache.get({ doctype: "Item", name: "new" })).toBeUndefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("keeps shared refreshes active until the last consumer releases", async () => {
|
|
164
|
+
const { manager, db, id } = await setup();
|
|
165
|
+
expect((await manager.subscribe(query)).subscriptionId).toBe(id);
|
|
166
|
+
const first = pending(db);
|
|
167
|
+
const one = manager.refreshSubscriptions();
|
|
168
|
+
await first.started;
|
|
169
|
+
manager.unsubscribe(id);
|
|
170
|
+
first.resolve([row("updated")]);
|
|
171
|
+
expect(await one).toEqual([{ subscriptionId: id, ok: true }]);
|
|
172
|
+
const last = pending(db);
|
|
173
|
+
const two = manager.refreshSubscriptions();
|
|
174
|
+
await last.started;
|
|
175
|
+
manager.unsubscribe(id);
|
|
176
|
+
last.resolve([row("obsolete")]);
|
|
177
|
+
expect((await two)[0].ok).toBe(false);
|
|
178
|
+
expect(manager.getSubscription(id)).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("invalidates clear and allows a subsequent activation", async () => {
|
|
182
|
+
const { manager, db, cache, id } = await setup();
|
|
183
|
+
const old = pending(db);
|
|
184
|
+
const result = manager.refreshSubscriptions();
|
|
185
|
+
await old.started;
|
|
186
|
+
manager.clear();
|
|
187
|
+
cache.clear();
|
|
188
|
+
const fresh = await manager.subscribe(query);
|
|
189
|
+
old.resolve([row("a", "obsolete"), row("obsolete")]);
|
|
190
|
+
expect((await result)[0].ok).toBe(false);
|
|
191
|
+
expect(manager.getSubscription(id)).toBeUndefined();
|
|
192
|
+
expect(manager.getDocsForSubscription(fresh.subscriptionId)).toEqual([row("a")]);
|
|
193
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("does not seed in the microtask between HTTP completion and acceptance", async () => {
|
|
197
|
+
const { manager, db, cache, id } = await setup();
|
|
198
|
+
const first = pending(db);
|
|
199
|
+
const one = manager.refreshSubscriptions();
|
|
200
|
+
await first.started;
|
|
201
|
+
const last = pending(db);
|
|
202
|
+
first.resolve([row("obsolete")]);
|
|
203
|
+
const two = Promise.resolve().then(() => manager.refreshSubscriptions());
|
|
204
|
+
await last.started;
|
|
205
|
+
expect(cache.get({ doctype: "Item", name: "obsolete" })).toBeUndefined();
|
|
206
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("a")]);
|
|
207
|
+
last.resolve([row("final")]);
|
|
208
|
+
expect(await one).toEqual(await two);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("allows different subscriptions to finish independently", async () => {
|
|
212
|
+
const { manager, db, id } = await setup();
|
|
213
|
+
const second = await manager.subscribe({ doctype: "Other" });
|
|
214
|
+
const slow = pending(db);
|
|
215
|
+
const fast = pending(db);
|
|
216
|
+
const result = manager.refreshSubscriptions();
|
|
217
|
+
await Promise.all([slow.started, fast.started]);
|
|
218
|
+
fast.resolve([row("fast")]);
|
|
219
|
+
await Promise.resolve();
|
|
220
|
+
await Promise.resolve();
|
|
221
|
+
expect(manager.getSubscription(second.subscriptionId)?.docIds).toEqual(["fast"]);
|
|
222
|
+
expect(manager.getSubscription(id)?.docIds).toEqual(["a"]);
|
|
223
|
+
slow.resolve([row("slow")]);
|
|
224
|
+
expect((await result).every((entry) => entry.ok)).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("excludes dormant restored subscriptions and selects each shared id once", async () => {
|
|
229
|
+
const { manager, db, id } = await setup();
|
|
230
|
+
await manager.subscribe(query);
|
|
231
|
+
manager.restoreSubscription({ id: "dormant", query: { doctype: "Dormant" }, docIds: ["cached"] });
|
|
232
|
+
expect(await manager.refreshSubscriptions({ doctypes: ["Dormant"] })).toEqual([]);
|
|
233
|
+
expect(await manager.refreshSubscriptions({ doctypes: [] })).toEqual([]);
|
|
234
|
+
expect(db.getDocList).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(await manager.refreshSubscriptions()).toEqual([{ subscriptionId: id, ok: true }]);
|
|
236
|
+
expect(db.getDocList).toHaveBeenCalledTimes(2);
|
|
237
|
+
manager.unsubscribe(id);
|
|
238
|
+
manager.unsubscribe(id);
|
|
239
|
+
expect(manager.getSubscription(id)).toBeUndefined();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("retains cached projections and documents outside the refreshed membership", async () => {
|
|
243
|
+
const { manager, db, cache, id } = await setup();
|
|
244
|
+
cache.upsert({ ...row("a"), detail: "other view" });
|
|
245
|
+
db.getDocList.mockResolvedValueOnce([row("b")]);
|
|
246
|
+
const other = await manager.subscribe({ doctype: "Item", filters: { enabled: 1 } });
|
|
247
|
+
db.getDocList
|
|
248
|
+
.mockResolvedValueOnce([{ name: "a", title: "projected" }])
|
|
249
|
+
.mockResolvedValueOnce([]);
|
|
250
|
+
await manager.refreshSubscriptions();
|
|
251
|
+
expect(manager.getDocsForSubscription(id)).toEqual([
|
|
252
|
+
{ ...row("a", "projected"), detail: "other view" },
|
|
253
|
+
]);
|
|
254
|
+
expect(manager.getDocsForSubscription(other.subscriptionId)).toEqual([]);
|
|
255
|
+
expect(cache.get({ doctype: "Item", name: "b" })).toEqual(row("b"));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("shares the first read when callers arrive before it starts", async () => {
|
|
259
|
+
const { manager, db, id } = await setup();
|
|
260
|
+
const first = pending(db);
|
|
261
|
+
const one = manager.refreshSubscriptions();
|
|
262
|
+
const two = manager.refreshSubscriptions();
|
|
263
|
+
await first.started;
|
|
264
|
+
first.resolve([row("final")]);
|
|
265
|
+
expect(await one).toEqual(await two);
|
|
266
|
+
expect(manager.getDocsForSubscription(id)).toEqual([row("final")]);
|
|
267
|
+
expect(db.getDocList).toHaveBeenCalledTimes(2);
|
|
268
|
+
});
|