@multiplatform.one/frappe 6.4.2 → 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.mjs.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.mjs.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.mjs.map +1 -1
- package/dist/esm/plugin/index.native.js.map +1 -1
- package/dist/esm/realtime.mjs.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.mjs.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.mjs.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.mjs.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.mjs.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/core/http.mjs.map +1 -1
- package/dist/jsx/core/types.mjs.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/plugin/index.mjs.map +1 -1
- package/dist/jsx/realtime.mjs.map +1 -1
- package/dist/jsx/realtime.native.js.map +1 -1
- package/dist/jsx/sync/backfill.mjs.map +1 -1
- package/dist/jsx/sync/filters.mjs.map +1 -1
- package/dist/jsx/sync/filters.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/merge.mjs.map +1 -1
- package/dist/jsx/sync/merge.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/dist/jsx/sync/virtualPageManager.mjs.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
package/src/collection.ts
CHANGED
|
@@ -34,6 +34,40 @@ export interface FrappeCollectionOptionsConfig<
|
|
|
34
34
|
onSyncError?: (error: unknown) => void;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// External sync release
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
//
|
|
41
|
+
// Registered per collection while a Frappe sync session is active. Lets the
|
|
42
|
+
// shared-collection GC in frappeReact free the REAL resources (SyncModule
|
|
43
|
+
// subscription, store listener, realtime room) of a collection that leaked
|
|
44
|
+
// live queries still pin, WITHOUT calling collection.cleanup(): TanStack DB
|
|
45
|
+
// transitions every attached live query to a permanent error state (plus a
|
|
46
|
+
// console.error per query — "Source collection … was manually cleaned up
|
|
47
|
+
// while live query … depends on it") when a depended-on collection is
|
|
48
|
+
// cleaned up. Releasing externally leaves the collection object alive but
|
|
49
|
+
// inert: attached queries keep their last data, no error, no leak beyond
|
|
50
|
+
// the plain object graph the leaked query itself already holds.
|
|
51
|
+
const externalSyncReleases = new WeakMap<object, () => void>();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Releases the Frappe-side sync resources (SyncModule subscription + store
|
|
55
|
+
* listener → realtime room) of a collection created by
|
|
56
|
+
* {@link createFrappeCollection}, without touching TanStack DB's lifecycle.
|
|
57
|
+
* Idempotent; also runs automatically when TanStack stops the sync itself.
|
|
58
|
+
*
|
|
59
|
+
* @returns true when an active sync session was released, false when the
|
|
60
|
+
* collection has no registered session (sync never started, already
|
|
61
|
+
* stopped, or not a Frappe collection) — callers may fall back to
|
|
62
|
+
* `collection.cleanup()` in that case.
|
|
63
|
+
*/
|
|
64
|
+
export function releaseFrappeCollectionSync(collection: object): boolean {
|
|
65
|
+
const release = externalSyncReleases.get(collection);
|
|
66
|
+
if (!release) return false;
|
|
67
|
+
release();
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
/**
|
|
38
72
|
* Creates TanStack DB collection options wired to Frappe's SyncModule.
|
|
39
73
|
*
|
|
@@ -74,6 +108,22 @@ export function frappeCollectionOptions<TDoc extends object & { name: string; do
|
|
|
74
108
|
// (invalid "cleaned-up" → "ready" status transition).
|
|
75
109
|
let cancelled = false;
|
|
76
110
|
|
|
111
|
+
// Idempotent resource release shared by TanStack's own sync teardown
|
|
112
|
+
// and the external escape hatch (releaseFrappeCollectionSync).
|
|
113
|
+
const releaseResources = () => {
|
|
114
|
+
cancelled = true;
|
|
115
|
+
if (storeUnsubscribe) {
|
|
116
|
+
storeUnsubscribe();
|
|
117
|
+
storeUnsubscribe = undefined;
|
|
118
|
+
}
|
|
119
|
+
if (subscriptionId) {
|
|
120
|
+
syncModule.unsubscribe(subscriptionId);
|
|
121
|
+
subscriptionId = undefined;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const syncedCollection = (params as { collection?: object }).collection;
|
|
125
|
+
if (syncedCollection) externalSyncReleases.set(syncedCollection, releaseResources);
|
|
126
|
+
|
|
77
127
|
async function initSync() {
|
|
78
128
|
try {
|
|
79
129
|
// Subscribe to the SyncModule for this doctype
|
|
@@ -147,15 +197,10 @@ export function frappeCollectionOptions<TDoc extends object & { name: string; do
|
|
|
147
197
|
|
|
148
198
|
// Return cleanup function
|
|
149
199
|
return () => {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
storeUnsubscribe();
|
|
153
|
-
storeUnsubscribe = undefined;
|
|
154
|
-
}
|
|
155
|
-
if (subscriptionId) {
|
|
156
|
-
syncModule.unsubscribe(subscriptionId);
|
|
157
|
-
subscriptionId = undefined;
|
|
200
|
+
if (syncedCollection && externalSyncReleases.get(syncedCollection) === releaseResources) {
|
|
201
|
+
externalSyncReleases.delete(syncedCollection);
|
|
158
202
|
}
|
|
203
|
+
releaseResources();
|
|
159
204
|
};
|
|
160
205
|
};
|
|
161
206
|
|
package/src/db.spec.ts
CHANGED
|
@@ -511,6 +511,54 @@ describe("DbModule", () => {
|
|
|
511
511
|
expect.any(Object),
|
|
512
512
|
);
|
|
513
513
|
});
|
|
514
|
+
|
|
515
|
+
it("should unwrap the live server's {message} envelope so the ack surfaces the real name", async () => {
|
|
516
|
+
// Regression: whitelisted method returns arrive as {message: {...doc}}.
|
|
517
|
+
// Returning the envelope made created.name undefined, so the mutation
|
|
518
|
+
// engine's temporary→real reconciliation never ran (optimistic rows
|
|
519
|
+
// lingered next to their realtime server rows until reload).
|
|
520
|
+
const createdDoc: TestDoc = {
|
|
521
|
+
doctype: "TestDocType",
|
|
522
|
+
name: "SERVER-NAME-001",
|
|
523
|
+
title: "New Document",
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
(mockHttpClient.post as any).mockResolvedValue({
|
|
527
|
+
payload: { message: createdDoc },
|
|
528
|
+
headers: new Headers(),
|
|
529
|
+
status: 200,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const result = await db.createDoc<TestDoc>({
|
|
533
|
+
doctype: "TestDocType",
|
|
534
|
+
name: "new-todo-placeholder",
|
|
535
|
+
title: "New Document",
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
expect(result).toEqual(createdDoc);
|
|
539
|
+
expect(result.name).toBe("SERVER-NAME-001");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("should not unwrap a document that itself has a message field", async () => {
|
|
543
|
+
// A doc with a scalar `message` field (e.g. Email Template) must pass
|
|
544
|
+
// through untouched — only the envelope shape (object message, no
|
|
545
|
+
// top-level name) unwraps.
|
|
546
|
+
const docWithMessageField = {
|
|
547
|
+
doctype: "TestDocType",
|
|
548
|
+
name: "TEST-004",
|
|
549
|
+
message: "I am a doc field, not an envelope",
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
(mockHttpClient.post as any).mockResolvedValue({
|
|
553
|
+
payload: docWithMessageField,
|
|
554
|
+
headers: new Headers(),
|
|
555
|
+
status: 200,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const result = await db.createDoc(docWithMessageField as any);
|
|
559
|
+
|
|
560
|
+
expect(result).toEqual(docWithMessageField);
|
|
561
|
+
});
|
|
514
562
|
});
|
|
515
563
|
|
|
516
564
|
describe("updateDoc", () => {
|
|
@@ -664,6 +712,28 @@ describe("DbModule", () => {
|
|
|
664
712
|
expect.any(Object),
|
|
665
713
|
);
|
|
666
714
|
});
|
|
715
|
+
|
|
716
|
+
it("should unwrap the live server's {message} envelope", async () => {
|
|
717
|
+
const updatedDoc: TestDoc = {
|
|
718
|
+
doctype: "TestDocType",
|
|
719
|
+
name: "TEST-001",
|
|
720
|
+
title: "Updated Title",
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
(mockHttpClient.post as any).mockResolvedValue({
|
|
724
|
+
payload: { message: updatedDoc },
|
|
725
|
+
headers: new Headers(),
|
|
726
|
+
status: 200,
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
const result = await db.updateDoc<TestDoc>({
|
|
730
|
+
doctype: "TestDocType",
|
|
731
|
+
name: "TEST-001",
|
|
732
|
+
title: "Updated Title",
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
expect(result).toEqual(updatedDoc);
|
|
736
|
+
});
|
|
667
737
|
});
|
|
668
738
|
|
|
669
739
|
describe("deleteDoc", () => {
|
package/src/db.ts
CHANGED
|
@@ -142,13 +142,14 @@ export class DbModule {
|
|
|
142
142
|
if (client_mutation_id) {
|
|
143
143
|
headers["Idempotency-Key"] = client_mutation_id;
|
|
144
144
|
}
|
|
145
|
-
|
|
145
|
+
const raw = (
|
|
146
146
|
await this.http.post<FrappeDoc<Data>>(
|
|
147
147
|
`/api/method/${this.endpoints.createDoc}`,
|
|
148
148
|
cleanDoc,
|
|
149
149
|
headers,
|
|
150
150
|
)
|
|
151
151
|
).payload;
|
|
152
|
+
return unwrapMethodDoc(raw);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
async updateDoc<Data extends FrappeDocData>(
|
|
@@ -165,13 +166,14 @@ export class DbModule {
|
|
|
165
166
|
if (base_modified) {
|
|
166
167
|
headers["If-Match"] = base_modified;
|
|
167
168
|
}
|
|
168
|
-
|
|
169
|
+
const raw = (
|
|
169
170
|
await this.http.post<FrappeDoc<Data>>(
|
|
170
171
|
`/api/method/${this.endpoints.updateDoc}`,
|
|
171
172
|
{ doctype, name, data },
|
|
172
173
|
headers,
|
|
173
174
|
)
|
|
174
175
|
).payload;
|
|
176
|
+
return unwrapMethodDoc(raw);
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
async deleteDoc<Data extends FrappeDocData>(
|
|
@@ -291,6 +293,30 @@ function normalizeFilters(
|
|
|
291
293
|
return Object.entries(filters).map(([k, v]) => [k, "=", v]);
|
|
292
294
|
}
|
|
293
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Unwraps Frappe's whitelisted-method response envelope.
|
|
298
|
+
*
|
|
299
|
+
* The live server returns the document wrapped as `{ message: {...doc} }`
|
|
300
|
+
* (every `@frappe.whitelist` return is enveloped). The old code returned the
|
|
301
|
+
* envelope itself, so `created.name` was always undefined and the mutation
|
|
302
|
+
* engine's temporary→real name reconciliation was dead code on the live wire
|
|
303
|
+
* (optimistic rows lingered next to their realtime server rows).
|
|
304
|
+
*
|
|
305
|
+
* Mocks and custom endpoints may return the document directly, so only
|
|
306
|
+
* unwrap when the payload actually looks like an envelope: a `message`
|
|
307
|
+
* object (a doc's own scalar `message` field or a child-table `message`
|
|
308
|
+
* array never matches) on a payload that has no `name` of its own.
|
|
309
|
+
*/
|
|
310
|
+
function unwrapMethodDoc<Doc>(raw: Doc): Doc {
|
|
311
|
+
if (raw && typeof raw === "object" && !("name" in raw)) {
|
|
312
|
+
const message = (raw as { message?: unknown }).message;
|
|
313
|
+
if (message && typeof message === "object" && !Array.isArray(message)) {
|
|
314
|
+
return message as Doc;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return raw;
|
|
318
|
+
}
|
|
319
|
+
|
|
294
320
|
export interface BackfillArgs {
|
|
295
321
|
method?: string;
|
|
296
322
|
doctype: string;
|
package/src/frappeReact.spec.ts
CHANGED
|
@@ -44,7 +44,13 @@ const { mockCreateFrappeCollection, mockIsBrowser } = vi.hoisted(() => ({
|
|
|
44
44
|
mockIsBrowser: vi.fn(() => true),
|
|
45
45
|
}));
|
|
46
46
|
|
|
47
|
-
vi.mock("./collection", () => ({
|
|
47
|
+
vi.mock("./collection", () => ({
|
|
48
|
+
createFrappeCollection: mockCreateFrappeCollection,
|
|
49
|
+
// Mocked collections never register a sync session, so the registry's
|
|
50
|
+
// force-GC falls back to collection.cleanup() (the pre-existing contract
|
|
51
|
+
// these specs assert).
|
|
52
|
+
releaseFrappeCollectionSync: () => false,
|
|
53
|
+
}));
|
|
48
54
|
vi.mock("./util", () => ({ isBrowser: mockIsBrowser }));
|
|
49
55
|
vi.mock("./fixtureDb", () => ({ FixtureDbModule: class {} }));
|
|
50
56
|
|
package/src/frappeReact.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { isWindowDefined } from "@multiplatform.one/platform";
|
|
2
2
|
import type { Collection } from "@tanstack/db";
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createFrappeCollection,
|
|
6
|
+
releaseFrappeCollectionSync,
|
|
7
|
+
type FrappeCollectionOptionsConfig,
|
|
8
|
+
} from "./collection";
|
|
5
9
|
import { HttpClient } from "./core/http";
|
|
6
10
|
import type { AuthConfig } from "./core/types";
|
|
7
11
|
import { DbModule } from "./db";
|
|
@@ -233,8 +237,20 @@ function scheduleEntryGc(
|
|
|
233
237
|
// Past the deadline: a leaked query (created by a render that never
|
|
234
238
|
// committed — TanStack DB never GCs a live query that never had a
|
|
235
239
|
// subscriber) is pinning the sync subscription + realtime room.
|
|
236
|
-
//
|
|
237
|
-
//
|
|
240
|
+
// Calling cleanup() here would transition EVERY attached live query
|
|
241
|
+
// to a permanent error state with a console.error per query ("Source
|
|
242
|
+
// collection 'X' was manually cleaned up while live query … depends
|
|
243
|
+
// on it" — the Workspace console-sweep bug), so release the
|
|
244
|
+
// Frappe-side resources directly instead and leave the collection
|
|
245
|
+
// object alive but inert. The entry is still evicted: any consumer
|
|
246
|
+
// that appears later gets a fresh instance via the stale-entry paths
|
|
247
|
+
// in getOrCreateSharedCollection / the hook.
|
|
248
|
+
entry.cleaned = true;
|
|
249
|
+
entry.gcForceDeadline = undefined;
|
|
250
|
+
if (cache.get(key) === entry) cache.delete(key);
|
|
251
|
+
if (releaseFrappeCollectionSync(entry.collection)) return;
|
|
252
|
+
// No active sync session registered (non-frappe collection or sync
|
|
253
|
+
// never started) — fall through to the plain cleanup below.
|
|
238
254
|
}
|
|
239
255
|
entry.cleaned = true;
|
|
240
256
|
entry.gcForceDeadline = undefined;
|
|
@@ -242,6 +258,7 @@ function scheduleEntryGc(
|
|
|
242
258
|
// Best-effort: Collection.cleanup() stops the sync (unsubscribes from the
|
|
243
259
|
// SyncModule, leaving the realtime room). A consumer that raced eviction
|
|
244
260
|
// still works — TanStack DB restarts a cleaned-up collection on next use.
|
|
261
|
+
// Safe here: subscriberCount is 0, so no live query depends on it.
|
|
245
262
|
Promise.resolve(entry.collection.cleanup?.()).catch(() => {});
|
|
246
263
|
}, SHARED_COLLECTION_GC_MS);
|
|
247
264
|
}
|
|
@@ -366,9 +383,16 @@ function cleanupEntryCollectionWhenIdle(entry: SharedCollectionEntry): void {
|
|
|
366
383
|
const tick = () => {
|
|
367
384
|
entry.gcTimer = undefined;
|
|
368
385
|
const subscriberCount = (entry.collection as { subscriberCount?: number }).subscriberCount ?? 0;
|
|
369
|
-
if (subscriberCount > 0
|
|
370
|
-
|
|
371
|
-
|
|
386
|
+
if (subscriberCount > 0) {
|
|
387
|
+
if (Date.now() < deadline) {
|
|
388
|
+
entry.gcTimer = setTimeout(tick, SHARED_COLLECTION_GC_MS);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// Queries are still attached past the force window (leaked queries
|
|
392
|
+
// from never-committed renders). cleanup() would permanently error
|
|
393
|
+
// each of them with a console.error — release the sync resources
|
|
394
|
+
// externally instead; the collection object stays alive but inert.
|
|
395
|
+
if (releaseFrappeCollectionSync(entry.collection)) return;
|
|
372
396
|
}
|
|
373
397
|
Promise.resolve(entry.collection.cleanup?.()).catch(() => {});
|
|
374
398
|
};
|
|
@@ -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,
|