@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.
Files changed (87) hide show
  1. package/README.md +89 -1
  2. package/dist/cjs/frappeReact.cjs +54 -30
  3. package/dist/cjs/frappeReact.native.js +67 -32
  4. package/dist/cjs/frappeReact.native.js.map +1 -1
  5. package/dist/cjs/index.cjs +1 -0
  6. package/dist/cjs/index.native.js +1 -0
  7. package/dist/cjs/index.native.js.map +1 -1
  8. package/dist/cjs/sync/index.cjs +97 -13
  9. package/dist/cjs/sync/index.native.js +127 -13
  10. package/dist/cjs/sync/index.native.js.map +1 -1
  11. package/dist/cjs/sync/subscriptionManager.cjs +94 -50
  12. package/dist/cjs/sync/subscriptionManager.native.js +133 -73
  13. package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
  14. package/dist/cjs/sync/types.native.js.map +1 -1
  15. package/dist/cjs/syncOptionsKey.cjs +59 -0
  16. package/dist/cjs/syncOptionsKey.native.js +82 -0
  17. package/dist/cjs/syncOptionsKey.native.js.map +1 -0
  18. package/dist/esm/frappeReact.mjs +55 -32
  19. package/dist/esm/frappeReact.mjs.map +1 -1
  20. package/dist/esm/frappeReact.native.js +68 -34
  21. package/dist/esm/frappeReact.native.js.map +1 -1
  22. package/dist/esm/index.mjs +2 -2
  23. package/dist/esm/index.mjs.map +1 -1
  24. package/dist/esm/index.native.js +2 -2
  25. package/dist/esm/index.native.js.map +1 -1
  26. package/dist/esm/sync/index.mjs +97 -13
  27. package/dist/esm/sync/index.mjs.map +1 -1
  28. package/dist/esm/sync/index.native.js +127 -13
  29. package/dist/esm/sync/index.native.js.map +1 -1
  30. package/dist/esm/sync/subscriptionManager.mjs +94 -50
  31. package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
  32. package/dist/esm/sync/subscriptionManager.native.js +133 -73
  33. package/dist/esm/sync/subscriptionManager.native.js.map +1 -1
  34. package/dist/esm/sync/types.mjs.map +1 -1
  35. package/dist/esm/sync/types.native.js.map +1 -1
  36. package/dist/esm/syncOptionsKey.mjs +34 -0
  37. package/dist/esm/syncOptionsKey.mjs.map +1 -0
  38. package/dist/esm/syncOptionsKey.native.js +54 -0
  39. package/dist/esm/syncOptionsKey.native.js.map +1 -0
  40. package/dist/jsx/frappeReact.mjs +55 -32
  41. package/dist/jsx/frappeReact.mjs.map +1 -1
  42. package/dist/jsx/frappeReact.native.js +67 -32
  43. package/dist/jsx/frappeReact.native.js.map +1 -1
  44. package/dist/jsx/index.js +2 -2
  45. package/dist/jsx/index.js.map +1 -1
  46. package/dist/jsx/index.mjs +2 -2
  47. package/dist/jsx/index.mjs.map +1 -1
  48. package/dist/jsx/index.native.js +1 -0
  49. package/dist/jsx/index.native.js.map +1 -1
  50. package/dist/jsx/sync/index.mjs +97 -13
  51. package/dist/jsx/sync/index.mjs.map +1 -1
  52. package/dist/jsx/sync/index.native.js +127 -13
  53. package/dist/jsx/sync/index.native.js.map +1 -1
  54. package/dist/jsx/sync/subscriptionManager.mjs +94 -50
  55. package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
  56. package/dist/jsx/sync/subscriptionManager.native.js +133 -73
  57. package/dist/jsx/sync/subscriptionManager.native.js.map +1 -1
  58. package/dist/jsx/sync/types.mjs.map +1 -1
  59. package/dist/jsx/sync/types.native.js.map +1 -1
  60. package/dist/jsx/syncOptionsKey.mjs +34 -0
  61. package/dist/jsx/syncOptionsKey.mjs.map +1 -0
  62. package/dist/jsx/syncOptionsKey.native.js +82 -0
  63. package/dist/jsx/syncOptionsKey.native.js.map +1 -0
  64. package/package.json +9 -9
  65. package/src/frappeReact.catchUp.spec.tsx +105 -0
  66. package/src/frappeReact.ts +62 -38
  67. package/src/index.ts +8 -1
  68. package/src/sync/catchUp.spec.ts +142 -0
  69. package/src/sync/index.ts +108 -8
  70. package/src/sync/refreshSubscriptions.lifecycle.spec.ts +268 -0
  71. package/src/sync/refreshSubscriptions.spec.ts +295 -0
  72. package/src/sync/subscriptionManager.ts +107 -44
  73. package/src/sync/types.ts +9 -1
  74. package/src/syncOptionsKey.spec.ts +46 -0
  75. package/src/syncOptionsKey.ts +45 -0
  76. package/types/frappeReact.d.ts +6 -1
  77. package/types/frappeReact.d.ts.map +1 -1
  78. package/types/index.d.ts +4 -4
  79. package/types/index.d.ts.map +1 -1
  80. package/types/sync/index.d.ts +13 -1
  81. package/types/sync/index.d.ts.map +1 -1
  82. package/types/sync/subscriptionManager.d.ts +13 -10
  83. package/types/sync/subscriptionManager.d.ts.map +1 -1
  84. package/types/sync/types.d.ts +8 -1
  85. package/types/sync/types.d.ts.map +1 -1
  86. package/types/syncOptionsKey.d.ts +3 -0
  87. 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: () => this.backfill({ scope: BackfillScope.All }),
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(() => this.backfill({ scope: BackfillScope.All }));
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(() => this.backfill({ scope: BackfillScope.All }));
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
- () => this.backfill({ scope: BackfillScope.All }),
209
- this.options.cdcInterval,
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
+ });