@rebasepro/server-mongo 0.17.3 → 0.18.1

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 (50) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +4 -0
  3. package/dist/MongoBootstrapper.d.ts +0 -1
  4. package/dist/auth/ensure-collections.d.ts +0 -1
  5. package/dist/auth/services.d.ts +0 -1
  6. package/dist/connection.d.ts +0 -1
  7. package/dist/db/MongoConditionBuilder.d.ts +0 -1
  8. package/dist/db/MongoDataService.d.ts +0 -1
  9. package/dist/db/securityRuleFilter.d.ts +0 -1
  10. package/dist/factory.d.ts +0 -1
  11. package/dist/history/ensure-history-collection.d.ts +0 -1
  12. package/dist/index.d.ts +0 -1
  13. package/dist/index.es.js +83 -79
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/schema/plan-schema-change.d.ts +0 -1
  16. package/dist/services/MongoDriver.d.ts +0 -1
  17. package/dist/services/MongoHistoryService.d.ts +0 -1
  18. package/dist/services/MongoRealtimeService.d.ts +0 -1
  19. package/dist/websocket.d.ts +0 -1
  20. package/package.json +28 -24
  21. package/dist/MongoBootstrapper.d.ts.map +0 -1
  22. package/dist/auth/ensure-collections.d.ts.map +0 -1
  23. package/dist/auth/services.d.ts.map +0 -1
  24. package/dist/connection.d.ts.map +0 -1
  25. package/dist/db/MongoConditionBuilder.d.ts.map +0 -1
  26. package/dist/db/MongoDataService.d.ts.map +0 -1
  27. package/dist/db/securityRuleFilter.d.ts.map +0 -1
  28. package/dist/factory.d.ts.map +0 -1
  29. package/dist/history/ensure-history-collection.d.ts.map +0 -1
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/schema/plan-schema-change.d.ts.map +0 -1
  32. package/dist/services/MongoDriver.d.ts.map +0 -1
  33. package/dist/services/MongoHistoryService.d.ts.map +0 -1
  34. package/dist/services/MongoRealtimeService.d.ts.map +0 -1
  35. package/dist/websocket.d.ts.map +0 -1
  36. package/src/MongoBootstrapper.ts +0 -204
  37. package/src/auth/ensure-collections.ts +0 -153
  38. package/src/auth/services.ts +0 -866
  39. package/src/connection.ts +0 -60
  40. package/src/db/MongoConditionBuilder.ts +0 -348
  41. package/src/db/MongoDataService.ts +0 -412
  42. package/src/db/securityRuleFilter.ts +0 -398
  43. package/src/factory.ts +0 -331
  44. package/src/history/ensure-history-collection.ts +0 -22
  45. package/src/index.ts +0 -25
  46. package/src/schema/plan-schema-change.ts +0 -159
  47. package/src/services/MongoDriver.ts +0 -950
  48. package/src/services/MongoHistoryService.ts +0 -186
  49. package/src/services/MongoRealtimeService.ts +0 -592
  50. package/src/websocket.ts +0 -387
