@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
|
@@ -0,0 +1,307 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { CommittedChange } from "@arcote.tech/arc";
|
|
3
|
+
import { filterChangeForSubscriber } from "./ws";
|
|
4
|
+
|
|
5
|
+
const R = { workspaceId: "w1" };
|
|
6
|
+
|
|
7
|
+
function change(partial: Partial<CommittedChange>): CommittedChange {
|
|
8
|
+
return { store: "tasks", id: "t1", oldRow: null, newRow: null, ...partial };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("filterChangeForSubscriber — old/new restriction matrix", () => {
|
|
12
|
+
it("new row matches → set with the full row", () => {
|
|
13
|
+
const result = filterChangeForSubscriber(
|
|
14
|
+
change({ newRow: { _id: "t1", title: "A", workspaceId: "w1" } }),
|
|
15
|
+
R,
|
|
16
|
+
);
|
|
17
|
+
expect(result).toEqual({
|
|
18
|
+
type: "set",
|
|
19
|
+
id: "t1",
|
|
20
|
+
item: { _id: "t1", title: "A", workspaceId: "w1" },
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("neither row matches → null (no id leakage to other tenants)", () => {
|
|
25
|
+
const result = filterChangeForSubscriber(
|
|
26
|
+
change({
|
|
27
|
+
oldRow: { _id: "t1", workspaceId: "w2" },
|
|
28
|
+
newRow: { _id: "t1", workspaceId: "w2" },
|
|
29
|
+
}),
|
|
30
|
+
R,
|
|
31
|
+
);
|
|
32
|
+
expect(result).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("scope transition OUT: old matched, new does not → delete", () => {
|
|
36
|
+
const result = filterChangeForSubscriber(
|
|
37
|
+
change({
|
|
38
|
+
oldRow: { _id: "t1", workspaceId: "w1" },
|
|
39
|
+
newRow: { _id: "t1", workspaceId: "w2" },
|
|
40
|
+
}),
|
|
41
|
+
R,
|
|
42
|
+
);
|
|
43
|
+
expect(result).toEqual({ type: "delete", id: "t1", item: null });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("scope transition IN: old did not match, new does → set", () => {
|
|
47
|
+
const result = filterChangeForSubscriber(
|
|
48
|
+
change({
|
|
49
|
+
oldRow: { _id: "t1", workspaceId: "w2" },
|
|
50
|
+
newRow: { _id: "t1", workspaceId: "w1" },
|
|
51
|
+
}),
|
|
52
|
+
R,
|
|
53
|
+
);
|
|
54
|
+
expect(result?.type).toBe("set");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("delete of an item in my slice → delete", () => {
|
|
58
|
+
const result = filterChangeForSubscriber(
|
|
59
|
+
change({ oldRow: { _id: "t1", workspaceId: "w1" }, newRow: null }),
|
|
60
|
+
R,
|
|
61
|
+
);
|
|
62
|
+
expect(result).toEqual({ type: "delete", id: "t1", item: null });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("delete of another tenant's item → null", () => {
|
|
66
|
+
const result = filterChangeForSubscriber(
|
|
67
|
+
change({ oldRow: { _id: "t1", workspaceId: "w2" }, newRow: null }),
|
|
68
|
+
R,
|
|
69
|
+
);
|
|
70
|
+
expect(result).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("brand new item outside my slice → null", () => {
|
|
74
|
+
const result = filterChangeForSubscriber(
|
|
75
|
+
change({ newRow: { _id: "t1", workspaceId: "w2" } }),
|
|
76
|
+
R,
|
|
77
|
+
);
|
|
78
|
+
expect(result).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("null restrictions (unprotected view) → everything as set/delete", () => {
|
|
82
|
+
expect(
|
|
83
|
+
filterChangeForSubscriber(
|
|
84
|
+
change({ newRow: { _id: "t1", workspaceId: "anything" } }),
|
|
85
|
+
null,
|
|
86
|
+
)?.type,
|
|
87
|
+
).toBe("set");
|
|
88
|
+
expect(
|
|
89
|
+
filterChangeForSubscriber(
|
|
90
|
+
change({ oldRow: { _id: "t1", workspaceId: "anything" }, newRow: null }),
|
|
91
|
+
null,
|
|
92
|
+
)?.type,
|
|
93
|
+
).toBe("delete");
|
|
94
|
+
});
|
|
95
|
+
});
|