@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.
@@ -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
+ });
@@ -1,19 +1,23 @@
1
- import { ScopedModel } from "@arcote.tech/arc";
1
+ import { LiveQuery } from "@arcote.tech/arc";
2
2
  import { filterEventsForTokens } from "../event-auth";
3
- import type { ConnectedClient } from "../types";
4
- import type { ArcWsContext, ArcWsHandler } from "./types";
3
+ import type { ArcWsHandler } from "./types";
5
4
 
6
5
  // ---------------------------------------------------------------------------
7
- // View subscription tracking (per client)
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
- const clientViewSubs = new Map<string, Map<string, () => void>>();
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 = clientViewSubs.get(clientId);
17
+ const subs = clientQuerySubs.get(clientId);
14
18
  if (subs) {
15
- for (const unsub of subs.values()) unsub();
16
- clientViewSubs.delete(clientId);
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-view / unsubscribe-viewwith scope-aware token selection
171
+ // subscribe-query / unsubscribe-querylive 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, scope } = message;
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 = scope ? client.scopeTokens.get(scope) : null;
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 specified
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
- // Per-request scoped model with the right token
176
- const scoped = new ScopedModel(ctx.contextHandler.getModel(), scope ?? "default");
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 cleanup = () => {
227
- if (debounceTimer) clearTimeout(debounceTimer);
228
- for (const unsub of unsubscribes) unsub();
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
- // Track for cleanup on disconnect
232
- if (!clientViewSubs.has(client.id)) {
233
- clientViewSubs.set(client.id, new Map());
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 = clientViewSubs.get(client.id);
241
- if (subs) {
242
- const unsub = subs.get(message.subscriptionId);
243
- if (unsub) {
244
- unsub();
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-data";
146
+ type: "query-snapshot";
141
147
  subscriptionId: string;
142
- data: any[];
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
  };