@arcote.tech/arc-host 0.7.15 → 0.7.17

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.
@@ -0,0 +1,432 @@
1
+ import { beforeEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ MasterDataStorage,
4
+ aggregate,
5
+ applyQueryChanges,
6
+ boolean,
7
+ context,
8
+ id,
9
+ string,
10
+ token,
11
+ } from "@arcote.tech/arc";
12
+ import type { ConnectedClient } from "../types";
13
+ import { cleanupClientSubs, querySubscriptionHandler } from "./ws";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Test rig: real MasterDataStorage + a real aggregate with custom
17
+ // clientQuery handlers (the NDT-shaped case: handler runs $query.find with
18
+ // protectBy restrictions) + the real querySubscriptionHandler. The fake
19
+ // connection manager collects outgoing messages.
20
+ //
21
+ // Covers the full server pipeline: subscribe-query → LiveQuery tracks the
22
+ // handler's inner finds → store commit → resolveQueryChange → re-execute
23
+ // from cache → diff → query-changes / query-snapshot.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Persistent in-memory DatabaseAdapter (shared tables across transactions). */
27
+ function memoryAdapter() {
28
+ const tables = new Map<string, Map<string, any>>();
29
+ const table = (s: string) => {
30
+ if (!tables.has(s)) tables.set(s, new Map());
31
+ return tables.get(s)!;
32
+ };
33
+ const matches = (row: any, where?: Record<string, any>) =>
34
+ !where || Object.entries(where).every(([k, v]) => row[k] === v);
35
+ const tx = {
36
+ async find(store: string, options: any) {
37
+ let rows = Array.from(table(store).values()).filter((r) =>
38
+ matches(r, options?.where),
39
+ );
40
+ if (options?.orderBy) {
41
+ const entries = Object.entries(options.orderBy) as [string, string][];
42
+ rows = [...rows].sort((a, b) => {
43
+ for (const [k, dir] of entries) {
44
+ if (a[k] < b[k]) return dir === "asc" ? -1 : 1;
45
+ if (a[k] > b[k]) return dir === "asc" ? 1 : -1;
46
+ }
47
+ return 0;
48
+ });
49
+ }
50
+ if (options?.limit !== undefined) rows = rows.slice(0, options.limit);
51
+ return rows;
52
+ },
53
+ async set(store: string, data: any) {
54
+ table(store).set(data._id, structuredClone(data));
55
+ },
56
+ async remove(store: string, id: any) {
57
+ table(store).delete(String(id));
58
+ },
59
+ async commit() {},
60
+ };
61
+ return {
62
+ readWriteTransaction: () => tx,
63
+ readTransaction: () => tx,
64
+ executeReinitTables: async () => {},
65
+ } as any;
66
+ }
67
+
68
+ const wsToken = token("workspace", {});
69
+ const taskId = id("task");
70
+
71
+ const tasks = aggregate("tasks", taskId, {
72
+ title: string(),
73
+ workspaceId: string(),
74
+ done: boolean(),
75
+ })
76
+ .clientQuery("getAll", (fn: any) =>
77
+ fn.handle(async (ctx: any) =>
78
+ ctx.$query.find({ orderBy: { title: "asc" } }),
79
+ ),
80
+ )
81
+ .clientQuery("getDone", (fn: any) =>
82
+ fn.handle(async (ctx: any) =>
83
+ ctx.$query.find({ where: { done: true }, orderBy: { title: "asc" } }),
84
+ ),
85
+ )
86
+ .clientQuery("getTop2", (fn: any) =>
87
+ fn.handle(async (ctx: any) =>
88
+ ctx.$query.find({ orderBy: { title: "asc" }, limit: 2 }),
89
+ ),
90
+ )
91
+ .clientQuery("countAll", (fn: any) =>
92
+ fn.handle(async (ctx: any) => {
93
+ const rows = await ctx.$query.find({});
94
+ return { count: rows.length };
95
+ }),
96
+ )
97
+ .protectBy(wsToken as any, (p: any) => ({ workspaceId: p.workspaceId }));
98
+
99
+ function fakeJwt(tokenName: string, params: Record<string, any>): string {
100
+ const b64 = (o: any) => Buffer.from(JSON.stringify(o)).toString("base64");
101
+ return `${b64({ alg: "none" })}.${b64({ tokenName, params })}.sig`;
102
+ }
103
+
104
+ function fakeClient(
105
+ clientId: string,
106
+ scope: string,
107
+ params: Record<string, any>,
108
+ ): ConnectedClient {
109
+ return {
110
+ id: clientId,
111
+ scopeTokens: new Map([
112
+ [
113
+ scope,
114
+ {
115
+ decoded: { tokenType: "workspace", params },
116
+ raw: fakeJwt("workspace", params),
117
+ },
118
+ ],
119
+ ]),
120
+ lastHostEventId: null,
121
+ ws: null,
122
+ };
123
+ }
124
+
125
+ /** Microtask + async re-execute settle. */
126
+ const settle = () => new Promise((r) => setTimeout(r, 10));
127
+
128
+ function rig() {
129
+ const storage = new MasterDataStorage(memoryAdapter());
130
+ const testContext = context([tasks as any]);
131
+ const fakeModel = {
132
+ context: testContext,
133
+ getAdapters: () => ({ dataStorage: storage }),
134
+ getEnvironment: () => "server" as const,
135
+ };
136
+
137
+ const sent: Array<{ clientId: string; message: any }> = [];
138
+ const wsCtx = {
139
+ contextHandler: { getModel: () => fakeModel },
140
+ connectionManager: {
141
+ sendToClient: (clientId: string, message: any) => {
142
+ sent.push({ clientId, message });
143
+ return true;
144
+ },
145
+ },
146
+ verifyToken: () => null,
147
+ } as any;
148
+
149
+ const handler = querySubscriptionHandler();
150
+
151
+ const subscribe = (
152
+ client: ConnectedClient,
153
+ subscriptionId: string,
154
+ method: string,
155
+ scope = "workspace",
156
+ ) =>
157
+ handler(
158
+ client,
159
+ {
160
+ type: "subscribe-query",
161
+ subscriptionId,
162
+ descriptor: { element: "tasks", method, args: [] },
163
+ scope,
164
+ },
165
+ wsCtx,
166
+ );
167
+
168
+ const seed = (rows: any[]) =>
169
+ storage
170
+ .getStore<any>("tasks")
171
+ .applyChanges(rows.map((data) => ({ type: "set" as const, data })));
172
+
173
+ const messagesFor = (clientId: string, type?: string) =>
174
+ sent
175
+ .filter((s) => s.clientId === clientId)
176
+ .map((s) => s.message)
177
+ .filter((m) => (type ? m.type === type : true));
178
+
179
+ return { storage, wsCtx, handler, subscribe, seed, sent, messagesFor };
180
+ }
181
+
182
+ const row = (id: string, title: string, workspaceId: string, done = false) => ({
183
+ _id: id,
184
+ title,
185
+ workspaceId,
186
+ done,
187
+ });
188
+
189
+ let seq = 0;
190
+ const uid = (p: string) => `${p}_${++seq}`;
191
+
192
+ describe("live query subscriptions — end-to-end host pipeline", () => {
193
+ beforeEach(() => {
194
+ for (let i = 0; i <= seq; i++) {
195
+ cleanupClientSubs(`A_${i}`);
196
+ cleanupClientSubs(`B_${i}`);
197
+ }
198
+ });
199
+
200
+ it("snapshot contains the QUERY result for the subscriber's scope only", async () => {
201
+ const r = rig();
202
+ await r.seed([
203
+ row("t1", "Beta", "w1"),
204
+ row("t2", "Alpha", "w1"),
205
+ row("t3", "Other", "w2"),
206
+ ]);
207
+
208
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
209
+ await r.subscribe(a, "s1", "getAll");
210
+ await settle();
211
+
212
+ const snapshots = r.messagesFor(a.id, "query-snapshot");
213
+ expect(snapshots).toHaveLength(1);
214
+ expect(snapshots[0].subscriptionId).toBe("s1");
215
+ // orderBy from the HANDLER (title asc), restrictions from protectBy
216
+ expect(snapshots[0].result.map((t: any) => t._id)).toEqual(["t2", "t1"]);
217
+ });
218
+
219
+ it("matching insert → query-changes with the correct position", async () => {
220
+ const r = rig();
221
+ await r.seed([row("t1", "Beta", "w1"), row("t2", "Delta", "w1")]);
222
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
223
+ await r.subscribe(a, "s1", "getAll");
224
+ await settle();
225
+ r.sent.length = 0;
226
+
227
+ await r.storage
228
+ .getStore<any>("tasks")
229
+ .applyChanges([{ type: "set", data: row("t3", "Charlie", "w1") }]);
230
+ await settle();
231
+
232
+ const changes = r.messagesFor(a.id, "query-changes");
233
+ expect(changes).toHaveLength(1);
234
+ expect(changes[0].changes).toEqual([
235
+ {
236
+ type: "set",
237
+ id: "t3",
238
+ item: row("t3", "Charlie", "w1"),
239
+ index: 1, // Beta, Charlie, Delta
240
+ },
241
+ ]);
242
+ });
243
+
244
+ it("tenant isolation: a change in another workspace produces NO message", async () => {
245
+ const r = rig();
246
+ await r.seed([row("t1", "Beta", "w1")]);
247
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
248
+ await r.subscribe(a, "s1", "getAll");
249
+ await settle();
250
+ r.sent.length = 0;
251
+
252
+ await r.storage
253
+ .getStore<any>("tasks")
254
+ .applyChanges([{ type: "set", data: row("t9", "Foreign", "w2") }]);
255
+ await settle();
256
+
257
+ expect(r.messagesFor(a.id)).toHaveLength(0);
258
+ });
259
+
260
+ it("item leaving the scope → delete for the old tenant, set for the new one", async () => {
261
+ const r = rig();
262
+ await r.seed([row("t1", "Task", "w1"), row("t2", "Keep", "w1")]);
263
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
264
+ const b = fakeClient(uid("B"), "workspace", { workspaceId: "w2" });
265
+ await r.subscribe(a, "sa", "getAll");
266
+ await r.subscribe(b, "sb", "getAll");
267
+ await settle();
268
+ r.sent.length = 0;
269
+
270
+ // Move t1 from w1 to w2 (modify → full-item set event)
271
+ await r.storage
272
+ .getStore<any>("tasks")
273
+ .applyChanges([{ type: "modify", id: "t1", data: { workspaceId: "w2" } }]);
274
+ await settle();
275
+
276
+ const toA = r.messagesFor(a.id, "query-changes");
277
+ expect(toA).toHaveLength(1);
278
+ expect(toA[0].changes).toEqual([{ type: "delete", id: "t1" }]);
279
+
280
+ const toB = r.messagesFor(b.id, "query-changes");
281
+ expect(toB).toHaveLength(1);
282
+ expect(toB[0].changes).toEqual([
283
+ { type: "set", id: "t1", item: row("t1", "Task", "w2"), index: 0 },
284
+ ]);
285
+ });
286
+
287
+ it("where inside the HANDLER: toggling done moves items in/out of the result", async () => {
288
+ const r = rig();
289
+ await r.seed([row("t1", "A", "w1", true), row("t2", "B", "w1", false)]);
290
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
291
+ await r.subscribe(a, "s1", "getDone");
292
+ await settle();
293
+
294
+ expect(r.messagesFor(a.id, "query-snapshot")[0].result).toEqual([
295
+ row("t1", "A", "w1", true),
296
+ ]);
297
+ r.sent.length = 0;
298
+
299
+ // t2 becomes done → enters the result
300
+ await r.storage
301
+ .getStore<any>("tasks")
302
+ .applyChanges([{ type: "modify", id: "t2", data: { done: true } }]);
303
+ await settle();
304
+ expect(r.messagesFor(a.id, "query-changes")[0].changes).toEqual([
305
+ { type: "set", id: "t2", item: row("t2", "B", "w1", true), index: 1 },
306
+ ]);
307
+ r.sent.length = 0;
308
+
309
+ // t1 becomes undone → leaves the result
310
+ await r.storage
311
+ .getStore<any>("tasks")
312
+ .applyChanges([{ type: "modify", id: "t1", data: { done: false } }]);
313
+ await settle();
314
+ expect(r.messagesFor(a.id, "query-changes")[0].changes).toEqual([
315
+ { type: "delete", id: "t1" },
316
+ ]);
317
+ });
318
+
319
+ it("limit underflow: removing from a full top-N refills from the store", async () => {
320
+ const r = rig();
321
+ await r.seed([
322
+ row("t1", "A", "w1"),
323
+ row("t2", "B", "w1"),
324
+ row("t3", "C", "w1"),
325
+ ]);
326
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
327
+ await r.subscribe(a, "s1", "getTop2");
328
+ await settle();
329
+
330
+ expect(
331
+ r.messagesFor(a.id, "query-snapshot")[0].result.map((t: any) => t._id),
332
+ ).toEqual(["t1", "t2"]);
333
+ r.sent.length = 0;
334
+
335
+ // Remove t1 from the full top-2 → t3 must be pulled in from the store.
336
+ // The refill may arrive as a snapshot or as deltas — verify the FINAL
337
+ // result the client would hold either way.
338
+ await r.storage
339
+ .getStore<any>("tasks")
340
+ .applyChanges([{ type: "delete", id: "t1" }]);
341
+ await settle();
342
+
343
+ const all = r.messagesFor(a.id);
344
+ expect(all).toHaveLength(1);
345
+ const result =
346
+ all[0].type === "query-snapshot"
347
+ ? all[0].result
348
+ : applyQueryChanges(
349
+ [row("t1", "A", "w1"), row("t2", "B", "w1")],
350
+ all[0].changes,
351
+ );
352
+ expect(result.map((t: any) => t._id)).toEqual(["t2", "t3"]);
353
+ });
354
+
355
+ it("non-list result (count) → full snapshot on every change", async () => {
356
+ const r = rig();
357
+ await r.seed([row("t1", "A", "w1")]);
358
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
359
+ await r.subscribe(a, "s1", "countAll");
360
+ await settle();
361
+
362
+ expect(r.messagesFor(a.id, "query-snapshot")[0].result).toEqual({
363
+ count: 1,
364
+ });
365
+ r.sent.length = 0;
366
+
367
+ await r.storage
368
+ .getStore<any>("tasks")
369
+ .applyChanges([{ type: "set", data: row("t2", "B", "w1") }]);
370
+ await settle();
371
+
372
+ const snapshots = r.messagesFor(a.id, "query-snapshot");
373
+ expect(snapshots).toHaveLength(1);
374
+ expect(snapshots[0].result).toEqual({ count: 2 });
375
+ expect(r.messagesFor(a.id, "query-changes")).toHaveLength(0);
376
+ });
377
+
378
+ it("unsubscribe-query stops deltas", async () => {
379
+ const r = rig();
380
+ await r.seed([row("t1", "A", "w1")]);
381
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
382
+ await r.subscribe(a, "s1", "getAll");
383
+ await settle();
384
+ await r.handler(
385
+ a,
386
+ { type: "unsubscribe-query", subscriptionId: "s1" },
387
+ r.wsCtx,
388
+ );
389
+ r.sent.length = 0;
390
+
391
+ await r.storage
392
+ .getStore<any>("tasks")
393
+ .applyChanges([{ type: "set", data: row("t2", "B", "w1") }]);
394
+ await settle();
395
+ expect(r.messagesFor(a.id)).toHaveLength(0);
396
+ });
397
+
398
+ it("disconnect cleanup stops deltas", async () => {
399
+ const r = rig();
400
+ await r.seed([row("t1", "A", "w1")]);
401
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
402
+ await r.subscribe(a, "s1", "getAll");
403
+ await settle();
404
+ cleanupClientSubs(a.id);
405
+ r.sent.length = 0;
406
+
407
+ await r.storage
408
+ .getStore<any>("tasks")
409
+ .applyChanges([{ type: "set", data: row("t2", "B", "w1") }]);
410
+ await settle();
411
+ expect(r.messagesFor(a.id)).toHaveLength(0);
412
+ });
413
+
414
+ it("re-subscribe with the same id (reconnect resend) replaces the old subscription", async () => {
415
+ const r = rig();
416
+ await r.seed([row("t1", "A", "w1")]);
417
+ const a = fakeClient(uid("A"), "workspace", { workspaceId: "w1" });
418
+ await r.subscribe(a, "s1", "getAll");
419
+ await settle();
420
+ await r.subscribe(a, "s1", "getAll"); // resend after reconnect
421
+ await settle();
422
+ r.sent.length = 0;
423
+
424
+ await r.storage
425
+ .getStore<any>("tasks")
426
+ .applyChanges([{ type: "set", data: row("t2", "B", "w1") }]);
427
+ await settle();
428
+
429
+ // Exactly ONE delta (old subscription was stopped, not duplicated)
430
+ expect(r.messagesFor(a.id, "query-changes")).toHaveLength(1);
431
+ });
432
+ });