@@ -1,592 +0,0 @@
1
- /**
2
- * MongoDB Realtime Service
3
- *
4
- * Implements RealtimeProvider interface using MongoDB Change Streams.
5
- * Provides real-time subscriptions to collection and row changes.
6
- */
7
-
8
- import { Db, ChangeStream, ChangeStreamDocument, Document, ObjectId } from "mongodb";
9
- import {
10
- ANONYMOUS_USER_ID,
11
- DataDriver,
12
- FilterValues,
13
- RealtimeProvider,
14
- CollectionSubscriptionConfig,
15
- SingleSubscriptionConfig,
16
- WebSocketMessage,
17
- User,
18
- ListLimitError,
19
- resolveClientListLimit
20
- } from "@rebasepro/types";
21
- import { WebSocket } from "ws";
22
-
23
- import type { MongoDriver } from "./MongoDriver";
24
- import { logger } from "@rebasepro/server";
25
-
26
- /** The acting user for a subscription, as the driver and the socket carry it. */
27
- export interface SubscriptionAuthContext {
28
- uid: string;
29
- roles: string[];
30
- }
31
-
32
- /**
33
- * The query half of a subscription config — everything except who is watching.
34
- *
35
- * Spread into the re-fetch rather than re-listed field by field. Re-listing is
36
- * how `logical` and `offset` went missing twice on this path: the type declares
37
- * them, every boundary accepted them, and each hand-written list quietly named
38
- * a subset. A function that removes the two non-query fields cannot fall behind
39
- * the type the way a list of the other nine can.
40
- */
41
- const queryOf = <T extends { clientId: string; authContext?: SubscriptionAuthContext }>(
42
- config: T
43
- ): Omit<T, "clientId" | "authContext"> => {
44
- const { clientId: _clientId, authContext: _authContext, ...query } = config;
45
- return query;
46
- };
47
-
48
- interface Subscription {
49
- type: "collection" | "single";
50
- /**
51
- * Carries `authContext`. There is deliberately no second copy on this
52
- * object: every fetch reads `config.authContext`, and the one that used to
53
- * live here was written from three places and read from none — so a
54
- * subscription that looked authorized was re-fetched as nobody.
55
- */
56
- config: (CollectionSubscriptionConfig | SingleSubscriptionConfig) & { authContext?: SubscriptionAuthContext };
57
- changeStream?: ChangeStream;
58
- callback?: (data: any) => void;
59
- /**
60
- * How many deliveries have been started for this subscription, and the
61
- * highest that has already reached the callback.
62
- *
63
- * Every delivery here is a re-fetch, and three independent things start one
64
- * for the same subscription: the initial fetch, the change stream, and
65
- * `notifyUpdate` after a save. They overlap, and a fetch that started
66
- * earlier can finish later — at which point the callback replaces the
67
- * client's whole list with the state before the change. Nothing corrects it
68
- * until something else happens to that collection.
69
- *
70
- * A counter taken before the await and checked after it is what makes the
71
- * last *started* delivery the last *delivered* one.
72
- */
73
- started: number;
74
- delivered: number;
75
- }
76
-
77
- /**
78
- * MongoDB Realtime Service
79
- *
80
- * Implements real-time subscriptions using MongoDB Change Streams.
81
- * Requires MongoDB replica set for change streams to work.
82
- */
83
- export class MongoRealtimeService implements RealtimeProvider {
84
- private subscriptions = new Map<string, Subscription>();
85
- private clients = new Map<string, WebSocket>();
86
- private driver?: MongoDriver;
87
-
88
- constructor(private db: Db) {}
89
-
90
- setDataDriver(driver: MongoDriver) {
91
- this.driver = driver;
92
- }
93
-
94
- /**
95
- * Get the collection name from a path
96
- */
97
- private getCollectionName(path: string): string {
98
- return path.replace(/\//g, "_");
99
- }
100
-
101
- /**
102
- * Claim a delivery slot for a subscription, before doing the work.
103
- *
104
- * Returns the check to run immediately before calling the callback. It
105
- * refuses in three cases, all of which used to deliver:
106
- *
107
- * - **Out of order.** A newer fetch has already delivered, so this one is
108
- * stale — the client would go back to the state before the change.
109
- * - **Unsubscribed.** The subscription was cancelled while the fetch was in
110
- * flight, and its callback belongs to a client that stopped listening.
111
- * - **Re-subscribed.** `subscribeToCollection` unsubscribes first, so the
112
- * same id can name a *different* subscription by the time a fetch lands —
113
- * with a different filter, and a different caller's rows.
114
- *
115
- * Synchronous deliveries claim a slot too. A `delete` notification with no
116
- * fetch behind it is the newest thing known about the row, so it must also
117
- * be the thing that closes the door on an older fetch still in flight —
118
- * otherwise the deleted row reappears a moment after it vanished.
119
- */
120
- private beginDelivery(subscriptionId: string, subscription: Subscription): () => boolean {
121
- const seq = ++subscription.started;
122
- return () => {
123
- if (this.subscriptions.get(subscriptionId) !== subscription) return false;
124
- if (seq <= subscription.delivered) return false;
125
- subscription.delivered = seq;
126
- return true;
127
- };
128
- }
129
-
130
- /**
131
- * Subscribe to collection changes
132
- */
133
- subscribeToCollection(
134
- subscriptionId: string,
135
- config: CollectionSubscriptionConfig & { authContext?: SubscriptionAuthContext },
136
- callback?: (rows: Record<string, unknown>[]) => void
137
- ): void {
138
- // Clean up existing subscription if any
139
- this.unsubscribe(subscriptionId);
140
-
141
- const collectionName = this.getCollectionName(config.path);
142
- const collection = this.db.collection(collectionName);
143
-
144
- // Build pipeline for change stream filtering
145
- const pipeline: Document[] = [];
146
-
147
- // Filter by operation types we care about
148
- pipeline.push({
149
- $match: {
150
- operationType: { $in: ["insert", "update", "replace", "delete"] }
151
- }
152
- });
153
-
154
- try {
155
- // Create change stream
156
- const changeStream = collection.watch(pipeline, {
157
- fullDocument: "updateLookup"
158
- });
159
-
160
- const subscription: Subscription = {
161
- type: "collection",
162
- config,
163
- changeStream,
164
- callback,
165
- started: 0,
166
- delivered: 0
167
- };
168
-
169
- this.subscriptions.set(subscriptionId, subscription);
170
-
171
- // Fetch initial data
172
- this.fetchAndNotifyCollection(subscriptionId, subscription);
173
-
174
- // Listen for changes
175
- changeStream.on("change", async (change: ChangeStreamDocument) => {
176
- // Re-fetch the entire collection when any change happens
177
- // This is simpler and ensures consistent sorting/filtering
178
- await this.fetchAndNotifyCollection(subscriptionId, subscription);
179
- });
180
-
181
- changeStream.on("error", (error: Error) => {
182
- logger.error(`Change stream error for subscription ${subscriptionId}`, { error: error });
183
- });
184
-
185
- } catch (error) {
186
- // Change streams might not be available (e.g., standalone MongoDB)
187
- logger.warn("Change streams not available, falling back to polling", { error: error });
188
-
189
- // Store subscription without change stream for manual notifications
190
- const subscription: Subscription = {
191
- type: "collection",
192
- config,
193
- callback,
194
- started: 0,
195
- delivered: 0
196
- };
197
-
198
- this.subscriptions.set(subscriptionId, subscription);
199
-
200
- // Fetch initial data
201
- this.fetchAndNotifyCollection(subscriptionId, subscription);
202
- }
203
- }
204
-
205
- /**
206
- * Fetch collection and notify callback
207
- */
208
- private async fetchAndNotifyCollection(
209
- subscriptionId: string,
210
- subscription: Subscription
211
- ): Promise<void> {
212
- const config = subscription.config as CollectionSubscriptionConfig & { authContext?: SubscriptionAuthContext };
213
- const callback = subscription.callback;
214
- const canDeliver = this.beginDelivery(subscriptionId, subscription);
215
- try {
216
- const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
217
- // One path, authenticated or not. The `else` branch used to reach
218
- // past the driver into the repository, which applies no security
219
- // rules at all — the fallback stubbing out the contract the primary
220
- // branch honours, and granting more while doing it. An anonymous
221
- // subscriber is now a user like any other: rules are evaluated
222
- // against the anonymous uid, and a rule that needs a real one
223
- // matches nothing.
224
- const driver = await this.scopedDriver(config.authContext);
225
- // The stored config forwarded whole. Re-listing its fields here is
226
- // how `logical` and `offset` went missing a second time, one layer
227
- // below where they went missing the first time: the subscription
228
- // carried them and the re-fetch did not ask for them.
229
- const rows = await driver.fetchCollection({
230
- ...queryOf(config),
231
- filter: config.filter as FilterValues<string> | undefined,
232
- collection: registryCollection
233
- });
234
-
235
- if (callback && canDeliver()) {
236
- callback(rows);
237
- }
238
- } catch (error) {
239
- logger.error(`Error fetching collection for subscription ${subscriptionId}`, { error: error });
240
- }
241
- }
242
-
243
- /**
244
- * The driver scoped to a subscriber.
245
- *
246
- * Never the bare repository: everything a subscription delivers has to pass
247
- * the same row authorization an HTTP read does.
248
- */
249
- private async scopedDriver(authContext?: SubscriptionAuthContext): Promise<DataDriver> {
250
- if (!this.driver) {
251
- throw new Error("MongoRealtimeService has no data driver — subscriptions cannot be authorized");
252
- }
253
- const user = { uid: authContext?.uid ?? ANONYMOUS_USER_ID,
254
- roles: authContext?.roles ?? [] } as User;
255
- return this.driver.withAuth(user);
256
- }
257
-
258
- /**
259
- * Subscribe to single row changes
260
- */
261
- subscribeToOne(
262
- subscriptionId: string,
263
- config: SingleSubscriptionConfig & { authContext?: SubscriptionAuthContext },
264
- callback?: (row: Record<string, unknown> | null) => void
265
- ): void {
266
- // Clean up existing subscription if any
267
- this.unsubscribe(subscriptionId);
268
-
269
- const collectionName = this.getCollectionName(config.path);
270
- const collection = this.db.collection(collectionName);
271
-
272
- // Build pipeline to watch specific document
273
- const id = typeof config.id === "string" && ObjectId.isValid(config.id)
274
- ? new ObjectId(config.id)
275
- : config.id;
276
-
277
- const pipeline: Document[] = [
278
- {
279
- $match: {
280
- "documentKey._id": id,
281
- operationType: { $in: ["insert", "update", "replace", "delete"] }
282
- }
283
- }
284
- ];
285
-
286
- try {
287
- const changeStream = collection.watch(pipeline, {
288
- fullDocument: "updateLookup"
289
- });
290
-
291
- const subscription: Subscription = {
292
- type: "single",
293
- config,
294
- changeStream,
295
- callback,
296
- started: 0,
297
- delivered: 0
298
- };
299
-
300
- this.subscriptions.set(subscriptionId, subscription);
301
-
302
- // Fetch initial data
303
- this.fetchAndNotifyOne(subscriptionId, subscription);
304
-
305
- // Listen for changes
306
- changeStream.on("change", async (change: ChangeStreamDocument) => {
307
- if (change.operationType === "delete") {
308
- // Claims a slot like any other delivery: the deletion is the
309
- // newest fact about this row, so an older fetch still in
310
- // flight must not put it back.
311
- const canDeliver = this.beginDelivery(subscriptionId, subscription);
312
- if (callback && canDeliver()) {
313
- callback(null);
314
- }
315
- } else {
316
- await this.fetchAndNotifyOne(subscriptionId, subscription);
317
- }
318
- });
319
-
320
- changeStream.on("error", (error: Error) => {
321
- logger.error(`Change stream error for subscription ${subscriptionId}`, { error: error });
322
- });
323
-
324
- } catch (error) {
325
- logger.warn("Change streams not available, falling back to polling", { error: error });
326
-
327
- const subscription: Subscription = {
328
- type: "single",
329
- config,
330
- callback,
331
- started: 0,
332
- delivered: 0
333
- };
334
-
335
- this.subscriptions.set(subscriptionId, subscription);
336
-
337
- // Fetch initial data
338
- this.fetchAndNotifyOne(subscriptionId, subscription);
339
- }
340
- }
341
-
342
- /**
343
- * Fetch row and notify callback
344
- */
345
- private async fetchAndNotifyOne(
346
- subscriptionId: string,
347
- subscription: Subscription
348
- ): Promise<void> {
349
- const config = subscription.config as SingleSubscriptionConfig & { authContext?: SubscriptionAuthContext };
350
- const callback = subscription.callback;
351
- const canDeliver = this.beginDelivery(subscriptionId, subscription);
352
- try {
353
- const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
354
- const driver = await this.scopedDriver(config.authContext);
355
- const row = await driver.fetchOne({
356
- path: config.path,
357
- id: config.id,
358
- collection: registryCollection
359
- });
360
-
361
- if (callback && canDeliver()) {
362
- callback(row || null);
363
- }
364
- } catch (error) {
365
- logger.error(`Error fetching row for subscription ${subscriptionId}`, { error: error });
366
- }
367
- }
368
-
369
- /**
370
- * Unsubscribe from a subscription
371
- */
372
- unsubscribe(subscriptionId: string): void {
373
- const subscription = this.subscriptions.get(subscriptionId);
374
- if (subscription) {
375
- if (subscription.changeStream) {
376
- subscription.changeStream.close().catch((err) => logger.error("Operation failed", { error: err }));
377
- }
378
- this.subscriptions.delete(subscriptionId);
379
- }
380
- }
381
-
382
- /**
383
- * Notify all relevant subscribers of an row update
384
- * This is called after save/delete operations to push updates
385
- */
386
- async notifyUpdate(
387
- path: string,
388
- id: string,
389
- row: Record<string, unknown> | null,
390
- _databaseId?: string
391
- ): Promise<void> {
392
- // Find all subscriptions that might be affected by this update
393
- for (const [subscriptionId, subscription] of this.subscriptions) {
394
- if (subscription.type === "single") {
395
- const config = subscription.config as SingleSubscriptionConfig & { authContext?: SubscriptionAuthContext };
396
- if (config.path === path && config.id.toString() === id) {
397
- if (row === null) {
398
- // A deletion carries no row to authorize — but it still
399
- // claims a delivery slot, so a re-fetch already in
400
- // flight cannot land after it and resurrect the row.
401
- const canDeliver = this.beginDelivery(subscriptionId, subscription);
402
- if (canDeliver()) subscription.callback?.(null);
403
- } else {
404
- // Re-fetched through the subscriber's own driver rather
405
- // than pushed verbatim: `notifyUpdate` runs after every
406
- // save, and handing it the row as written broadcast any
407
- // document to whoever happened to be watching its id.
408
- await this.fetchAndNotifyOne(subscriptionId, subscription);
409
- }
410
- }
411
- } else if (subscription.type === "collection") {
412
- const config = subscription.config as CollectionSubscriptionConfig & { authContext?: SubscriptionAuthContext };
413
- if (config.path === path) {
414
- // Re-fetch the collection to get updated data
415
- await this.fetchAndNotifyCollection(subscriptionId, subscription);
416
- }
417
- }
418
- }
419
- }
420
-
421
- /**
422
- * Get all active subscriptions (for debugging)
423
- */
424
- getSubscriptions(): Map<string, Subscription> {
425
- return this.subscriptions;
426
- }
427
-
428
- /**
429
- * Close all subscriptions
430
- */
431
- async closeAll(): Promise<void> {
432
- for (const [subscriptionId] of this.subscriptions) {
433
- this.unsubscribe(subscriptionId);
434
- }
435
- }
436
-
437
- // =============================================================================
438
- // WebSocket Client Management (parity with PostgreSQL RealtimeService)
439
- // =============================================================================
440
-
441
- /**
442
- * Register a WebSocket client for real-time communication
443
- */
444
- addClient(clientId: string, ws: WebSocket) {
445
- this.clients.set(clientId, ws);
446
-
447
- ws.on("close", () => {
448
- this.removeClient(clientId);
449
- });
450
-
451
- ws.on("error", (error) => {
452
- logger.error("WebSocket error for client", { detail: clientId, error });
453
- this.removeClient(clientId);
454
- });
455
- }
456
-
457
- /**
458
- * Remove a WebSocket client and clean up its subscriptions
459
- */
460
- private removeClient(clientId: string) {
461
- this.clients.delete(clientId);
462
- }
463
-
464
- /**
465
- * Handle an incoming WebSocket message for subscription management
466
- */
467
- async handleClientMessage(
468
- clientId: string,
469
- message: { type: string; payload?: any; subscriptionId?: string },
470
- _authContext?: { uid: string; roles: unknown[] }
471
- ): Promise<void> {
472
- const ws = this.clients.get(clientId);
473
- if (!ws) return;
474
-
475
- const authContext = _authContext ? { uid: _authContext.uid,
476
- roles: (_authContext.roles ?? []).map(String) } : undefined;
477
-
478
- switch (message.type) {
479
- case "subscribe_collection": {
480
- const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
481
- if (!subscriptionId) return;
482
-
483
- // The same list bound the Postgres socket and every REST route
484
- // apply. This ingress applied none: an absent limit reached the
485
- // driver as `undefined` and emitted no `.limit()` at all, so one
486
- // subscribe frame streamed the whole collection — and re-streamed
487
- // it on every matching write. An over-large limit is refused
488
- // rather than shrunk, because a `collection_update` frame carries
489
- // no `total` or `hasMore` for the client to notice with.
490
- let boundedLimit: number;
491
- try {
492
- boundedLimit = resolveClientListLimit(message.payload?.limit);
493
- } catch (e) {
494
- if (!(e instanceof ListLimitError)) throw e;
495
- logger.warn(`⚠️ [MongoRealtime] Refused subscription to '${message.payload?.path}': ${e.message}`);
496
- ws.send(JSON.stringify({
497
- type: "ERROR",
498
- subscriptionId,
499
- payload: { error: { message: e.message, code: "INVALID_LIMIT" } },
500
- error: e.message
501
- }));
502
- return;
503
- }
504
-
505
- this.subscribeToCollection(
506
- subscriptionId,
507
- {
508
- clientId,
509
- path: message.payload?.path,
510
- filter: message.payload?.filter,
511
- // `logical` and `offset` were absent from this list, so
512
- // an `or(...)` subscription was pushed every row the
513
- // caller's policies allowed and a subscription to page
514
- // two was pushed page one. The client has been sending
515
- // both since it stopped stringifying `offset` into
516
- // `startAfter`; nothing here read them.
517
- logical: message.payload?.logical,
518
- offset: message.payload?.offset,
519
- orderBy: message.payload?.orderBy,
520
- order: message.payload?.order,
521
- limit: boundedLimit,
522
- startAfter: message.payload?.startAfter,
523
- searchString: message.payload?.searchString,
524
- searchExplain: message.payload?.searchExplain,
525
- authContext
526
- },
527
- (rows) => {
528
- ws.send(JSON.stringify({
529
- type: "collection_update",
530
- subscriptionId,
531
- rows
532
- }));
533
- }
534
- );
535
- break;
536
- }
537
- case "subscribe_one": {
538
- const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
539
- if (!subscriptionId) return;
540
-
541
- this.subscribeToOne(
542
- subscriptionId,
543
- {
544
- clientId,
545
- path: message.payload?.path,
546
- id: message.payload?.id,
547
- authContext
548
- },
549
- (row) => {
550
- ws.send(JSON.stringify({
551
- type: "single_update",
552
- subscriptionId,
553
- row
554
- }));
555
- }
556
- );
557
- break;
558
- }
559
- case "unsubscribe": {
560
- const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
561
- if (subscriptionId) {
562
- this.unsubscribe(subscriptionId);
563
- }
564
- break;
565
- }
566
- default: {
567
- // A silent `switch` over a wire protocol is how channel,
568
- // presence and broadcast frames came to be accepted here and
569
- // dropped: the client's `broadcast()` resolved, `onPresence`
570
- // never fired, and a `channel_history` request buffered live
571
- // messages until the catch-up timeout on every join. Say so,
572
- // and tell the sender rather than leaving it waiting.
573
- logger.warn(
574
- `⚠️ [MongoRealtime] Unhandled realtime message type "${message.type}" — ` +
575
- "channels, presence and broadcast are not implemented by the Mongo driver."
576
- );
577
- ws.send(JSON.stringify({
578
- type: "ERROR",
579
- subscriptionId: message.subscriptionId,
580
- payload: {
581
- error: {
582
- message: `Realtime message type "${message.type}" is not supported by the Mongo driver`,
583
- code: "REALTIME_UNSUPPORTED"
584
- }
585
- },
586
- error: `Realtime message type "${message.type}" is not supported by the Mongo driver`
587
- }));
588
- break;
589
- }
590
- }
591
- }
592
- }