@rebasepro/server-postgresql 0.6.0 → 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.
Files changed (82) hide show
  1. package/package.json +24 -18
  2. package/src/PostgresBackendDriver.ts +65 -44
  3. package/src/PostgresBootstrapper.ts +49 -20
  4. package/src/auth/ensure-tables.ts +56 -1
  5. package/src/auth/services.ts +94 -1
  6. package/src/cli-errors.ts +162 -0
  7. package/src/cli-helpers.ts +183 -0
  8. package/src/cli.ts +198 -251
  9. package/src/data-transformer.ts +9 -1
  10. package/src/schema/auth-default-policies.ts +90 -0
  11. package/src/schema/auth-schema.ts +25 -2
  12. package/src/schema/doctor.ts +2 -4
  13. package/src/schema/generate-drizzle-schema-logic.ts +4 -3
  14. package/src/schema/generate-drizzle-schema.ts +3 -5
  15. package/src/schema/generate-postgres-ddl-logic.ts +524 -0
  16. package/src/schema/generate-postgres-ddl.ts +116 -0
  17. package/src/services/EntityPersistService.ts +18 -16
  18. package/src/services/entityService.ts +28 -3
  19. package/src/utils/pg-array-null-patch.ts +42 -0
  20. package/src/utils/pg-error-utils.ts +16 -0
  21. package/src/utils/table-classification.ts +16 -0
  22. package/src/websocket.ts +9 -0
  23. package/test/array-null-safety.test.ts +335 -0
  24. package/test/auth-default-policies.test.ts +89 -0
  25. package/test/cli-helpers-extended.test.ts +324 -0
  26. package/test/cli-helpers.test.ts +59 -0
  27. package/test/connection.test.ts +292 -0
  28. package/test/data-transformer.test.ts +53 -2
  29. package/test/databasePoolManager.test.ts +289 -0
  30. package/test/doctor-extended.test.ts +443 -0
  31. package/test/e2e/db-e2e.test.ts +293 -0
  32. package/test/e2e/pg-setup.ts +79 -0
  33. package/test/entity-persist-composite-keys.test.ts +451 -0
  34. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  35. package/test/generate-postgres-ddl.test.ts +300 -0
  36. package/test/mfa-service.test.ts +544 -0
  37. package/test/pg-array-null-patch.test.ts +65 -0
  38. package/test/pg-error-utils.test.ts +50 -1
  39. package/test/realtimeService-channels.test.ts +696 -0
  40. package/test/unmapped-tables-safety.test.ts +55 -342
  41. package/vite.config.ts +8 -6
  42. package/vitest.e2e.config.ts +10 -0
  43. package/build-errors.txt +0 -37
  44. package/dist/PostgresAdapter.d.ts +0 -6
  45. package/dist/PostgresBackendDriver.d.ts +0 -110
  46. package/dist/PostgresBootstrapper.d.ts +0 -46
  47. package/dist/auth/ensure-tables.d.ts +0 -10
  48. package/dist/auth/services.d.ts +0 -231
  49. package/dist/cli.d.ts +0 -1
  50. package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
  51. package/dist/connection.d.ts +0 -65
  52. package/dist/data-transformer.d.ts +0 -55
  53. package/dist/databasePoolManager.d.ts +0 -20
  54. package/dist/history/HistoryService.d.ts +0 -71
  55. package/dist/history/ensure-history-table.d.ts +0 -7
  56. package/dist/index.d.ts +0 -14
  57. package/dist/index.es.js +0 -10764
  58. package/dist/index.es.js.map +0 -1
  59. package/dist/index.umd.js +0 -11055
  60. package/dist/index.umd.js.map +0 -1
  61. package/dist/interfaces.d.ts +0 -18
  62. package/dist/schema/auth-schema.d.ts +0 -2149
  63. package/dist/schema/doctor-cli.d.ts +0 -2
  64. package/dist/schema/doctor.d.ts +0 -52
  65. package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
  66. package/dist/schema/generate-drizzle-schema.d.ts +0 -1
  67. package/dist/schema/introspect-db-inference.d.ts +0 -5
  68. package/dist/schema/introspect-db-logic.d.ts +0 -118
  69. package/dist/schema/introspect-db.d.ts +0 -1
  70. package/dist/schema/test-schema.d.ts +0 -24
  71. package/dist/services/BranchService.d.ts +0 -47
  72. package/dist/services/EntityFetchService.d.ts +0 -214
  73. package/dist/services/EntityPersistService.d.ts +0 -40
  74. package/dist/services/RelationService.d.ts +0 -98
  75. package/dist/services/entity-helpers.d.ts +0 -38
  76. package/dist/services/entityService.d.ts +0 -110
  77. package/dist/services/index.d.ts +0 -4
  78. package/dist/services/realtimeService.d.ts +0 -220
  79. package/dist/types.d.ts +0 -3
  80. package/dist/utils/drizzle-conditions.d.ts +0 -138
  81. package/dist/utils/pg-error-utils.d.ts +0 -55
  82. 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
+ });