@arcote.tech/arc-host 0.7.26 → 0.7.28

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,9 +1,27 @@
1
1
  import type { ArcRouteAny } from "@arcote.tech/arc";
2
- import { ScopedModel } from "@arcote.tech/arc";
2
+ import { ArcValidationError, arcErrorStatus, ScopedModel } from "@arcote.tech/arc";
3
3
  import type { ConnectionManager } from "../connection-manager";
4
4
  import type { ContextHandler } from "../context-handler";
5
5
  import type { ArcHttpHandler, ArcRequestContext } from "./types";
6
6
 
7
+ // ---------------------------------------------------------------------------
8
+ // Error → HTTP mapping
9
+ //
10
+ // Typed Arc errors map to 400/401/403 with safe bodies. Anything else is an
11
+ // unexpected failure → 500 with a GENERIC message (never leak error.message
12
+ // or stack to the client).
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function errorBody(error: unknown): { status: number; body: Record<string, any> } {
16
+ const status = arcErrorStatus(error) ?? 500;
17
+ if (error instanceof ArcValidationError) {
18
+ return { status, body: { error: "Invalid parameters", fields: error.fields } };
19
+ }
20
+ if (status === 401) return { status, body: { error: "Unauthorized" } };
21
+ if (status === 403) return { status, body: { error: "Forbidden" } };
22
+ return { status: 500, body: { error: "Internal server error" } };
23
+ }
24
+
7
25
  // ---------------------------------------------------------------------------
8
26
  // Shared
9
27
  // ---------------------------------------------------------------------------
@@ -76,11 +94,11 @@ export function commandHandler(ch: ContextHandler): ArcHttpHandler {
76
94
  headers: ctx.corsHeaders,
77
95
  });
78
96
  } catch (error) {
79
- console.error(`[ARC] Command '${commandName}' error:`, error);
80
- return Response.json(
81
- { error: (error as Error).message },
82
- { status: 500, headers: ctx.corsHeaders },
83
- );
97
+ const { status, body } = errorBody(error);
98
+ if (status === 500) {
99
+ console.error(`[ARC] Command '${commandName}' error:`, error);
100
+ }
101
+ return Response.json(body, { status, headers: ctx.corsHeaders });
84
102
  }
85
103
  };
86
104
  }
@@ -119,10 +137,9 @@ export function queryHandler(ch: ContextHandler): ArcHttpHandler {
119
137
 
120
138
  return Response.json(result, { headers: ctx.corsHeaders });
121
139
  } catch (error) {
122
- return Response.json(
123
- { error: (error as Error).message },
124
- { status: 500, headers: ctx.corsHeaders },
125
- );
140
+ const { status, body } = errorBody(error);
141
+ if (status === 500) console.error(`[ARC] Query error:`, error);
142
+ return Response.json(body, { status, headers: ctx.corsHeaders });
126
143
  }
127
144
  };
128
145
  }
@@ -153,9 +170,11 @@ export function eventSyncHandler(ch: ContextHandler): ArcHttpHandler {
153
170
  { headers: ctx.corsHeaders },
154
171
  );
155
172
  } catch (error) {
173
+ const { status, body } = errorBody(error);
174
+ if (status === 500) console.error(`[ARC] Event sync error:`, error);
156
175
  return Response.json(
157
- { success: false, error: (error as Error).message },
158
- { status: 500, headers: ctx.corsHeaders },
176
+ { success: false, ...body },
177
+ { status, headers: ctx.corsHeaders },
159
178
  );
160
179
  }
161
180
  };
@@ -269,10 +288,9 @@ export function routeHandler(ch: ContextHandler): ArcHttpHandler {
269
288
  headers: newHeaders,
270
289
  });
271
290
  } catch (error) {
272
- return Response.json(
273
- { error: (error as Error).message },
274
- { status: 500, headers: ctx.corsHeaders },
275
- );
291
+ const { status, body: errBody } = errorBody(error);
292
+ if (status === 500) console.error(`[ARC] Route error:`, error);
293
+ return Response.json(errBody, { status, headers: ctx.corsHeaders });
276
294
  }
277
295
  };
278
296
  }
