@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.
@@ -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 { ConnectedClient, HostToClientMessage } from "../types";
10
- import type { ArcWsContext, ArcWsHandler } from "./types";
3
+ import type { ArcWsHandler } from "./types";
11
4
 
12
5
  // ---------------------------------------------------------------------------
13
- // View replica subscriptions
6
+ // Live query subscriptions
14
7
  //
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
+ // 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
- 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>>();
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 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
+ 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-view / unsubscribe-viewview replica subscriptions
171
+ // subscribe-query / unsubscribe-querylive query subscriptions
271
172
  // ---------------------------------------------------------------------------
272
173
 
273
- export function viewSubscriptionHandler(): ArcWsHandler {
174
+ export function querySubscriptionHandler(): ArcWsHandler {
274
175
  return async (client, message, ctx) => {
275
- if (message.type === "subscribe-view") {
276
- const { element: elementName } = message;
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
- // Scoped model just to resolve restrictions for this token
289
- const scoped = new ScopedModel(ctx.contextHandler.getModel(), scope);
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 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
- }
300
-
301
- const { restrictions, denied } = element.getRestrictionsFor(
302
- scoped.getAdapters(),
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
- 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;
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
- viewSubscribers.get(elementName)!.set(subKey, subscriber);
217
+ clientQuerySubs.get(client.id)!.set(subscriptionId, live);
332
218
 
333
219
  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
-
220
+ const result = await live.start();
341
221
  ctx.connectionManager.sendToClient(client.id, {
342
- type: "view-snapshot",
343
- element: elementName,
344
- scope,
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
- viewSubscribers.get(elementName)?.delete(subKey);
349
- console.error(`[Arc] View subscription error (${elementName}):`, 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
+ );
350
236
  ctx.connectionManager.sendToClient(client.id, {
351
237
  type: "error",
352
- message: `Failed to subscribe to view "${elementName}"`,
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-view") {
372
- const scope: string = message.scope ?? "default";
373
- viewSubscribers.get(message.element)?.delete(`${client.id}:${scope}`);
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
- viewSubscriptionHandler(),
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-view";
114
- element: string;
115
- scope: string;
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-view";
119
- element: string;
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: "view-snapshot";
147
- element: string;
148
- scope: string;
149
- items: any[];
146
+ type: "query-snapshot";
147
+ subscriptionId: string;
148
+ result: any;
150
149
  }
151
150
  | {
152
- type: "view-changes";
153
- element: string;
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; item: null }
154
+ | { type: "set"; id: string; item: any; index: number }
155
+ | { type: "delete"; id: string }
158
156
  >;
159
157
  };
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=view-subscription.integration.test.d.ts.map
@@ -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,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=ws.test.d.ts.map
@@ -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
- });