@multiplatform.one/frappe 6.4.3 → 6.6.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/dist/cjs/auth.native.js.map +1 -1
- package/dist/cjs/call.native.js.map +1 -1
- package/dist/cjs/collection.cjs +25 -9
- package/dist/cjs/collection.native.js +25 -9
- package/dist/cjs/collection.native.js.map +1 -1
- package/dist/cjs/common/types.native.js.map +1 -1
- package/dist/cjs/core/http.native.js.map +1 -1
- package/dist/cjs/core/types.native.js.map +1 -1
- package/dist/cjs/core/wire.native.js.map +1 -1
- package/dist/cjs/db.cjs +13 -2
- package/dist/cjs/db.native.js +18 -2
- package/dist/cjs/db.native.js.map +1 -1
- package/dist/cjs/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
- package/dist/cjs/devtools/index.native.js.map +1 -1
- package/dist/cjs/devtools/plugin.native.js.map +1 -1
- package/dist/cjs/devtools/theme.native.js.map +1 -1
- package/dist/cjs/doctype.native.js.map +1 -1
- package/dist/cjs/file.native.js.map +1 -1
- package/dist/cjs/fixtureDb.native.js.map +1 -1
- package/dist/cjs/fixtures.native.js.map +1 -1
- package/dist/cjs/frappeApp.native.js.map +1 -1
- package/dist/cjs/frappeReact.cjs +10 -3
- package/dist/cjs/frappeReact.native.js +11 -4
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.native.js +2 -2
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/optimistic.native.js.map +1 -1
- package/dist/cjs/pacer.native.js.map +1 -1
- package/dist/cjs/plugin/index.native.js.map +1 -1
- package/dist/cjs/realtime.native.js.map +1 -1
- package/dist/cjs/sync/backfill.native.js.map +1 -1
- package/dist/cjs/sync/filters.native.js.map +1 -1
- package/dist/cjs/sync/index.cjs +6 -1
- package/dist/cjs/sync/index.native.js +6 -1
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/merge.native.js.map +1 -1
- package/dist/cjs/sync/mutationEngine.cjs +43 -19
- package/dist/cjs/sync/mutationEngine.native.js +51 -21
- package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
- package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
- package/dist/cjs/sync/normalizedCache.native.js.map +1 -1
- package/dist/cjs/sync/persistence.native.js.map +1 -1
- package/dist/cjs/sync/realtime.native.js.map +1 -1
- package/dist/cjs/sync/rehydration.native.js.map +1 -1
- package/dist/cjs/sync/storage.native.js.map +1 -1
- package/dist/cjs/sync/subscriptionManager.cjs +4 -0
- package/dist/cjs/sync/subscriptionManager.native.js +4 -0
- package/dist/cjs/sync/subscriptionManager.native.js.map +1 -1
- package/dist/cjs/sync/types.native.js.map +1 -1
- package/dist/cjs/sync/virtualPageManager.native.js.map +1 -1
- package/dist/cjs/useLiveQuery.native.js.map +1 -1
- package/dist/cjs/util.native.js.map +1 -1
- package/dist/esm/auth.native.js.map +1 -1
- package/dist/esm/call.native.js.map +1 -1
- package/dist/esm/collection.mjs +24 -9
- package/dist/esm/collection.mjs.map +1 -1
- package/dist/esm/collection.native.js +25 -10
- package/dist/esm/collection.native.js.map +1 -1
- package/dist/esm/common/types.native.js.map +1 -1
- package/dist/esm/core/http.native.js +2 -2
- package/dist/esm/core/http.native.js.map +1 -1
- package/dist/esm/core/types.native.js.map +1 -1
- package/dist/esm/core/wire.native.js.map +1 -1
- package/dist/esm/db.mjs +13 -2
- package/dist/esm/db.mjs.map +1 -1
- package/dist/esm/db.native.js +19 -3
- package/dist/esm/db.native.js.map +1 -1
- package/dist/esm/devtools/FrappeDevtoolsPanel.native.js +2 -2
- package/dist/esm/devtools/FrappeDevtoolsPanel.native.js.map +1 -1
- package/dist/esm/devtools/index.native.js +2 -2
- package/dist/esm/devtools/index.native.js.map +1 -1
- package/dist/esm/devtools/plugin.native.js +1 -1
- package/dist/esm/devtools/plugin.native.js.map +1 -1
- package/dist/esm/devtools/theme.native.js.map +1 -1
- package/dist/esm/doctype.native.js +1 -1
- package/dist/esm/doctype.native.js.map +1 -1
- package/dist/esm/file.native.js.map +1 -1
- package/dist/esm/fixtureDb.native.js +1 -1
- package/dist/esm/fixtureDb.native.js.map +1 -1
- package/dist/esm/fixtures.native.js.map +1 -1
- package/dist/esm/frappeApp.native.js +7 -7
- package/dist/esm/frappeApp.native.js.map +1 -1
- package/dist/esm/frappeReact.mjs +11 -4
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +17 -10
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.native.js +21 -21
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/optimistic.native.js.map +1 -1
- package/dist/esm/pacer.native.js +4 -4
- package/dist/esm/pacer.native.js.map +1 -1
- package/dist/esm/plugin/index.native.js.map +1 -1
- package/dist/esm/realtime.native.js +1 -1
- package/dist/esm/realtime.native.js.map +1 -1
- package/dist/esm/sync/backfill.native.js +1 -1
- package/dist/esm/sync/backfill.native.js.map +1 -1
- package/dist/esm/sync/filters.native.js.map +1 -1
- package/dist/esm/sync/index.mjs +6 -1
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +14 -9
- package/dist/esm/sync/index.native.js.map +1 -1
- package/dist/esm/sync/merge.native.js.map +1 -1
- package/dist/esm/sync/mutationEngine.mjs +43 -19
- package/dist/esm/sync/mutationEngine.mjs.map +1 -1
- package/dist/esm/sync/mutationEngine.native.js +55 -25
- package/dist/esm/sync/mutationEngine.native.js.map +1 -1
- package/dist/esm/sync/mutationJournal.native.js +1 -1
- package/dist/esm/sync/mutationJournal.native.js.map +1 -1
- package/dist/esm/sync/normalizedCache.native.js.map +1 -1
- package/dist/esm/sync/persistence.native.js +2 -2
- package/dist/esm/sync/persistence.native.js.map +1 -1
- package/dist/esm/sync/realtime.native.js +1 -1
- package/dist/esm/sync/realtime.native.js.map +1 -1
- package/dist/esm/sync/rehydration.native.js +1 -1
- package/dist/esm/sync/rehydration.native.js.map +1 -1
- package/dist/esm/sync/storage.native.js.map +1 -1
- package/dist/esm/sync/subscriptionManager.mjs +4 -0
- package/dist/esm/sync/subscriptionManager.mjs.map +1 -1
- package/dist/esm/sync/subscriptionManager.native.js +8 -4
- 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/sync/virtualPageManager.native.js +1 -1
- package/dist/esm/sync/virtualPageManager.native.js.map +1 -1
- package/dist/esm/useLiveQuery.native.js.map +1 -1
- package/dist/esm/util.native.js.map +1 -1
- package/dist/jsx/collection.mjs +24 -9
- package/dist/jsx/collection.mjs.map +1 -1
- package/dist/jsx/collection.native.js +25 -9
- package/dist/jsx/collection.native.js.map +1 -1
- package/dist/jsx/db.mjs +13 -2
- package/dist/jsx/db.mjs.map +1 -1
- package/dist/jsx/db.native.js +18 -2
- package/dist/jsx/db.native.js.map +1 -1
- package/dist/jsx/frappeReact.mjs +11 -4
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +10 -3
- package/dist/jsx/frappeReact.native.js.map +1 -1
- package/dist/jsx/sync/index.mjs +6 -1
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +6 -1
- package/dist/jsx/sync/index.native.js.map +1 -1
- package/dist/jsx/sync/mutationEngine.mjs +43 -19
- package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
- package/dist/jsx/sync/mutationEngine.native.js +51 -21
- package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
- package/dist/jsx/sync/subscriptionManager.mjs +4 -0
- package/dist/jsx/sync/subscriptionManager.mjs.map +1 -1
- package/dist/jsx/sync/subscriptionManager.native.js +4 -0
- 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/package.json +6 -6
- package/src/collection.ts +53 -8
- package/src/db.spec.ts +70 -0
- package/src/db.ts +28 -2
- package/src/frappeReact.spec.ts +7 -1
- package/src/frappeReact.ts +30 -6
- package/src/frappeReactLiveQueryGc.spec.ts +44 -4
- package/src/sync/index.ts +10 -1
- package/src/sync/mutationEngine.ts +81 -20
- package/src/sync/optimisticCreateReconcile.spec.ts +372 -0
- package/src/sync/subscriptionManager.spec.ts +41 -0
- package/src/sync/subscriptionManager.ts +10 -0
- package/src/sync/subscriptions.spec.ts +4 -0
- package/src/sync/types.ts +9 -0
- package/types/collection.d.ts +12 -0
- package/types/collection.d.ts.map +1 -1
- package/types/db.d.ts.map +1 -1
- package/types/frappeReact.d.ts.map +1 -1
- package/types/sync/index.d.ts.map +1 -1
- package/types/sync/mutationEngine.d.ts.map +1 -1
- package/types/sync/subscriptionManager.d.ts.map +1 -1
- package/types/sync/types.d.ts +9 -0
- package/types/sync/types.d.ts.map +1 -1
|
@@ -281,7 +281,7 @@ describe("shared collection GC vs real live queries", () => {
|
|
|
281
281
|
expect(liveQueryErrors()).toHaveLength(0);
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
-
it("force window: a leaked query
|
|
284
|
+
it("force window: a leaked query releases the sync WITHOUT erroring, and a fresh mount recovers", async () => {
|
|
285
285
|
const config = configFor("leak");
|
|
286
286
|
const hook = renderHook(() => useFrappeCollection<WorkspaceDoc>(config, "Workspace"));
|
|
287
287
|
await flushMicrotasks();
|
|
@@ -290,18 +290,29 @@ describe("shared collection GC vs real live queries", () => {
|
|
|
290
290
|
// A leaked query: created (sync started, source subscribed) but never
|
|
291
291
|
// given a subscriber — the shape a discarded render leaves behind.
|
|
292
292
|
// TanStack DB never arms its GC for it, so only the force window frees
|
|
293
|
-
// the
|
|
294
|
-
createLiveQueryCollection({
|
|
293
|
+
// the sync resources.
|
|
294
|
+
const leakedQuery = createLiveQueryCollection({
|
|
295
295
|
query: (q) => q.from({ workspace: collection }),
|
|
296
296
|
startSync: true,
|
|
297
297
|
gcTime: 300_000,
|
|
298
298
|
});
|
|
299
299
|
await flushMicrotasks();
|
|
300
300
|
expect(collection.subscriberCount).toBeGreaterThan(0);
|
|
301
|
+
const unsubscribesBefore = mockUnsubscribe.mock.calls.length;
|
|
301
302
|
|
|
302
303
|
hook.unmount();
|
|
303
304
|
await advanceTime(FORCE_WAIT_MS);
|
|
304
|
-
|
|
305
|
+
// The regression this pins: the force window used to call cleanup() on
|
|
306
|
+
// the pinned collection, permanently erroring every attached query with
|
|
307
|
+
// "[Live Query Error] Source collection 'Workspace' was manually cleaned
|
|
308
|
+
// up while live query … depends on it" (7× in the console sweep).
|
|
309
|
+
// Now the SyncModule subscription is released externally instead: the
|
|
310
|
+
// collection is never transitioned to cleaned-up, the leaked query
|
|
311
|
+
// never errors, and no console error is emitted.
|
|
312
|
+
expect(mockUnsubscribe.mock.calls.length).toBeGreaterThan(unsubscribesBefore);
|
|
313
|
+
expect(collection.status).not.toBe("cleaned-up");
|
|
314
|
+
expect(leakedQuery.status).not.toBe("error");
|
|
315
|
+
expect(liveQueryErrors()).toHaveLength(0);
|
|
305
316
|
|
|
306
317
|
// A consumer mounting afterwards gets a FRESH working collection — a new
|
|
307
318
|
// live query over it loads and reaches ready.
|
|
@@ -316,5 +327,34 @@ describe("shared collection GC vs real live queries", () => {
|
|
|
316
327
|
expect(fresh.result.current!.status).toBe("ready");
|
|
317
328
|
expect(freshQuery.status).toBe("ready");
|
|
318
329
|
expect(freshQuery.toArray).toHaveLength(2);
|
|
330
|
+
expect(liveQueryErrors()).toHaveLength(0);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("errored entry: idle cleanup with a leaked query attached releases sync without erroring", async () => {
|
|
334
|
+
// The production path from the console sweep: initial sync fails (401),
|
|
335
|
+
// consumers unmount (error screen), the errored entry is evicted through
|
|
336
|
+
// cleanupEntryCollectionWhenIdle — which, past the force window, used to
|
|
337
|
+
// cleanup() the collection while leaked queries were still attached.
|
|
338
|
+
const config = configFor("error");
|
|
339
|
+
mockSubscribe.mockRejectedValueOnce(new Error("AuthenticationError"));
|
|
340
|
+
|
|
341
|
+
const hook = renderHook(() => useFrappeCollection<WorkspaceDoc>(config, "Workspace"));
|
|
342
|
+
await flushMicrotasks();
|
|
343
|
+
const collection = hook.result.current!;
|
|
344
|
+
|
|
345
|
+
const leakedQuery = createLiveQueryCollection({
|
|
346
|
+
query: (q) => q.from({ workspace: collection }),
|
|
347
|
+
startSync: true,
|
|
348
|
+
gcTime: 300_000,
|
|
349
|
+
});
|
|
350
|
+
await flushMicrotasks();
|
|
351
|
+
expect(collection.subscriberCount).toBeGreaterThan(0);
|
|
352
|
+
|
|
353
|
+
hook.unmount();
|
|
354
|
+
await advanceTime(FORCE_WAIT_MS);
|
|
355
|
+
|
|
356
|
+
expect(collection.status).not.toBe("cleaned-up");
|
|
357
|
+
expect(leakedQuery.status).not.toBe("error");
|
|
358
|
+
expect(liveQueryErrors()).toHaveLength(0);
|
|
319
359
|
});
|
|
320
360
|
});
|
package/src/sync/index.ts
CHANGED
|
@@ -226,7 +226,16 @@ export class SyncModule {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
async mutate(mutation: Mutation<FrappeDocData>) {
|
|
229
|
-
|
|
229
|
+
try {
|
|
230
|
+
await this.mutationEngine.mutate(mutation);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
// The failure path rolled back optimistic state (e.g. a rejected
|
|
233
|
+
// create's temp row was evicted) — store listeners must see that
|
|
234
|
+
// rollback too, or consumers that mirrored the optimistic membership
|
|
235
|
+
// keep rendering a ghost row the engine already dropped.
|
|
236
|
+
this.emit();
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
230
239
|
// Broadcast mutation to other tabs for cross-tab sync
|
|
231
240
|
this.persistenceManager.broadcastMutation(mutation);
|
|
232
241
|
this.emit();
|
|
@@ -147,10 +147,22 @@ export class MutationEngine {
|
|
|
147
147
|
if (result.data) {
|
|
148
148
|
this.normalizedCache.upsert(result.data);
|
|
149
149
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
// Reconcile optimistic identity for creates — the full remap
|
|
151
|
+
// (subscription membership + temp cache row eviction), not just
|
|
152
|
+
// the id maps, or the optimistic row lingers beside the server
|
|
153
|
+
// row exactly like the non-batched path used to.
|
|
154
|
+
if (mutation.operation === Operation.Create) {
|
|
155
|
+
const temporaryName = mutation.temporaryName ?? mutation.doc.name;
|
|
156
|
+
if (temporaryName && result.data.name && temporaryName !== result.data.name) {
|
|
157
|
+
this.handleTemporaryToRealMapping(
|
|
158
|
+
mutation.doc.doctype,
|
|
159
|
+
temporaryName,
|
|
160
|
+
result.data.name,
|
|
161
|
+
);
|
|
162
|
+
this.rehydrationManager.registerTempIdMapping(temporaryName, result.data.name);
|
|
163
|
+
} else if (temporaryName) {
|
|
164
|
+
this.globalOptimisticDocIds.get(mutation.doc.doctype)?.delete(temporaryName);
|
|
165
|
+
}
|
|
154
166
|
}
|
|
155
167
|
}
|
|
156
168
|
|
|
@@ -244,7 +256,11 @@ export class MutationEngine {
|
|
|
244
256
|
: mutation.doc;
|
|
245
257
|
this.mutationJournal.add(mutation.mutationId, {
|
|
246
258
|
doctype: doc.doctype,
|
|
247
|
-
|
|
259
|
+
// Creates journal the optimistic identity (consumer placeholder or
|
|
260
|
+
// engine-generated temp id) so page refreshes keep the pending row
|
|
261
|
+
// and rollback can evict it even when doc.name was never set.
|
|
262
|
+
docname:
|
|
263
|
+
mutation.operation === Operation.Create ? (mutation.temporaryName ?? doc.name) : doc.name,
|
|
248
264
|
values,
|
|
249
265
|
operation: mutation.operation,
|
|
250
266
|
version,
|
|
@@ -318,6 +334,11 @@ export class MutationEngine {
|
|
|
318
334
|
|
|
319
335
|
private async handleOptimisticCreate<Data extends FrappeDocData>(mutation: CreateMutation<Data>) {
|
|
320
336
|
const temporaryName = mutation.doc.name || `tmp_${cryptoRandomId()}`;
|
|
337
|
+
// Record the optimistic identity on the mutation itself: the server-ack
|
|
338
|
+
// remap, rollback, and journal paths must all resolve the SAME name.
|
|
339
|
+
// For engine-generated names, mutation.doc.name stays unset so the
|
|
340
|
+
// synthetic id is never sent to the server.
|
|
341
|
+
mutation.temporaryName = temporaryName;
|
|
321
342
|
const optimisticDoc = {
|
|
322
343
|
...mutation.doc,
|
|
323
344
|
name: temporaryName,
|
|
@@ -370,11 +391,27 @@ export class MutationEngine {
|
|
|
370
391
|
}
|
|
371
392
|
|
|
372
393
|
private async handleServerCreate<Data extends FrappeDocData>(mutation: CreateMutation<Data>) {
|
|
373
|
-
|
|
394
|
+
// Idempotency-Key rides the first attempt (not just replays) so an
|
|
395
|
+
// HTTP-level retry after a lost response replays the recorded outcome
|
|
396
|
+
// instead of double-inserting (live-protocol FRAPPE-EXT-001).
|
|
397
|
+
const created = await this.db.createDoc<FrappeDoc<Data>>({
|
|
398
|
+
...mutation.doc,
|
|
399
|
+
client_mutation_id: mutation.mutationId,
|
|
400
|
+
});
|
|
374
401
|
// Authoritative name assigned by the backend (may equal the provided temp name)
|
|
375
402
|
const realName = created?.name;
|
|
376
403
|
// The client-synthesized or user-provided temporary name used during optimism
|
|
377
|
-
const temporaryName = mutation.doc.name;
|
|
404
|
+
const temporaryName = mutation.temporaryName ?? mutation.doc.name;
|
|
405
|
+
// The ack payload IS the authoritative post-insert document (same user
|
|
406
|
+
// context as any refetch) — seed the cache unconditionally so remapped
|
|
407
|
+
// membership resolves immediately instead of waiting for the realtime
|
|
408
|
+
// echo (which used to leave the row blank until the socket delivered).
|
|
409
|
+
if (realName) {
|
|
410
|
+
this.normalizedCache.upsert({
|
|
411
|
+
...created,
|
|
412
|
+
doctype: created.doctype || mutation.doc.doctype,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
378
415
|
// If backend assigned a different name, remap temporary → real across subscriptions.
|
|
379
416
|
if (realName && temporaryName && realName !== temporaryName) {
|
|
380
417
|
this.handleTemporaryToRealMapping(mutation.doc.doctype, temporaryName, realName);
|
|
@@ -387,10 +424,6 @@ export class MutationEngine {
|
|
|
387
424
|
globalDocs.delete(temporaryName);
|
|
388
425
|
}
|
|
389
426
|
}
|
|
390
|
-
// When realtime is unhealthy, seed cache with authoritative document.
|
|
391
|
-
if (!this.realtime?.isHealthy()) {
|
|
392
|
-
this.normalizedCache.upsert(created);
|
|
393
|
-
}
|
|
394
427
|
}
|
|
395
428
|
|
|
396
429
|
private async handleServerUpdate<Data extends FrappeDocData>(
|
|
@@ -433,10 +466,16 @@ export class MutationEngine {
|
|
|
433
466
|
globalDocs.delete(temporaryName);
|
|
434
467
|
// Real name is no longer optimistic, so we don't add it back
|
|
435
468
|
}
|
|
436
|
-
//
|
|
469
|
+
// Replace in subscriptions. When the realtime event / CDC backfill /
|
|
470
|
+
// page-refresh reconcile already delivered the server row (the ack lost
|
|
471
|
+
// the race), a plain map would leave the same name twice — drop the
|
|
472
|
+
// temporary id instead so exactly one row remains.
|
|
437
473
|
for (const subscription of this.subscriptionManager.getAllSubscriptions().values()) {
|
|
438
474
|
if (subscription.query.doctype !== doctype) continue;
|
|
439
|
-
|
|
475
|
+
if (!subscription.docIds.includes(temporaryName)) continue;
|
|
476
|
+
subscription.docIds = subscription.docIds.includes(realName)
|
|
477
|
+
? subscription.docIds.filter((id) => id !== temporaryName)
|
|
478
|
+
: subscription.docIds.map((id) => (id === temporaryName ? realName : id));
|
|
440
479
|
}
|
|
441
480
|
// Remove the temporary cached row now that the authoritative name exists
|
|
442
481
|
this.normalizedCache.remove({ doctype, name: temporaryName });
|
|
@@ -450,7 +489,12 @@ export class MutationEngine {
|
|
|
450
489
|
const realName = (docname && this.temporaryIdToRealNameMap.get(docname)) || docname;
|
|
451
490
|
// A deleted doc no longer exists server-side — refetching it would 404
|
|
452
491
|
// and route a server-confirmed delete into the error/rollback path.
|
|
453
|
-
|
|
492
|
+
// A created doc is the insert-side sibling of the same bug: its cache
|
|
493
|
+
// copy was already seeded from the authoritative ack response, and
|
|
494
|
+
// refetching the pre-remap temporary name is a guaranteed 404 that used
|
|
495
|
+
// to roll back a server-confirmed create. Only updates refetch the
|
|
496
|
+
// main document here.
|
|
497
|
+
if (realName && mutation.operation === Operation.Update) {
|
|
454
498
|
targets.push({ doctype, name: realName });
|
|
455
499
|
}
|
|
456
500
|
if (mutation.affected?.length) targets.push(...mutation.affected);
|
|
@@ -503,11 +547,9 @@ export class MutationEngine {
|
|
|
503
547
|
switch (mutation.operation) {
|
|
504
548
|
case Operation.Create:
|
|
505
549
|
// Remove temporary optimistic document from cache and subscriptions
|
|
550
|
+
// (handleFailedCreate resolves the optimistic identity — consumer
|
|
551
|
+
// placeholder or engine-generated temp id — and evicts both).
|
|
506
552
|
await this.handleFailedCreate(mutation);
|
|
507
|
-
// Also remove from cache
|
|
508
|
-
if (docname) {
|
|
509
|
-
this.normalizedCache.remove({ doctype, name: docname });
|
|
510
|
-
}
|
|
511
553
|
break;
|
|
512
554
|
|
|
513
555
|
case Operation.Update:
|
|
@@ -541,7 +583,10 @@ export class MutationEngine {
|
|
|
541
583
|
}
|
|
542
584
|
|
|
543
585
|
private async handleFailedCreate<Data extends FrappeDocData>(mutation: Mutation<Data>) {
|
|
544
|
-
const temporaryName =
|
|
586
|
+
const temporaryName =
|
|
587
|
+
mutation.operation === Operation.Create
|
|
588
|
+
? (mutation.temporaryName ?? mutation.doc.name)
|
|
589
|
+
: mutation.doc.name;
|
|
545
590
|
const doctype = mutation.doc.doctype;
|
|
546
591
|
|
|
547
592
|
if (temporaryName) {
|
|
@@ -555,6 +600,9 @@ export class MutationEngine {
|
|
|
555
600
|
if (subscription.query.doctype !== doctype) continue;
|
|
556
601
|
subscription.docIds = subscription.docIds.filter((id) => id !== temporaryName);
|
|
557
602
|
}
|
|
603
|
+
// Evict the optimistic cache row (generated temp ids never lived in
|
|
604
|
+
// mutation.doc.name, so keying off the resolved name matters here).
|
|
605
|
+
this.normalizedCache.remove({ doctype, name: temporaryName });
|
|
558
606
|
}
|
|
559
607
|
}
|
|
560
608
|
|
|
@@ -788,12 +836,25 @@ export class MutationEngine {
|
|
|
788
836
|
}
|
|
789
837
|
try {
|
|
790
838
|
if (entry.operation === Operation.Create) {
|
|
791
|
-
await this.db.createDoc({
|
|
839
|
+
const created = await this.db.createDoc({
|
|
792
840
|
...entry.values,
|
|
793
841
|
client_mutation_id: id,
|
|
794
842
|
doctype: entry.doctype,
|
|
795
843
|
name: entry.docname || cryptoRandomId(),
|
|
796
844
|
});
|
|
845
|
+
// A replayed create acks with the authoritative document (possibly
|
|
846
|
+
// an idempotent replay of an earlier success) — reconcile the
|
|
847
|
+
// optimistic identity exactly like the first attempt.
|
|
848
|
+
if (created?.name) {
|
|
849
|
+
this.normalizedCache.upsert({
|
|
850
|
+
...created,
|
|
851
|
+
doctype: created.doctype || entry.doctype,
|
|
852
|
+
});
|
|
853
|
+
if (entry.docname && created.name !== entry.docname) {
|
|
854
|
+
this.handleTemporaryToRealMapping(entry.doctype, entry.docname, created.name);
|
|
855
|
+
this.rehydrationManager.registerTempIdMapping(entry.docname, created.name);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
797
858
|
} else if (entry.operation === Operation.Update && entry.docname) {
|
|
798
859
|
await this.db.updateDoc({
|
|
799
860
|
...entry.values,
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optimistic create → server row identity reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* Regression suite for the "optimistic insert row lingers beside the realtime
|
|
5
|
+
* server row" bug (p-results/fix-backoffice-resilience.md gap 3 known limit):
|
|
6
|
+
* the live wire wraps whitelisted method returns as `{message: {...doc}}`, so
|
|
7
|
+
* `createDoc().name` was undefined and the temporary→real remap never ran —
|
|
8
|
+
* the temp row stayed in membership forever, the realtime row was inserted
|
|
9
|
+
* alongside (2 UI rows / 1 server doc), the temp row's mutations 404'd, and
|
|
10
|
+
* with realtime unhealthy the post-create refetch of the temp name 404'd and
|
|
11
|
+
* rolled back a server-confirmed create (the insert-side sibling of the
|
|
12
|
+
* delete-refetch rollback bug).
|
|
13
|
+
*
|
|
14
|
+
* These specs run the REAL engine stack — SubscriptionManager,
|
|
15
|
+
* NormalizedCache, MutationJournal, RehydrationManager, RealtimeHandler,
|
|
16
|
+
* MutationEngine — over a scripted DbModule, so the ack/realtime race is
|
|
17
|
+
* exercised end-to-end in both orders (subscriptionManager.spec.ts pattern:
|
|
18
|
+
* real internals, mocked db).
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
|
+
import type { FrappeDoc, FrappeDocData } from "../common/types";
|
|
22
|
+
import type { DbModule } from "../db";
|
|
23
|
+
import type { RealtimeModule } from "../realtime";
|
|
24
|
+
import { MutationEngine } from "./mutationEngine";
|
|
25
|
+
import { MutationJournal } from "./mutationJournal";
|
|
26
|
+
import { NormalizedCache } from "./normalizedCache";
|
|
27
|
+
import { RealtimeHandler } from "./realtime";
|
|
28
|
+
import { RehydrationManager } from "./rehydration";
|
|
29
|
+
import { SubscriptionManager } from "./subscriptionManager";
|
|
30
|
+
import { Operation } from "./types";
|
|
31
|
+
import type { CreateMutation } from "./types";
|
|
32
|
+
|
|
33
|
+
type Doc = FrappeDoc<FrappeDocData>;
|
|
34
|
+
|
|
35
|
+
interface Harness {
|
|
36
|
+
engine: MutationEngine;
|
|
37
|
+
subscriptionManager: SubscriptionManager;
|
|
38
|
+
cache: NormalizedCache;
|
|
39
|
+
journal: MutationJournal;
|
|
40
|
+
db: {
|
|
41
|
+
getDoc: ReturnType<typeof vi.fn>;
|
|
42
|
+
getDocList: ReturnType<typeof vi.fn>;
|
|
43
|
+
createDoc: ReturnType<typeof vi.fn>;
|
|
44
|
+
updateDoc: ReturnType<typeof vi.fn>;
|
|
45
|
+
deleteDoc: ReturnType<typeof vi.fn>;
|
|
46
|
+
backfill: ReturnType<typeof vi.fn>;
|
|
47
|
+
};
|
|
48
|
+
/** Server-side rows the mocked db serves (list + getDoc). */
|
|
49
|
+
serverRows: Doc[];
|
|
50
|
+
/** Fires a realtime list_update the way the socket would. */
|
|
51
|
+
emitListUpdate: (name: string) => Promise<void>;
|
|
52
|
+
realtimeHealthy: { value: boolean };
|
|
53
|
+
dispose: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createHarness(): Harness {
|
|
57
|
+
const serverRows: Doc[] = [];
|
|
58
|
+
const realtimeHealthy = { value: true };
|
|
59
|
+
|
|
60
|
+
const db = {
|
|
61
|
+
getDoc: vi.fn(async (doctype: string, name: string) => {
|
|
62
|
+
const row = serverRows.find((doc) => doc.doctype === doctype && doc.name === name);
|
|
63
|
+
if (!row) {
|
|
64
|
+
const error = new Error(`${doctype}/${name} not found`) as Error & { status: number };
|
|
65
|
+
error.status = 404;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
return { ...row };
|
|
69
|
+
}),
|
|
70
|
+
getDocList: vi.fn(async (doctype: string) =>
|
|
71
|
+
serverRows.filter((doc) => doc.doctype === doctype).map((doc) => ({ ...doc })),
|
|
72
|
+
),
|
|
73
|
+
createDoc: vi.fn(),
|
|
74
|
+
updateDoc: vi.fn(),
|
|
75
|
+
deleteDoc: vi.fn(),
|
|
76
|
+
backfill: vi.fn(async () => ({ rows: [], checkpoint: undefined })),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const realtimeHandlers = new Map<string, (payload: unknown) => Promise<void> | void>();
|
|
80
|
+
const realtime = {
|
|
81
|
+
isHealthy: () => realtimeHealthy.value,
|
|
82
|
+
on: (event: string, handler: (payload: unknown) => Promise<void> | void) => {
|
|
83
|
+
realtimeHandlers.set(event, handler);
|
|
84
|
+
},
|
|
85
|
+
subscribeList: vi.fn(),
|
|
86
|
+
unsubscribeList: vi.fn(),
|
|
87
|
+
disconnect: vi.fn(),
|
|
88
|
+
} as unknown as RealtimeModule;
|
|
89
|
+
|
|
90
|
+
const cache = new NormalizedCache({});
|
|
91
|
+
const journal = new MutationJournal({ enableAutoCleanup: false });
|
|
92
|
+
const subscriptionManager = new SubscriptionManager(db as unknown as DbModule, cache, {});
|
|
93
|
+
const rehydrationManager = new RehydrationManager(
|
|
94
|
+
db as unknown as DbModule,
|
|
95
|
+
cache,
|
|
96
|
+
subscriptionManager,
|
|
97
|
+
journal,
|
|
98
|
+
);
|
|
99
|
+
const engine = new MutationEngine(
|
|
100
|
+
db as unknown as DbModule,
|
|
101
|
+
realtime,
|
|
102
|
+
cache,
|
|
103
|
+
journal,
|
|
104
|
+
subscriptionManager,
|
|
105
|
+
rehydrationManager,
|
|
106
|
+
{},
|
|
107
|
+
);
|
|
108
|
+
const realtimeHandler = new RealtimeHandler(
|
|
109
|
+
db as unknown as DbModule,
|
|
110
|
+
realtime,
|
|
111
|
+
cache,
|
|
112
|
+
subscriptionManager,
|
|
113
|
+
rehydrationManager,
|
|
114
|
+
{},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
engine,
|
|
119
|
+
subscriptionManager,
|
|
120
|
+
cache,
|
|
121
|
+
journal,
|
|
122
|
+
db,
|
|
123
|
+
serverRows,
|
|
124
|
+
emitListUpdate: async (name: string) => {
|
|
125
|
+
await realtimeHandlers.get("list_update")?.({ doctype: "TestDoc", name });
|
|
126
|
+
},
|
|
127
|
+
realtimeHealthy,
|
|
128
|
+
dispose: () => {
|
|
129
|
+
engine.dispose();
|
|
130
|
+
realtimeHandler.dispose();
|
|
131
|
+
rehydrationManager.clear();
|
|
132
|
+
journal.dispose();
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const serverDoc = (name: string): Doc => ({
|
|
138
|
+
doctype: "TestDoc",
|
|
139
|
+
name,
|
|
140
|
+
title: "Created",
|
|
141
|
+
modified: "2024-01-01T10:00:00",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const createMutation = (
|
|
145
|
+
doc: Partial<Doc>,
|
|
146
|
+
mutationId = "create-1",
|
|
147
|
+
): CreateMutation<FrappeDocData> => ({
|
|
148
|
+
mutationId,
|
|
149
|
+
operation: Operation.Create,
|
|
150
|
+
doc: { doctype: "TestDoc", ...doc } as Doc,
|
|
151
|
+
affected: [],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("optimistic create reconciliation (engine-level)", () => {
|
|
155
|
+
let harness: Harness;
|
|
156
|
+
let subscriptionId: string;
|
|
157
|
+
|
|
158
|
+
const rowNames = () =>
|
|
159
|
+
harness.subscriptionManager.getDocsForSubscription(subscriptionId).map((doc) => doc.name);
|
|
160
|
+
const docIds = () => [
|
|
161
|
+
...(harness.subscriptionManager.getSubscription(subscriptionId)?.docIds ?? []),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
beforeEach(async () => {
|
|
165
|
+
vi.useFakeTimers();
|
|
166
|
+
harness = createHarness();
|
|
167
|
+
const result = await harness.subscriptionManager.subscribe({
|
|
168
|
+
doctype: "TestDoc",
|
|
169
|
+
fields: ["name", "title", "modified"],
|
|
170
|
+
});
|
|
171
|
+
subscriptionId = result.subscriptionId;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
harness.dispose();
|
|
176
|
+
vi.useRealTimers();
|
|
177
|
+
vi.restoreAllMocks();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("server ack reconciles the optimistic row to a single server-named row", async () => {
|
|
181
|
+
harness.db.createDoc.mockImplementation(async () => {
|
|
182
|
+
harness.serverRows.push(serverDoc("real-1"));
|
|
183
|
+
return serverDoc("real-1");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const mutation = createMutation({ name: "new-todo-placeholder" });
|
|
187
|
+
const mutatePromise = harness.engine.mutate(mutation);
|
|
188
|
+
|
|
189
|
+
// Optimistic state: the placeholder row is live immediately.
|
|
190
|
+
expect(docIds()).toEqual(["new-todo-placeholder"]);
|
|
191
|
+
expect(rowNames()).toEqual(["new-todo-placeholder"]);
|
|
192
|
+
|
|
193
|
+
await mutatePromise;
|
|
194
|
+
|
|
195
|
+
// Ack reconciled identity: exactly one row, under the server name, and
|
|
196
|
+
// the row resolves from the cache (the ack payload seeded it — no wait
|
|
197
|
+
// for the realtime echo).
|
|
198
|
+
expect(docIds()).toEqual(["real-1"]);
|
|
199
|
+
expect(rowNames()).toEqual(["real-1"]);
|
|
200
|
+
expect(harness.cache.get({ doctype: "TestDoc", name: "new-todo-placeholder" })).toBeUndefined();
|
|
201
|
+
expect(harness.engine.getTemporaryIdToRealNameMap().get("new-todo-placeholder")).toBe("real-1");
|
|
202
|
+
|
|
203
|
+
// The idempotency machinery rides the first attempt.
|
|
204
|
+
expect(harness.db.createDoc).toHaveBeenCalledWith(
|
|
205
|
+
expect.objectContaining({ client_mutation_id: "create-1" }),
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("reconciles when the realtime insert arrives BEFORE the REST ack", async () => {
|
|
210
|
+
let resolveAck: (doc: Doc) => void = () => {};
|
|
211
|
+
harness.db.createDoc.mockImplementation(() => {
|
|
212
|
+
// Server commits immediately (realtime will observe the row); only the
|
|
213
|
+
// HTTP response is still in flight.
|
|
214
|
+
harness.serverRows.push(serverDoc("real-2"));
|
|
215
|
+
return new Promise<Doc>((resolve) => {
|
|
216
|
+
resolveAck = resolve;
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const mutation = createMutation({ name: "new-todo-racer" });
|
|
221
|
+
const mutatePromise = harness.engine.mutate(mutation);
|
|
222
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
223
|
+
expect(docIds()).toEqual(["new-todo-racer"]);
|
|
224
|
+
|
|
225
|
+
// Realtime beats the ack: the doctype-room event lands and the page
|
|
226
|
+
// refresh it schedules runs while the create is still pending.
|
|
227
|
+
await harness.emitListUpdate("real-2");
|
|
228
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
229
|
+
|
|
230
|
+
// Pre-ack the engine honestly shows both: the unmatched server row and
|
|
231
|
+
// the still-pending optimistic row (they cannot be correlated until the
|
|
232
|
+
// ack names the create).
|
|
233
|
+
expect([...docIds()].sort()).toEqual(["new-todo-racer", "real-2"]);
|
|
234
|
+
|
|
235
|
+
resolveAck(serverDoc("real-2"));
|
|
236
|
+
await mutatePromise;
|
|
237
|
+
|
|
238
|
+
// Ack lands: the temp row is dropped rather than remapped onto the
|
|
239
|
+
// already-present server row — exactly one row, no duplicates.
|
|
240
|
+
expect(docIds()).toEqual(["real-2"]);
|
|
241
|
+
expect(rowNames()).toEqual(["real-2"]);
|
|
242
|
+
expect(harness.cache.get({ doctype: "TestDoc", name: "new-todo-racer" })).toBeUndefined();
|
|
243
|
+
|
|
244
|
+
// Late rehydrations stay idempotent.
|
|
245
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
246
|
+
expect(docIds()).toEqual(["real-2"]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("reconciles when the REST ack lands BEFORE the realtime insert", async () => {
|
|
250
|
+
harness.db.createDoc.mockImplementation(async () => {
|
|
251
|
+
harness.serverRows.push(serverDoc("real-3"));
|
|
252
|
+
return serverDoc("real-3");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await harness.engine.mutate(createMutation({ name: "new-todo-early-ack" }));
|
|
256
|
+
expect(docIds()).toEqual(["real-3"]);
|
|
257
|
+
|
|
258
|
+
// The realtime echo for the same doc arrives afterwards — membership
|
|
259
|
+
// already contains the server name, so nothing duplicates.
|
|
260
|
+
await harness.emitListUpdate("real-3");
|
|
261
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
262
|
+
|
|
263
|
+
expect(docIds()).toEqual(["real-3"]);
|
|
264
|
+
expect(rowNames()).toEqual(["real-3"]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("acks a create with an engine-generated temp name without sending the temp name", async () => {
|
|
268
|
+
harness.db.createDoc.mockImplementation(async () => {
|
|
269
|
+
harness.serverRows.push(serverDoc("real-4"));
|
|
270
|
+
return serverDoc("real-4");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const mutation = createMutation({});
|
|
274
|
+
const mutatePromise = harness.engine.mutate(mutation);
|
|
275
|
+
const optimisticIds = docIds();
|
|
276
|
+
expect(optimisticIds).toHaveLength(1);
|
|
277
|
+
expect(optimisticIds[0]).toMatch(/^tmp_/);
|
|
278
|
+
|
|
279
|
+
await mutatePromise;
|
|
280
|
+
|
|
281
|
+
// The generated temp id was never sent to the server…
|
|
282
|
+
expect(harness.db.createDoc.mock.calls[0][0].name).toBeUndefined();
|
|
283
|
+
// …and the ack still reconciled it away.
|
|
284
|
+
expect(docIds()).toEqual(["real-4"]);
|
|
285
|
+
expect(harness.cache.get({ doctype: "TestDoc", name: optimisticIds[0] })).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("does not roll back a server-confirmed create when realtime is unhealthy", async () => {
|
|
289
|
+
// The insert-side sibling of the delete-refetch rollback bug: with the
|
|
290
|
+
// socket down, the post-mutation refetch used to target the PRE-remap
|
|
291
|
+
// temporary name → guaranteed 404 → journal.fail + rollback of a create
|
|
292
|
+
// the server had already committed.
|
|
293
|
+
harness.realtimeHealthy.value = false;
|
|
294
|
+
harness.db.createDoc.mockImplementation(async () => {
|
|
295
|
+
harness.serverRows.push(serverDoc("real-5"));
|
|
296
|
+
return serverDoc("real-5");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
await expect(
|
|
300
|
+
harness.engine.mutate(createMutation({ name: "new-todo-offline-socket" })),
|
|
301
|
+
).resolves.not.toThrow();
|
|
302
|
+
|
|
303
|
+
expect(docIds()).toEqual(["real-5"]);
|
|
304
|
+
expect(rowNames()).toEqual(["real-5"]);
|
|
305
|
+
// The temp name was never refetched (it would 404).
|
|
306
|
+
expect(harness.db.getDoc).not.toHaveBeenCalledWith("TestDoc", "new-todo-offline-socket");
|
|
307
|
+
expect(harness.journal.get("create-1")).toBeUndefined(); // confirmed → removed
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("confirms (not rolls back) a create whose ack carries no document name", async () => {
|
|
311
|
+
// Custom endpoints (and the pre-fix live envelope) can yield an ack
|
|
312
|
+
// without a usable name. The engine then cannot remap — but it must NOT
|
|
313
|
+
// refetch the temporary name (guaranteed 404) and roll back a create
|
|
314
|
+
// the server just confirmed. The optimistic row stays until realtime /
|
|
315
|
+
// backfill reconciles it.
|
|
316
|
+
harness.realtimeHealthy.value = false;
|
|
317
|
+
harness.db.createDoc.mockResolvedValue({} as Doc);
|
|
318
|
+
|
|
319
|
+
await expect(
|
|
320
|
+
harness.engine.mutate(createMutation({ name: "new-todo-nameless-ack" })),
|
|
321
|
+
).resolves.not.toThrow();
|
|
322
|
+
|
|
323
|
+
expect(harness.db.getDoc).not.toHaveBeenCalled();
|
|
324
|
+
expect(harness.journal.get("create-1")).toBeUndefined(); // confirmed → removed
|
|
325
|
+
expect(docIds()).toEqual(["new-todo-nameless-ack"]);
|
|
326
|
+
expect(rowNames()).toEqual(["new-todo-nameless-ack"]);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("rolls back a rejected create (temp row evicted from membership and cache)", async () => {
|
|
330
|
+
harness.db.createDoc.mockRejectedValue(
|
|
331
|
+
Object.assign(new Error("Validation failed"), {
|
|
332
|
+
status: 417,
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
await expect(
|
|
337
|
+
harness.engine.mutate(createMutation({ name: "new-todo-rejected" })),
|
|
338
|
+
).rejects.toThrow("Validation failed");
|
|
339
|
+
|
|
340
|
+
expect(docIds()).toEqual([]);
|
|
341
|
+
expect(rowNames()).toEqual([]);
|
|
342
|
+
expect(harness.cache.get({ doctype: "TestDoc", name: "new-todo-rejected" })).toBeUndefined();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("rolls back a rejected create even when the realtime row already landed", async () => {
|
|
346
|
+
// Rejection while a foreign-looking server row is present (e.g. another
|
|
347
|
+
// user's concurrent insert): only the optimistic temp row is rolled
|
|
348
|
+
// back — the authoritative server row stays.
|
|
349
|
+
harness.serverRows.push(serverDoc("real-other"));
|
|
350
|
+
let rejectAck: (err: unknown) => void = () => {};
|
|
351
|
+
harness.db.createDoc.mockImplementation(
|
|
352
|
+
() =>
|
|
353
|
+
new Promise((_resolve, reject) => {
|
|
354
|
+
rejectAck = reject;
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const mutatePromise = harness.engine.mutate(createMutation({ name: "new-todo-doomed" }));
|
|
359
|
+
const settled = mutatePromise.catch((err) => err);
|
|
360
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
361
|
+
await harness.emitListUpdate("real-other");
|
|
362
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
363
|
+
expect([...docIds()].sort()).toEqual(["new-todo-doomed", "real-other"]);
|
|
364
|
+
|
|
365
|
+
rejectAck(Object.assign(new Error("Server said no"), { status: 417 }));
|
|
366
|
+
expect(await settled).toBeInstanceOf(Error);
|
|
367
|
+
|
|
368
|
+
expect(docIds()).toEqual(["real-other"]);
|
|
369
|
+
expect(rowNames()).toEqual(["real-other"]);
|
|
370
|
+
expect(harness.cache.get({ doctype: "TestDoc", name: "new-todo-doomed" })).toBeUndefined();
|
|
371
|
+
});
|
|
372
|
+
});
|