@arcote.tech/arc-host 0.7.14 → 0.7.15

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.
@@ -1,19 +1,122 @@
1
- import { ScopedModel } from "@arcote.tech/arc";
1
+ import {
2
+ ScopedModel,
3
+ ScopedStore,
4
+ checkItemMatchesWhere,
5
+ type CommittedChange,
6
+ } from "@arcote.tech/arc";
7
+ import type { ConnectionManager } from "../connection-manager";
2
8
  import { filterEventsForTokens } from "../event-auth";
3
- import type { ConnectedClient } from "../types";
9
+ import type { ConnectedClient, HostToClientMessage } from "../types";
4
10
  import type { ArcWsContext, ArcWsHandler } from "./types";
5
11
 
6
12
  // ---------------------------------------------------------------------------
7
- // View subscription tracking (per client)
13
+ // View replica subscriptions
14
+ //
15
+ // One entry per (view, client, scope). Restrictions are resolved ONCE at
16
+ // subscribe time (token changes invalidate the subscription client-side and
17
+ // re-subscribe). Deltas committed between registration and the snapshot send
18
+ // are buffered and flushed after the snapshot — sets/deletes are idempotent,
19
+ // so replaying a delta the snapshot already contains is harmless.
8
20
  // ---------------------------------------------------------------------------
9
21
 
10
- const clientViewSubs = new Map<string, Map<string, () => void>>();
22
+ type WireViewChange =
23
+ | { type: "set"; id: string; item: any }
24
+ | { type: "delete"; id: string; item: null };
25
+
26
+ interface ViewSubscriber {
27
+ clientId: string;
28
+ scope: string;
29
+ restrictions: Record<string, unknown> | null;
30
+ /** Non-null while the snapshot is being read/sent — deltas land here. */
31
+ buffer: WireViewChange[] | null;
32
+ }
33
+
34
+ /** viewName → `${clientId}:${scope}` → subscriber */
35
+ const viewSubscribers = new Map<string, Map<string, ViewSubscriber>>();
11
36
 
