@terreno/api 0.13.3 → 0.14.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 (172) hide show
  1. package/dist/__tests__/versionCheckPlugin.test.js +136 -3
  2. package/dist/api.arrayOperations.test.js +1 -0
  3. package/dist/api.d.ts +15 -4
  4. package/dist/api.errors.test.js +1 -0
  5. package/dist/api.hooks.test.js +1 -0
  6. package/dist/api.js +153 -104
  7. package/dist/api.query.test.js +1 -0
  8. package/dist/api.test.js +174 -0
  9. package/dist/auth.d.ts +10 -5
  10. package/dist/auth.js +163 -90
  11. package/dist/auth.test.js +159 -0
  12. package/dist/betterAuthApp.test.js +1 -0
  13. package/dist/betterAuthSetup.d.ts +5 -6
  14. package/dist/betterAuthSetup.js +30 -17
  15. package/dist/betterAuthSetup.test.js +1 -0
  16. package/dist/config.d.ts +48 -0
  17. package/dist/config.js +257 -0
  18. package/dist/config.test.d.ts +1 -0
  19. package/dist/config.test.js +328 -0
  20. package/dist/configuration.test.js +1 -0
  21. package/dist/configurationApp.d.ts +1 -1
  22. package/dist/configurationApp.js +17 -13
  23. package/dist/configurationPlugin.test.js +1 -0
  24. package/dist/consentApp.test.js +1 -0
  25. package/dist/envConfigurationPlugin.d.ts +2 -0
  26. package/dist/envConfigurationPlugin.js +173 -0
  27. package/dist/envConfigurationPlugin.test.d.ts +1 -0
  28. package/dist/envConfigurationPlugin.test.js +322 -0
  29. package/dist/errors.d.ts +18 -7
  30. package/dist/errors.js +111 -12
  31. package/dist/errors.test.js +16 -1
  32. package/dist/example.js +19 -7
  33. package/dist/expressServer.d.ts +10 -9
  34. package/dist/expressServer.js +62 -53
  35. package/dist/expressServer.test.js +165 -2
  36. package/dist/githubAuth.d.ts +2 -1
  37. package/dist/githubAuth.js +41 -26
  38. package/dist/githubAuth.test.js +1 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.js +4 -0
  41. package/dist/logger.d.ts +1 -1
  42. package/dist/logger.js +42 -20
  43. package/dist/models/versionConfig.d.ts +2 -0
  44. package/dist/models/versionConfig.js +8 -0
  45. package/dist/notifiers/googleChatNotifier.js +14 -16
  46. package/dist/notifiers/googleChatNotifier.test.js +1 -0
  47. package/dist/notifiers/slackNotifier.js +16 -14
  48. package/dist/notifiers/slackNotifier.test.js +41 -3
  49. package/dist/notifiers/zoomNotifier.js +7 -10
  50. package/dist/notifiers/zoomNotifier.test.js +1 -0
  51. package/dist/openApi.d.ts +1 -1
  52. package/dist/openApi.test.js +1 -0
  53. package/dist/openApiBuilder.d.ts +39 -6
  54. package/dist/openApiBuilder.js +1 -31
  55. package/dist/openApiBuilder.test.js +1 -0
  56. package/dist/openApiValidator.js +1 -0
  57. package/dist/openApiValidator.test.js +1 -0
  58. package/dist/permissions.d.ts +4 -4
  59. package/dist/permissions.js +67 -65
  60. package/dist/permissions.middleware.test.js +1 -0
  61. package/dist/permissions.test.js +1 -0
  62. package/dist/plugins.d.ts +5 -5
  63. package/dist/plugins.js +18 -9
  64. package/dist/plugins.test.js +1 -1
  65. package/dist/populate.d.ts +15 -8
  66. package/dist/populate.js +23 -24
  67. package/dist/populate.test.js +1 -0
  68. package/dist/realtime/changeStreamWatcher.d.ts +73 -0
  69. package/dist/realtime/changeStreamWatcher.js +724 -0
  70. package/dist/realtime/index.d.ts +6 -0
  71. package/dist/realtime/index.js +27 -0
  72. package/dist/realtime/queryMatcher.d.ts +14 -0
  73. package/dist/realtime/queryMatcher.js +250 -0
  74. package/dist/realtime/queryStore.d.ts +37 -0
  75. package/dist/realtime/queryStore.js +195 -0
  76. package/dist/realtime/realtime.test.d.ts +10 -0
  77. package/dist/realtime/realtime.test.js +3066 -0
  78. package/dist/realtime/realtimeApp.d.ts +93 -0
  79. package/dist/realtime/realtimeApp.js +560 -0
  80. package/dist/realtime/registry.d.ts +40 -0
  81. package/dist/realtime/registry.js +38 -0
  82. package/dist/realtime/socketUser.d.ts +10 -0
  83. package/dist/realtime/socketUser.js +17 -0
  84. package/dist/realtime/types.d.ts +100 -0
  85. package/dist/realtime/types.js +2 -0
  86. package/dist/requestContext.d.ts +37 -0
  87. package/dist/requestContext.js +344 -0
  88. package/dist/requestContext.test.d.ts +1 -0
  89. package/dist/requestContext.test.js +384 -0
  90. package/dist/terrenoApp.d.ts +8 -0
  91. package/dist/terrenoApp.js +50 -13
  92. package/dist/terrenoApp.test.js +194 -21
  93. package/dist/terrenoPlugin.d.ts +11 -0
  94. package/dist/tests/bunSetup.js +1 -0
  95. package/dist/tests.js +1 -1
  96. package/dist/transformers.d.ts +2 -2
  97. package/dist/transformers.js +5 -3
  98. package/dist/transformers.test.js +90 -0
  99. package/dist/types/consentResponse.d.ts +6 -3
  100. package/dist/versionCheckPlugin.d.ts +2 -0
  101. package/dist/versionCheckPlugin.js +18 -12
  102. package/package.json +4 -2
  103. package/src/__tests__/versionCheckPlugin.test.ts +94 -3
  104. package/src/api.arrayOperations.test.ts +1 -0
  105. package/src/api.errors.test.ts +1 -0
  106. package/src/api.hooks.test.ts +1 -0
  107. package/src/api.query.test.ts +1 -0
  108. package/src/api.test.ts +132 -0
  109. package/src/api.ts +199 -84
  110. package/src/auth.test.ts +160 -0
  111. package/src/auth.ts +120 -50
  112. package/src/betterAuthApp.test.ts +1 -0
  113. package/src/betterAuthSetup.test.ts +1 -0
  114. package/src/betterAuthSetup.ts +59 -22
  115. package/src/config.test.ts +255 -0
  116. package/src/config.ts +216 -0
  117. package/src/configuration.test.ts +1 -0
  118. package/src/configurationApp.ts +59 -24
  119. package/src/configurationPlugin.test.ts +1 -0
  120. package/src/consentApp.test.ts +1 -0
  121. package/src/envConfigurationPlugin.test.ts +143 -0
  122. package/src/envConfigurationPlugin.ts +100 -0
  123. package/src/errors.test.ts +19 -1
  124. package/src/errors.ts +118 -38
  125. package/src/example.ts +49 -21
  126. package/src/express.d.ts +18 -1
  127. package/src/expressServer.test.ts +147 -2
  128. package/src/expressServer.ts +80 -50
  129. package/src/githubAuth.test.ts +1 -0
  130. package/src/githubAuth.ts +59 -38
  131. package/src/index.ts +4 -0
  132. package/src/logger.ts +47 -17
  133. package/src/models/versionConfig.ts +13 -2
  134. package/src/notifiers/googleChatNotifier.test.ts +1 -0
  135. package/src/notifiers/googleChatNotifier.ts +7 -9
  136. package/src/notifiers/slackNotifier.test.ts +29 -3
  137. package/src/notifiers/slackNotifier.ts +9 -7
  138. package/src/notifiers/zoomNotifier.test.ts +1 -0
  139. package/src/notifiers/zoomNotifier.ts +8 -11
  140. package/src/openApi.test.ts +1 -0
  141. package/src/openApi.ts +4 -4
  142. package/src/openApiBuilder.test.ts +1 -0
  143. package/src/openApiBuilder.ts +14 -11
  144. package/src/openApiValidator.test.ts +1 -0
  145. package/src/openApiValidator.ts +3 -2
  146. package/src/permissions.middleware.test.ts +1 -0
  147. package/src/permissions.test.ts +1 -0
  148. package/src/permissions.ts +30 -25
  149. package/src/plugins.test.ts +1 -1
  150. package/src/plugins.ts +21 -14
  151. package/src/populate.test.ts +1 -0
  152. package/src/populate.ts +44 -36
  153. package/src/realtime/changeStreamWatcher.ts +572 -0
  154. package/src/realtime/index.ts +34 -0
  155. package/src/realtime/queryMatcher.ts +179 -0
  156. package/src/realtime/queryStore.ts +132 -0
  157. package/src/realtime/realtime.test.ts +2465 -0
  158. package/src/realtime/realtimeApp.ts +478 -0
  159. package/src/realtime/registry.ts +64 -0
  160. package/src/realtime/socketUser.ts +25 -0
  161. package/src/realtime/types.ts +112 -0
  162. package/src/requestContext.test.ts +321 -0
  163. package/src/requestContext.ts +368 -0
  164. package/src/terrenoApp.test.ts +137 -11
  165. package/src/terrenoApp.ts +64 -17
  166. package/src/terrenoPlugin.ts +12 -0
  167. package/src/tests/bunSetup.ts +1 -0
  168. package/src/tests.ts +7 -2
  169. package/src/transformers.test.ts +70 -2
  170. package/src/transformers.ts +15 -7
  171. package/src/types/consentResponse.ts +8 -10
  172. package/src/versionCheckPlugin.ts +15 -7
