@multiplatform.one/frappe 6.4.3 → 6.7.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/collection.cjs +25 -9
- package/dist/cjs/collection.native.js +25 -9
- package/dist/cjs/collection.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/frappeReact.cjs +10 -3
- package/dist/cjs/frappeReact.native.js +10 -3
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +1 -0
- package/dist/cjs/index.native.js +1 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/realtime.cjs +222 -11
- package/dist/cjs/realtime.native.js +352 -14
- package/dist/cjs/realtime.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/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/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/esm/collection.mjs +24 -9
- package/dist/esm/collection.mjs.map +1 -1
- package/dist/esm/collection.native.js +24 -9
- package/dist/esm/collection.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 +18 -2
- package/dist/esm/db.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 +11 -4
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.mjs +2 -2
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -2
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/realtime.mjs +222 -11
- package/dist/esm/realtime.mjs.map +1 -1
- package/dist/esm/realtime.native.js +352 -14
- package/dist/esm/realtime.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 +6 -1
- package/dist/esm/sync/index.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 +51 -21
- package/dist/esm/sync/mutationEngine.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 +4 -0
- 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/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/index.js +2 -2
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +2 -2
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +1 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/realtime.mjs +222 -11
- package/dist/jsx/realtime.mjs.map +1 -1
- package/dist/jsx/realtime.native.js +352 -14
- package/dist/jsx/realtime.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 +7 -7
- 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/index.ts +13 -1
- package/src/realtime.ts +406 -14
- package/src/realtimeSse.spec.ts +547 -0
- 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/index.d.ts +2 -1
- package/types/index.d.ts.map +1 -1
- package/types/realtime.d.ts +166 -1
- package/types/realtime.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
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { io } from "socket.io-client";
|
|
3
|
+
import type { RealtimeConfig, RealtimeSSEConfig, SSEMessageEventLike } from "./realtime";
|
|
4
|
+
import { RealtimeModule } from "./realtime";
|
|
5
|
+
|
|
6
|
+
// Mock socket.io-client so the "socketio default untouched" checks can assert
|
|
7
|
+
// which transport was constructed without ever touching the network.
|
|
8
|
+
const mockSocket = {
|
|
9
|
+
connected: false,
|
|
10
|
+
on: vi.fn(),
|
|
11
|
+
off: vi.fn(),
|
|
12
|
+
emit: vi.fn(),
|
|
13
|
+
connect: vi.fn(),
|
|
14
|
+
disconnect: vi.fn(),
|
|
15
|
+
removeAllListeners: vi.fn(),
|
|
16
|
+
id: "mock-socket-id",
|
|
17
|
+
io: { opts: {} as Record<string, unknown>, on: vi.fn() },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
vi.mock("socket.io-client", () => ({
|
|
21
|
+
io: vi.fn(() => mockSocket),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
type FakeListener = (event: SSEMessageEventLike) => void;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Fake WHATWG EventSource injected via config.sse.EventSource. Records every
|
|
28
|
+
* construction (URL + init) and lets tests drive the lifecycle: `open()` /
|
|
29
|
+
* `fail()` dispatch dataless lifecycle events, `frame()` dispatches a server
|
|
30
|
+
* frame (a MessageEvent-like object WITH data).
|
|
31
|
+
*/
|
|
32
|
+
class FakeEventSource {
|
|
33
|
+
static instances: FakeEventSource[] = [];
|
|
34
|
+
url: string;
|
|
35
|
+
withCredentials?: boolean;
|
|
36
|
+
closed = false;
|
|
37
|
+
private listeners = new Map<string, Set<FakeListener>>();
|
|
38
|
+
|
|
39
|
+
constructor(url: string, init?: { withCredentials?: boolean }) {
|
|
40
|
+
this.url = url;
|
|
41
|
+
this.withCredentials = init?.withCredentials;
|
|
42
|
+
FakeEventSource.instances.push(this);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addEventListener(type: string, listener: FakeListener) {
|
|
46
|
+
let set = this.listeners.get(type);
|
|
47
|
+
if (!set) {
|
|
48
|
+
set = new Set();
|
|
49
|
+
this.listeners.set(type, set);
|
|
50
|
+
}
|
|
51
|
+
set.add(listener);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
removeEventListener(type: string, listener: FakeListener) {
|
|
55
|
+
this.listeners.get(type)?.delete(listener);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
close() {
|
|
59
|
+
this.closed = true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
listenerCount(type: string) {
|
|
63
|
+
return this.listeners.get(type)?.size ?? 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
dispatch(type: string, event: { data?: unknown; lastEventId?: string } = {}) {
|
|
67
|
+
for (const listener of [...(this.listeners.get(type) ?? [])]) {
|
|
68
|
+
listener({ type, ...event });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Dataless lifecycle open — EventSource connected. */
|
|
73
|
+
open() {
|
|
74
|
+
this.dispatch("open");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Dataless lifecycle error — connection lost / failed. */
|
|
78
|
+
fail() {
|
|
79
|
+
this.dispatch("error");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A server frame: `event: <type>` + `data: <data>` (+ optional `id:`). */
|
|
83
|
+
frame(type: string, data: string, lastEventId = "") {
|
|
84
|
+
this.dispatch(type, { data, lastEventId });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static last(): FakeEventSource {
|
|
88
|
+
return FakeEventSource.instances[FakeEventSource.instances.length - 1];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe("RealtimeModule (SSE transport)", () => {
|
|
93
|
+
const created: RealtimeModule[] = [];
|
|
94
|
+
|
|
95
|
+
function createSSE(
|
|
96
|
+
sseOverrides: Partial<RealtimeSSEConfig> = {},
|
|
97
|
+
configOverrides: Partial<RealtimeConfig> = {},
|
|
98
|
+
): RealtimeModule {
|
|
99
|
+
const realtime = new RealtimeModule({
|
|
100
|
+
baseURL: "http://localhost:3000",
|
|
101
|
+
transport: "sse",
|
|
102
|
+
sse: {
|
|
103
|
+
path: "/api/method/shc.events.stream",
|
|
104
|
+
EventSource: FakeEventSource,
|
|
105
|
+
...sseOverrides,
|
|
106
|
+
},
|
|
107
|
+
...configOverrides,
|
|
108
|
+
});
|
|
109
|
+
created.push(realtime);
|
|
110
|
+
return realtime;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
vi.clearAllMocks();
|
|
115
|
+
FakeEventSource.instances = [];
|
|
116
|
+
mockSocket.connected = false;
|
|
117
|
+
mockSocket.on.mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
|
|
118
|
+
if (event === "connect") {
|
|
119
|
+
mockSocket.connected = true;
|
|
120
|
+
setTimeout(() => handler(), 0);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
afterEach(() => {
|
|
126
|
+
for (const realtime of created.splice(0)) realtime.disconnect();
|
|
127
|
+
vi.clearAllMocks();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("connect", () => {
|
|
131
|
+
it("opens an EventSource against baseURL + path and turns healthy on open", async () => {
|
|
132
|
+
const realtime = createSSE();
|
|
133
|
+
const connected = vi.fn();
|
|
134
|
+
await realtime.on("connected", connected);
|
|
135
|
+
|
|
136
|
+
await realtime.connect();
|
|
137
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
138
|
+
expect(FakeEventSource.last().url).toBe("http://localhost:3000/api/method/shc.events.stream");
|
|
139
|
+
expect(realtime.isHealthy()).toBe(false);
|
|
140
|
+
|
|
141
|
+
FakeEventSource.last().open();
|
|
142
|
+
expect(realtime.isHealthy()).toBe(true);
|
|
143
|
+
expect(connected).toHaveBeenCalledWith({ transport: "sse" });
|
|
144
|
+
expect(realtime.getEventSource()).toBe(FakeEventSource.last());
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("does not open a second EventSource while one exists", async () => {
|
|
148
|
+
const realtime = createSSE();
|
|
149
|
+
await realtime.connect();
|
|
150
|
+
await realtime.connect();
|
|
151
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("prefers sse.url over sse.path", async () => {
|
|
155
|
+
const realtime = createSSE({ url: "https://daemon.example/stream", path: "/ignored" });
|
|
156
|
+
await realtime.connect();
|
|
157
|
+
expect(FakeEventSource.last().url).toBe("https://daemon.example/stream");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("appends queryParams (encoded, merged onto an existing query string)", async () => {
|
|
161
|
+
const realtime = createSSE({
|
|
162
|
+
url: "https://daemon.example/stream?tenant=acme",
|
|
163
|
+
queryParams: { token: "se cret&", kind: "docker" },
|
|
164
|
+
});
|
|
165
|
+
await realtime.connect();
|
|
166
|
+
expect(FakeEventSource.last().url).toBe(
|
|
167
|
+
"https://daemon.example/stream?tenant=acme&token=se%20cret%26&kind=docker",
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("passes withCredentials through to the EventSource init", async () => {
|
|
172
|
+
const realtime = createSSE({ withCredentials: true });
|
|
173
|
+
await realtime.connect();
|
|
174
|
+
expect(FakeEventSource.last().withCredentials).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("throws a descriptive error when neither sse.url nor sse.path is set", async () => {
|
|
178
|
+
const realtime = createSSE({ path: undefined });
|
|
179
|
+
await expect(realtime.connect()).rejects.toThrow(/config\.sse\.url or config\.sse\.path/);
|
|
180
|
+
// isConnecting must be reset so a corrected config could retry.
|
|
181
|
+
await expect(realtime.connect()).rejects.toThrow(/config\.sse\.url or config\.sse\.path/);
|
|
182
|
+
expect(FakeEventSource.instances).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("throws an error naming config.sse.EventSource when no constructor exists", async () => {
|
|
186
|
+
const globalRef = globalThis as { EventSource?: unknown };
|
|
187
|
+
const originalEventSource = globalRef.EventSource;
|
|
188
|
+
globalRef.EventSource = undefined;
|
|
189
|
+
try {
|
|
190
|
+
const realtime = createSSE({ EventSource: undefined });
|
|
191
|
+
await expect(realtime.connect()).rejects.toThrow(/config\.sse\.EventSource/);
|
|
192
|
+
} finally {
|
|
193
|
+
globalRef.EventSource = originalEventSource;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("feature-detects the global EventSource when none is injected", async () => {
|
|
198
|
+
const globalRef = globalThis as { EventSource?: unknown };
|
|
199
|
+
const originalEventSource = globalRef.EventSource;
|
|
200
|
+
globalRef.EventSource = FakeEventSource;
|
|
201
|
+
try {
|
|
202
|
+
const realtime = createSSE({ EventSource: undefined });
|
|
203
|
+
await realtime.connect();
|
|
204
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
205
|
+
} finally {
|
|
206
|
+
globalRef.EventSource = originalEventSource;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("default frame mapping", () => {
|
|
212
|
+
it("delivers frames under the SSE event type name with JSON-parsed payload", async () => {
|
|
213
|
+
const realtime = createSSE();
|
|
214
|
+
const onDocker = vi.fn();
|
|
215
|
+
await realtime.on("docker", onDocker);
|
|
216
|
+
await realtime.connect();
|
|
217
|
+
FakeEventSource.last().open();
|
|
218
|
+
|
|
219
|
+
FakeEventSource.last().frame("docker", '{"code":"container.start","tenant":"acme"}');
|
|
220
|
+
expect(onDocker).toHaveBeenCalledTimes(1);
|
|
221
|
+
expect(onDocker).toHaveBeenCalledWith({ code: "container.start", tenant: "acme" });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('delivers unnamed frames under the "message" type', async () => {
|
|
225
|
+
const realtime = createSSE();
|
|
226
|
+
const onMessage = vi.fn();
|
|
227
|
+
await realtime.on("message", onMessage);
|
|
228
|
+
await realtime.connect();
|
|
229
|
+
FakeEventSource.last().open();
|
|
230
|
+
|
|
231
|
+
FakeEventSource.last().frame("message", '{"sequence":7}');
|
|
232
|
+
expect(onMessage).toHaveBeenCalledWith({ sequence: 7 });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("attaches a listener dynamically when a handler is added after connect", async () => {
|
|
236
|
+
const realtime = createSSE();
|
|
237
|
+
await realtime.connect();
|
|
238
|
+
FakeEventSource.last().open();
|
|
239
|
+
expect(FakeEventSource.last().listenerCount("audit")).toBe(0);
|
|
240
|
+
|
|
241
|
+
const onAudit = vi.fn();
|
|
242
|
+
await realtime.on("audit", onAudit);
|
|
243
|
+
expect(FakeEventSource.last().listenerCount("audit")).toBe(1);
|
|
244
|
+
|
|
245
|
+
FakeEventSource.last().frame("audit", '{"actor":"admin"}');
|
|
246
|
+
expect(onAudit).toHaveBeenCalledWith({ actor: "admin" });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("falls back to the raw string when frame data is not JSON", async () => {
|
|
250
|
+
const realtime = createSSE();
|
|
251
|
+
const onDocker = vi.fn();
|
|
252
|
+
await realtime.on("docker", onDocker);
|
|
253
|
+
await realtime.connect();
|
|
254
|
+
FakeEventSource.last().open();
|
|
255
|
+
|
|
256
|
+
FakeEventSource.last().frame("docker", "plain text payload");
|
|
257
|
+
expect(onDocker).toHaveBeenCalledWith("plain text payload");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("fans one frame out to every registered handler", async () => {
|
|
261
|
+
const realtime = createSSE();
|
|
262
|
+
const first = vi.fn();
|
|
263
|
+
const second = vi.fn();
|
|
264
|
+
await realtime.on("docker", first);
|
|
265
|
+
await realtime.on("docker", second);
|
|
266
|
+
await realtime.connect();
|
|
267
|
+
FakeEventSource.last().open();
|
|
268
|
+
expect(FakeEventSource.last().listenerCount("docker")).toBe(1);
|
|
269
|
+
|
|
270
|
+
FakeEventSource.last().frame("docker", '{"code":"x"}');
|
|
271
|
+
expect(first).toHaveBeenCalledWith({ code: "x" });
|
|
272
|
+
expect(second).toHaveBeenCalledWith({ code: "x" });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("off() removes frame listeners but keeps lifecycle and declared server types attached", async () => {
|
|
276
|
+
const realtime = createSSE({ events: ["docker"] });
|
|
277
|
+
const onDocker = vi.fn();
|
|
278
|
+
const onAudit = vi.fn();
|
|
279
|
+
await realtime.on("docker", onDocker);
|
|
280
|
+
await realtime.on("audit", onAudit);
|
|
281
|
+
await realtime.connect();
|
|
282
|
+
FakeEventSource.last().open();
|
|
283
|
+
|
|
284
|
+
realtime.off("audit", onAudit);
|
|
285
|
+
expect(FakeEventSource.last().listenerCount("audit")).toBe(0);
|
|
286
|
+
|
|
287
|
+
// "docker" is declared in sse.events — it keeps feeding mapFrame/default
|
|
288
|
+
// routing even after the same-named handler goes away.
|
|
289
|
+
realtime.off("docker", onDocker);
|
|
290
|
+
expect(FakeEventSource.last().listenerCount("docker")).toBe(1);
|
|
291
|
+
expect(FakeEventSource.last().listenerCount("open")).toBe(1);
|
|
292
|
+
expect(FakeEventSource.last().listenerCount("error")).toBe(1);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("custom mapFrame", () => {
|
|
297
|
+
it("listens for declared sse.events and fans one frame out to multiple events", async () => {
|
|
298
|
+
const mapFrame = vi.fn((frame: { type: string; data: string; lastEventId: string }) => {
|
|
299
|
+
if (frame.type !== "docker") return null;
|
|
300
|
+
const payload = JSON.parse(frame.data);
|
|
301
|
+
return [
|
|
302
|
+
{ event: "doc_update", payload: { doctype: "Container", name: payload.name } },
|
|
303
|
+
{ event: "list_update", payload: { doctype: "Container", name: payload.name } },
|
|
304
|
+
];
|
|
305
|
+
});
|
|
306
|
+
const realtime = createSSE({ events: ["docker", "audit"], mapFrame });
|
|
307
|
+
const onDocUpdate = vi.fn();
|
|
308
|
+
const onListUpdate = vi.fn();
|
|
309
|
+
await realtime.on("doc_update", onDocUpdate);
|
|
310
|
+
await realtime.on("list_update", onListUpdate);
|
|
311
|
+
await realtime.connect();
|
|
312
|
+
FakeEventSource.last().open();
|
|
313
|
+
|
|
314
|
+
// Named listeners exist even though no handler carries these names.
|
|
315
|
+
expect(FakeEventSource.last().listenerCount("docker")).toBe(1);
|
|
316
|
+
expect(FakeEventSource.last().listenerCount("audit")).toBe(1);
|
|
317
|
+
|
|
318
|
+
FakeEventSource.last().frame("docker", '{"name":"web-1"}', "seq-42");
|
|
319
|
+
expect(mapFrame).toHaveBeenCalledWith({
|
|
320
|
+
type: "docker",
|
|
321
|
+
data: '{"name":"web-1"}',
|
|
322
|
+
lastEventId: "seq-42",
|
|
323
|
+
});
|
|
324
|
+
expect(onDocUpdate).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(onDocUpdate).toHaveBeenCalledWith({ doctype: "Container", name: "web-1" });
|
|
326
|
+
expect(onListUpdate).toHaveBeenCalledTimes(1);
|
|
327
|
+
expect(onListUpdate).toHaveBeenCalledWith({ doctype: "Container", name: "web-1" });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("drops frames when mapFrame returns null", async () => {
|
|
331
|
+
const realtime = createSSE({
|
|
332
|
+
events: ["audit"],
|
|
333
|
+
mapFrame: () => null,
|
|
334
|
+
});
|
|
335
|
+
const onAnything = vi.fn();
|
|
336
|
+
await realtime.on("doc_update", onAnything);
|
|
337
|
+
await realtime.on("audit", onAnything);
|
|
338
|
+
await realtime.connect();
|
|
339
|
+
FakeEventSource.last().open();
|
|
340
|
+
|
|
341
|
+
FakeEventSource.last().frame("audit", '{"actor":"admin"}');
|
|
342
|
+
FakeEventSource.last().frame("message", '{"sequence":1}');
|
|
343
|
+
expect(onAnything).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("routes unnamed message frames through mapFrame too", async () => {
|
|
347
|
+
const realtime = createSSE({
|
|
348
|
+
mapFrame: (frame) => [{ event: "operational", payload: frame.data }],
|
|
349
|
+
});
|
|
350
|
+
const onOperational = vi.fn();
|
|
351
|
+
await realtime.on("operational", onOperational);
|
|
352
|
+
await realtime.connect();
|
|
353
|
+
FakeEventSource.last().open();
|
|
354
|
+
|
|
355
|
+
FakeEventSource.last().frame("message", "raw-data");
|
|
356
|
+
expect(onOperational).toHaveBeenCalledWith("raw-data");
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("health lifecycle", () => {
|
|
361
|
+
it("error marks unhealthy and delivers a disconnect event, without closing the stream", async () => {
|
|
362
|
+
const realtime = createSSE();
|
|
363
|
+
const onDisconnect = vi.fn();
|
|
364
|
+
await realtime.on("disconnect", onDisconnect);
|
|
365
|
+
await realtime.connect();
|
|
366
|
+
FakeEventSource.last().open();
|
|
367
|
+
expect(realtime.isHealthy()).toBe(true);
|
|
368
|
+
|
|
369
|
+
FakeEventSource.last().fail();
|
|
370
|
+
expect(realtime.isHealthy()).toBe(false);
|
|
371
|
+
expect(onDisconnect).toHaveBeenCalledWith({ reason: "sse-error" });
|
|
372
|
+
// EventSource auto-reconnects on its own — the module must not fight it.
|
|
373
|
+
expect(FakeEventSource.last().closed).toBe(false);
|
|
374
|
+
expect(FakeEventSource.instances).toHaveLength(1);
|
|
375
|
+
|
|
376
|
+
// Native auto-reconnect succeeding fires open again on the SAME instance.
|
|
377
|
+
FakeEventSource.last().open();
|
|
378
|
+
expect(realtime.isHealthy()).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("a server frame classified `event: error` is data, not a connection error", async () => {
|
|
382
|
+
const realtime = createSSE();
|
|
383
|
+
const onError = vi.fn();
|
|
384
|
+
await realtime.on("error", onError);
|
|
385
|
+
await realtime.connect();
|
|
386
|
+
FakeEventSource.last().open();
|
|
387
|
+
|
|
388
|
+
FakeEventSource.last().frame("error", '{"code":"tenant.quota","message":"exceeded"}');
|
|
389
|
+
expect(onError).toHaveBeenCalledWith({ code: "tenant.quota", message: "exceeded" });
|
|
390
|
+
// Still healthy — this was a frame delivery, not a transport failure.
|
|
391
|
+
expect(realtime.isHealthy()).toBe(true);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("disconnect() closes the stream and clears handlers; next connect() reopens fresh", async () => {
|
|
395
|
+
const realtime = createSSE();
|
|
396
|
+
const onDocker = vi.fn();
|
|
397
|
+
await realtime.on("docker", onDocker);
|
|
398
|
+
await realtime.connect();
|
|
399
|
+
FakeEventSource.last().open();
|
|
400
|
+
const firstInstance = FakeEventSource.last();
|
|
401
|
+
|
|
402
|
+
realtime.disconnect();
|
|
403
|
+
expect(firstInstance.closed).toBe(true);
|
|
404
|
+
expect(realtime.isHealthy()).toBe(false);
|
|
405
|
+
expect(realtime.getEventSource()).toBeUndefined();
|
|
406
|
+
// Listeners were detached — a late frame on the dead stream reaches nothing.
|
|
407
|
+
firstInstance.frame("docker", '{"code":"late"}');
|
|
408
|
+
expect(onDocker).not.toHaveBeenCalled();
|
|
409
|
+
|
|
410
|
+
await realtime.connect();
|
|
411
|
+
expect(FakeEventSource.instances).toHaveLength(2);
|
|
412
|
+
expect(FakeEventSource.last()).not.toBe(firstInstance);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("handlers registered before connect are attached once the stream opens after a teardown", async () => {
|
|
416
|
+
const realtime = createSSE();
|
|
417
|
+
const onDocker = vi.fn();
|
|
418
|
+
await realtime.on("docker", onDocker);
|
|
419
|
+
await realtime.connect();
|
|
420
|
+
FakeEventSource.last().open();
|
|
421
|
+
expect(FakeEventSource.last().listenerCount("docker")).toBe(1);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("rooms (client-side bookkeeping)", () => {
|
|
426
|
+
it("records rooms, connects the stream, and never emits a subscription", async () => {
|
|
427
|
+
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
|
428
|
+
try {
|
|
429
|
+
const realtime = createSSE();
|
|
430
|
+
const subscription = realtime.subscribeDoc("Task", "TASK-001");
|
|
431
|
+
await vi.waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
432
|
+
FakeEventSource.last().open();
|
|
433
|
+
await subscription;
|
|
434
|
+
|
|
435
|
+
await realtime.subscribeList("Task", { status: "Open" });
|
|
436
|
+
|
|
437
|
+
expect(realtime.getDocRooms()).toEqual([{ doctype: "Task", name: "TASK-001" }]);
|
|
438
|
+
expect(realtime.getListRooms()).toEqual([{ doctype: "Task", filters: { status: "Open" } }]);
|
|
439
|
+
// No socket exists and nothing was emitted anywhere.
|
|
440
|
+
expect(io).not.toHaveBeenCalled();
|
|
441
|
+
expect(realtime.getSocket()).toBeUndefined();
|
|
442
|
+
|
|
443
|
+
// The client-side-only notice logs exactly once across both joins.
|
|
444
|
+
const notices = debugSpy.mock.calls.filter((call) =>
|
|
445
|
+
String(call[0]).includes("client-side only"),
|
|
446
|
+
);
|
|
447
|
+
expect(notices).toHaveLength(1);
|
|
448
|
+
} finally {
|
|
449
|
+
debugSpy.mockRestore();
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("tears the stream down when the last room is released (idle teardown parity)", async () => {
|
|
454
|
+
const realtime = createSSE();
|
|
455
|
+
const onDisconnect = vi.fn();
|
|
456
|
+
await realtime.on("disconnect", onDisconnect);
|
|
457
|
+
const subscription = realtime.subscribeDoc("Task", "TASK-001");
|
|
458
|
+
await vi.waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
459
|
+
FakeEventSource.last().open();
|
|
460
|
+
await subscription;
|
|
461
|
+
|
|
462
|
+
realtime.unsubscribeDoc("Task", "TASK-001");
|
|
463
|
+
expect(FakeEventSource.last().closed).toBe(true);
|
|
464
|
+
expect(realtime.getEventSource()).toBeUndefined();
|
|
465
|
+
expect(realtime.getDocRooms()).toEqual([]);
|
|
466
|
+
expect(onDisconnect).toHaveBeenCalledWith({ reason: "idle-teardown" });
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("emit() is a silent no-op (one-way transport)", async () => {
|
|
470
|
+
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
|
471
|
+
try {
|
|
472
|
+
const realtime = createSSE();
|
|
473
|
+
await realtime.connect();
|
|
474
|
+
FakeEventSource.last().open();
|
|
475
|
+
expect(() => realtime.emit("doc_subscribe", { doctype: "Task" })).not.toThrow();
|
|
476
|
+
expect(io).not.toHaveBeenCalled();
|
|
477
|
+
expect(mockSocket.emit).not.toHaveBeenCalled();
|
|
478
|
+
} finally {
|
|
479
|
+
debugSpy.mockRestore();
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
describe("updateAuth", () => {
|
|
485
|
+
it("recreates the EventSource with re-resolved query params", async () => {
|
|
486
|
+
let token = "tok-1";
|
|
487
|
+
const realtime = createSSE({ queryParams: { token: () => token } });
|
|
488
|
+
const onDocker = vi.fn();
|
|
489
|
+
await realtime.on("docker", onDocker);
|
|
490
|
+
await realtime.connect();
|
|
491
|
+
const firstInstance = FakeEventSource.last();
|
|
492
|
+
firstInstance.open();
|
|
493
|
+
expect(firstInstance.url).toContain("token=tok-1");
|
|
494
|
+
|
|
495
|
+
token = "tok-2";
|
|
496
|
+
await realtime.updateAuth({});
|
|
497
|
+
|
|
498
|
+
expect(FakeEventSource.instances).toHaveLength(2);
|
|
499
|
+
expect(firstInstance.closed).toBe(true);
|
|
500
|
+
expect(FakeEventSource.last().url).toContain("token=tok-2");
|
|
501
|
+
|
|
502
|
+
// Handlers survive the rotation and re-attach to the fresh stream.
|
|
503
|
+
FakeEventSource.last().open();
|
|
504
|
+
FakeEventSource.last().frame("docker", '{"code":"post-rotation"}');
|
|
505
|
+
expect(onDocker).toHaveBeenCalledWith({ code: "post-rotation" });
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("stays idle when no stream and no rooms exist", async () => {
|
|
509
|
+
const realtime = createSSE();
|
|
510
|
+
await realtime.updateAuth({ useToken: true, token: "rotated" });
|
|
511
|
+
expect(FakeEventSource.instances).toHaveLength(0);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("reconnects for recorded rooms with the fresh stream URL", async () => {
|
|
515
|
+
let token = "tok-1";
|
|
516
|
+
const realtime = createSSE({ queryParams: { token: () => token } });
|
|
517
|
+
const subscription = realtime.subscribeList("Task");
|
|
518
|
+
await vi.waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
519
|
+
FakeEventSource.last().open();
|
|
520
|
+
await subscription;
|
|
521
|
+
|
|
522
|
+
token = "tok-2";
|
|
523
|
+
await realtime.updateAuth({});
|
|
524
|
+
|
|
525
|
+
expect(FakeEventSource.instances).toHaveLength(2);
|
|
526
|
+
expect(FakeEventSource.last().url).toContain("token=tok-2");
|
|
527
|
+
expect(realtime.getListRooms()).toEqual([{ doctype: "Task", filters: undefined }]);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("socketio default path untouched", () => {
|
|
532
|
+
it("constructs a socket (never an EventSource) when transport is omitted", async () => {
|
|
533
|
+
const realtime = new RealtimeModule({
|
|
534
|
+
baseURL: "http://localhost:3000",
|
|
535
|
+
// sse config present but transport absent — must be ignored entirely.
|
|
536
|
+
sse: { path: "/api/method/shc.events.stream", EventSource: FakeEventSource },
|
|
537
|
+
});
|
|
538
|
+
created.push(realtime);
|
|
539
|
+
|
|
540
|
+
await realtime.subscribeList("Task");
|
|
541
|
+
|
|
542
|
+
expect(io).toHaveBeenCalledTimes(1);
|
|
543
|
+
expect(FakeEventSource.instances).toHaveLength(0);
|
|
544
|
+
expect(mockSocket.emit).toHaveBeenCalledWith("doctype_subscribe", "Task");
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
});
|
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();
|