12
37
  export function cleanupClientSubs(clientId: string): void {
13
- const subs = clientViewSubs.get(clientId);
14
- if (subs) {
15
- for (const unsub of subs.values()) unsub();
16
- clientViewSubs.delete(clientId);
38
+ const prefix = `${clientId}:`;
39
+ for (const subs of viewSubscribers.values()) {
40
+ for (const key of subs.keys()) {
41
+ if (key.startsWith(prefix)) subs.delete(key);
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Filter a committed change for one subscriber:
48
+ * - new row matches → `set` with the full row
49
+ * - only old row matched → `delete` (item left the subscriber's slice)
50
+ * - neither → nothing (not in this tenant's slice; no id leakage)
51
+ */
52
+ export function filterChangeForSubscriber(
53
+ change: CommittedChange,
54
+ restrictions: Record<string, unknown> | null,
55
+ ): WireViewChange | null {
56
+ const newMatches =
57
+ change.newRow !== null &&
58
+ (restrictions === null || checkItemMatchesWhere(change.newRow, restrictions));
59
+ if (newMatches) {
60
+ return { type: "set", id: change.id, item: change.newRow };
61
+ }
62
+ const oldMatches =
63
+ change.oldRow !== null &&
64
+ (restrictions === null || checkItemMatchesWhere(change.oldRow, restrictions));
65
+ if (oldMatches) {
66
+ return { type: "delete", id: change.id, item: null };
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Broadcast committed view deltas to subscribed clients. Wired to
73
+ * `LocalEventPublisher.onViewChanges` in create-server — fires after each
74
+ * successful publish commit, regardless of the event's origin
75
+ * (execute-command, client sync-events, server-side listeners).
76
+ *
77
+ * One `view-changes` message per (commit × view × client); empty results
78
+ * are skipped entirely.
79
+ */
80
+ export function broadcastViewChanges(
81
+ committed: CommittedChange[],
82
+ connectionManager: ConnectionManager,
83
+ ): void {
84
+ // Group by view store, preserving commit order
85
+ const byStore = new Map<string, CommittedChange[]>();
86
+ for (const change of committed) {
87
+ let list = byStore.get(change.store);
88
+ if (!list) {
89
+ list = [];
90
+ byStore.set(change.store, list);
91
+ }
92
+ list.push(change);
93
+ }
94
+
95
+ for (const [viewName, changes] of byStore) {
96
+ const subs = viewSubscribers.get(viewName);
97
+ if (!subs || subs.size === 0) continue;
98
+
99
+ for (const sub of subs.values()) {
100
+ const filtered: WireViewChange[] = [];
101
+ for (const change of changes) {
102
+ const wireChange = filterChangeForSubscriber(change, sub.restrictions);
103
+ if (wireChange) filtered.push(wireChange);
104
+ }
105
+ if (filtered.length === 0) continue;
106
+
107
+ if (sub.buffer) {
108
+ // Snapshot in-flight — flushed right after it is sent
109
+ sub.buffer.push(...filtered);
110
+ continue;
111
+ }
112
+
113
+ connectionManager.sendToClient(sub.clientId, {
114
+ type: "view-changes",
115
+ element: viewName,
116
+ scope: sub.scope,
117
+ changes: filtered,
118
+ } as HostToClientMessage);
119
+ }
17
120
  }
18
121
  }
19
122
 
@@ -45,6 +148,10 @@ export function syncEventsHandler(): ArcWsHandler {
45
148
  return async (client, message, ctx) => {
46
149
  if (message.type !== "sync-events") return false;
47
150
 
151
+ // NOTE: sync-events alone does NOT mark the client as local-mode —
152
+ // streaming clients also push their optimistic mutations through it.
153
+ // Only request-sync (local-mode catch-up) sets `wantsEventSync`.
154
+
48
155
  const allTokens = ctx.connectionManager.getAllScopeTokens(client.id);
49
156
  const token = allTokens.length > 0 ? allTokens[0] : null;
50
157
 
@@ -55,9 +162,10 @@ export function syncEventsHandler(): ArcWsHandler {
55
162
  );
56
163
  if (persisted.length === 0) return true;
57
164
 
58
- // Broadcast to other authorized clients
165
+ // Broadcast to other authorized LOCAL-MODE clients
59
166
  for (const c of ctx.connectionManager.getAllClients()) {
60
167
  if (c.id === client.id) continue;
168
+ if (!c.wantsEventSync) continue;
61
169
  const clientTokens = ctx.connectionManager.getAllScopeTokens(c.id);
62
170
  const authorized = filterEventsForTokens(
63
171
  clientTokens,
@@ -90,6 +198,10 @@ export function requestSyncHandler(): ArcWsHandler {
90
198
  return async (client, message, ctx) => {
91
199
  if (message.type !== "request-sync") return false;
92
200
 
201
+ // Only local-mode clients request the event log (streaming clients use
202
+ // view replicas) — mark so domain-event broadcasts reach this client.
203
+ client.wantsEventSync = true;
204
+
93
205
  const allTokens = ctx.connectionManager.getAllScopeTokens(client.id);
94
206
  const token = allTokens.length > 0 ? allTokens[0] : null;
95
207
  const events = await ctx.contextHandler.getEventsSince(
@@ -155,96 +267,110 @@ export function executeCommandHandler(): ArcWsHandler {
155
267
  }
156
268
 
157
269
  // ---------------------------------------------------------------------------
158
- // subscribe-view / unsubscribe-view — with scope-aware token selection
270
+ // subscribe-view / unsubscribe-view — view replica subscriptions
159
271
  // ---------------------------------------------------------------------------
160
272
 
161
- export function querySubscriptionHandler(): ArcWsHandler {
273
+ export function viewSubscriptionHandler(): ArcWsHandler {
162
274
  return async (client, message, ctx) => {
163
- if (message.type === "subscribe-query") {
164
- const { subscriptionId, descriptor, scope } = message;
275
+ if (message.type === "subscribe-view") {
276
+ const { element: elementName } = message;
277
+ const scope: string = message.scope ?? "default";
165
278
 
166
279
  // Pick the token matching the requested scope
167
- const scopeToken = scope ? client.scopeTokens.get(scope) : null;
280
+ const scopeToken = client.scopeTokens.get(scope) ?? null;
168
281
  let rawToken = scopeToken?.raw ?? null;
169
282
 
170
- // Fallback to first available token if no scope specified
283
+ // Fallback to first available token if no scope match
171
284
  if (!rawToken && client.scopeTokens.size > 0) {
172
285
  rawToken = client.scopeTokens.values().next().value!.raw;
173
286
  }
174
287
 
175
- // Per-request scoped model with the right token
176
- const scoped = new ScopedModel(ctx.contextHandler.getModel(), scope ?? "default");
288
+ // Scoped model just to resolve restrictions for this token
289
+ const scoped = new ScopedModel(ctx.contextHandler.getModel(), scope);
177
290
  if (rawToken) scoped.setToken(rawToken);
178
291
 
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) : [];
292
+ const element = ctx.contextHandler.getModel().context.get(elementName) as any;
293
+ if (!element || typeof element.getRestrictionsFor !== "function") {
294
+ ctx.connectionManager.sendToClient(client.id, {
295
+ type: "error",
296
+ message: `Cannot subscribe to view "${elementName}": unknown element`,
297
+ });
298
+ return true;
299
+ }
205
300
 
206
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
207
- const debouncedSend = () => {
208
- if (debounceTimer) clearTimeout(debounceTimer);
209
- debounceTimer = setTimeout(() => sendData(), 50);
210
- };
301
+ const { restrictions, denied } = element.getRestrictionsFor(
302
+ scoped.getAdapters(),
303
+ );
211
304
 
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
- );
305
+ const subKey = `${client.id}:${scope}`;
306
+
307
+ if (denied) {
308
+ // No access — empty snapshot, no registration (token change
309
+ // re-subscribes via invalidateScope on the client)
310
+ viewSubscribers.get(elementName)?.delete(subKey);
311
+ ctx.connectionManager.sendToClient(client.id, {
312
+ type: "view-snapshot",
313
+ element: elementName,
314
+ scope,
315
+ items: [],
316
+ });
317
+ return true;
224
318
  }
225
319
 
226
- const cleanup = () => {
227
- if (debounceTimer) clearTimeout(debounceTimer);
228
- for (const unsub of unsubscribes) unsub();
320
+ // Register FIRST (buffering deltas) so changes committed while the
321
+ // snapshot is being read are not lost; flushed after the snapshot.
322
+ const subscriber: ViewSubscriber = {
323
+ clientId: client.id,
324
+ scope,
325
+ restrictions,
326
+ buffer: [],
229
327
  };
328
+ if (!viewSubscribers.has(elementName)) {
329
+ viewSubscribers.set(elementName, new Map());
330
+ }
331
+ viewSubscribers.get(elementName)!.set(subKey, subscriber);
332
+
333
+ try {
334
+ const store = ctx.contextHandler
335
+ .getDataStorage()
336
+ .getStore<any>(elementName);
337
+ const items = restrictions
338
+ ? await new ScopedStore(store, restrictions, false).find({})
339
+ : await store.find({});
340
+
341
+ ctx.connectionManager.sendToClient(client.id, {
342
+ type: "view-snapshot",
343
+ element: elementName,
344
+ scope,
345
+ items,
346
+ });
347
+ } catch (err) {
348
+ viewSubscribers.get(elementName)?.delete(subKey);
349
+ console.error(`[Arc] View subscription error (${elementName}):`, err);
350
+ ctx.connectionManager.sendToClient(client.id, {
351
+ type: "error",
352
+ message: `Failed to subscribe to view "${elementName}"`,
353
+ });
354
+ return true;
355
+ }
230
356
 
231
- // Track for cleanup on disconnect
232
- if (!clientViewSubs.has(client.id)) {
233
- clientViewSubs.set(client.id, new Map());
357
+ // Go live: flush deltas buffered during the snapshot read
358
+ const buffered = subscriber.buffer!;
359
+ subscriber.buffer = null;
360
+ if (buffered.length > 0) {
361
+ ctx.connectionManager.sendToClient(client.id, {
362
+ type: "view-changes",
363
+ element: elementName,
364
+ scope,
365
+ changes: buffered,
366
+ });
234
367
  }
235
- clientViewSubs.get(client.id)!.set(subscriptionId, cleanup);
236
368
  return true;
237
369
  }
238
370
 
239
- 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
- }
247
- }
371
+ if (message.type === "unsubscribe-view") {
372
+ const scope: string = message.scope ?? "default";
373
+ viewSubscribers.get(message.element)?.delete(`${client.id}:${scope}`);
248
374
  return true;
249
375
  }
250
376
 
@@ -262,6 +388,6 @@ export function arcWsHandlers(): ArcWsHandler[] {
262
388
  syncEventsHandler(),
263
389
  requestSyncHandler(),
264
390
  executeCommandHandler(),
265
- querySubscriptionHandler(),
391
+ viewSubscriptionHandler(),
266
392
  ];
267
393
  }
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
  /**
@@ -104,14 +110,14 @@ export type ClientToHostMessage =
104
110
  token: string;
105
111
  }
106
112
  | {
107
- type: "subscribe-query";
108
- subscriptionId: string;
109
- descriptor: { element: string; method: string; args: any[] };
110
- scope?: string;
113
+ type: "subscribe-view";
114
+ element: string;
115
+ scope: string;
111
116
  }
112
117
  | {
113
- type: "unsubscribe-query";
114
- subscriptionId: string;
118
+ type: "unsubscribe-view";
119
+ element: string;
120
+ scope: string;
115
121
  };
116
122
 
117
123
  /**
@@ -137,7 +143,17 @@ export type HostToClientMessage =
137
143
  message: string;
138
144
  }
139
145
  | {
140
- type: "query-data";
141
- subscriptionId: string;
142
- data: any[];
146
+ type: "view-snapshot";
147
+ element: string;
148
+ scope: string;
149
+ items: any[];
150
+ }
151
+ | {
152
+ type: "view-changes";
153
+ element: string;
154
+ scope: string;
155
+ changes: Array<
156
+ | { type: "set"; id: string; item: any }
157
+ | { type: "delete"; id: string; item: null }
158
+ >;
143
159
  };