@@ -0,0 +1,572 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: change stream and socket handlers use dynamic document shapes
2
+ import * as Sentry from "@sentry/bun";
3
+ import type express from "express";
4
+ import {DateTime} from "luxon";
5
+ import mongoose from "mongoose";
6
+ import type {Server, Socket} from "socket.io";
7
+
8
+ type ChangeStream = mongoose.mongo.ChangeStream;
9
+ type ChangeStreamDocument = mongoose.mongo.ChangeStreamDocument;
10
+ type ChangeStreamOptions = mongoose.mongo.ChangeStreamOptions;
11
+
12
+ /**
13
+ * The subset of ChangeStreamDocument variants this watcher actually processes.
14
+ * The pipeline filters for ["insert", "update", "replace", "delete"], so we never
15
+ * see drop / rename / invalidate / index events at runtime.
16
+ */
17
+ type WatchedChange = Extract<
18
+ ChangeStreamDocument,
19
+ {operationType: "insert" | "update" | "replace" | "delete"}
20
+ >;
21
+
22
+ import type {User} from "../auth";
23
+ import {APIError} from "../errors";
24
+ import {logger} from "../logger";
25
+ import {checkPermissions} from "../permissions";
26
+ import {matchesQuery} from "./queryMatcher";
27
+ import {getQuerySubscriptionsForCollection} from "./queryStore";
28
+ import {findRegistryEntryByCollection, type RealtimeRegistryEntry} from "./registry";
29
+ import {getSocketUser, type SocketWithDecodedToken} from "./socketUser";
30
+ import type {ChangeStreamConfig, RealtimeEvent} from "./types";
31
+
32
+ let changeWatcher: ChangeStream | null = null;
33
+
34
+ const DEFAULT_IGNORED_COLLECTIONS = ["socketio", "sessions"];
35
+
36
+ /**
37
+ * Map MongoDB change stream operation types to our method names.
38
+ *
39
+ * Soft deletes (an `update` that sets `deleted: true`) are reclassified as
40
+ * `"delete"` only when the model has `"delete"` enabled in its realtime
41
+ * methods. Otherwise they fall back to `"update"` so models that subscribe
42
+ * to updates (but not deletes) still see the change — without this fallback,
43
+ * a model configured with `methods: ["create", "update"]` would silently
44
+ * drop soft-delete events.
45
+ *
46
+ * Exported for testing.
47
+ */
48
+ export const mapOperationType = (
49
+ operationType: string,
50
+ change: ChangeStreamDocument,
51
+ enabledMethods: ReadonlyArray<"create" | "update" | "delete"> = ["create", "update", "delete"]
52
+ ): "create" | "update" | "delete" | null => {
53
+ if (operationType === "insert") {
54
+ return "create";
55
+ }
56
+ if (operationType === "update" || operationType === "replace") {
57
+ // Soft delete on an update event: the document was patched with deleted=true.
58
+ // `change` is typed as the full union (without operationType narrowing) because
59
+ // callers/tests pass change objects without setting `operationType` on the change.
60
+ const updateChange = change as Extract<ChangeStreamDocument, {operationType: "update"}>;
61
+ const isSoftDelete =
62
+ operationType === "update" && updateChange.updateDescription?.updatedFields?.deleted === true;
63
+ if (isSoftDelete && enabledMethods.includes("delete")) {
64
+ return "delete";
65
+ }
66
+ return "update";
67
+ }
68
+ if (operationType === "delete") {
69
+ return "delete";
70
+ }
71
+ return null;
72
+ };
73
+
74
+ /**
75
+ * Get the collection tag from a route path (strips leading "/").
76
+ * E.g. "/todos" -> "todos"
77
+ */
78
+ const getCollectionTag = (routePath: string): string => routePath.replace(/^\//, "");
79
+
80
+ type RealtimeSocketWithAuth = Socket & SocketWithDecodedToken;
81
+
82
+ const getSocketsInRoom = (io: Server, room: string): RealtimeSocketWithAuth[] => {
83
+ const socketIds = io.sockets.adapter.rooms.get(room);
84
+ if (!socketIds) {
85
+ return [];
86
+ }
87
+
88
+ const sockets: RealtimeSocketWithAuth[] = [];
89
+ for (const socketId of socketIds) {
90
+ const socket = io.sockets.sockets.get(socketId);
91
+ if (socket) {
92
+ sockets.push(socket as RealtimeSocketWithAuth);
93
+ }
94
+ }
95
+ return sockets;
96
+ };
97
+
98
+ const canReadDocument = async (
99
+ entry: RealtimeRegistryEntry,
100
+ user?: User,
101
+ doc?: any
102
+ ): Promise<boolean> => {
103
+ return checkPermissions("read", entry.options.permissions.read, user, doc);
104
+ };
105
+
106
+ /**
107
+ * Determine which Socket.io rooms to emit to based on the room strategy.
108
+ * Exported for testing.
109
+ */
110
+ export const resolveRooms = (entry: RealtimeRegistryEntry, doc: any, method: string): string[] => {
111
+ const {roomStrategy} = entry.config;
112
+ // Use the collection tag (e.g. "todos") for model rooms, matching what the frontend subscribes to
113
+ const collectionTag = getCollectionTag(entry.routePath);
114
+
115
+ if (typeof roomStrategy === "function") {
116
+ // Custom room resolver — pass a minimal pseudo-request. The strategy only inspects
117
+ // doc/method; we never read fields off req here, so an empty cast is safe.
118
+ return roomStrategy(doc, method, {} as unknown as express.Request);
119
+ }
120
+
121
+ switch (roomStrategy) {
122
+ case "owner": {
123
+ const ownerId = doc?.ownerId?.toString?.() ?? doc?.ownerId;
124
+ if (ownerId) {
125
+ return [`user:${ownerId}`];
126
+ }
127
+ // If no ownerId, fall back to model room
128
+ return [`model:${collectionTag}`];
129
+ }
130
+ case "model":
131
+ return [`model:${collectionTag}`];
132
+ case "broadcast":
133
+ return ["authenticated"];
134
+ default:
135
+ return [`model:${collectionTag}`];
136
+ }
137
+ };
138
+
139
+ /**
140
+ * Ensure serialized documents include `id` to match REST API responses.
141
+ * Change stream fullDocument payloads are raw BSON objects with `_id` only.
142
+ */
143
+ export const ensureApiId = (data: unknown): unknown => {
144
+ if (data == null || typeof data !== "object" || Array.isArray(data)) {
145
+ return data;
146
+ }
147
+ const serialized = data as Record<string, unknown>;
148
+ if (serialized._id != null && serialized.id == null) {
149
+ return {...serialized, id: serialized._id};
150
+ }
151
+ return data;
152
+ };
153
+
154
+ /**
155
+ * Serialize a document for emission.
156
+ *
157
+ * Precedence:
158
+ * 1. `realtimeResponseHandler` if provided (full control over what's emitted).
159
+ * 2. modelRouter `responseHandler` if provided — invoked with a synthetic request
160
+ * so the same stripping logic used for REST responses (e.g. removing `hash`/`salt`)
161
+ * applies to realtime events. This prevents accidental leaks when an app only
162
+ * configures sanitization in the REST `responseHandler`.
163
+ * 3. `toJSON()` fallback.
164
+ *
165
+ * If a user-supplied handler throws, we re-throw so the caller's outer try/catch
166
+ * records the failure and the event is dropped. Falling back to `toJSON()` here
167
+ * would risk leaking unsanitized fields (e.g. `hash`/`salt`) that the handler
168
+ * was supposed to strip.
169
+ */
170
+ export const serializeDoc = async (
171
+ entry: RealtimeRegistryEntry,
172
+ doc: any,
173
+ method: "create" | "update" | "delete",
174
+ user?: User
175
+ ): Promise<any> => {
176
+ if (entry.config.realtimeResponseHandler) {
177
+ try {
178
+ return ensureApiId(await entry.config.realtimeResponseHandler(doc, method));
179
+ } catch (error) {
180
+ logger.error(
181
+ `[realtime] realtimeResponseHandler threw for ${entry.modelName}/${method}: ${error}. ` +
182
+ "Dropping event to avoid leaking unsanitized data."
183
+ );
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ const responseHandler = entry.options?.responseHandler;
189
+ if (responseHandler) {
190
+ try {
191
+ // The REST responseHandler signature expects a "list" | "create" | "read" | "update" method.
192
+ // Map "delete" → "read" so handlers that branch on method receive a sane value.
193
+ const restMethod = method === "delete" ? "read" : method;
194
+ // Synthesize the minimal request shape responseHandlers commonly inspect.
195
+ const syntheticReq = {params: {}, query: {}, user} as unknown as express.Request;
196
+ return ensureApiId(await responseHandler(doc, restMethod, syntheticReq, entry.options));
197
+ } catch (error) {
198
+ logger.error(
199
+ `[realtime] modelRouter responseHandler threw during realtime serialization for ` +
200
+ `${entry.modelName}/${method}: ${error}. Dropping event to avoid leaking unsanitized data.`
201
+ );
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ return ensureApiId(typeof doc.toJSON === "function" ? doc.toJSON() : doc);
207
+ };
208
+
209
+ export const emitToAuthorizedRoom = async (
210
+ io: Server,
211
+ room: string,
212
+ event: RealtimeEvent,
213
+ entry: RealtimeRegistryEntry,
214
+ fullDocument: any,
215
+ logDebug: (msg: string) => void
216
+ ): Promise<void> => {
217
+ const sockets = getSocketsInRoom(io, room);
218
+ for (const socket of sockets) {
219
+ const user = getSocketUser(socket);
220
+ try {
221
+ // Hard deletes have no document context; use an empty object so object-scoped
222
+ // permission helpers fail closed instead of treating the check as preflight.
223
+ const permissionDocument = fullDocument ?? {};
224
+ const canRead = await canReadDocument(entry, user, permissionDocument);
225
+ if (!canRead) {
226
+ logDebug(`[realtime] Skipped ${room} for ${socket.id}: read permission denied`);
227
+ continue;
228
+ }
229
+
230
+ if (!fullDocument) {
231
+ socket.emit("sync", event);
232
+ continue;
233
+ }
234
+
235
+ const data = await serializeDoc(entry, fullDocument, event.method, user);
236
+ socket.emit("sync", {...event, data});
237
+ } catch (error) {
238
+ logger.error(
239
+ `[realtime] Failed to emit ${entry.modelName}/${event.method} to socket ${socket.id}: ${error}`
240
+ );
241
+ Sentry.captureException(error);
242
+ }
243
+ }
244
+ };
245
+
246
+ /**
247
+ * Emit a sync event to document-specific and query rooms.
248
+ *
249
+ * Document rooms: `document:{collection}:{docId}` — clients subscribed to a single document.
250
+ * Query rooms: `query:{queryId}` — clients subscribed to a query filter. The change stream
251
+ * watcher evaluates whether the document matches each active query for the collection.
252
+ *
253
+ * For deletes, we are careful not to leak cross-user activity:
254
+ * - Soft deletes (fullDocument present) are matched against each query like updates so
255
+ * query subscribers only see deletes for docs that matched their filter.
256
+ * - Hard deletes (fullDocument absent) on owner-strategy collections are NOT forwarded
257
+ * to query rooms — subscribers will reconcile on their next fetch. Other strategies
258
+ * forward the delete because the model/broadcast rooms are not user-scoped.
259
+ *
260
+ * Exported for testing.
261
+ */
262
+ export const emitToDocumentAndQueryRooms = async (
263
+ io: Server,
264
+ collection: string,
265
+ event: RealtimeEvent,
266
+ fullDocument: any,
267
+ logDebug: (msg: string) => void,
268
+ entry?: RealtimeRegistryEntry
269
+ ): Promise<void> => {
270
+ // Emit to document-specific room
271
+ const docRoom = `document:${collection}:${event.id}`;
272
+ if (entry) {
273
+ await emitToAuthorizedRoom(io, docRoom, event, entry, fullDocument, logDebug);
274
+ } else {
275
+ io.to(docRoom).emit("sync", event);
276
+ }
277
+ logDebug(`[realtime] Emitted ${event.method} to ${docRoom}`);
278
+
279
+ const isOwnerStrategy = entry?.config.roomStrategy === "owner";
280
+
281
+ // Evaluate query subscriptions
282
+ const querySubscriptions = getQuerySubscriptionsForCollection(collection);
283
+ for (const {queryId, query} of querySubscriptions) {
284
+ const queryRoom = `query:${queryId}`;
285
+
286
+ if (event.method === "delete") {
287
+ if (!fullDocument) {
288
+ // Hard delete with no document context. For owner-strategy collections we can't
289
+ // tell which query rooms belong to the owner without leaking activity to others,
290
+ // so skip query fanout entirely — subscribers will reconcile on next fetch.
291
+ if (isOwnerStrategy) {
292
+ logDebug(
293
+ `[realtime] Skipping hard delete fanout to ${queryRoom} (owner strategy, no fullDocument)`
294
+ );
295
+ continue;
296
+ }
297
+ if (entry) {
298
+ await emitToAuthorizedRoom(io, queryRoom, event, entry, fullDocument, logDebug);
299
+ } else {
300
+ io.to(queryRoom).emit("sync", event);
301
+ }
302
+ logDebug(`[realtime] Emitted hard delete to ${queryRoom}`);
303
+ continue;
304
+ }
305
+
306
+ // Soft delete: only forward to query rooms whose filter the document satisfies.
307
+ if (matchesQuery(fullDocument, query)) {
308
+ if (entry) {
309
+ await emitToAuthorizedRoom(io, queryRoom, event, entry, fullDocument, logDebug);
310
+ } else {
311
+ io.to(queryRoom).emit("sync", event);
312
+ }
313
+ logDebug(`[realtime] Emitted soft delete to ${queryRoom} (query matched)`);
314
+ }
315
+ continue;
316
+ }
317
+
318
+ if (!fullDocument) {
319
+ continue;
320
+ }
321
+
322
+ const docMatches = matchesQuery(fullDocument, query);
323
+
324
+ if (event.method === "create" && docMatches) {
325
+ // New document matches the query — send create event
326
+ if (entry) {
327
+ await emitToAuthorizedRoom(io, queryRoom, event, entry, fullDocument, logDebug);
328
+ } else {
329
+ io.to(queryRoom).emit("sync", event);
330
+ }
331
+ logDebug(`[realtime] Emitted create to ${queryRoom} (query matched)`);
332
+ } else if (event.method === "update") {
333
+ if (docMatches) {
334
+ // Document still matches (or newly matches) — send update event
335
+ if (entry) {
336
+ await emitToAuthorizedRoom(io, queryRoom, event, entry, fullDocument, logDebug);
337
+ } else {
338
+ io.to(queryRoom).emit("sync", event);
339
+ }
340
+ logDebug(`[realtime] Emitted update to ${queryRoom} (query matched)`);
341
+ } else {
342
+ // Document no longer matches the query — send delete event so client removes it
343
+ const removeEvent: RealtimeEvent = {
344
+ ...event,
345
+ method: "delete",
346
+ };
347
+ if (entry) {
348
+ await emitToAuthorizedRoom(io, queryRoom, removeEvent, entry, fullDocument, logDebug);
349
+ } else {
350
+ io.to(queryRoom).emit("sync", removeEvent);
351
+ }
352
+ logDebug(`[realtime] Emitted delete to ${queryRoom} (query no longer matched)`);
353
+ }
354
+ }
355
+ }
356
+ };
357
+
358
+ /**
359
+ * Start watching MongoDB change streams and emitting real-time events.
360
+ */
361
+ export const startChangeStreamWatcher = (
362
+ io: Server,
363
+ config: ChangeStreamConfig = {},
364
+ debug = false
365
+ ): void => {
366
+ const logInfo = (message: string): void => {
367
+ if (debug) {
368
+ logger.info(message);
369
+ }
370
+ };
371
+
372
+ const logDebug = (message: string): void => {
373
+ if (debug) {
374
+ logger.debug(message);
375
+ }
376
+ };
377
+
378
+ try {
379
+ logInfo("[realtime] Initializing change stream watcher...");
380
+
381
+ const ignored = new Set([...DEFAULT_IGNORED_COLLECTIONS, ...(config.ignoredCollections ?? [])]);
382
+
383
+ const ignoredOps = new Set(config.ignoredOperations ?? []);
384
+
385
+ // Build the change stream pipeline
386
+ const pipeline = [
387
+ {
388
+ $match: {
389
+ "ns.coll": {$nin: Array.from(ignored)},
390
+ operationType: {$in: ["insert", "update", "replace", "delete"]},
391
+ },
392
+ },
393
+ {
394
+ $project: {
395
+ documentKey: 1,
396
+ fullDocument: 1,
397
+ ns: 1,
398
+ operationType: 1,
399
+ updateDescription: 1,
400
+ },
401
+ },
402
+ ];
403
+
404
+ const nativeDb = mongoose.connection.db;
405
+ if (!nativeDb) {
406
+ throw new APIError({
407
+ status: 500,
408
+ title: "MongoDB connection not available for change stream",
409
+ });
410
+ }
411
+
412
+ const options: ChangeStreamOptions = {
413
+ batchSize: config.batchSize ?? 50,
414
+ fullDocument: config.fullDocument ?? "updateLookup",
415
+ fullDocumentBeforeChange: "off",
416
+ // How long the cursor waits for new events before yielding control.
417
+ // Lower values give more responsive updates at the cost of more frequent driver round-trips.
418
+ maxAwaitTimeMS: 1000,
419
+ };
420
+
421
+ changeWatcher = nativeDb.watch(pipeline, options);
422
+
423
+ if (!changeWatcher) {
424
+ throw new APIError({status: 500, title: "Failed to create change stream watcher"});
425
+ }
426
+
427
+ changeWatcher.on("change", async (rawChange: ChangeStreamDocument) => {
428
+ try {
429
+ // The pipeline restricts operationType to a subset that always has ns/documentKey;
430
+ // narrow once here so downstream code doesn't need repeated casts.
431
+ if (
432
+ rawChange.operationType !== "insert" &&
433
+ rawChange.operationType !== "update" &&
434
+ rawChange.operationType !== "replace" &&
435
+ rawChange.operationType !== "delete"
436
+ ) {
437
+ return;
438
+ }
439
+ const change = rawChange as WatchedChange;
440
+ const collectionName = change.ns?.coll;
441
+ const docId = change.documentKey?._id?.toString();
442
+
443
+ if (!collectionName || !docId) {
444
+ return;
445
+ }
446
+
447
+ // Check if this operation type is ignored
448
+ if (ignoredOps.has(change.operationType)) {
449
+ return;
450
+ }
451
+
452
+ // Find the registry entry for this collection
453
+ const entry = findRegistryEntryByCollection(collectionName);
454
+
455
+ if (!entry) {
456
+ // Not a registered realtime model — skip
457
+ logDebug(`[realtime] No registry entry for collection: ${collectionName}`);
458
+ return;
459
+ }
460
+
461
+ // Map to our method type. Pass enabledMethods so soft deletes only
462
+ // remap to "delete" when the model actually subscribes to deletes —
463
+ // otherwise the change is kept as "update" so update subscribers
464
+ // still receive it.
465
+ const method = mapOperationType(change.operationType, change, entry.config.methods);
466
+ if (!method) {
467
+ return;
468
+ }
469
+
470
+ // Check if this method is enabled for this model
471
+ if (!entry.config.methods.includes(method)) {
472
+ logDebug(`[realtime] Method ${method} not enabled for ${entry.modelName}`);
473
+ return;
474
+ }
475
+
476
+ // fullDocument is present on insert/update/replace; absent on delete.
477
+ const fullDocument = change.operationType === "delete" ? undefined : change.fullDocument;
478
+
479
+ // For hard deletes, we don't have the full document
480
+ const isHardDelete = method === "delete" && !fullDocument;
481
+
482
+ // Determine target rooms
483
+ let rooms: string[];
484
+ if (isHardDelete) {
485
+ // Hard delete: no fullDocument, so we can't resolve owner/custom rooms.
486
+ // For owner strategy we cannot safely fan out to a model room without
487
+ // leaking deletes across users — admins still receive the event via the
488
+ // admin room and any document-specific subscribers via document rooms.
489
+ if (entry.config.roomStrategy === "owner") {
490
+ rooms = ["admin"];
491
+ } else if (entry.config.roomStrategy === "broadcast") {
492
+ rooms = ["authenticated"];
493
+ } else {
494
+ const collectionTag = getCollectionTag(entry.routePath);
495
+ rooms = [`model:${collectionTag}`];
496
+ }
497
+ } else {
498
+ rooms = resolveRooms(entry, fullDocument, method);
499
+ }
500
+
501
+ const collection = getCollectionTag(entry.routePath);
502
+
503
+ const event: RealtimeEvent = {
504
+ collection,
505
+ id: docId,
506
+ method,
507
+ model: entry.modelName,
508
+ timestamp: DateTime.now().toMillis(),
509
+ ...(change.operationType === "update" && change.updateDescription?.updatedFields
510
+ ? {updatedFields: Object.keys(change.updateDescription.updatedFields)}
511
+ : {}),
512
+ };
513
+
514
+ // Emit to strategy-based rooms (model/owner/broadcast)
515
+ for (const room of rooms) {
516
+ await emitToAuthorizedRoom(io, room, event, entry, fullDocument, logDebug);
517
+ }
518
+
519
+ // Emit to document-specific and query rooms
520
+ await emitToDocumentAndQueryRooms(io, collection, event, fullDocument, logDebug, entry);
521
+
522
+ logDebug(
523
+ `[realtime] Emitted ${method} for ${entry.modelName}/${docId} to rooms: ${rooms.join(", ")}`
524
+ );
525
+ // Log only metadata — never the document payload, which may contain sensitive fields.
526
+ const metadata: Record<string, unknown> = {
527
+ collection: event.collection,
528
+ id: event.id,
529
+ method: event.method,
530
+ model: event.model,
531
+ timestamp: event.timestamp,
532
+ };
533
+ if (event.updatedFields) {
534
+ metadata.updatedFields = event.updatedFields;
535
+ }
536
+ logInfo(`[realtime] sync event: ${JSON.stringify(metadata)}`);
537
+ } catch (error) {
538
+ logger.error(`[realtime] Error processing change event: ${error}`);
539
+ Sentry.captureException(error);
540
+ }
541
+ });
542
+
543
+ changeWatcher.on("error", (err: Error) => {
544
+ Sentry.captureException(err);
545
+ logger.error(`[realtime] Change stream error: ${err?.message || err}`);
546
+ });
547
+
548
+ changeWatcher.on("close", () => {
549
+ logger.warn("[realtime] Change stream closed");
550
+ });
551
+
552
+ changeWatcher.on("end", () => {
553
+ logger.warn("[realtime] Change stream ended");
554
+ });
555
+
556
+ logInfo("[realtime] Change stream watcher initialized successfully");
557
+ } catch (error) {
558
+ logger.error(`[realtime] Failed to initialize change stream watcher: ${error}`);
559
+ Sentry.captureException(error);
560
+ throw error;
561
+ }
562
+ };
563
+
564
+ /**
565
+ * Stop the change stream watcher.
566
+ */
567
+ export const stopChangeStreamWatcher = async (): Promise<void> => {
568
+ if (changeWatcher) {
569
+ await changeWatcher.close();
570
+ changeWatcher = null;
571
+ }
572
+ };
@@ -0,0 +1,34 @@
1
+ export {startChangeStreamWatcher, stopChangeStreamWatcher} from "./changeStreamWatcher";
2
+ export {matchesQuery} from "./queryMatcher";
3
+ export {
4
+ addQuerySubscription,
5
+ clearQueryStore,
6
+ computeQueryId,
7
+ getQuerySubscriptionsForCollection,
8
+ removeAllSocketQueries,
9
+ removeQuerySubscription,
10
+ } from "./queryStore";
11
+ export {
12
+ installRealtimeSocketHandlers,
13
+ MAX_DOCUMENT_SUBSCRIPTIONS,
14
+ MAX_MODEL_SUBSCRIPTIONS,
15
+ MAX_QUERY_SUBSCRIPTIONS,
16
+ RealtimeApp,
17
+ type RealtimeSocketLike,
18
+ } from "./realtimeApp";
19
+ export {
20
+ clearRealtimeRegistry,
21
+ findRegistryEntryByCollection,
22
+ findRegistryEntryByRoutePath,
23
+ getRealtimeRegistry,
24
+ type RealtimeRegistryEntry,
25
+ registerRealtime,
26
+ } from "./registry";
27
+ export type {
28
+ ChangeStreamConfig,
29
+ DocumentSubscription,
30
+ QuerySubscription,
31
+ RealtimeAppOptions,
32
+ RealtimeConfig,
33
+ RealtimeEvent,
34
+ } from "./types";