@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.
- package/dist/index.js +131 -157
- 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/view-subscription.integration.test.d.ts +2 -0
- package/dist/src/middleware/view-subscription.integration.test.d.ts.map +1 -0
- package/dist/src/middleware/ws.d.ts +30 -1
- package/dist/src/middleware/ws.d.ts.map +1 -1
- package/dist/src/middleware/ws.test.d.ts +2 -0
- package/dist/src/middleware/ws.test.d.ts.map +1 -0
- package/dist/src/types.d.ts +29 -13
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/create-server.ts +8 -2
- package/src/index.ts +2 -3
- package/src/middleware/http.ts +0 -119
- package/src/middleware/index.ts +2 -3
- package/src/middleware/view-subscription.integration.test.ts +307 -0
- package/src/middleware/ws.test.ts +95 -0
- package/src/middleware/ws.ts +203 -77
- package/src/types.ts +25 -9
package/src/middleware/ws.ts
CHANGED
|
@@ -1,19 +1,122 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
for (const
|
|
16
|
-
|
|
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 —
|
|
270
|
+
// subscribe-view / unsubscribe-view — view replica subscriptions
|
|
159
271
|
// ---------------------------------------------------------------------------
|
|
160
272
|
|
|
161
|
-
export function
|
|
273
|
+
export function viewSubscriptionHandler(): ArcWsHandler {
|
|
162
274
|
return async (client, message, ctx) => {
|
|
163
|
-
if (message.type === "subscribe-
|
|
164
|
-
const {
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
176
|
-
const scoped = new ScopedModel(ctx.contextHandler.getModel(), scope
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
debounceTimer = setTimeout(() => sendData(), 50);
|
|
210
|
-
};
|
|
301
|
+
const { restrictions, denied } = element.getRestrictionsFor(
|
|
302
|
+
scoped.getAdapters(),
|
|
303
|
+
);
|
|
211
304
|
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
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-
|
|
240
|
-
const
|
|
241
|
-
|
|
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
|
-
|
|
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-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
scope?: string;
|
|
113
|
+
type: "subscribe-view";
|
|
114
|
+
element: string;
|
|
115
|
+
scope: string;
|
|
111
116
|
}
|
|
112
117
|
| {
|
|
113
|
-
type: "unsubscribe-
|
|
114
|
-
|
|
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: "
|
|
141
|
-
|
|
142
|
-
|
|
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
|
};
|