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