@tmlmobilidade/controllers 20260203.1103.48 → 20260203.1129.22

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.
@@ -3,6 +3,7 @@ import { HttpStatus } from '@tmlmobilidade/consts';
3
3
  import { rides, ridesBatchAggregationPipeline } from '@tmlmobilidade/interfaces';
4
4
  import { normalizeRide } from '@tmlmobilidade/normalizers';
5
5
  import { GetRidesBatchQuerySchema, PermissionCatalog } from '@tmlmobilidade/types';
6
+ import { ridesChangeStream } from './watch.js';
6
7
  /* * */
7
8
  export class RidesSharedController {
8
9
  //
@@ -108,30 +109,19 @@ export class RidesSharedController {
108
109
  socket.on('message', async () => {
109
110
  //
110
111
  //
111
- // Connect to and prepare Rides database collection.
112
- const ridesCollection = await rides.getCollection();
113
- //
114
- // Start a watch service for the database
115
- // and send updates to the client as they occur.
116
- const changeStream = ridesCollection
117
- .watch([], { fullDocument: 'updateLookup' })
118
- .on('change', (databaseOperation) => {
119
- if (typeof databaseOperation['fullDocument'] === 'undefined') {
120
- console.log('Undefined document:', databaseOperation);
121
- return;
122
- }
123
- const normalizedRide = normalizeRide(databaseOperation['fullDocument']);
124
- const message = {
125
- data: normalizedRide,
126
- error: null,
127
- statusCode: HttpStatus.OK,
128
- };
112
+ // Create a listener that sends updates to this WebSocket client
113
+ const listener = (message) => {
129
114
  if (socket.readyState === socket.OPEN && socket.bufferedAmount < 1_000_000) {
130
115
  socket.send(JSON.stringify(message));
131
116
  }
132
- });
117
+ };
118
+ //
119
+ // Subscribe to the singleton change stream
120
+ ridesChangeStream.subscribe(listener);
121
+ //
122
+ // Cleanup: unsubscribe when socket closes or errors
133
123
  const cleanup = async () => {
134
- await changeStream.close();
124
+ ridesChangeStream.unsubscribe(listener);
135
125
  };
136
126
  socket.on('close', cleanup);
137
127
  socket.on('error', cleanup);
@@ -0,0 +1,56 @@
1
+ import { RideNormalized } from '@tmlmobilidade/types';
2
+ import { HttpResponse } from '@tmlmobilidade/utils';
3
+ /**
4
+ * A listener function for ride changes.
5
+ * Receives normalized ride data wrapped in an HTTP response format.
6
+ */
7
+ export type RideChangeListener = (message: HttpResponse<RideNormalized>) => void;
8
+ /**
9
+ * Singleton manager for MongoDB change streams with pub/sub capabilities.
10
+ *
11
+ * This class creates a single MongoDB change stream that watches for ride updates
12
+ * and broadcasts them to multiple subscribers using an in-memory EventEmitter.
13
+ *
14
+ * The singleton pattern ensures only one change stream is active, regardless of
15
+ * how many WebSocket clients are connected.
16
+ */
17
+ declare class RidesChangeStreamManager {
18
+ private static instance;
19
+ /**
20
+ * In-memory pub/sub event emitter. (@see https://nodejs.org/api/events.html#class-eventemitter)
21
+ *
22
+ * This EventEmitter acts as the pub/sub broker between the MongoDB change stream
23
+ * and WebSocket clients:
24
+ * - The MongoDB change stream publishes to this emitter when rides change
25
+ * - WebSocket clients subscribe to this emitter to receive updates
26
+ * - When unsubscribing, clients are removed from the emitter's listener list
27
+ *
28
+ * Setting maxListeners to 0 allows unlimited subscribers without warnings,
29
+ * which is necessary since we may have many concurrent WebSocket connections.
30
+ */
31
+ private emitter;
32
+ private initialized;
33
+ /**
34
+ * Private constructor enforces singleton pattern.
35
+ * Use `getInstance()` to access the instance.
36
+ */
37
+ private constructor();
38
+ static getInstance(): Promise<RidesChangeStreamManager>;
39
+ subscribe(listener: RideChangeListener): void;
40
+ unsubscribe(listener: RideChangeListener): void;
41
+ /**
42
+ * Initializes the MongoDB change stream.
43
+ *
44
+ * This is called once during getInstance() to set up the change stream.
45
+ * The stream watches all operations on the rides collection and publishes
46
+ * changes to the EventEmitter.
47
+ *
48
+ * Flow:
49
+ * 1. MongoDB detects a change → 2. Change stream emits 'change' event →
50
+ * 3. Normalizes the ride data → 4. Publishes to EventEmitter →
51
+ * 5. All subscribed listeners receive the update
52
+ */
53
+ private init;
54
+ }
55
+ export declare const ridesChangeStream: RidesChangeStreamManager;
56
+ export {};
@@ -0,0 +1,113 @@
1
+ /* * */
2
+ /**
3
+ * Rides Change Stream Manager
4
+ *
5
+ * This module implements a singleton pattern for MongoDB change streams with in-memory pub/sub:
6
+ *
7
+ * Architecture:
8
+ * ```
9
+ * MongoDB Change Stream (1 singleton)
10
+ * ↓
11
+ * EventEmitter pub/sub
12
+ * ↓
13
+ * WebSocket clients
14
+ * ```
15
+ *
16
+ * Instead of creating a MongoDB change stream per WebSocket connection (inefficient),
17
+ * this creates a single change stream that publishes to an in-memory EventEmitter.
18
+ * Multiple WebSocket clients can subscribe/unsubscribe to receive real-time updates.
19
+ *
20
+ * Benefits:
21
+ * - Single MongoDB change stream regardless of number of clients
22
+ * - Lazy initialization - stream only starts when first client connects through "AsyncSingletonProxy"
23
+ * - Clean subscription management per client
24
+ * - Reduced database load and network overhead
25
+ */
26
+ import { HttpStatus } from '@tmlmobilidade/consts';
27
+ import { rides } from '@tmlmobilidade/interfaces';
28
+ import { normalizeRide } from '@tmlmobilidade/normalizers';
29
+ import { AsyncSingletonProxy } from '@tmlmobilidade/utils';
30
+ import EventEmitter from 'events';
31
+ /**
32
+ * Singleton manager for MongoDB change streams with pub/sub capabilities.
33
+ *
34
+ * This class creates a single MongoDB change stream that watches for ride updates
35
+ * and broadcasts them to multiple subscribers using an in-memory EventEmitter.
36
+ *
37
+ * The singleton pattern ensures only one change stream is active, regardless of
38
+ * how many WebSocket clients are connected.
39
+ */
40
+ class RidesChangeStreamManager {
41
+ //
42
+ static instance = null;
43
+ /**
44
+ * In-memory pub/sub event emitter. (@see https://nodejs.org/api/events.html#class-eventemitter)
45
+ *
46
+ * This EventEmitter acts as the pub/sub broker between the MongoDB change stream
47
+ * and WebSocket clients:
48
+ * - The MongoDB change stream publishes to this emitter when rides change
49
+ * - WebSocket clients subscribe to this emitter to receive updates
50
+ * - When unsubscribing, clients are removed from the emitter's listener list
51
+ *
52
+ * Setting maxListeners to 0 allows unlimited subscribers without warnings,
53
+ * which is necessary since we may have many concurrent WebSocket connections.
54
+ */
55
+ emitter = new EventEmitter();
56
+ initialized = false;
57
+ /**
58
+ * Private constructor enforces singleton pattern.
59
+ * Use `getInstance()` to access the instance.
60
+ */
61
+ constructor() {
62
+ this.emitter.setMaxListeners(0); // Allow unlimited listeners
63
+ }
64
+ static async getInstance() {
65
+ if (!RidesChangeStreamManager.instance) {
66
+ RidesChangeStreamManager.instance = new RidesChangeStreamManager();
67
+ await RidesChangeStreamManager.instance.init();
68
+ }
69
+ return RidesChangeStreamManager.instance;
70
+ }
71
+ subscribe(listener) {
72
+ this.emitter.on('change', listener);
73
+ }
74
+ unsubscribe(listener) {
75
+ this.emitter.off('change', listener);
76
+ }
77
+ /**
78
+ * Initializes the MongoDB change stream.
79
+ *
80
+ * This is called once during getInstance() to set up the change stream.
81
+ * The stream watches all operations on the rides collection and publishes
82
+ * changes to the EventEmitter.
83
+ *
84
+ * Flow:
85
+ * 1. MongoDB detects a change → 2. Change stream emits 'change' event →
86
+ * 3. Normalizes the ride data → 4. Publishes to EventEmitter →
87
+ * 5. All subscribed listeners receive the update
88
+ */
89
+ async init() {
90
+ if (this.initialized)
91
+ return;
92
+ const ridesCollection = await rides.getCollection();
93
+ // Watch all operations with full document updates
94
+ ridesCollection
95
+ .watch([], { fullDocument: 'updateLookup' })
96
+ .on('change', (databaseOperation) => {
97
+ if (typeof databaseOperation['fullDocument'] === 'undefined') {
98
+ console.log('Undefined document:', databaseOperation);
99
+ return;
100
+ }
101
+ const normalizedRide = normalizeRide(databaseOperation['fullDocument']);
102
+ const message = {
103
+ data: normalizedRide,
104
+ error: null,
105
+ statusCode: HttpStatus.OK,
106
+ };
107
+ // Publish to all subscribers via EventEmitter
108
+ this.emitter.emit('change', message);
109
+ });
110
+ this.initialized = true;
111
+ }
112
+ }
113
+ export const ridesChangeStream = AsyncSingletonProxy(RidesChangeStreamManager);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmlmobilidade/controllers",
3
- "version": "20260203.1103.48",
3
+ "version": "20260203.1129.22",
4
4
  "author": {
5
5
  "email": "iso@tmlmobilidade.pt",
6
6
  "name": "TML-ISO"