@rebasepro/server-postgresql 0.6.1 → 0.7.0
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/package.json +24 -18
- package/src/PostgresBackendDriver.ts +65 -44
- package/src/PostgresBootstrapper.ts +34 -2
- package/src/auth/ensure-tables.ts +56 -1
- package/src/auth/services.ts +94 -1
- package/src/cli-errors.ts +162 -0
- package/src/cli-helpers.ts +183 -0
- package/src/cli.ts +198 -251
- package/src/schema/auth-default-policies.ts +90 -0
- package/src/schema/auth-schema.ts +25 -2
- package/src/schema/doctor.ts +2 -4
- package/src/schema/generate-drizzle-schema-logic.ts +4 -3
- package/src/schema/generate-drizzle-schema.ts +3 -5
- package/src/schema/generate-postgres-ddl-logic.ts +524 -0
- package/src/schema/generate-postgres-ddl.ts +116 -0
- package/src/services/EntityPersistService.ts +10 -8
- package/src/services/entityService.ts +28 -3
- package/src/utils/pg-error-utils.ts +16 -0
- package/src/utils/table-classification.ts +16 -0
- package/src/websocket.ts +9 -0
- package/test/auth-default-policies.test.ts +89 -0
- package/test/cli-helpers-extended.test.ts +324 -0
- package/test/cli-helpers.test.ts +59 -0
- package/test/connection.test.ts +292 -0
- package/test/databasePoolManager.test.ts +289 -0
- package/test/doctor-extended.test.ts +443 -0
- package/test/e2e/db-e2e.test.ts +293 -0
- package/test/e2e/pg-setup.ts +79 -0
- package/test/entity-persist-composite-keys.test.ts +451 -0
- package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
- package/test/generate-postgres-ddl.test.ts +300 -0
- package/test/mfa-service.test.ts +544 -0
- package/test/pg-error-utils.test.ts +50 -1
- package/test/realtimeService-channels.test.ts +696 -0
- package/test/unmapped-tables-safety.test.ts +55 -342
- package/vitest.e2e.config.ts +10 -0
- package/build-errors.txt +0 -37
- package/dist/PostgresAdapter.d.ts +0 -6
- package/dist/PostgresBackendDriver.d.ts +0 -110
- package/dist/PostgresBootstrapper.d.ts +0 -46
- package/dist/auth/ensure-tables.d.ts +0 -10
- package/dist/auth/services.d.ts +0 -231
- package/dist/cli.d.ts +0 -1
- package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
- package/dist/connection.d.ts +0 -65
- package/dist/data-transformer.d.ts +0 -55
- package/dist/databasePoolManager.d.ts +0 -20
- package/dist/history/HistoryService.d.ts +0 -71
- package/dist/history/ensure-history-table.d.ts +0 -7
- package/dist/index.d.ts +0 -14
- package/dist/index.es.js +0 -10803
- package/dist/index.es.js.map +0 -1
- package/dist/interfaces.d.ts +0 -18
- package/dist/schema/auth-schema.d.ts +0 -2149
- package/dist/schema/doctor-cli.d.ts +0 -2
- package/dist/schema/doctor.d.ts +0 -52
- package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
- package/dist/schema/generate-drizzle-schema.d.ts +0 -1
- package/dist/schema/introspect-db-inference.d.ts +0 -5
- package/dist/schema/introspect-db-logic.d.ts +0 -118
- package/dist/schema/introspect-db.d.ts +0 -1
- package/dist/schema/test-schema.d.ts +0 -24
- package/dist/services/BranchService.d.ts +0 -47
- package/dist/services/EntityFetchService.d.ts +0 -214
- package/dist/services/EntityPersistService.d.ts +0 -40
- package/dist/services/RelationService.d.ts +0 -98
- package/dist/services/entity-helpers.d.ts +0 -38
- package/dist/services/entityService.d.ts +0 -110
- package/dist/services/index.d.ts +0 -4
- package/dist/services/realtimeService.d.ts +0 -220
- package/dist/types.d.ts +0 -3
- package/dist/utils/drizzle-conditions.d.ts +0 -138
- package/dist/utils/pg-array-null-patch.d.ts +0 -16
- package/dist/utils/pg-error-utils.d.ts +0 -55
- package/dist/websocket.d.ts +0 -11
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { RealtimeService } from "../src/services/realtimeService";
|
|
2
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { PostgresCollectionRegistry } from "../src/collections/PostgresCollectionRegistry";
|
|
4
|
+
import { EntityCollection } from "@rebasepro/types";
|
|
5
|
+
import { WebSocket } from "ws";
|
|
6
|
+
|
|
7
|
+
jest.mock("../src/services/entityService", () => ({
|
|
8
|
+
EntityService: jest.fn().mockImplementation(() => ({
|
|
9
|
+
fetchCollection: jest.fn().mockResolvedValue([]),
|
|
10
|
+
fetchEntity: jest.fn().mockResolvedValue(null),
|
|
11
|
+
searchEntities: jest.fn().mockResolvedValue([])
|
|
12
|
+
}))
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// --- Mock Classes ---
|
|
16
|
+
class MockWebSocket {
|
|
17
|
+
public readyState = WebSocket.OPEN;
|
|
18
|
+
public send = jest.fn();
|
|
19
|
+
public on = jest.fn();
|
|
20
|
+
constructor() {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mockPostsCollection: EntityCollection = {
|
|
24
|
+
slug: "posts",
|
|
25
|
+
name: "Posts",
|
|
26
|
+
table: "posts",
|
|
27
|
+
properties: {
|
|
28
|
+
id: { type: "number" },
|
|
29
|
+
title: { type: "string" }
|
|
30
|
+
},
|
|
31
|
+
idField: "id"
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function createService() {
|
|
35
|
+
const db = {
|
|
36
|
+
execute: jest.fn().mockResolvedValue({ rows: [] }),
|
|
37
|
+
transaction: jest.fn((callback: (tx: unknown) => unknown) => callback(db)),
|
|
38
|
+
select: jest.fn().mockReturnThis(),
|
|
39
|
+
from: jest.fn().mockReturnThis(),
|
|
40
|
+
where: jest.fn().mockReturnThis(),
|
|
41
|
+
limit: jest.fn().mockReturnThis()
|
|
42
|
+
} as unknown as jest.Mocked<NodePgDatabase<Record<string, unknown>>>;
|
|
43
|
+
(db as unknown as Record<string, unknown>).then = jest.fn((resolve: (v: unknown[]) => void) => resolve([]));
|
|
44
|
+
|
|
45
|
+
const registry = new PostgresCollectionRegistry();
|
|
46
|
+
jest.spyOn(registry, "getCollectionByPath").mockReturnValue(mockPostsCollection);
|
|
47
|
+
|
|
48
|
+
const service = new RealtimeService(db, registry);
|
|
49
|
+
return { service, db, registry };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createClient(service: RealtimeService, clientId: string): MockWebSocket {
|
|
53
|
+
const ws = new MockWebSocket();
|
|
54
|
+
service.addClient(clientId, ws as unknown as WebSocket);
|
|
55
|
+
return ws;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("RealtimeService — Channels, Presence & Lifecycle", () => {
|
|
59
|
+
let service: RealtimeService;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
jest.useFakeTimers();
|
|
63
|
+
({ service } = createService());
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
await service.destroy();
|
|
68
|
+
jest.clearAllMocks();
|
|
69
|
+
jest.useRealTimers();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// =========================================================================
|
|
73
|
+
// 1. Channel Operations
|
|
74
|
+
// =========================================================================
|
|
75
|
+
describe("Channel Operations", () => {
|
|
76
|
+
it("joinChannel() adds the client to the channel set", () => {
|
|
77
|
+
createClient(service, "c1");
|
|
78
|
+
service.joinChannel("c1", "room:lobby");
|
|
79
|
+
|
|
80
|
+
// Access internal state via the handleMessage path
|
|
81
|
+
// Verify by trying to broadcast — if the client joined, it will be a member
|
|
82
|
+
const ws2 = createClient(service, "c2");
|
|
83
|
+
service.joinChannel("c2", "room:lobby");
|
|
84
|
+
|
|
85
|
+
service.broadcastToChannel("c1", "room:lobby", "hello", { text: "hi" });
|
|
86
|
+
|
|
87
|
+
// c2 should have received the broadcast (c1 is the sender, excluded)
|
|
88
|
+
expect(ws2.send).toHaveBeenCalledTimes(1);
|
|
89
|
+
const msg = JSON.parse(ws2.send.mock.calls[0][0] as string);
|
|
90
|
+
expect(msg.type).toBe("broadcast");
|
|
91
|
+
expect(msg.channel).toBe("room:lobby");
|
|
92
|
+
expect(msg.event).toBe("hello");
|
|
93
|
+
expect(msg.payload).toEqual({ text: "hi" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("joinChannel() via handleClientMessage works", async () => {
|
|
97
|
+
const ws1 = createClient(service, "c1");
|
|
98
|
+
const ws2 = createClient(service, "c2");
|
|
99
|
+
|
|
100
|
+
await service.handleClientMessage("c1", {
|
|
101
|
+
type: "join_channel",
|
|
102
|
+
payload: { channel: "room:lobby" }
|
|
103
|
+
});
|
|
104
|
+
await service.handleClientMessage("c2", {
|
|
105
|
+
type: "join_channel",
|
|
106
|
+
payload: { channel: "room:lobby" }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Broadcast from c1 — c2 should receive
|
|
110
|
+
await service.handleClientMessage("c1", {
|
|
111
|
+
type: "broadcast",
|
|
112
|
+
payload: { channel: "room:lobby", event: "ping", payload: { seq: 1 } }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(ws2.send).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(ws1.send).not.toHaveBeenCalled(); // sender excluded
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("joinChannel() with undefined channel name does not crash and does not pollute state", () => {
|
|
120
|
+
createClient(service, "c1");
|
|
121
|
+
// Should not throw
|
|
122
|
+
expect(() => {
|
|
123
|
+
service.joinChannel("c1", undefined as unknown as string);
|
|
124
|
+
}).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("joinChannel() with empty string channel name does not crash", () => {
|
|
128
|
+
createClient(service, "c1");
|
|
129
|
+
expect(() => {
|
|
130
|
+
service.joinChannel("c1", "");
|
|
131
|
+
}).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("leaveChannel() removes the client from the channel", async () => {
|
|
135
|
+
const ws1 = createClient(service, "c1");
|
|
136
|
+
const ws2 = createClient(service, "c2");
|
|
137
|
+
|
|
138
|
+
service.joinChannel("c1", "room:lobby");
|
|
139
|
+
service.joinChannel("c2", "room:lobby");
|
|
140
|
+
|
|
141
|
+
// c1 leaves
|
|
142
|
+
service.leaveChannel("c1", "room:lobby");
|
|
143
|
+
|
|
144
|
+
// Now broadcast from c2 — c1 should NOT receive
|
|
145
|
+
service.broadcastToChannel("c2", "room:lobby", "ping", {});
|
|
146
|
+
expect(ws1.send).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("leaveChannel() from a channel not joined does not crash", () => {
|
|
150
|
+
createClient(service, "c1");
|
|
151
|
+
expect(() => {
|
|
152
|
+
service.leaveChannel("c1", "nonexistent-channel");
|
|
153
|
+
}).not.toThrow();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("leaveChannel() cleans up empty channel set", () => {
|
|
157
|
+
createClient(service, "c1");
|
|
158
|
+
service.joinChannel("c1", "room:temp");
|
|
159
|
+
service.leaveChannel("c1", "room:temp");
|
|
160
|
+
|
|
161
|
+
// After leaving, broadcasting to that channel should be a no-op (no members)
|
|
162
|
+
const ws2 = createClient(service, "c2");
|
|
163
|
+
service.broadcastToChannel("c2", "room:temp", "ping", {});
|
|
164
|
+
expect(ws2.send).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("broadcastToChannel() sends to all members except the sender", () => {
|
|
168
|
+
const ws1 = createClient(service, "c1");
|
|
169
|
+
const ws2 = createClient(service, "c2");
|
|
170
|
+
const ws3 = createClient(service, "c3");
|
|
171
|
+
|
|
172
|
+
service.joinChannel("c1", "room:game");
|
|
173
|
+
service.joinChannel("c2", "room:game");
|
|
174
|
+
service.joinChannel("c3", "room:game");
|
|
175
|
+
|
|
176
|
+
service.broadcastToChannel("c1", "room:game", "move", { x: 10 });
|
|
177
|
+
|
|
178
|
+
expect(ws1.send).not.toHaveBeenCalled(); // sender excluded
|
|
179
|
+
expect(ws2.send).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(ws3.send).toHaveBeenCalledTimes(1);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("broadcastToChannel() to a non-existent channel does not crash", () => {
|
|
184
|
+
createClient(service, "c1");
|
|
185
|
+
expect(() => {
|
|
186
|
+
service.broadcastToChannel("c1", "no-such-channel", "ping", {});
|
|
187
|
+
}).not.toThrow();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("broadcastToChannel() skips clients with readyState !== OPEN", () => {
|
|
191
|
+
createClient(service, "c1");
|
|
192
|
+
const ws2 = createClient(service, "c2");
|
|
193
|
+
|
|
194
|
+
service.joinChannel("c1", "room:lobby");
|
|
195
|
+
service.joinChannel("c2", "room:lobby");
|
|
196
|
+
|
|
197
|
+
// Simulate c2's socket closing
|
|
198
|
+
ws2.readyState = WebSocket.CLOSED;
|
|
199
|
+
|
|
200
|
+
service.broadcastToChannel("c1", "room:lobby", "ping", {});
|
|
201
|
+
|
|
202
|
+
// c2 should NOT have received anything since its readyState is CLOSED
|
|
203
|
+
expect(ws2.send).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("broadcastToChannel() only affects the specified channel", () => {
|
|
207
|
+
const ws1 = createClient(service, "c1");
|
|
208
|
+
const ws2 = createClient(service, "c2");
|
|
209
|
+
|
|
210
|
+
service.joinChannel("c1", "room:A");
|
|
211
|
+
service.joinChannel("c2", "room:B");
|
|
212
|
+
|
|
213
|
+
service.broadcastToChannel("c1", "room:A", "msg", {});
|
|
214
|
+
|
|
215
|
+
// c2 is in a different channel, should not receive
|
|
216
|
+
expect(ws2.send).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// =========================================================================
|
|
221
|
+
// 2. Presence
|
|
222
|
+
// =========================================================================
|
|
223
|
+
describe("Presence", () => {
|
|
224
|
+
it("trackPresence() stores state and broadcasts presence_diff with join", async () => {
|
|
225
|
+
const ws1 = createClient(service, "c1");
|
|
226
|
+
const ws2 = createClient(service, "c2");
|
|
227
|
+
|
|
228
|
+
// Both join the channel
|
|
229
|
+
service.joinChannel("c1", "room:collab");
|
|
230
|
+
service.joinChannel("c2", "room:collab");
|
|
231
|
+
|
|
232
|
+
ws1.send.mockClear();
|
|
233
|
+
ws2.send.mockClear();
|
|
234
|
+
|
|
235
|
+
// c1 tracks presence
|
|
236
|
+
service.trackPresence("c1", "room:collab", { name: "Alice" });
|
|
237
|
+
|
|
238
|
+
// Both members should receive the presence_diff
|
|
239
|
+
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
240
|
+
expect(ws2.send).toHaveBeenCalledTimes(1);
|
|
241
|
+
|
|
242
|
+
const diff = JSON.parse(ws1.send.mock.calls[0][0] as string);
|
|
243
|
+
expect(diff.type).toBe("presence_diff");
|
|
244
|
+
expect(diff.channel).toBe("room:collab");
|
|
245
|
+
expect(diff.joins).toEqual({ c1: { name: "Alice" } });
|
|
246
|
+
expect(diff.leaves).toEqual({});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("trackPresence() via handleClientMessage auto-joins the channel", async () => {
|
|
250
|
+
const ws1 = createClient(service, "c1");
|
|
251
|
+
|
|
252
|
+
await service.handleClientMessage("c1", {
|
|
253
|
+
type: "presence_track",
|
|
254
|
+
payload: { channel: "room:auto", state: { status: "online" } }
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// After tracking, the client should be in the channel
|
|
258
|
+
// Verify by requesting presence state
|
|
259
|
+
ws1.send.mockClear();
|
|
260
|
+
service.sendPresenceState("c1", "room:auto");
|
|
261
|
+
|
|
262
|
+
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
263
|
+
const state = JSON.parse(ws1.send.mock.calls[0][0] as string);
|
|
264
|
+
expect(state.type).toBe("presence_state");
|
|
265
|
+
expect(state.presences).toEqual({ c1: { status: "online" } });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("removePresence() removes state and broadcasts absence", () => {
|
|
269
|
+
const ws1 = createClient(service, "c1");
|
|
270
|
+
const ws2 = createClient(service, "c2");
|
|
271
|
+
|
|
272
|
+
service.joinChannel("c1", "room:collab");
|
|
273
|
+
service.joinChannel("c2", "room:collab");
|
|
274
|
+
|
|
275
|
+
service.trackPresence("c1", "room:collab", { name: "Alice" });
|
|
276
|
+
|
|
277
|
+
ws1.send.mockClear();
|
|
278
|
+
ws2.send.mockClear();
|
|
279
|
+
|
|
280
|
+
// Remove c1's presence
|
|
281
|
+
service.removePresence("c1", "room:collab");
|
|
282
|
+
|
|
283
|
+
// Should broadcast a presence_diff with c1 in leaves
|
|
284
|
+
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
285
|
+
const diff = JSON.parse(ws1.send.mock.calls[0][0] as string);
|
|
286
|
+
expect(diff.type).toBe("presence_diff");
|
|
287
|
+
expect(diff.leaves).toEqual({ c1: { name: "Alice" } });
|
|
288
|
+
expect(diff.joins).toEqual({});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("removePresence() for non-tracked client does not crash", () => {
|
|
292
|
+
createClient(service, "c1");
|
|
293
|
+
expect(() => {
|
|
294
|
+
service.removePresence("c1", "nonexistent-channel");
|
|
295
|
+
}).not.toThrow();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("sendPresenceState() sends full state to requesting client", () => {
|
|
299
|
+
const ws1 = createClient(service, "c1");
|
|
300
|
+
const ws2 = createClient(service, "c2");
|
|
301
|
+
|
|
302
|
+
service.joinChannel("c1", "room:collab");
|
|
303
|
+
service.joinChannel("c2", "room:collab");
|
|
304
|
+
service.trackPresence("c1", "room:collab", { name: "Alice" });
|
|
305
|
+
service.trackPresence("c2", "room:collab", { name: "Bob" });
|
|
306
|
+
|
|
307
|
+
ws1.send.mockClear();
|
|
308
|
+
|
|
309
|
+
service.sendPresenceState("c1", "room:collab");
|
|
310
|
+
|
|
311
|
+
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
312
|
+
const state = JSON.parse(ws1.send.mock.calls[0][0] as string);
|
|
313
|
+
expect(state.type).toBe("presence_state");
|
|
314
|
+
expect(state.channel).toBe("room:collab");
|
|
315
|
+
expect(state.presences).toEqual({
|
|
316
|
+
c1: { name: "Alice" },
|
|
317
|
+
c2: { name: "Bob" }
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("sendPresenceState() returns empty presences for unknown channel", () => {
|
|
322
|
+
const ws1 = createClient(service, "c1");
|
|
323
|
+
ws1.send.mockClear();
|
|
324
|
+
|
|
325
|
+
service.sendPresenceState("c1", "no-such-channel");
|
|
326
|
+
|
|
327
|
+
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
328
|
+
const state = JSON.parse(ws1.send.mock.calls[0][0] as string);
|
|
329
|
+
expect(state.type).toBe("presence_state");
|
|
330
|
+
expect(state.presences).toEqual({});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("sendPresenceState() does not send if client socket is closed", () => {
|
|
334
|
+
const ws1 = createClient(service, "c1");
|
|
335
|
+
service.joinChannel("c1", "room:collab");
|
|
336
|
+
service.trackPresence("c1", "room:collab", { name: "Alice" });
|
|
337
|
+
|
|
338
|
+
ws1.send.mockClear();
|
|
339
|
+
ws1.readyState = WebSocket.CLOSED;
|
|
340
|
+
|
|
341
|
+
service.sendPresenceState("c1", "room:collab");
|
|
342
|
+
|
|
343
|
+
expect(ws1.send).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("stale presence is cleaned up after 30s timeout", () => {
|
|
347
|
+
const ws1 = createClient(service, "c1");
|
|
348
|
+
const ws2 = createClient(service, "c2");
|
|
349
|
+
|
|
350
|
+
service.joinChannel("c1", "room:collab");
|
|
351
|
+
service.joinChannel("c2", "room:collab");
|
|
352
|
+
service.trackPresence("c1", "room:collab", { name: "Alice" });
|
|
353
|
+
|
|
354
|
+
ws1.send.mockClear();
|
|
355
|
+
ws2.send.mockClear();
|
|
356
|
+
|
|
357
|
+
// The cleanup interval runs every 10s, the timeout threshold is >30s.
|
|
358
|
+
// At 30s, Date.now() - lastSeen === 30000 which is NOT > 30000.
|
|
359
|
+
// We need at least 40001ms so the interval fires after the 30s+ window.
|
|
360
|
+
jest.advanceTimersByTime(41000);
|
|
361
|
+
|
|
362
|
+
// Verify by requesting presence state — c1 should be gone
|
|
363
|
+
ws2.send.mockClear();
|
|
364
|
+
service.sendPresenceState("c2", "room:collab");
|
|
365
|
+
|
|
366
|
+
const state = JSON.parse(ws2.send.mock.calls[ws2.send.mock.calls.length - 1][0] as string);
|
|
367
|
+
expect(state.type).toBe("presence_state");
|
|
368
|
+
expect(state.presences).toEqual({});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("presence_untrack via handleClientMessage removes presence", async () => {
|
|
372
|
+
const ws1 = createClient(service, "c1");
|
|
373
|
+
const ws2 = createClient(service, "c2");
|
|
374
|
+
|
|
375
|
+
service.joinChannel("c1", "room:collab");
|
|
376
|
+
service.joinChannel("c2", "room:collab");
|
|
377
|
+
service.trackPresence("c1", "room:collab", { name: "Alice" });
|
|
378
|
+
|
|
379
|
+
ws1.send.mockClear();
|
|
380
|
+
ws2.send.mockClear();
|
|
381
|
+
|
|
382
|
+
await service.handleClientMessage("c1", {
|
|
383
|
+
type: "presence_untrack",
|
|
384
|
+
payload: { channel: "room:collab" }
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// Should have broadcast a presence_diff with leave
|
|
388
|
+
const diffCalls = ws2.send.mock.calls
|
|
389
|
+
.map(c => JSON.parse(c[0] as string))
|
|
390
|
+
.filter((m: Record<string, unknown>) => m.type === "presence_diff");
|
|
391
|
+
expect(diffCalls.length).toBe(1);
|
|
392
|
+
expect(diffCalls[0].leaves).toEqual({ c1: { name: "Alice" } });
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("presence_state via handleClientMessage sends full state", async () => {
|
|
396
|
+
const ws1 = createClient(service, "c1");
|
|
397
|
+
service.joinChannel("c1", "room:collab");
|
|
398
|
+
service.trackPresence("c1", "room:collab", { status: "active" });
|
|
399
|
+
|
|
400
|
+
ws1.send.mockClear();
|
|
401
|
+
|
|
402
|
+
await service.handleClientMessage("c1", {
|
|
403
|
+
type: "presence_state",
|
|
404
|
+
payload: { channel: "room:collab" }
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
expect(ws1.send).toHaveBeenCalledTimes(1);
|
|
408
|
+
const state = JSON.parse(ws1.send.mock.calls[0][0] as string);
|
|
409
|
+
expect(state.type).toBe("presence_state");
|
|
410
|
+
expect(state.presences).toEqual({ c1: { status: "active" } });
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// =========================================================================
|
|
415
|
+
// 3. Client Lifecycle
|
|
416
|
+
// =========================================================================
|
|
417
|
+
describe("Client Lifecycle", () => {
|
|
418
|
+
it("addClient() + removeClient() cleans up subscriptions, channels, and presence", async () => {
|
|
419
|
+
const ws1 = createClient(service, "c1");
|
|
420
|
+
|
|
421
|
+
// Subscribe to a collection
|
|
422
|
+
await service.handleClientMessage("c1", {
|
|
423
|
+
type: "subscribe_collection",
|
|
424
|
+
payload: { path: "posts", subscriptionId: "sub-1" }
|
|
425
|
+
});
|
|
426
|
+
expect(service.subscriptions.has("sub-1")).toBe(true);
|
|
427
|
+
|
|
428
|
+
// Join a channel and track presence
|
|
429
|
+
service.joinChannel("c1", "room:test");
|
|
430
|
+
service.trackPresence("c1", "room:test", { online: true });
|
|
431
|
+
|
|
432
|
+
// Remove client
|
|
433
|
+
await service.removeClient("c1");
|
|
434
|
+
|
|
435
|
+
// Subscription should be cleaned up
|
|
436
|
+
expect(service.subscriptions.has("sub-1")).toBe(false);
|
|
437
|
+
|
|
438
|
+
// Client should be gone
|
|
439
|
+
expect(service.clients.has("c1")).toBe(false);
|
|
440
|
+
|
|
441
|
+
// Channel membership should be cleaned up:
|
|
442
|
+
// Broadcasting to the channel should not reach any client
|
|
443
|
+
const ws2 = createClient(service, "c2");
|
|
444
|
+
service.joinChannel("c2", "room:test");
|
|
445
|
+
ws2.send.mockClear();
|
|
446
|
+
|
|
447
|
+
service.sendPresenceState("c2", "room:test");
|
|
448
|
+
const state = JSON.parse(ws2.send.mock.calls[0][0] as string);
|
|
449
|
+
// c1's presence should be gone
|
|
450
|
+
expect(state.presences.c1).toBeUndefined();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("removeClient() for non-existent client does not crash", async () => {
|
|
454
|
+
await expect(service.removeClient("non-existent-client")).resolves.toBeUndefined();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("removeClient() cancels pending refetch timers", async () => {
|
|
458
|
+
createClient(service, "c1");
|
|
459
|
+
|
|
460
|
+
await service.handleClientMessage("c1", {
|
|
461
|
+
type: "subscribe_collection",
|
|
462
|
+
payload: { path: "posts", subscriptionId: "sub-timer" }
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Trigger a refetch timer by notifying
|
|
466
|
+
const dummyEntity = { id: "1", path: "posts", values: { _rebase_invalidated: true } } as unknown as import("@rebasepro/types").Entity;
|
|
467
|
+
await service.notifyEntityUpdate("posts", "1", dummyEntity, undefined, false);
|
|
468
|
+
|
|
469
|
+
// Remove client before the timer fires
|
|
470
|
+
await service.removeClient("c1");
|
|
471
|
+
|
|
472
|
+
// Advance past refetch debounce — should not throw or trigger errors
|
|
473
|
+
jest.advanceTimersByTime(500);
|
|
474
|
+
await Promise.resolve();
|
|
475
|
+
await Promise.resolve();
|
|
476
|
+
|
|
477
|
+
// Subscription should be gone
|
|
478
|
+
expect(service.subscriptions.has("sub-timer")).toBe(false);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("removeClient() with entity subscriptions cleans up properly", async () => {
|
|
482
|
+
createClient(service, "c1");
|
|
483
|
+
|
|
484
|
+
await service.handleClientMessage("c1", {
|
|
485
|
+
type: "subscribe_entity",
|
|
486
|
+
payload: { path: "posts", entityId: "42", subscriptionId: "sub-ent-1" }
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
expect(service.subscriptions.has("sub-ent-1")).toBe(true);
|
|
490
|
+
|
|
491
|
+
await service.removeClient("c1");
|
|
492
|
+
|
|
493
|
+
expect(service.subscriptions.has("sub-ent-1")).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// =========================================================================
|
|
498
|
+
// 4. Subscription Limits & Edge Cases
|
|
499
|
+
// =========================================================================
|
|
500
|
+
describe("Subscription Limits & Edge Cases", () => {
|
|
501
|
+
it("multiple subscriptions from one client are all tracked", async () => {
|
|
502
|
+
createClient(service, "c1");
|
|
503
|
+
|
|
504
|
+
for (let i = 0; i < 10; i++) {
|
|
505
|
+
await service.handleClientMessage("c1", {
|
|
506
|
+
type: "subscribe_collection",
|
|
507
|
+
payload: { path: "posts", subscriptionId: `sub-${i}` }
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
for (let i = 0; i < 10; i++) {
|
|
512
|
+
expect(service.subscriptions.has(`sub-${i}`)).toBe(true);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("multiple subscriptions are all cleaned up on removeClient", async () => {
|
|
517
|
+
createClient(service, "c1");
|
|
518
|
+
|
|
519
|
+
for (let i = 0; i < 10; i++) {
|
|
520
|
+
await service.handleClientMessage("c1", {
|
|
521
|
+
type: "subscribe_collection",
|
|
522
|
+
payload: { path: "posts", subscriptionId: `sub-${i}` }
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
await service.removeClient("c1");
|
|
527
|
+
|
|
528
|
+
for (let i = 0; i < 10; i++) {
|
|
529
|
+
expect(service.subscriptions.has(`sub-${i}`)).toBe(false);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("handleUnsubscribe with non-existent subscriptionId does not crash", async () => {
|
|
534
|
+
createClient(service, "c1");
|
|
535
|
+
|
|
536
|
+
await expect(
|
|
537
|
+
service.handleClientMessage("c1", {
|
|
538
|
+
type: "unsubscribe",
|
|
539
|
+
subscriptionId: "does-not-exist"
|
|
540
|
+
})
|
|
541
|
+
).resolves.toBeUndefined();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("handleUnsubscribe removes the subscription", async () => {
|
|
545
|
+
createClient(service, "c1");
|
|
546
|
+
|
|
547
|
+
await service.handleClientMessage("c1", {
|
|
548
|
+
type: "subscribe_collection",
|
|
549
|
+
payload: { path: "posts", subscriptionId: "sub-to-remove" }
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
expect(service.subscriptions.has("sub-to-remove")).toBe(true);
|
|
553
|
+
|
|
554
|
+
await service.handleClientMessage("c1", {
|
|
555
|
+
type: "unsubscribe",
|
|
556
|
+
subscriptionId: "sub-to-remove"
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
expect(service.subscriptions.has("sub-to-remove")).toBe(false);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("subscriptions from different clients are independent", async () => {
|
|
563
|
+
createClient(service, "c1");
|
|
564
|
+
createClient(service, "c2");
|
|
565
|
+
|
|
566
|
+
await service.handleClientMessage("c1", {
|
|
567
|
+
type: "subscribe_collection",
|
|
568
|
+
payload: { path: "posts", subscriptionId: "sub-c1" }
|
|
569
|
+
});
|
|
570
|
+
await service.handleClientMessage("c2", {
|
|
571
|
+
type: "subscribe_collection",
|
|
572
|
+
payload: { path: "posts", subscriptionId: "sub-c2" }
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// Remove c1 — c2's subscription should remain
|
|
576
|
+
await service.removeClient("c1");
|
|
577
|
+
|
|
578
|
+
expect(service.subscriptions.has("sub-c1")).toBe(false);
|
|
579
|
+
expect(service.subscriptions.has("sub-c2")).toBe(true);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// =========================================================================
|
|
584
|
+
// 5. destroy()
|
|
585
|
+
// =========================================================================
|
|
586
|
+
describe("destroy()", () => {
|
|
587
|
+
it("clears all clients, subscriptions, channels, and presence", async () => {
|
|
588
|
+
const ws1 = createClient(service, "c1");
|
|
589
|
+
const ws2 = createClient(service, "c2");
|
|
590
|
+
|
|
591
|
+
// Set up some state
|
|
592
|
+
await service.handleClientMessage("c1", {
|
|
593
|
+
type: "subscribe_collection",
|
|
594
|
+
payload: { path: "posts", subscriptionId: "sub-d1" }
|
|
595
|
+
});
|
|
596
|
+
service.joinChannel("c1", "room:test");
|
|
597
|
+
service.joinChannel("c2", "room:test");
|
|
598
|
+
service.trackPresence("c1", "room:test", { online: true });
|
|
599
|
+
|
|
600
|
+
await service.destroy();
|
|
601
|
+
|
|
602
|
+
// All should be empty
|
|
603
|
+
expect(service.clients.has("c1")).toBe(false);
|
|
604
|
+
expect(service.clients.has("c2")).toBe(false);
|
|
605
|
+
expect(service.subscriptions.has("sub-d1")).toBe(false);
|
|
606
|
+
expect(service.subscriptions.size).toBe(0);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it("destroy() cancels pending refetch timers without errors", async () => {
|
|
610
|
+
createClient(service, "c1");
|
|
611
|
+
|
|
612
|
+
await service.handleClientMessage("c1", {
|
|
613
|
+
type: "subscribe_collection",
|
|
614
|
+
payload: { path: "posts", subscriptionId: "sub-timer-d" }
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// Trigger a refetch debounce
|
|
618
|
+
const dummyEntity = { id: "1", path: "posts", values: { _rebase_invalidated: true } } as unknown as import("@rebasepro/types").Entity;
|
|
619
|
+
await service.notifyEntityUpdate("posts", "1", dummyEntity, undefined, false);
|
|
620
|
+
|
|
621
|
+
// Destroy before the timer fires
|
|
622
|
+
await service.destroy();
|
|
623
|
+
|
|
624
|
+
// Advance time — should not throw
|
|
625
|
+
jest.advanceTimersByTime(500);
|
|
626
|
+
await Promise.resolve();
|
|
627
|
+
await Promise.resolve();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("destroy() can be called multiple times safely", async () => {
|
|
631
|
+
createClient(service, "c1");
|
|
632
|
+
await service.destroy();
|
|
633
|
+
await expect(service.destroy()).resolves.toBeUndefined();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
it("destroy() stops the presence cleanup interval", async () => {
|
|
637
|
+
createClient(service, "c1");
|
|
638
|
+
service.joinChannel("c1", "room:test");
|
|
639
|
+
service.trackPresence("c1", "room:test", { active: true });
|
|
640
|
+
|
|
641
|
+
await service.destroy();
|
|
642
|
+
|
|
643
|
+
// Advancing time should not cause errors from the interval
|
|
644
|
+
jest.advanceTimersByTime(60000);
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// =========================================================================
|
|
649
|
+
// 6. sendMessage edge cases
|
|
650
|
+
// =========================================================================
|
|
651
|
+
describe("sendMessage edge cases", () => {
|
|
652
|
+
it("sendMessage does not crash when client socket is CLOSED", async () => {
|
|
653
|
+
const ws = createClient(service, "c1");
|
|
654
|
+
ws.readyState = WebSocket.CLOSED;
|
|
655
|
+
|
|
656
|
+
await service.handleClientMessage("c1", {
|
|
657
|
+
type: "subscribe_collection",
|
|
658
|
+
payload: { path: "posts", subscriptionId: "sub-closed" }
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
// The initial collection_update should not be sent
|
|
662
|
+
expect(ws.send).not.toHaveBeenCalled();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("sendMessage does not crash for unknown clientId", () => {
|
|
666
|
+
// Directly test by trying to broadcast in a channel with a non-existent client
|
|
667
|
+
service.joinChannel("ghost", "room:test");
|
|
668
|
+
createClient(service, "c1");
|
|
669
|
+
service.joinChannel("c1", "room:test");
|
|
670
|
+
|
|
671
|
+
// Should not throw even though "ghost" has no WebSocket
|
|
672
|
+
expect(() => {
|
|
673
|
+
service.broadcastToChannel("c1", "room:test", "ping", {});
|
|
674
|
+
}).not.toThrow();
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// =========================================================================
|
|
679
|
+
// 7. Unknown message type
|
|
680
|
+
// =========================================================================
|
|
681
|
+
describe("Unknown message type", () => {
|
|
682
|
+
it("sends an error for unknown message types", async () => {
|
|
683
|
+
const ws = createClient(service, "c1");
|
|
684
|
+
|
|
685
|
+
await service.handleClientMessage("c1", {
|
|
686
|
+
type: "totally_unknown_type" as "subscribe_collection",
|
|
687
|
+
payload: {}
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
691
|
+
const msg = JSON.parse(ws.send.mock.calls[0][0] as string);
|
|
692
|
+
expect(msg.type).toBe("error");
|
|
693
|
+
expect(msg.error).toContain("Unknown message type");
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
});
|