@arcote.tech/arc-host 0.7.14 → 0.7.16
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/index.js +49 -149
- package/dist/index.js.map +5 -5
- package/dist/src/create-server.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/middleware/http.d.ts +0 -5
- package/dist/src/middleware/http.d.ts.map +1 -1
- package/dist/src/middleware/index.d.ts +2 -2
- package/dist/src/middleware/index.d.ts.map +1 -1
- package/dist/src/middleware/live-query.integration.test.d.ts +2 -0
- package/dist/src/middleware/live-query.integration.test.d.ts.map +1 -0
- package/dist/src/middleware/ws.d.ts.map +1 -1
- package/dist/src/types.d.ts +20 -2
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/create-server.ts +0 -2
- package/src/index.ts +1 -3
- package/src/middleware/http.ts +0 -119
- package/src/middleware/index.ts +1 -3
- package/src/middleware/live-query.integration.test.ts +432 -0
- package/src/middleware/ws.ts +81 -78
- package/src/types.ts +16 -2
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
MasterDataStorage,
|
|
4
|
+
aggregate,
|
|
5
|
+
applyQueryChanges,
|
|
6
|
+
boolean,
|
|
7
|
+
context,
|
|
8
|
+
id,
|
|
9
|
+
string,
|
|
10
|
+
token,
|
|
11
|
+
} from "@arcote.tech/arc";
|
|
12
|
+
import type { ConnectedClient } from "../types";
|
|
13
|
+
import { cleanupClientSubs, querySubscriptionHandler } from "./ws";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Test rig: real MasterDataStorage + a real aggregate with custom
|
|
17
|
+
// clientQuery handlers (the NDT-shaped case: handler runs $query.find with
|
|
18
|
+
// protectBy restrictions) + the real querySubscriptionHandler. The fake
|
|
19
|
+
// connection manager collects outgoing messages.
|
|
20
|
+
//
|
|
21
|
+
// Covers the full server pipeline: subscribe-query → LiveQuery tracks the
|
|
22
|
+
// handler's inner finds → store commit → resolveQueryChange → re-execute
|
|
23
|
+
// from cache → diff → query-changes / query-snapshot.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Persistent in-memory DatabaseAdapter (shared tables across transactions). */
|
|
27
|
+
function memoryAdapter() {
|
|
28
|
+
const tables = new Map<string, Map<string, any>>();
|
|
29
|
+
const table = (s: string) => {
|
|
30
|
+
if (!tables.has(s)) tables.set(s, new Map());
|
|
31
|
+
return tables.get(s)!;
|
|
32
|
+
};
|
|
33
|
+
const matches = (row: any, where?: Record<string, any>) =>
|
|
34
|
+
!where || Object.entries(where).every(([k, v]) => row[k] === v);
|
|
35
|
+
const tx = {
|
|
36
|
+
async find(store: string, options: any) {
|
|
37
|
+
let rows = Array.from(table(store).values()).filter((r) =>
|
|
38
|
+
matches(r, options?.where),
|
|
39
|
+
);
|
|
40
|
+
if (options?.orderBy) {
|
|
41
|
+
const entries = Object.entries(options.orderBy) as [string, string][];
|
|
42
|
+
rows = [...rows].sort((a, b) => {
|
|
43
|
+
for (const [k, dir] of entries) {
|
|
44
|
+
if (a[k] < b[k]) return dir === "asc" ? -1 : 1;
|
|
45
|
+
if (a[k] > b[k]) return dir === "asc" ? 1 : -1;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (options?.limit !== undefined) rows = rows.slice(0, options.limit);
|
|
51
|
+
return rows;
|
|
52
|
+
},
|
|
53
|
+
async set(store: string, data: any) {
|
|
54
|
+
table(store).set(data._id, structuredClone(data));
|
|
55
|
+
},
|
|
56
|
+
async remove(store: string, id: any) {
|
|
57
|
+
table(store).delete(String(id));
|
|
58
|
+
},
|
|
59
|
+
async commit() {},
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
readWriteTransaction: () => tx,
|
|
63
|
+
readTransaction: () => tx,
|
|
64
|
+
executeReinitTables: async () => {},
|
|
65
|
+
} as any;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const wsToken = token("workspace", {});
|
|
69
|
+
const taskId = id("task");
|
|
70
|
+
|
|
71
|
+
const tasks = aggregate("tasks", taskId, {
|
|
72
|
+
title: string(),
|
|
73
|
+
workspaceId: string(),
|
|
74
|
+
done: boolean(),
|
|
75
|
+
})
|
|
76
|
+
.clientQuery("getAll", (fn: any) =>
|
|
77
|
+
fn.handle(async (ctx: any) =>
|
|
78
|
+
ctx.$query.find({ orderBy: { title: "asc" } }),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
.clientQuery("getDone", (fn: any) =>
|
|
82
|
+
fn.handle(async (ctx: any) =>
|
|
83
|
+
ctx.$query.find({ where: { done: true }, orderBy: { title: "asc" } }),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
.clientQuery("getTop2", (fn: any) =>
|
|
87
|
+
fn.handle(async (ctx: any) =>
|
|
88
|
+
ctx.$query.find({ orderBy: { title: "asc" }, limit: 2 }),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
.clientQuery("countAll", (fn: any) =>
|
|
92
|
+
fn.handle(async (ctx: any) => {
|
|
93
|
+
const rows = await ctx.$query.find({});
|
|
94
|
+
return { count: rows.length };
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
.protectBy(wsToken as any, (p: any) => ({ workspaceId: p.workspaceId }));
|
|
98
|
+
|
|
99
|
+
function fakeJwt(tokenName: string, params: Record<string, any>): string {
|
|
100
|
+
const b64 = (o: any) => Buffer.from(JSON.stringify(o)).toString("base64");
|
|
101
|
+
return `${b64({ alg: "none" })}.${b64({ tokenName, params })}.sig`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fakeClient(
|
|
105
|
+
clientId: string,
|
|
106
|
+
scope: string,
|
|
107
|
+
params: Record<string, any>,
|
|
108
|
+
): ConnectedClient {
|
|
109
|
+
return {
|
|
110
|
+
id: clientId,
|
|
111
|
+
scopeTokens: new Map([
|
|
112
|
+
[
|
|
113
|
+
scope,
|
|
114
|
+
{
|
|
115
|
+
decoded: { tokenType: "workspace", params },
|
|
116
|
+
raw: fakeJwt("workspace", params),
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
]),
|
|
120
|
+
lastHostEventId: null,
|
|
121
|
+
ws: null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Microtask + async re-execute settle. */
|
|
126
|
+
const settle = () => new Promise((r) => setTimeout(r, 10));
|
|
127
|
+
|
|
128
|
+
function rig() {
|
|
129
|
+
const storage = new MasterDataStorage(memoryAdapter());
|
|
130
|
+
const testContext = context([tasks as any]);
|
|
131
|
+
const fakeModel = {
|
|
132
|
+
context: testContext,
|
|
133
|
+
getAdapters: () => ({ dataStorage: storage }),
|
|
134
|
+
getEnvironment: () => "server" as const,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const sent: Array<{ clientId: string; message: any }> = [];
|
|
138
|
+
const wsCtx = {
|
|
139
|
+
contextHandler: { getModel: () => fakeModel },
|
|
140
|
+
connectionManager: {
|
|
141
|
+
sendToClient: (clientId: string, message: any) => {
|
|
142
|
+
sent.push({ clientId, message });
|
|
143
|
+
return true;
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
verifyToken: () => null,
|
|
147
|
+
} as any;
|
|
148
|
+
|
|
149
|
+
const handler = querySubscriptionHandler();
|
|
150
|
+
|
|
151
|
+
const subscribe = (
|
|
152
|
+
client: ConnectedClient,
|
|
153
|
+
subscriptionId: string,
|
|
154
|
+
method: string,
|
|
155
|
+
scope = "workspace",
|
|
156
|
+
) =>
|
|
157
|
+
handler(
|
|
158
|
+
client,
|
|
159
|
+
{
|
|
160
|
+
type: "subscribe-query",
|
|
161
|
+
subscriptionId,
|
|
162
|
+
descriptor: { element: "tasks", method, args: [] },
|
|
163
|
+
scope,
|
|
164
|
+
},
|
|
165
|
+
wsCtx,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const seed = (rows: any[]) =>
|
|
169
|
+
storage
|
|
170
|
+
.getStore<any>("tasks")
|
|
171
|
+
.applyChanges(rows.map((data) => ({ type: "set" as const, data })));
|
|
172
|
+
|
|
173
|
+
const messagesFor = (clientId: string, type?: string) =>
|
|
174
|
+
sent
|
|
175
|
+
.filter((s) => s.clientId === clientId)
|
|
176
|
+
.map((s) => s.message)
|
|
177
|
+
.filter((m) => (type ? m.type === type : true));
|
|
178
|
+
|
|
179
|
+
return { storage, wsCtx, handler, subscribe, seed, sent, messagesFor };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const row = (id: string, title: string, workspaceId: string, done = false) => ({
|
|
183
|
+
_id: id,
|
|
184
|
+
title,
|
|
185
|
+
workspaceId,
|
|
186
|
+
done,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
let seq = 0;
|
|
190
|
+
const uid = (p: string) => `${p}_${++seq}`;
|
|
191
|
+
|
|
192
|
+
describe("live query subscriptions — end-to-end host pipeline", () => {
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
for (let i = 0; i <= seq; i++) {
|
|
195
|
+
cleanupClientSubs(`A_${i}`);
|
|
196
|
+
cleanupClientSubs(`B_${i}`);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("snapshot contains the QUERY result for the subscriber's scope only", async () => {
|
|
201
|
+
const r = rig();
|
|
202
|
+
await r.seed([
|
|
203
|
+
row("t1", "Beta", "w1"),
|
|
204
|
+
row("t2", "Alpha", "w1"),
|
|
205
|
+
row("t3", "Other", "w2"),
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
209
|
+
await r.subscribe(a, "s1", "getAll");
|
|
210
|
+
await settle();
|
|
211
|
+
|
|
212
|
+
const snapshots = r.messagesFor(a.id, "query-snapshot");
|
|
213
|
+
expect(snapshots).toHaveLength(1);
|
|
214
|
+
expect(snapshots[0].subscriptionId).toBe("s1");
|
|
215
|
+
// orderBy from the HANDLER (title asc), restrictions from protectBy
|
|
216
|
+
expect(snapshots[0].result.map((t: any) => t._id)).toEqual(["t2", "t1"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("matching insert → query-changes with the correct position", async () => {
|
|
220
|
+
const r = rig();
|
|
221
|
+
await r.seed([row("t1", "Beta", "w1"), row("t2", "Delta", "w1")]);
|
|
222
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
223
|
+
await r.subscribe(a, "s1", "getAll");
|
|
224
|
+
await settle();
|
|
225
|
+
r.sent.length = 0;
|
|
226
|
+
|
|
227
|
+
await r.storage
|
|
228
|
+
.getStore<any>("tasks")
|
|
229
|
+
.applyChanges([{ type: "set", data: row("t3", "Charlie", "w1") }]);
|
|
230
|
+
await settle();
|
|
231
|
+
|
|
232
|
+
const changes = r.messagesFor(a.id, "query-changes");
|
|
233
|
+
expect(changes).toHaveLength(1);
|
|
234
|
+
expect(changes[0].changes).toEqual([
|
|
235
|
+
{
|
|
236
|
+
type: "set",
|
|
237
|
+
id: "t3",
|
|
238
|
+
item: row("t3", "Charlie", "w1"),
|
|
239
|
+
index: 1, // Beta, Charlie, Delta
|
|
240
|
+
},
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("tenant isolation: a change in another workspace produces NO message", async () => {
|
|
245
|
+
const r = rig();
|
|
246
|
+
await r.seed([row("t1", "Beta", "w1")]);
|
|
247
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
248
|
+
await r.subscribe(a, "s1", "getAll");
|
|
249
|
+
await settle();
|
|
250
|
+
r.sent.length = 0;
|
|
251
|
+
|
|
252
|
+
await r.storage
|
|
253
|
+
.getStore<any>("tasks")
|
|
254
|
+
.applyChanges([{ type: "set", data: row("t9", "Foreign", "w2") }]);
|
|
255
|
+
await settle();
|
|
256
|
+
|
|
257
|
+
expect(r.messagesFor(a.id)).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("item leaving the scope → delete for the old tenant, set for the new one", async () => {
|
|
261
|
+
const r = rig();
|
|
262
|
+
await r.seed([row("t1", "Task", "w1"), row("t2", "Keep", "w1")]);
|
|
263
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
264
|
+
const b = fakeClient(uid("B"), "workspace", { workspaceId: "w2" });
|
|
265
|
+
await r.subscribe(a, "sa", "getAll");
|
|
266
|
+
await r.subscribe(b, "sb", "getAll");
|
|
267
|
+
await settle();
|
|
268
|
+
r.sent.length = 0;
|
|
269
|
+
|
|
270
|
+
// Move t1 from w1 to w2 (modify → full-item set event)
|
|
271
|
+
await r.storage
|
|
272
|
+
.getStore<any>("tasks")
|
|
273
|
+
.applyChanges([{ type: "modify", id: "t1", data: { workspaceId: "w2" } }]);
|
|
274
|
+
await settle();
|
|
275
|
+
|
|
276
|
+
const toA = r.messagesFor(a.id, "query-changes");
|
|
277
|
+
expect(toA).toHaveLength(1);
|
|
278
|
+
expect(toA[0].changes).toEqual([{ type: "delete", id: "t1" }]);
|
|
279
|
+
|
|
280
|
+
const toB = r.messagesFor(b.id, "query-changes");
|
|
281
|
+
expect(toB).toHaveLength(1);
|
|
282
|
+
expect(toB[0].changes).toEqual([
|
|
283
|
+
{ type: "set", id: "t1", item: row("t1", "Task", "w2"), index: 0 },
|
|
284
|
+
]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("where inside the HANDLER: toggling done moves items in/out of the result", async () => {
|
|
288
|
+
const r = rig();
|
|
289
|
+
await r.seed([row("t1", "A", "w1", true), row("t2", "B", "w1", false)]);
|
|
290
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
291
|
+
await r.subscribe(a, "s1", "getDone");
|
|
292
|
+
await settle();
|
|
293
|
+
|
|
294
|
+
expect(r.messagesFor(a.id, "query-snapshot")[0].result).toEqual([
|
|
295
|
+
row("t1", "A", "w1", true),
|
|
296
|
+
]);
|
|
297
|
+
r.sent.length = 0;
|
|
298
|
+
|
|
299
|
+
// t2 becomes done → enters the result
|
|
300
|
+
await r.storage
|
|
301
|
+
.getStore<any>("tasks")
|
|
302
|
+
.applyChanges([{ type: "modify", id: "t2", data: { done: true } }]);
|
|
303
|
+
await settle();
|
|
304
|
+
expect(r.messagesFor(a.id, "query-changes")[0].changes).toEqual([
|
|
305
|
+
{ type: "set", id: "t2", item: row("t2", "B", "w1", true), index: 1 },
|
|
306
|
+
]);
|
|
307
|
+
r.sent.length = 0;
|
|
308
|
+
|
|
309
|
+
// t1 becomes undone → leaves the result
|
|
310
|
+
await r.storage
|
|
311
|
+
.getStore<any>("tasks")
|
|
312
|
+
.applyChanges([{ type: "modify", id: "t1", data: { done: false } }]);
|
|
313
|
+
await settle();
|
|
314
|
+
expect(r.messagesFor(a.id, "query-changes")[0].changes).toEqual([
|
|
315
|
+
{ type: "delete", id: "t1" },
|
|
316
|
+
]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("limit underflow: removing from a full top-N refills from the store", async () => {
|
|
320
|
+
const r = rig();
|
|
321
|
+
await r.seed([
|
|
322
|
+
row("t1", "A", "w1"),
|
|
323
|
+
row("t2", "B", "w1"),
|
|
324
|
+
row("t3", "C", "w1"),
|
|
325
|
+
]);
|
|
326
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
327
|
+
await r.subscribe(a, "s1", "getTop2");
|
|
328
|
+
await settle();
|
|
329
|
+
|
|
330
|
+
expect(
|
|
331
|
+
r.messagesFor(a.id, "query-snapshot")[0].result.map((t: any) => t._id),
|
|
332
|
+
).toEqual(["t1", "t2"]);
|
|
333
|
+
r.sent.length = 0;
|
|
334
|
+
|
|
335
|
+
// Remove t1 from the full top-2 → t3 must be pulled in from the store.
|
|
336
|
+
// The refill may arrive as a snapshot or as deltas — verify the FINAL
|
|
337
|
+
// result the client would hold either way.
|
|
338
|
+
await r.storage
|
|
339
|
+
.getStore<any>("tasks")
|
|
340
|
+
.applyChanges([{ type: "delete", id: "t1" }]);
|
|
341
|
+
await settle();
|
|
342
|
+
|
|
343
|
+
const all = r.messagesFor(a.id);
|
|
344
|
+
expect(all).toHaveLength(1);
|
|
345
|
+
const result =
|
|
346
|
+
all[0].type === "query-snapshot"
|
|
347
|
+
? all[0].result
|
|
348
|
+
: applyQueryChanges(
|
|
349
|
+
[row("t1", "A", "w1"), row("t2", "B", "w1")],
|
|
350
|
+
all[0].changes,
|
|
351
|
+
);
|
|
352
|
+
expect(result.map((t: any) => t._id)).toEqual(["t2", "t3"]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("non-list result (count) → full snapshot on every change", async () => {
|
|
356
|
+
const r = rig();
|
|
357
|
+
await r.seed([row("t1", "A", "w1")]);
|
|
358
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
359
|
+
await r.subscribe(a, "s1", "countAll");
|
|
360
|
+
await settle();
|
|
361
|
+
|
|
362
|
+
expect(r.messagesFor(a.id, "query-snapshot")[0].result).toEqual({
|
|
363
|
+
count: 1,
|
|
364
|
+
});
|
|
365
|
+
r.sent.length = 0;
|
|
366
|
+
|
|
367
|
+
await r.storage
|
|
368
|
+
.getStore<any>("tasks")
|
|
369
|
+
.applyChanges([{ type: "set", data: row("t2", "B", "w1") }]);
|
|
370
|
+
await settle();
|
|
371
|
+
|
|
372
|
+
const snapshots = r.messagesFor(a.id, "query-snapshot");
|
|
373
|
+
expect(snapshots).toHaveLength(1);
|
|
374
|
+
expect(snapshots[0].result).toEqual({ count: 2 });
|
|
375
|
+
expect(r.messagesFor(a.id, "query-changes")).toHaveLength(0);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("unsubscribe-query stops deltas", async () => {
|
|
379
|
+
const r = rig();
|
|
380
|
+
await r.seed([row("t1", "A", "w1")]);
|
|
381
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
382
|
+
await r.subscribe(a, "s1", "getAll");
|
|
383
|
+
await settle();
|
|
384
|
+
await r.handler(
|
|
385
|
+
a,
|
|
386
|
+
{ type: "unsubscribe-query", subscriptionId: "s1" },
|
|
387
|
+
r.wsCtx,
|
|
388
|
+
);
|
|
389
|
+
r.sent.length = 0;
|
|
390
|
+
|
|
391
|
+
await r.storage
|
|
392
|
+
.getStore<any>("tasks")
|
|
393
|
+
.applyChanges([{ type: "set", data: row("t2", "B", "w1") }]);
|
|
394
|
+
await settle();
|
|
395
|
+
expect(r.messagesFor(a.id)).toHaveLength(0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("disconnect cleanup stops deltas", async () => {
|
|
399
|
+
const r = rig();
|
|
400
|
+
await r.seed([row("t1", "A", "w1")]);
|
|
401
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
402
|
+
await r.subscribe(a, "s1", "getAll");
|
|
403
|
+
await settle();
|
|
404
|
+
cleanupClientSubs(a.id);
|
|
405
|
+
r.sent.length = 0;
|
|
406
|
+
|
|
407
|
+
await r.storage
|
|
408
|
+
.getStore<any>("tasks")
|
|
409
|
+
.applyChanges([{ type: "set", data: row("t2", "B", "w1") }]);
|
|
410
|
+
await settle();
|
|
411
|
+
expect(r.messagesFor(a.id)).toHaveLength(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("re-subscribe with the same id (reconnect resend) replaces the old subscription", async () => {
|
|
415
|
+
const r = rig();
|
|
416
|
+
await r.seed([row("t1", "A", "w1")]);
|
|
417
|
+
const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
|
|
418
|
+
await r.subscribe(a, "s1", "getAll");
|
|
419
|
+
await settle();
|
|
420
|
+
await r.subscribe(a, "s1", "getAll"); // resend after reconnect
|
|
421
|
+
await settle();
|
|
422
|
+
r.sent.length = 0;
|
|
423
|
+
|
|
424
|
+
await r.storage
|
|
425
|
+
.getStore<any>("tasks")
|
|
426
|
+
.applyChanges([{ type: "set", data: row("t2", "B", "w1") }]);
|
|
427
|
+
await settle();
|
|
428
|
+
|
|
429
|
+
// Exactly ONE delta (old subscription was stopped, not duplicated)
|
|
430
|
+
expect(r.messagesFor(a.id, "query-changes")).toHaveLength(1);
|
|
431
|
+
});
|
|
432
|
+
});
|
package/src/middleware/ws.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LiveQuery } from "@arcote.tech/arc";
|
|
2
2
|
import { filterEventsForTokens } from "../event-auth";
|
|
3
|
-
import type {
|
|
4
|
-
import type { ArcWsContext, ArcWsHandler } from "./types";
|
|
3
|
+
import type { ArcWsHandler } from "./types";
|
|
5
4
|
|
|
6
5
|
// ---------------------------------------------------------------------------
|
|
7
|
-
//
|
|
6
|
+
// Live query subscriptions
|
|
7
|
+
//
|
|
8
|
+
// One LiveQuery per (client, subscriptionId). ALL query logic — execution,
|
|
9
|
+
// token restrictions, change detection, delta computation — lives in core's
|
|
10
|
+
// LiveQuery (query layer); this module only forwards its updates over WS.
|
|
8
11
|
// ---------------------------------------------------------------------------
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
/** clientId → subscriptionId → LiveQuery */
|
|
14
|
+
const clientQuerySubs = new Map<string, Map<string, LiveQuery>>();
|
|
11
15
|
|
|
12
16
|
export function cleanupClientSubs(clientId: string): void {
|
|
13
|
-
const subs =
|
|
17
|
+
const subs = clientQuerySubs.get(clientId);
|
|
14
18
|
if (subs) {
|
|
15
|
-
for (const
|
|
16
|
-
|
|
19
|
+
for (const live of subs.values()) live.stop();
|
|
20
|
+
clientQuerySubs.delete(clientId);
|
|
17
21
|
}
|
|
18
22
|
}
|
|
19
23
|
|
|
@@ -45,6 +49,10 @@ export function syncEventsHandler(): ArcWsHandler {
|
|
|
45
49
|
return async (client, message, ctx) => {
|
|
46
50
|
if (message.type !== "sync-events") return false;
|
|
47
51
|
|
|
52
|
+
// NOTE: sync-events alone does NOT mark the client as local-mode —
|
|
53
|
+
// streaming clients also push their optimistic mutations through it.
|
|
54
|
+
// Only request-sync (local-mode catch-up) sets `wantsEventSync`.
|
|
55
|
+
|
|
48
56
|
const allTokens = ctx.connectionManager.getAllScopeTokens(client.id);
|
|
49
57
|
const token = allTokens.length > 0 ? allTokens[0] : null;
|
|
50
58
|
|
|
@@ -55,9 +63,10 @@ export function syncEventsHandler(): ArcWsHandler {
|
|
|
55
63
|
);
|
|
56
64
|
if (persisted.length === 0) return true;
|
|
57
65
|
|
|
58
|
-
// Broadcast to other authorized clients
|
|
66
|
+
// Broadcast to other authorized LOCAL-MODE clients
|
|
59
67
|
for (const c of ctx.connectionManager.getAllClients()) {
|
|
60
68
|
if (c.id === client.id) continue;
|
|
69
|
+
if (!c.wantsEventSync) continue;
|
|
61
70
|
const clientTokens = ctx.connectionManager.getAllScopeTokens(c.id);
|
|
62
71
|
const authorized = filterEventsForTokens(
|
|
63
72
|
clientTokens,
|
|
@@ -90,6 +99,10 @@ export function requestSyncHandler(): ArcWsHandler {
|
|
|
90
99
|
return async (client, message, ctx) => {
|
|
91
100
|
if (message.type !== "request-sync") return false;
|
|
92
101
|
|
|
102
|
+
// Only local-mode clients request the event log (streaming clients use
|
|
103
|
+
// view replicas) — mark so domain-event broadcasts reach this client.
|
|
104
|
+
client.wantsEventSync = true;
|
|
105
|
+
|
|
93
106
|
const allTokens = ctx.connectionManager.getAllScopeTokens(client.id);
|
|
94
107
|
const token = allTokens.length > 0 ? allTokens[0] : null;
|
|
95
108
|
const events = await ctx.contextHandler.getEventsSince(
|
|
@@ -155,95 +168,85 @@ export function executeCommandHandler(): ArcWsHandler {
|
|
|
155
168
|
}
|
|
156
169
|
|
|
157
170
|
// ---------------------------------------------------------------------------
|
|
158
|
-
// subscribe-
|
|
171
|
+
// subscribe-query / unsubscribe-query — live query subscriptions
|
|
159
172
|
// ---------------------------------------------------------------------------
|
|
160
173
|
|
|
161
174
|
export function querySubscriptionHandler(): ArcWsHandler {
|
|
162
175
|
return async (client, message, ctx) => {
|
|
163
176
|
if (message.type === "subscribe-query") {
|
|
164
|
-
const { subscriptionId, descriptor
|
|
177
|
+
const { subscriptionId, descriptor } = message;
|
|
178
|
+
const scope: string = message.scope ?? "default";
|
|
165
179
|
|
|
166
180
|
// Pick the token matching the requested scope
|
|
167
|
-
const scopeToken =
|
|
181
|
+
const scopeToken = client.scopeTokens.get(scope) ?? null;
|
|
168
182
|
let rawToken = scopeToken?.raw ?? null;
|
|
169
183
|
|
|
170
|
-
// Fallback to first available token if no scope
|
|
184
|
+
// Fallback to first available token if no scope match
|
|
171
185
|
if (!rawToken && client.scopeTokens.size > 0) {
|
|
172
186
|
rawToken = client.scopeTokens.values().next().value!.raw;
|
|
173
187
|
}
|
|
174
188
|
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
if (rawToken) scoped.setToken(rawToken);
|
|
178
|
-
|
|
179
|
-
// Cache last result to avoid re-sending unchanged data
|
|
180
|
-
let lastResultJson = "";
|
|
181
|
-
|
|
182
|
-
const sendData = async () => {
|
|
183
|
-
try {
|
|
184
|
-
const data = await scoped.callQuery(descriptor);
|
|
185
|
-
const json = JSON.stringify(data ?? null);
|
|
186
|
-
if (json === lastResultJson) return; // No change — skip
|
|
187
|
-
lastResultJson = json;
|
|
188
|
-
ctx.connectionManager.sendToClient(client.id, {
|
|
189
|
-
type: "query-data",
|
|
190
|
-
subscriptionId,
|
|
191
|
-
data: data ?? null,
|
|
192
|
-
} as any);
|
|
193
|
-
} catch (err) {
|
|
194
|
-
console.error(`[Arc] Query subscription error:`, err);
|
|
195
|
-
}
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
// Initial data
|
|
199
|
-
sendData();
|
|
200
|
-
|
|
201
|
-
// Subscribe to SPECIFIC event types this element handles (namespaced keys)
|
|
202
|
-
const element = ctx.contextHandler.getModel().context.get(descriptor.element);
|
|
203
|
-
const handlers = (element as any)?.getHandlers?.();
|
|
204
|
-
const eventTypes: string[] = handlers ? Object.keys(handlers) : [];
|
|
205
|
-
|
|
206
|
-
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
207
|
-
const debouncedSend = () => {
|
|
208
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
209
|
-
debounceTimer = setTimeout(() => sendData(), 50);
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const unsubscribes: (() => void)[] = [];
|
|
213
|
-
if (eventTypes.length > 0) {
|
|
214
|
-
for (const eventType of eventTypes) {
|
|
215
|
-
unsubscribes.push(
|
|
216
|
-
ctx.contextHandler.getEventPublisher().subscribe(eventType, debouncedSend),
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
} else {
|
|
220
|
-
// Fallback to wildcard if no event types declared
|
|
221
|
-
unsubscribes.push(
|
|
222
|
-
ctx.contextHandler.getEventPublisher().subscribe("*", debouncedSend),
|
|
223
|
-
);
|
|
224
|
-
}
|
|
189
|
+
// Re-subscribe with the same id (reconnect resend) — drop the old one
|
|
190
|
+
clientQuerySubs.get(client.id)?.get(subscriptionId)?.stop();
|
|
225
191
|
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
192
|
+
const live = new LiveQuery(
|
|
193
|
+
ctx.contextHandler.getModel(),
|
|
194
|
+
descriptor,
|
|
195
|
+
scope,
|
|
196
|
+
rawToken,
|
|
197
|
+
(update) => {
|
|
198
|
+
if (update.type === "changes") {
|
|
199
|
+
ctx.connectionManager.sendToClient(client.id, {
|
|
200
|
+
type: "query-changes",
|
|
201
|
+
subscriptionId,
|
|
202
|
+
changes: update.changes,
|
|
203
|
+
});
|
|
204
|
+
} else {
|
|
205
|
+
ctx.connectionManager.sendToClient(client.id, {
|
|
206
|
+
type: "query-snapshot",
|
|
207
|
+
subscriptionId,
|
|
208
|
+
result: update.result ?? null,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
);
|
|
230
213
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
214
|
+
if (!clientQuerySubs.has(client.id)) {
|
|
215
|
+
clientQuerySubs.set(client.id, new Map());
|
|
216
|
+
}
|
|
217
|
+
clientQuerySubs.get(client.id)!.set(subscriptionId, live);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const result = await live.start();
|
|
221
|
+
ctx.connectionManager.sendToClient(client.id, {
|
|
222
|
+
type: "query-snapshot",
|
|
223
|
+
subscriptionId,
|
|
224
|
+
result: result ?? null,
|
|
225
|
+
});
|
|
226
|
+
// Catch-up pass AFTER the snapshot went out — diffs out any commit
|
|
227
|
+
// that slipped into the initial-execute window.
|
|
228
|
+
live.flush();
|
|
229
|
+
} catch (err) {
|
|
230
|
+
live.stop();
|
|
231
|
+
clientQuerySubs.get(client.id)?.delete(subscriptionId);
|
|
232
|
+
console.error(
|
|
233
|
+
`[Arc] Query subscription error (${descriptor?.element}.${descriptor?.method}):`,
|
|
234
|
+
err,
|
|
235
|
+
);
|
|
236
|
+
ctx.connectionManager.sendToClient(client.id, {
|
|
237
|
+
type: "error",
|
|
238
|
+
message: `Failed to subscribe to query "${descriptor?.element}.${descriptor?.method}"`,
|
|
239
|
+
});
|
|
234
240
|
}
|
|
235
|
-
clientViewSubs.get(client.id)!.set(subscriptionId, cleanup);
|
|
236
241
|
return true;
|
|
237
242
|
}
|
|
238
243
|
|
|
239
244
|
if (message.type === "unsubscribe-query") {
|
|
240
|
-
const subs =
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
subs.delete(message.subscriptionId);
|
|
246
|
-
}
|
|
245
|
+
const subs = clientQuerySubs.get(client.id);
|
|
246
|
+
const live = subs?.get(message.subscriptionId);
|
|
247
|
+
if (live) {
|
|
248
|
+
live.stop();
|
|
249
|
+
subs!.delete(message.subscriptionId);
|
|
247
250
|
}
|
|
248
251
|
return true;
|
|
249
252
|
}
|
package/src/types.ts
CHANGED
|
@@ -42,6 +42,12 @@ export interface ConnectedClient {
|
|
|
42
42
|
lastHostEventId: string | null;
|
|
43
43
|
/** WebSocket instance */
|
|
44
44
|
ws: any;
|
|
45
|
+
/**
|
|
46
|
+
* True for local-mode clients (they sent request-sync / sync-events).
|
|
47
|
+
* Only these receive domain-event broadcasts — streaming clients are
|
|
48
|
+
* served by view snapshots + deltas instead.
|
|
49
|
+
*/
|
|
50
|
+
wantsEventSync?: boolean;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/**
|
|
@@ -137,7 +143,15 @@ export type HostToClientMessage =
|
|
|
137
143
|
message: string;
|
|
138
144
|
}
|
|
139
145
|
| {
|
|
140
|
-
type: "query-
|
|
146
|
+
type: "query-snapshot";
|
|
141
147
|
subscriptionId: string;
|
|
142
|
-
|
|
148
|
+
result: any;
|
|
149
|
+
}
|
|
150
|
+
| {
|
|
151
|
+
type: "query-changes";
|
|
152
|
+
subscriptionId: string;
|
|
153
|
+
changes: Array<
|
|
154
|
+
| { type: "set"; id: string; item: any; index: number }
|
|
155
|
+
| { type: "delete"; id: string }
|
|
156
|
+
>;
|
|
143
157
|
};
|