@arcote.tech/arc-host 0.7.15 → 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 +48 -122
- package/dist/index.js.map +4 -4
- 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/index.d.ts +1 -1
- 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 +1 -30
- package/dist/src/middleware/ws.d.ts.map +1 -1
- package/dist/src/types.d.ts +16 -14
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/create-server.ts +0 -8
- package/src/index.ts +1 -2
- package/src/middleware/index.ts +1 -2
- package/src/middleware/live-query.integration.test.ts +432 -0
- package/src/middleware/ws.ts +63 -186
- package/src/types.ts +13 -15
- package/dist/src/middleware/view-subscription.integration.test.d.ts +0 -2
- package/dist/src/middleware/view-subscription.integration.test.d.ts.map +0 -1
- package/dist/src/middleware/ws.test.d.ts +0 -2
- package/dist/src/middleware/ws.test.d.ts.map +0 -1
- package/src/middleware/view-subscription.integration.test.ts +0 -307
- package/src/middleware/ws.test.ts +0 -95
package/src/middleware/ws.ts
CHANGED
|
@@ -1,122 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ScopedModel,
|
|
3
|
-
ScopedStore,
|
|
4
|
-
checkItemMatchesWhere,
|
|
5
|
-
type CommittedChange,
|
|
6
|
-
} from "@arcote.tech/arc";
|
|
7
|
-
import type { ConnectionManager } from "../connection-manager";
|
|
1
|
+
import { LiveQuery } from "@arcote.tech/arc";
|
|
8
2
|
import { filterEventsForTokens } from "../event-auth";
|
|
9
|
-
import type {
|
|
10
|
-
import type { ArcWsContext, ArcWsHandler } from "./types";
|
|
3
|
+
import type { ArcWsHandler } from "./types";
|
|
11
4
|
|
|
12
5
|
// ---------------------------------------------------------------------------
|
|
13
|
-
//
|
|
6
|
+
// Live query subscriptions
|
|
14
7
|
//
|
|
15
|
-
// One
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// are buffered and flushed after the snapshot — sets/deletes are idempotent,
|
|
19
|
-
// so replaying a delta the snapshot already contains is harmless.
|
|
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.
|
|
20
11
|
// ---------------------------------------------------------------------------
|
|
21
12
|
|
|
22
|
-
|
|
23
|
-
|
|
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>>();
|
|
13
|
+
/** clientId → subscriptionId → LiveQuery */
|
|
14
|
+
const clientQuerySubs = new Map<string, Map<string, LiveQuery>>();
|
|
36
15
|
|
|
37
16
|
export function cleanupClientSubs(clientId: string): void {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
for (const
|
|
41
|
-
|
|
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
|
+
const subs = clientQuerySubs.get(clientId);
|
|
18
|
+
if (subs) {
|
|
19
|
+
for (const live of subs.values()) live.stop();
|
|
20
|
+
clientQuerySubs.delete(clientId);
|
|
120
21
|
}
|
|
121
22
|
}
|
|
122
23
|
|
|
@@ -267,13 +168,13 @@ export function executeCommandHandler(): ArcWsHandler {
|
|
|
267
168
|
}
|
|
268
169
|
|
|
269
170
|
// ---------------------------------------------------------------------------
|
|
270
|
-
// subscribe-
|
|
171
|
+
// subscribe-query / unsubscribe-query — live query subscriptions
|
|
271
172
|
// ---------------------------------------------------------------------------
|
|
272
173
|
|
|
273
|
-
export function
|
|
174
|
+
export function querySubscriptionHandler(): ArcWsHandler {
|
|
274
175
|
return async (client, message, ctx) => {
|
|
275
|
-
if (message.type === "subscribe-
|
|
276
|
-
const {
|
|
176
|
+
if (message.type === "subscribe-query") {
|
|
177
|
+
const { subscriptionId, descriptor } = message;
|
|
277
178
|
const scope: string = message.scope ?? "default";
|
|
278
179
|
|
|
279
180
|
// Pick the token matching the requested scope
|
|
@@ -285,92 +186,68 @@ export function viewSubscriptionHandler(): ArcWsHandler {
|
|
|
285
186
|
rawToken = client.scopeTokens.values().next().value!.raw;
|
|
286
187
|
}
|
|
287
188
|
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
if (rawToken) scoped.setToken(rawToken);
|
|
189
|
+
// Re-subscribe with the same id (reconnect resend) — drop the old one
|
|
190
|
+
clientQuerySubs.get(client.id)?.get(subscriptionId)?.stop();
|
|
291
191
|
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
},
|
|
303
212
|
);
|
|
304
213
|
|
|
305
|
-
|
|
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;
|
|
318
|
-
}
|
|
319
|
-
|
|
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: [],
|
|
327
|
-
};
|
|
328
|
-
if (!viewSubscribers.has(elementName)) {
|
|
329
|
-
viewSubscribers.set(elementName, new Map());
|
|
214
|
+
if (!clientQuerySubs.has(client.id)) {
|
|
215
|
+
clientQuerySubs.set(client.id, new Map());
|
|
330
216
|
}
|
|
331
|
-
|
|
217
|
+
clientQuerySubs.get(client.id)!.set(subscriptionId, live);
|
|
332
218
|
|
|
333
219
|
try {
|
|
334
|
-
const
|
|
335
|
-
.getDataStorage()
|
|
336
|
-
.getStore<any>(elementName);
|
|
337
|
-
const items = restrictions
|
|
338
|
-
? await new ScopedStore(store, restrictions, false).find({})
|
|
339
|
-
: await store.find({});
|
|
340
|
-
|
|
220
|
+
const result = await live.start();
|
|
341
221
|
ctx.connectionManager.sendToClient(client.id, {
|
|
342
|
-
type: "
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
items,
|
|
222
|
+
type: "query-snapshot",
|
|
223
|
+
subscriptionId,
|
|
224
|
+
result: result ?? null,
|
|
346
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();
|
|
347
229
|
} catch (err) {
|
|
348
|
-
|
|
349
|
-
|
|
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
|
+
);
|
|
350
236
|
ctx.connectionManager.sendToClient(client.id, {
|
|
351
237
|
type: "error",
|
|
352
|
-
message: `Failed to subscribe to
|
|
353
|
-
});
|
|
354
|
-
return true;
|
|
355
|
-
}
|
|
356
|
-
|
|
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,
|
|
238
|
+
message: `Failed to subscribe to query "${descriptor?.element}.${descriptor?.method}"`,
|
|
366
239
|
});
|
|
367
240
|
}
|
|
368
241
|
return true;
|
|
369
242
|
}
|
|
370
243
|
|
|
371
|
-
if (message.type === "unsubscribe-
|
|
372
|
-
const
|
|
373
|
-
|
|
244
|
+
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);
|
|
250
|
+
}
|
|
374
251
|
return true;
|
|
375
252
|
}
|
|
376
253
|
|
|
@@ -388,6 +265,6 @@ export function arcWsHandlers(): ArcWsHandler[] {
|
|
|
388
265
|
syncEventsHandler(),
|
|
389
266
|
requestSyncHandler(),
|
|
390
267
|
executeCommandHandler(),
|
|
391
|
-
|
|
268
|
+
querySubscriptionHandler(),
|
|
392
269
|
];
|
|
393
270
|
}
|
package/src/types.ts
CHANGED
|
@@ -110,14 +110,14 @@ export type ClientToHostMessage =
|
|
|
110
110
|
token: string;
|
|
111
111
|
}
|
|
112
112
|
| {
|
|
113
|
-
type: "subscribe-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
type: "subscribe-query";
|
|
114
|
+
subscriptionId: string;
|
|
115
|
+
descriptor: { element: string; method: string; args: any[] };
|
|
116
|
+
scope?: string;
|
|
116
117
|
}
|
|
117
118
|
| {
|
|
118
|
-
type: "unsubscribe-
|
|
119
|
-
|
|
120
|
-
scope: string;
|
|
119
|
+
type: "unsubscribe-query";
|
|
120
|
+
subscriptionId: string;
|
|
121
121
|
};
|
|
122
122
|
|
|
123
123
|
/**
|
|
@@ -143,17 +143,15 @@ export type HostToClientMessage =
|
|
|
143
143
|
message: string;
|
|
144
144
|
}
|
|
145
145
|
| {
|
|
146
|
-
type: "
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
items: any[];
|
|
146
|
+
type: "query-snapshot";
|
|
147
|
+
subscriptionId: string;
|
|
148
|
+
result: any;
|
|
150
149
|
}
|
|
151
150
|
| {
|
|
152
|
-
type: "
|
|
153
|
-
|
|
154
|
-
scope: string;
|
|
151
|
+
type: "query-changes";
|
|
152
|
+
subscriptionId: string;
|
|
155
153
|
changes: Array<
|
|
156
|
-
| { type: "set"; id: string; item: any }
|
|
157
|
-
| { type: "delete"; id: string
|
|
154
|
+
| { type: "set"; id: string; item: any; index: number }
|
|
155
|
+
| { type: "delete"; id: string }
|
|
158
156
|
>;
|
|
159
157
|
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"view-subscription.integration.test.d.ts","sourceRoot":"","sources":["../../../src/middleware/view-subscription.integration.test.ts"],"names":[],"mappings":""}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ws.test.d.ts","sourceRoot":"","sources":["../../../src/middleware/ws.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
LocalEventPublisher,
|
|
4
|
-
MasterDataStorage,
|
|
5
|
-
context,
|
|
6
|
-
token,
|
|
7
|
-
view,
|
|
8
|
-
} from "@arcote.tech/arc";
|
|
9
|
-
import type { ConnectedClient } from "../types";
|
|
10
|
-
import {
|
|
11
|
-
broadcastViewChanges,
|
|
12
|
-
cleanupClientSubs,
|
|
13
|
-
viewSubscriptionHandler,
|
|
14
|
-
} from "./ws";
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Test rig: real MasterDataStorage + LocalEventPublisher + ws handler,
|
|
18
|
-
// in-memory DB adapter, fake connection manager collecting messages.
|
|
19
|
-
// Covers the full server pipeline: subscribe-view → snapshot → publish →
|
|
20
|
-
// commit → onViewChanges → per-subscriber filtered view-changes.
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
function memoryAdapter() {
|
|
24
|
-
const tables = new Map<string, Map<string, any>>();
|
|
25
|
-
const table = (s: string) => {
|
|
26
|
-
if (!tables.has(s)) tables.set(s, new Map());
|
|
27
|
-
return tables.get(s)!;
|
|
28
|
-
};
|
|
29
|
-
const tx = {
|
|
30
|
-
async find(store: string, options: any) {
|
|
31
|
-
const rows = Array.from(table(store).values());
|
|
32
|
-
const where = options?.where;
|
|
33
|
-
if (!where) return rows;
|
|
34
|
-
return rows.filter((r) =>
|
|
35
|
-
Object.entries(where).every(([k, v]) => r[k] === v),
|
|
36
|
-
);
|
|
37
|
-
},
|
|
38
|
-
async set(store: string, data: any) {
|
|
39
|
-
table(store).set(data._id, structuredClone(data));
|
|
40
|
-
},
|
|
41
|
-
async remove(store: string, id: any) {
|
|
42
|
-
table(store).delete(String(id));
|
|
43
|
-
},
|
|
44
|
-
async commit() {},
|
|
45
|
-
};
|
|
46
|
-
return {
|
|
47
|
-
readWriteTransaction: () => tx,
|
|
48
|
-
readTransaction: () => tx,
|
|
49
|
-
executeReinitTables: async () => {},
|
|
50
|
-
} as any;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const wsToken = token("workspace", {});
|
|
54
|
-
|
|
55
|
-
const tasksView = view("tasksView", null as any, {} as any)
|
|
56
|
-
.handle({
|
|
57
|
-
"task.created": async (ctx: any, event: any) => {
|
|
58
|
-
await ctx.set(event.payload.taskId, {
|
|
59
|
-
title: event.payload.title,
|
|
60
|
-
workspaceId: event.payload.workspaceId,
|
|
61
|
-
});
|
|
62
|
-
},
|
|
63
|
-
"task.moved": async (ctx: any, event: any) => {
|
|
64
|
-
await ctx.modify(event.payload.taskId, { workspaceId: event.payload.to });
|
|
65
|
-
},
|
|
66
|
-
"task.removed": async (ctx: any, event: any) => {
|
|
67
|
-
await ctx.remove(event.payload.taskId);
|
|
68
|
-
},
|
|
69
|
-
})
|
|
70
|
-
.protectBy(wsToken as any, (p: any) => ({ workspaceId: p.workspaceId }));
|
|
71
|
-
|
|
72
|
-
function fakeJwt(tokenName: string, params: Record<string, any>): string {
|
|
73
|
-
const b64 = (o: any) => Buffer.from(JSON.stringify(o)).toString("base64");
|
|
74
|
-
return `${b64({ alg: "none" })}.${b64({ tokenName, params })}.sig`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function fakeClient(
|
|
78
|
-
id: string,
|
|
79
|
-
scope: string,
|
|
80
|
-
tokenName: string,
|
|
81
|
-
params: Record<string, any>,
|
|
82
|
-
): ConnectedClient {
|
|
83
|
-
return {
|
|
84
|
-
id,
|
|
85
|
-
scopeTokens: new Map([
|
|
86
|
-
[scope, { decoded: { tokenType: tokenName, params }, raw: fakeJwt(tokenName, params) }],
|
|
87
|
-
]),
|
|
88
|
-
lastHostEventId: null,
|
|
89
|
-
ws: null,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
let eventCounter = 0;
|
|
94
|
-
function makeEvent(type: string, payload: any) {
|
|
95
|
-
return {
|
|
96
|
-
id: `evt_${++eventCounter}`,
|
|
97
|
-
type,
|
|
98
|
-
payload,
|
|
99
|
-
createdAt: new Date(0),
|
|
100
|
-
authContext: null,
|
|
101
|
-
} as any;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function rig() {
|
|
105
|
-
const storage = new MasterDataStorage(memoryAdapter());
|
|
106
|
-
const testContext = context([tasksView as any]);
|
|
107
|
-
const fakeModel = {
|
|
108
|
-
context: testContext,
|
|
109
|
-
getAdapters: () => ({ dataStorage: storage }),
|
|
110
|
-
getEnvironment: () => "server" as const,
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
const sent: Array<{ clientId: string; message: any }> = [];
|
|
114
|
-
const connectionManager = {
|
|
115
|
-
sendToClient: (clientId: string, message: any) => {
|
|
116
|
-
sent.push({ clientId, message });
|
|
117
|
-
return true;
|
|
118
|
-
},
|
|
119
|
-
} as any;
|
|
120
|
-
|
|
121
|
-
const wsCtx = {
|
|
122
|
-
contextHandler: {
|
|
123
|
-
getModel: () => fakeModel,
|
|
124
|
-
getDataStorage: () => storage,
|
|
125
|
-
},
|
|
126
|
-
connectionManager,
|
|
127
|
-
verifyToken: () => null,
|
|
128
|
-
} as any;
|
|
129
|
-
|
|
130
|
-
const publisher = new LocalEventPublisher(storage);
|
|
131
|
-
publisher.registerViews([tasksView as any]);
|
|
132
|
-
publisher.onViewChanges((changes) =>
|
|
133
|
-
broadcastViewChanges(changes, connectionManager),
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
const handler = viewSubscriptionHandler();
|
|
137
|
-
const subscribe = (client: ConnectedClient, scope: string) =>
|
|
138
|
-
handler(client, { type: "subscribe-view", element: "tasksView", scope }, wsCtx);
|
|
139
|
-
|
|
140
|
-
const messagesFor = (clientId: string, type?: string) =>
|
|
141
|
-
sent
|
|
142
|
-
.filter((s) => s.clientId === clientId)
|
|
143
|
-
.map((s) => s.message)
|
|
144
|
-
.filter((m) => (type ? m.type === type : true));
|
|
145
|
-
|
|
146
|
-
return { storage, publisher, subscribe, handler, wsCtx, sent, messagesFor };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
let clientSeq = 0;
|
|
150
|
-
const uid = (p: string) => `${p}_${++clientSeq}`;
|
|
151
|
-
|
|
152
|
-
describe("view subscriptions — end-to-end host pipeline", () => {
|
|
153
|
-
beforeEach(() => {
|
|
154
|
-
// module-level registry — drop anything previous tests left behind
|
|
155
|
-
for (let i = 0; i <= clientSeq; i++) {
|
|
156
|
-
cleanupClientSubs(`A_${i}`);
|
|
157
|
-
cleanupClientSubs(`B_${i}`);
|
|
158
|
-
cleanupClientSubs(`X_${i}`);
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it("snapshot contains ONLY the subscriber's slice of the view", async () => {
|
|
163
|
-
const r = rig();
|
|
164
|
-
await r.publisher.publish(
|
|
165
|
-
makeEvent("task.created", { taskId: "t1", title: "Mine", workspaceId: "w1" }),
|
|
166
|
-
);
|
|
167
|
-
await r.publisher.publish(
|
|
168
|
-
makeEvent("task.created", { taskId: "t2", title: "Theirs", workspaceId: "w2" }),
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
const a = fakeClient(uid("A"), "workspace", "workspace", { workspaceId: "w1" });
|
|
172
|
-
await r.subscribe(a, "workspace");
|
|
173
|
-
|
|
174
|
-
const snapshots = r.messagesFor(a.id, "view-snapshot");
|
|
175
|
-
expect(snapshots).toHaveLength(1);
|
|
176
|
-
expect(snapshots[0].element).toBe("tasksView");
|
|
177
|
-
expect(snapshots[0].scope).toBe("workspace");
|
|
178
|
-
expect(snapshots[0].items).toHaveLength(1);
|
|
179
|
-
expect(snapshots[0].items[0]).toMatchObject({ _id: "t1", title: "Mine" });
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("publish → set delta to the owning tenant ONLY (no message to others)", async () => {
|
|
183
|
-
const r = rig();
|
|
184
|
-
const a = fakeClient(uid("A"), "workspace", "workspace", { workspaceId: "w1" });
|
|
185
|
-
const b = fakeClient(uid("B"), "workspace", "workspace", { workspaceId: "w2" });
|
|
186
|
-
await r.subscribe(a, "workspace");
|
|
187
|
-
await r.subscribe(b, "workspace");
|
|
188
|
-
r.sent.length = 0;
|
|
189
|
-
|
|
190
|
-
await r.publisher.publish(
|
|
191
|
-
makeEvent("task.created", { taskId: "t1", title: "New", workspaceId: "w1" }),
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
const toA = r.messagesFor(a.id, "view-changes");
|
|
195
|
-
expect(toA).toHaveLength(1);
|
|
196
|
-
expect(toA[0].changes).toEqual([
|
|
197
|
-
{
|
|
198
|
-
type: "set",
|
|
199
|
-
id: "t1",
|
|
200
|
-
item: { _id: "t1", title: "New", workspaceId: "w1" },
|
|
201
|
-
},
|
|
202
|
-
]);
|
|
203
|
-
|
|
204
|
-
// tenant isolation: B gets NOTHING (not even a delete for a foreign id)
|
|
205
|
-
expect(r.messagesFor(b.id)).toHaveLength(0);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("scope transition: move w1→w2 emits delete to A and full-item set to B", async () => {
|
|
209
|
-
const r = rig();
|
|
210
|
-
await r.publisher.publish(
|
|
211
|
-
makeEvent("task.created", { taskId: "t1", title: "Task", workspaceId: "w1" }),
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
const a = fakeClient(uid("A"), "workspace", "workspace", { workspaceId: "w1" });
|
|
215
|
-
const b = fakeClient(uid("B"), "workspace", "workspace", { workspaceId: "w2" });
|
|
216
|
-
await r.subscribe(a, "workspace");
|
|
217
|
-
await r.subscribe(b, "workspace");
|
|
218
|
-
r.sent.length = 0;
|
|
219
|
-
|
|
220
|
-
await r.publisher.publish(makeEvent("task.moved", { taskId: "t1", to: "w2" }));
|
|
221
|
-
|
|
222
|
-
const toA = r.messagesFor(a.id, "view-changes");
|
|
223
|
-
expect(toA).toHaveLength(1);
|
|
224
|
-
expect(toA[0].changes).toEqual([{ type: "delete", id: "t1", item: null }]);
|
|
225
|
-
|
|
226
|
-
// B receives the FULL merged item (modify was partial — deepMerge on server)
|
|
227
|
-
const toB = r.messagesFor(b.id, "view-changes");
|
|
228
|
-
expect(toB).toHaveLength(1);
|
|
229
|
-
expect(toB[0].changes).toEqual([
|
|
230
|
-
{
|
|
231
|
-
type: "set",
|
|
232
|
-
id: "t1",
|
|
233
|
-
item: { _id: "t1", title: "Task", workspaceId: "w2" },
|
|
234
|
-
},
|
|
235
|
-
]);
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("remove emits delete only to the tenant that owned the item", async () => {
|
|
239
|
-
const r = rig();
|
|
240
|
-
await r.publisher.publish(
|
|
241
|
-
makeEvent("task.created", { taskId: "t1", title: "Task", workspaceId: "w1" }),
|
|
242
|
-
);
|
|
243
|
-
const a = fakeClient(uid("A"), "workspace", "workspace", { workspaceId: "w1" });
|
|
244
|
-
const b = fakeClient(uid("B"), "workspace", "workspace", { workspaceId: "w2" });
|
|
245
|
-
await r.subscribe(a, "workspace");
|
|
246
|
-
await r.subscribe(b, "workspace");
|
|
247
|
-
r.sent.length = 0;
|
|
248
|
-
|
|
249
|
-
await r.publisher.publish(makeEvent("task.removed", { taskId: "t1" }));
|
|
250
|
-
|
|
251
|
-
expect(r.messagesFor(a.id, "view-changes")).toHaveLength(1);
|
|
252
|
-
expect(r.messagesFor(a.id, "view-changes")[0].changes).toEqual([
|
|
253
|
-
{ type: "delete", id: "t1", item: null },
|
|
254
|
-
]);
|
|
255
|
-
expect(r.messagesFor(b.id)).toHaveLength(0);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
it("token that matches no protection → empty snapshot and no deltas", async () => {
|
|
259
|
-
const r = rig();
|
|
260
|
-
await r.publisher.publish(
|
|
261
|
-
makeEvent("task.created", { taskId: "t1", title: "Task", workspaceId: "w1" }),
|
|
262
|
-
);
|
|
263
|
-
|
|
264
|
-
const x = fakeClient(uid("X"), "workspace", "intruder", {});
|
|
265
|
-
await r.subscribe(x, "workspace");
|
|
266
|
-
|
|
267
|
-
const snapshots = r.messagesFor(x.id, "view-snapshot");
|
|
268
|
-
expect(snapshots).toHaveLength(1);
|
|
269
|
-
expect(snapshots[0].items).toEqual([]);
|
|
270
|
-
|
|
271
|
-
r.sent.length = 0;
|
|
272
|
-
await r.publisher.publish(
|
|
273
|
-
makeEvent("task.created", { taskId: "t2", title: "More", workspaceId: "w1" }),
|
|
274
|
-
);
|
|
275
|
-
expect(r.messagesFor(x.id)).toHaveLength(0);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it("unsubscribe-view stops deltas", async () => {
|
|
279
|
-
const r = rig();
|
|
280
|
-
const a = fakeClient(uid("A"), "workspace", "workspace", { workspaceId: "w1" });
|
|
281
|
-
await r.subscribe(a, "workspace");
|
|
282
|
-
await r.handler(
|
|
283
|
-
a,
|
|
284
|
-
{ type: "unsubscribe-view", element: "tasksView", scope: "workspace" },
|
|
285
|
-
r.wsCtx,
|
|
286
|
-
);
|
|
287
|
-
r.sent.length = 0;
|
|
288
|
-
|
|
289
|
-
await r.publisher.publish(
|
|
290
|
-
makeEvent("task.created", { taskId: "t1", title: "New", workspaceId: "w1" }),
|
|
291
|
-
);
|
|
292
|
-
expect(r.messagesFor(a.id)).toHaveLength(0);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it("disconnect cleanup stops deltas", async () => {
|
|
296
|
-
const r = rig();
|
|
297
|
-
const a = fakeClient(uid("A"), "workspace", "workspace", { workspaceId: "w1" });
|
|
298
|
-
await r.subscribe(a, "workspace");
|
|
299
|
-
cleanupClientSubs(a.id);
|
|
300
|
-
r.sent.length = 0;
|
|
301
|
-
|
|
302
|
-
await r.publisher.publish(
|
|
303
|
-
makeEvent("task.created", { taskId: "t1", title: "New", workspaceId: "w1" }),
|
|
304
|
-
);
|
|
305
|
-
expect(r.messagesFor(a.id)).toHaveLength(0);
|
|
306
|
-
});
|
|
307
|
-
});
|