@@ -28,7 +28,7 @@ export type ArcHttpHandler = (
28
28
  export interface ArcWsContext {
29
29
  contextHandler: ContextHandler;
30
30
  connectionManager: ConnectionManager;
31
- verifyToken: (token: string) => TokenPayload | null;
31
+ verifyToken: (token: string) => Promise<TokenPayload | null>;
32
32
  }
33
33
 
34
34
  /**
@@ -1,23 +1,73 @@
1
- import { LiveQuery } from "@arcote.tech/arc";
1
+ import { applyQueryChanges, LiveQuery } from "@arcote.tech/arc";
2
2
  import { filterEventsForTokens } from "../event-auth";
3
3
  import type { ArcWsHandler } from "./types";
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
6
  // Live query subscriptions
7
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
+ // One LiveQuery per (scope, descriptor, token) clients subscribed to the
9
+ // SAME query with the SAME token share a single server-side LiveQuery, so a
10
+ // mutation triggers one re-execute + diff instead of one per subscriber
11
+ // (packages/benchmarks/FINDINGS.md, F-08). The token is part of the key
12
+ // because protectBy restrictions make results token-specific. ALL query
13
+ // logic — execution, token restrictions, change detection, delta
14
+ // computation — lives in core's LiveQuery (query layer); this module only
15
+ // fans its updates out over WS.
11
16
  // ---------------------------------------------------------------------------
12
17
 
13
- /** clientId → subscriptionId → LiveQuery */
14
- const clientQuerySubs = new Map<string, Map<string, LiveQuery>>();
18
+ interface SharedQueryEntry {
19
+ live: LiveQuery;
20
+ /** clientId → subscriptionIds subscribed under this shared query. */
21
+ subscribers: Map<string, Set<string>>;
22
+ /**
23
+ * Mirror of the current result — late joiners get their snapshot from it
24
+ * without re-executing. Maintained by the same update stream the clients
25
+ * receive, so it can't diverge from what they hold.
26
+ */
27
+ lastResult: unknown;
28
+ /** start() resolved and the initial snapshot is trustworthy. */
29
+ ready: boolean;
30
+ }
15
31
 
16
- export function cleanupClientSubs(clientId: string): void {
17
- const subs = clientQuerySubs.get(clientId);
32
+ /** sharedKey entry. */
33
+ const sharedQueries = new Map<string, SharedQueryEntry>();
34
+ /** clientId → subscriptionId → sharedKey (reverse index for cleanup). */
35
+ const clientSubKeys = new Map<string, Map<string, string>>();
36
+
37
+ function sharedKeyFor(
38
+ descriptor: unknown,
39
+ scope: string,
40
+ rawToken: string | null,
41
+ ): string {
42
+ // JSON tablicy = jednoznaczny klucz bez założeń o separatorach.
43
+ return JSON.stringify([scope, rawToken, descriptor]);
44
+ }
45
+
46
+ function detachSub(
47
+ clientId: string,
48
+ subscriptionId: string,
49
+ sharedKey: string,
50
+ ): void {
51
+ const entry = sharedQueries.get(sharedKey);
52
+ if (!entry) return;
53
+ const subs = entry.subscribers.get(clientId);
18
54
  if (subs) {
19
- for (const live of subs.values()) live.stop();
20
- clientQuerySubs.delete(clientId);
55
+ subs.delete(subscriptionId);
56
+ if (subs.size === 0) entry.subscribers.delete(clientId);
57
+ }
58
+ if (entry.subscribers.size === 0) {
59
+ entry.live.stop();
60
+ sharedQueries.delete(sharedKey);
61
+ }
62
+ }
63
+
64
+ export function cleanupClientSubs(clientId: string): void {
65
+ const keys = clientSubKeys.get(clientId);
66
+ if (keys) {
67
+ for (const [subscriptionId, sharedKey] of keys) {
68
+ detachSub(clientId, subscriptionId, sharedKey);
69
+ }
70
+ clientSubKeys.delete(clientId);
21
71
  }
22
72
  }
23
73
 
@@ -28,7 +78,7 @@ export function cleanupClientSubs(clientId: string): void {
28
78
  export function scopeAuthHandler(): ArcWsHandler {
29
79
  return async (client, message, ctx) => {
30
80
  if (message.type !== "scope:auth") return false;
31
- const decoded = ctx.verifyToken(message.token);
81
+ const decoded = await ctx.verifyToken(message.token);
32
82
  if (decoded) {
33
83
  ctx.connectionManager.setScopeToken(
34
84
  client.id,
@@ -187,48 +237,114 @@ export function querySubscriptionHandler(): ArcWsHandler {
187
237
  }
188
238
 
189
239
  // Re-subscribe with the same id (reconnect resend) — drop the old one
190
- clientQuerySubs.get(client.id)?.get(subscriptionId)?.stop();
240
+ const prevKey = clientSubKeys.get(client.id)?.get(subscriptionId);
241
+ if (prevKey) detachSub(client.id, subscriptionId, prevKey);
242
+
243
+ const sharedKey = sharedKeyFor(descriptor, scope, rawToken);
244
+ const attach = (entry: SharedQueryEntry) => {
245
+ if (!entry.subscribers.has(client.id)) {
246
+ entry.subscribers.set(client.id, new Set());
247
+ }
248
+ entry.subscribers.get(client.id)!.add(subscriptionId);
249
+ if (!clientSubKeys.has(client.id)) {
250
+ clientSubKeys.set(client.id, new Map());
251
+ }
252
+ clientSubKeys.get(client.id)!.set(subscriptionId, sharedKey);
253
+ };
254
+
255
+ const existing = sharedQueries.get(sharedKey);
256
+ if (existing && existing.ready) {
257
+ // Late joiner — snapshot from the mirror, no re-execute.
258
+ attach(existing);
259
+ ctx.connectionManager.sendToClient(client.id, {
260
+ type: "query-snapshot",
261
+ subscriptionId,
262
+ result: existing.lastResult ?? null,
263
+ });
264
+ return true;
265
+ }
266
+ if (existing) {
267
+ // start() in flight (another subscriber won the race) — attach;
268
+ // the starter's post-start broadcast covers this subscription too.
269
+ attach(existing);
270
+ return true;
271
+ }
191
272
 
192
- const live = new LiveQuery(
273
+ const entry: SharedQueryEntry = {
274
+ live: null as unknown as LiveQuery,
275
+ subscribers: new Map(),
276
+ lastResult: null,
277
+ ready: false,
278
+ };
279
+ entry.live = new LiveQuery(
193
280
  ctx.contextHandler.getModel(),
194
281
  descriptor,
195
282
  scope,
196
283
  rawToken,
197
284
  (update) => {
285
+ // Keep the mirror in the same sequence the clients apply.
198
286
  if (update.type === "changes") {
199
- ctx.connectionManager.sendToClient(client.id, {
200
- type: "query-changes",
201
- subscriptionId,
202
- changes: update.changes,
203
- });
287
+ if (Array.isArray(entry.lastResult)) {
288
+ entry.lastResult = applyQueryChanges(
289
+ entry.lastResult as Array<{ _id: string }>,
290
+ update.changes,
291
+ );
292
+ }
204
293
  } else {
205
- ctx.connectionManager.sendToClient(client.id, {
206
- type: "query-snapshot",
207
- subscriptionId,
208
- result: update.result ?? null,
209
- });
294
+ entry.lastResult = update.result ?? null;
295
+ }
296
+ for (const [subClientId, subIds] of entry.subscribers) {
297
+ for (const subId of subIds) {
298
+ ctx.connectionManager.sendToClient(
299
+ subClientId,
300
+ update.type === "changes"
301
+ ? {
302
+ type: "query-changes",
303
+ subscriptionId: subId,
304
+ changes: update.changes,
305
+ }
306
+ : {
307
+ type: "query-snapshot",
308
+ subscriptionId: subId,
309
+ result: update.result ?? null,
310
+ },
311
+ );
312
+ }
210
313
  }
211
314
  },
212
315
  );
213
-
214
- if (!clientQuerySubs.has(client.id)) {
215
- clientQuerySubs.set(client.id, new Map());
216
- }
217
- clientQuerySubs.get(client.id)!.set(subscriptionId, live);
316
+ sharedQueries.set(sharedKey, entry);
317
+ attach(entry);
218
318
 
219
319
  try {
220
- const result = await live.start();
221
- ctx.connectionManager.sendToClient(client.id, {
222
- type: "query-snapshot",
223
- subscriptionId,
224
- result: result ?? null,
225
- });
320
+ const result = await entry.live.start();
321
+ entry.lastResult = result ?? null;
322
+ entry.ready = true;
323
+ // Initial snapshot for EVERY subscription attached so far — the
324
+ // starter and any joiners that raced into the start window.
325
+ for (const [subClientId, subIds] of entry.subscribers) {
326
+ for (const subId of subIds) {
327
+ ctx.connectionManager.sendToClient(subClientId, {
328
+ type: "query-snapshot",
329
+ subscriptionId: subId,
330
+ result: result ?? null,
331
+ });
332
+ }
333
+ }
226
334
  // Catch-up pass AFTER the snapshot went out — diffs out any commit
227
335
  // that slipped into the initial-execute window.
228
- live.flush();
336
+ entry.live.flush();
229
337
  } catch (err) {
230
- live.stop();
231
- clientQuerySubs.get(client.id)?.delete(subscriptionId);
338
+ entry.live.stop();
339
+ sharedQueries.delete(sharedKey);
340
+ for (const subClientId of entry.subscribers.keys()) {
341
+ const keys = clientSubKeys.get(subClientId);
342
+ if (keys) {
343
+ for (const [subId, key] of keys) {
344
+ if (key === sharedKey) keys.delete(subId);
345
+ }
346
+ }
347
+ }
232
348
  console.error(
233
349
  `[Arc] Query subscription error (${descriptor?.element}.${descriptor?.method}):`,
234
350
  err,
@@ -242,11 +358,10 @@ export function querySubscriptionHandler(): ArcWsHandler {
242
358
  }
243
359
 
244
360
  if (message.type === "unsubscribe-query") {
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);
361
+ const key = clientSubKeys.get(client.id)?.get(message.subscriptionId);
362
+ if (key) {
363
+ detachSub(client.id, message.subscriptionId, key);
364
+ clientSubKeys.get(client.id)?.delete(message.subscriptionId);
250
365
  }
251
366
  return true;
252
367
  }