@flowerforce/flowerbase 1.7.5-beta.3 → 1.7.5-beta.5

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.
@@ -1,4 +1,6 @@
1
1
  import { FunctionController } from './interface';
2
+ export declare const mapWatchFilterToChangeStreamMatch: (value: unknown) => unknown;
3
+ export declare const mapWatchFilterToDocumentQuery: (value: unknown) => unknown;
2
4
  /**
3
5
  * > Creates a pre handler for every query
4
6
  * @param app -> the fastify instance
@@ -1 +1 @@
1
- {"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../../src/features/functions/controller.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAgGhD;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,EAAE,kBAsPjC,CAAA"}
1
+ {"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../../src/features/functions/controller.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AAmHhD,eAAO,MAAM,iCAAiC,GAAI,OAAO,OAAO,KAAG,OA0BlE,CAAA;AAID,eAAO,MAAM,6BAA6B,GAAI,OAAO,OAAO,KAAG,OAgD9D,CAAA;AAqHD;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,EAAE,kBAoRjC,CAAA"}
@@ -20,7 +20,7 @@ var __rest = (this && this.__rest) || function (s, e) {
20
20
  return t;
21
21
  };
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.functionsController = void 0;
23
+ exports.functionsController = exports.mapWatchFilterToDocumentQuery = exports.mapWatchFilterToChangeStreamMatch = void 0;
24
24
  const bson_1 = require("bson");
25
25
  const services_1 = require("../../services");
26
26
  const context_1 = require("../../utils/context");
@@ -58,6 +58,12 @@ const isReturnedError = (value) => {
58
58
  };
59
59
  const serializeEjson = (value) => JSON.stringify(bson_1.EJSON.serialize(value, { relaxed: false }));
60
60
  const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
61
+ const isPlainRecord = (value) => {
62
+ if (!isRecord(value))
63
+ return false;
64
+ const prototype = Object.getPrototypeOf(value);
65
+ return prototype === Object.prototype || prototype === null;
66
+ };
61
67
  const isCursorLike = (value) => {
62
68
  if (!value || typeof value !== 'object')
63
69
  return false;
@@ -70,12 +76,187 @@ const normalizeFunctionResult = (value) => __awaiter(void 0, void 0, void 0, fun
70
76
  });
71
77
  const sharedWatchStreams = new Map();
72
78
  let watchSubscriberCounter = 0;
79
+ const maxSharedWatchStreams = Number(process.env.MAX_SHARED_WATCH_STREAMS || 200);
80
+ const debugWatchStreams = process.env.DEBUG_FUNCTIONS === 'true';
81
+ const changeEventRootKeys = new Set([
82
+ '_id',
83
+ 'operationType',
84
+ 'clusterTime',
85
+ 'txnNumber',
86
+ 'lsid',
87
+ 'ns',
88
+ 'documentKey',
89
+ 'fullDocument',
90
+ 'updateDescription'
91
+ ]);
92
+ const isChangeEventPath = (key) => {
93
+ if (changeEventRootKeys.has(key))
94
+ return true;
95
+ return (key.startsWith('ns.') ||
96
+ key.startsWith('documentKey.') ||
97
+ key.startsWith('fullDocument.') ||
98
+ key.startsWith('updateDescription.'));
99
+ };
100
+ const isOpaqueChangeEventObjectKey = (key) => key === 'ns' || key === 'documentKey' || key === 'fullDocument' || key === 'updateDescription';
101
+ const mapWatchFilterToChangeStreamMatch = (value) => {
102
+ if (Array.isArray(value)) {
103
+ return value.map((item) => (0, exports.mapWatchFilterToChangeStreamMatch)(item));
104
+ }
105
+ if (!isPlainRecord(value))
106
+ return value;
107
+ return Object.entries(value).reduce((acc, [key, current]) => {
108
+ if (key.startsWith('$')) {
109
+ acc[key] = (0, exports.mapWatchFilterToChangeStreamMatch)(current);
110
+ return acc;
111
+ }
112
+ if (isOpaqueChangeEventObjectKey(key)) {
113
+ acc[key] = current;
114
+ return acc;
115
+ }
116
+ if (isChangeEventPath(key)) {
117
+ acc[key] = (0, exports.mapWatchFilterToChangeStreamMatch)(current);
118
+ return acc;
119
+ }
120
+ acc[`fullDocument.${key}`] = (0, exports.mapWatchFilterToChangeStreamMatch)(current);
121
+ return acc;
122
+ }, {});
123
+ };
124
+ exports.mapWatchFilterToChangeStreamMatch = mapWatchFilterToChangeStreamMatch;
125
+ const isLogicalOperator = (key) => key === '$and' || key === '$or' || key === '$nor';
126
+ const mapWatchFilterToDocumentQuery = (value) => {
127
+ if (Array.isArray(value)) {
128
+ const mapped = value
129
+ .map((item) => (0, exports.mapWatchFilterToDocumentQuery)(item))
130
+ .filter((item) => !(isRecord(item) && Object.keys(item).length === 0));
131
+ return mapped;
132
+ }
133
+ if (!isPlainRecord(value))
134
+ return value;
135
+ return Object.entries(value).reduce((acc, [key, current]) => {
136
+ if (key.startsWith('$')) {
137
+ const mapped = (0, exports.mapWatchFilterToDocumentQuery)(current);
138
+ if (isLogicalOperator(key) && Array.isArray(mapped)) {
139
+ if (mapped.length > 0) {
140
+ acc[key] = mapped;
141
+ }
142
+ return acc;
143
+ }
144
+ if (typeof mapped !== 'undefined') {
145
+ acc[key] = mapped;
146
+ }
147
+ return acc;
148
+ }
149
+ if (key === 'fullDocument') {
150
+ if (!isPlainRecord(current))
151
+ return acc;
152
+ const mapped = (0, exports.mapWatchFilterToDocumentQuery)(current);
153
+ if (isRecord(mapped)) {
154
+ Object.assign(acc, mapped);
155
+ }
156
+ return acc;
157
+ }
158
+ if (key.startsWith('fullDocument.')) {
159
+ const docKey = key.slice('fullDocument.'.length);
160
+ if (!docKey)
161
+ return acc;
162
+ acc[docKey] = (0, exports.mapWatchFilterToDocumentQuery)(current);
163
+ return acc;
164
+ }
165
+ if (isChangeEventPath(key)) {
166
+ return acc;
167
+ }
168
+ acc[key] = (0, exports.mapWatchFilterToDocumentQuery)(current);
169
+ return acc;
170
+ }, {});
171
+ };
172
+ exports.mapWatchFilterToDocumentQuery = mapWatchFilterToDocumentQuery;
173
+ const toStableValue = (value) => {
174
+ if (Array.isArray(value)) {
175
+ return value.map((item) => toStableValue(item));
176
+ }
177
+ if (!isPlainRecord(value))
178
+ return value;
179
+ const sortedEntries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
180
+ return sortedEntries.reduce((acc, [key, current]) => {
181
+ acc[key] = toStableValue(current);
182
+ return acc;
183
+ }, {});
184
+ };
185
+ const stableSerialize = (value) => {
186
+ const serialized = bson_1.EJSON.serialize(value, { relaxed: false });
187
+ return JSON.stringify(toStableValue(serialized));
188
+ };
189
+ const getWatchPermissionContext = (user) => ({
190
+ role: user.role,
191
+ roles: user.roles,
192
+ data: user.data,
193
+ custom_data: user.custom_data,
194
+ user_data: user.user_data
195
+ });
196
+ const resolveWatchStream = (database, collection, watchArgs, user) => {
197
+ const keys = Object.keys(watchArgs);
198
+ const hasOnlyAllowedKeys = keys.every((key) => key === 'filter' || key === 'ids');
199
+ if (!hasOnlyAllowedKeys) {
200
+ throw new Error('watch options support only "filter" or "ids"');
201
+ }
202
+ const extraFilter = parseWatchFilter(watchArgs);
203
+ const ids = watchArgs.ids;
204
+ if (extraFilter && typeof ids !== 'undefined') {
205
+ throw new Error('watch options cannot include both "ids" and "filter"');
206
+ }
207
+ const pipeline = [];
208
+ if (extraFilter) {
209
+ pipeline.push({ $match: (0, exports.mapWatchFilterToChangeStreamMatch)(extraFilter) });
210
+ }
211
+ if (typeof ids !== 'undefined') {
212
+ if (!Array.isArray(ids)) {
213
+ throw new Error('watch ids must be an array');
214
+ }
215
+ pipeline.push({
216
+ $match: {
217
+ $or: [
218
+ { 'documentKey._id': { $in: ids } },
219
+ { 'fullDocument._id': { $in: ids } }
220
+ ]
221
+ }
222
+ });
223
+ }
224
+ const options = { fullDocument: 'updateLookup' };
225
+ const streamKey = stableSerialize({
226
+ database,
227
+ collection,
228
+ pipeline,
229
+ options,
230
+ permissionContext: getWatchPermissionContext(user)
231
+ });
232
+ return { extraFilter, options, pipeline, streamKey };
233
+ };
234
+ const getWatchStats = () => {
235
+ let subscribers = 0;
236
+ for (const hub of sharedWatchStreams.values()) {
237
+ subscribers += hub.subscribers.size;
238
+ }
239
+ return {
240
+ hubs: sharedWatchStreams.size,
241
+ subscribers
242
+ };
243
+ };
244
+ const logWatchStats = (event, details) => {
245
+ if (!debugWatchStreams)
246
+ return;
247
+ const stats = getWatchStats();
248
+ console.log('[watch-pool]', event, Object.assign({ hubs: stats.hubs, subscribers: stats.subscribers }, details));
249
+ };
73
250
  const parseWatchFilter = (args) => {
74
- var _a;
75
251
  if (!isRecord(args))
76
252
  return undefined;
77
- const candidate = (_a = (isRecord(args.filter) ? args.filter : undefined)) !== null && _a !== void 0 ? _a : (isRecord(args.query) ? args.query : undefined);
78
- return candidate ? candidate : undefined;
253
+ const candidate = isRecord(args.filter) ? args.filter : undefined;
254
+ if (!candidate)
255
+ return undefined;
256
+ if ('$match' in candidate) {
257
+ throw new Error('watch filter must be a query object, not a $match stage');
258
+ }
259
+ return candidate;
79
260
  };
80
261
  const isReadableDocumentResult = (value) => !!value &&
81
262
  typeof value === 'object' &&
@@ -176,7 +357,8 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
176
357
  const { baas_request, stitch_request } = query;
177
358
  const decodedConfig = JSON.parse(Buffer.from(baas_request || stitch_request || '', 'base64').toString('utf8'));
178
359
  const config = bson_1.EJSON.deserialize(decodedConfig);
179
- const [_a] = config.arguments, { database, collection } = _a, watchArgs = __rest(_a, ["database", "collection"]);
360
+ const [_a] = config.arguments, { database, collection } = _a, watchArgsInput = __rest(_a, ["database", "collection"]);
361
+ const watchArgs = isRecord(watchArgsInput) ? watchArgsInput : {};
180
362
  const headers = {
181
363
  'Content-Type': 'text/event-stream',
182
364
  'Cache-Control': 'no-cache',
@@ -185,17 +367,27 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
185
367
  "access-control-allow-origin": '*',
186
368
  "access-control-allow-headers": "X-Stitch-Location, X-Baas-Location, Location",
187
369
  };
188
- res.raw.writeHead(200, headers);
189
- res.raw.flushHeaders();
190
- const streamKey = `${database}::${collection}`;
191
370
  const subscriberId = `${Date.now()}-${watchSubscriberCounter++}`;
192
- const extraFilter = parseWatchFilter(watchArgs);
193
- const mongoClient = app.mongo.client;
371
+ const { streamKey, extraFilter, options: watchOptions, pipeline: watchPipeline } = resolveWatchStream(database, collection, watchArgs, user);
194
372
  let hub = sharedWatchStreams.get(streamKey);
195
373
  if (!hub) {
196
- const stream = mongoClient.db(database).collection(collection).watch([], {
197
- fullDocument: 'whenAvailable'
198
- });
374
+ if (sharedWatchStreams.size >= maxSharedWatchStreams) {
375
+ res.status(503);
376
+ return JSON.stringify({
377
+ error: JSON.stringify({
378
+ message: 'Watch stream limit reached',
379
+ name: 'WatchStreamLimitError'
380
+ }),
381
+ error_code: 'WatchStreamLimitError'
382
+ });
383
+ }
384
+ const stream = services_1.services['mongodb-atlas'](app, {
385
+ user,
386
+ rules
387
+ })
388
+ .db(database)
389
+ .collection(collection)
390
+ .watch(watchPipeline, watchOptions);
199
391
  hub = {
200
392
  database,
201
393
  collection,
@@ -203,7 +395,13 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
203
395
  subscribers: new Map()
204
396
  };
205
397
  sharedWatchStreams.set(streamKey, hub);
398
+ logWatchStats('hub-created', { streamKey, database, collection });
399
+ }
400
+ else {
401
+ logWatchStats('hub-reused', { streamKey, database, collection });
206
402
  }
403
+ res.raw.writeHead(200, headers);
404
+ res.raw.flushHeaders();
207
405
  const ensureHubListeners = (currentHub) => {
208
406
  if (currentHub.listenersBound) {
209
407
  return;
@@ -212,6 +410,7 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
212
410
  currentHub.stream.off('change', onHubChange);
213
411
  currentHub.stream.off('error', onHubError);
214
412
  sharedWatchStreams.delete(streamKey);
413
+ logWatchStats('hub-closed', { streamKey, database, collection });
215
414
  try {
216
415
  yield currentHub.stream.close();
217
416
  }
@@ -226,13 +425,14 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
226
425
  const subscriberRes = subscriber.response;
227
426
  if (subscriberRes.writableEnded || subscriberRes.destroyed) {
228
427
  currentHub.subscribers.delete(subscriber.id);
428
+ logWatchStats('subscriber-auto-removed', { streamKey, subscriberId: subscriber.id });
229
429
  return;
230
430
  }
231
431
  const docId = (_b = (_a = change === null || change === void 0 ? void 0 : change.documentKey) === null || _a === void 0 ? void 0 : _a._id) !== null && _b !== void 0 ? _b : (_c = change === null || change === void 0 ? void 0 : change.fullDocument) === null || _c === void 0 ? void 0 : _c._id;
232
432
  if (typeof docId === 'undefined')
233
433
  return;
234
- const readQuery = subscriber.extraFilter
235
- ? { $and: [subscriber.extraFilter, { _id: docId }] }
434
+ const readQuery = subscriber.documentFilter
435
+ ? { $and: [subscriber.documentFilter, { _id: docId }] }
236
436
  : { _id: docId };
237
437
  try {
238
438
  const readableDoc = yield services_1.services['mongodb-atlas'](app, {
@@ -250,6 +450,7 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
250
450
  subscriberRes.write(`event: error\ndata: ${formatFunctionExecutionError(error)}\n\n`);
251
451
  subscriberRes.end();
252
452
  currentHub.subscribers.delete(subscriber.id);
453
+ logWatchStats('subscriber-error-removed', { streamKey, subscriberId: subscriber.id });
253
454
  }
254
455
  })));
255
456
  if (!currentHub.subscribers.size) {
@@ -276,17 +477,27 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
276
477
  id: subscriberId,
277
478
  user,
278
479
  response: res.raw,
279
- extraFilter
480
+ documentFilter: (() => {
481
+ if (!extraFilter)
482
+ return undefined;
483
+ const mapped = (0, exports.mapWatchFilterToDocumentQuery)(extraFilter);
484
+ if (!isRecord(mapped) || Object.keys(mapped).length === 0)
485
+ return undefined;
486
+ return mapped;
487
+ })()
280
488
  };
281
489
  hub.subscribers.set(subscriberId, subscriber);
490
+ logWatchStats('subscriber-added', { streamKey, subscriberId });
282
491
  req.raw.on('close', () => {
283
492
  const currentHub = sharedWatchStreams.get(streamKey);
284
493
  if (!currentHub)
285
494
  return;
286
495
  currentHub.subscribers.delete(subscriberId);
496
+ logWatchStats('subscriber-closed', { streamKey, subscriberId });
287
497
  if (!currentHub.subscribers.size) {
288
498
  void currentHub.stream.close();
289
499
  sharedWatchStreams.delete(streamKey);
500
+ logWatchStats('hub-empty-closed', { streamKey });
290
501
  }
291
502
  });
292
503
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowerforce/flowerbase",
3
- "version": "1.7.5-beta.3",
3
+ "version": "1.7.5-beta.5",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,116 @@
1
+ import { ObjectId } from 'mongodb'
2
+ import { mapWatchFilterToChangeStreamMatch, mapWatchFilterToDocumentQuery } from '../controller'
3
+
4
+ describe('watch filter mapping', () => {
5
+ it('keeps change-event fields untouched and prefixes only document fields', () => {
6
+ const input = {
7
+ accountId: '699efbc09729e3b79f79e9b4',
8
+ $and: [
9
+ {
10
+ $or: [
11
+ { requestId: '69a282a75cd849c244e001ca' },
12
+ { 'fullDocument.requestId': '69a282a75cd849c244e001ca' }
13
+ ]
14
+ },
15
+ {
16
+ $or: [
17
+ { operationType: 'insert' },
18
+ { operationType: 'replace' },
19
+ {
20
+ operationType: 'update',
21
+ 'updateDescription.updatedFields.stage': { $exists: true }
22
+ }
23
+ ]
24
+ }
25
+ ]
26
+ }
27
+
28
+ const output = mapWatchFilterToChangeStreamMatch(input)
29
+
30
+ expect(output).toEqual({
31
+ 'fullDocument.accountId': '699efbc09729e3b79f79e9b4',
32
+ $and: [
33
+ {
34
+ $or: [
35
+ { 'fullDocument.requestId': '69a282a75cd849c244e001ca' },
36
+ { 'fullDocument.requestId': '69a282a75cd849c244e001ca' }
37
+ ]
38
+ },
39
+ {
40
+ $or: [
41
+ { operationType: 'insert' },
42
+ { operationType: 'replace' },
43
+ {
44
+ operationType: 'update',
45
+ 'updateDescription.updatedFields.stage': { $exists: true }
46
+ }
47
+ ]
48
+ }
49
+ ]
50
+ })
51
+
52
+ const outputJson = JSON.stringify(output)
53
+ expect(outputJson).not.toContain('fullDocument.fullDocument.')
54
+ expect(outputJson).not.toContain('fullDocument.operationType')
55
+ })
56
+
57
+ it('supports stage-change filters and strips event-only clauses for document readability checks', () => {
58
+ const input = {
59
+ 'fullDocument.accountId': '699efbc09729e3b79f79e9b4',
60
+ $and: [
61
+ {
62
+ $or: [
63
+ { 'fullDocument.requestId': '69a282a75cd849c244e001ca' },
64
+ { 'fullDocument.requestId': '69a282a75cd849c244e001ca' }
65
+ ]
66
+ },
67
+ {
68
+ $or: [
69
+ { operationType: 'insert' },
70
+ { operationType: 'replace' },
71
+ {
72
+ operationType: 'update',
73
+ 'updateDescription.updatedFields.stage': { $exists: true }
74
+ }
75
+ ]
76
+ }
77
+ ]
78
+ }
79
+
80
+ const watchMatch = mapWatchFilterToChangeStreamMatch(input)
81
+ expect(watchMatch).toEqual(input)
82
+
83
+ const documentQuery = mapWatchFilterToDocumentQuery(input)
84
+ expect(documentQuery).toEqual({
85
+ accountId: '699efbc09729e3b79f79e9b4',
86
+ $and: [
87
+ {
88
+ $or: [
89
+ { requestId: '69a282a75cd849c244e001ca' },
90
+ { requestId: '69a282a75cd849c244e001ca' }
91
+ ]
92
+ }
93
+ ]
94
+ })
95
+
96
+ const documentQueryJson = JSON.stringify(documentQuery)
97
+ expect(documentQueryJson).not.toContain('operationType')
98
+ expect(documentQueryJson).not.toContain('updateDescription')
99
+ })
100
+
101
+ it('preserves ObjectId values in both change-stream and document mappings', () => {
102
+ const id = new ObjectId('69a282a75cd849c244e001ca')
103
+ const input = {
104
+ 'fullDocument._id': id,
105
+ operationType: 'update',
106
+ 'updateDescription.updatedFields.stage': { $exists: true }
107
+ }
108
+
109
+ const watchMatch = mapWatchFilterToChangeStreamMatch(input) as Record<string, unknown>
110
+ expect(watchMatch['fullDocument._id']).toEqual(id)
111
+
112
+ const documentQuery = mapWatchFilterToDocumentQuery(input) as Record<string, unknown>
113
+ expect(documentQuery._id).toEqual(id)
114
+ expect(documentQuery.operationType).toBeUndefined()
115
+ })
116
+ })
@@ -54,6 +54,12 @@ const serializeEjson = (value: unknown) =>
54
54
  const isRecord = (value: unknown): value is Record<string, unknown> =>
55
55
  !!value && typeof value === 'object' && !Array.isArray(value)
56
56
 
57
+ const isPlainRecord = (value: unknown): value is Record<string, unknown> => {
58
+ if (!isRecord(value)) return false
59
+ const prototype = Object.getPrototypeOf(value)
60
+ return prototype === Object.prototype || prototype === null
61
+ }
62
+
57
63
  const isCursorLike = (
58
64
  value: unknown
59
65
  ): value is { toArray: () => Promise<unknown> | unknown } => {
@@ -70,7 +76,7 @@ type WatchSubscriber = {
70
76
  id: string
71
77
  user: Record<string, any>
72
78
  response: ServerResponse
73
- extraFilter?: Document
79
+ documentFilter?: Document
74
80
  }
75
81
 
76
82
  type SharedWatchStream = {
@@ -86,13 +92,221 @@ type SharedWatchStream = {
86
92
 
87
93
  const sharedWatchStreams = new Map<string, SharedWatchStream>()
88
94
  let watchSubscriberCounter = 0
95
+ const maxSharedWatchStreams = Number(process.env.MAX_SHARED_WATCH_STREAMS || 200)
96
+ const debugWatchStreams = process.env.DEBUG_FUNCTIONS === 'true'
97
+
98
+ const changeEventRootKeys = new Set([
99
+ '_id',
100
+ 'operationType',
101
+ 'clusterTime',
102
+ 'txnNumber',
103
+ 'lsid',
104
+ 'ns',
105
+ 'documentKey',
106
+ 'fullDocument',
107
+ 'updateDescription'
108
+ ])
109
+
110
+ const isChangeEventPath = (key: string) => {
111
+ if (changeEventRootKeys.has(key)) return true
112
+ return (
113
+ key.startsWith('ns.') ||
114
+ key.startsWith('documentKey.') ||
115
+ key.startsWith('fullDocument.') ||
116
+ key.startsWith('updateDescription.')
117
+ )
118
+ }
119
+
120
+ const isOpaqueChangeEventObjectKey = (key: string) =>
121
+ key === 'ns' || key === 'documentKey' || key === 'fullDocument' || key === 'updateDescription'
122
+
123
+ export const mapWatchFilterToChangeStreamMatch = (value: unknown): unknown => {
124
+ if (Array.isArray(value)) {
125
+ return value.map((item) => mapWatchFilterToChangeStreamMatch(item))
126
+ }
127
+
128
+ if (!isPlainRecord(value)) return value
129
+
130
+ return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, current]) => {
131
+ if (key.startsWith('$')) {
132
+ acc[key] = mapWatchFilterToChangeStreamMatch(current)
133
+ return acc
134
+ }
135
+
136
+ if (isOpaqueChangeEventObjectKey(key)) {
137
+ acc[key] = current
138
+ return acc
139
+ }
140
+
141
+ if (isChangeEventPath(key)) {
142
+ acc[key] = mapWatchFilterToChangeStreamMatch(current)
143
+ return acc
144
+ }
145
+
146
+ acc[`fullDocument.${key}`] = mapWatchFilterToChangeStreamMatch(current)
147
+ return acc
148
+ }, {})
149
+ }
150
+
151
+ const isLogicalOperator = (key: string) => key === '$and' || key === '$or' || key === '$nor'
152
+
153
+ export const mapWatchFilterToDocumentQuery = (value: unknown): unknown => {
154
+ if (Array.isArray(value)) {
155
+ const mapped = value
156
+ .map((item) => mapWatchFilterToDocumentQuery(item))
157
+ .filter((item) => !(isRecord(item) && Object.keys(item).length === 0))
158
+ return mapped
159
+ }
160
+
161
+ if (!isPlainRecord(value)) return value
162
+
163
+ return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, current]) => {
164
+ if (key.startsWith('$')) {
165
+ const mapped = mapWatchFilterToDocumentQuery(current)
166
+ if (isLogicalOperator(key) && Array.isArray(mapped)) {
167
+ if (mapped.length > 0) {
168
+ acc[key] = mapped
169
+ }
170
+ return acc
171
+ }
172
+ if (typeof mapped !== 'undefined') {
173
+ acc[key] = mapped
174
+ }
175
+ return acc
176
+ }
177
+
178
+ if (key === 'fullDocument') {
179
+ if (!isPlainRecord(current)) return acc
180
+ const mapped = mapWatchFilterToDocumentQuery(current)
181
+ if (isRecord(mapped)) {
182
+ Object.assign(acc, mapped)
183
+ }
184
+ return acc
185
+ }
186
+
187
+ if (key.startsWith('fullDocument.')) {
188
+ const docKey = key.slice('fullDocument.'.length)
189
+ if (!docKey) return acc
190
+ acc[docKey] = mapWatchFilterToDocumentQuery(current)
191
+ return acc
192
+ }
193
+
194
+ if (isChangeEventPath(key)) {
195
+ return acc
196
+ }
197
+
198
+ acc[key] = mapWatchFilterToDocumentQuery(current)
199
+ return acc
200
+ }, {})
201
+ }
202
+
203
+ const toStableValue = (value: unknown): unknown => {
204
+ if (Array.isArray(value)) {
205
+ return value.map((item) => toStableValue(item))
206
+ }
207
+ if (!isPlainRecord(value)) return value
208
+
209
+ const sortedEntries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
210
+ return sortedEntries.reduce<Record<string, unknown>>((acc, [key, current]) => {
211
+ acc[key] = toStableValue(current)
212
+ return acc
213
+ }, {})
214
+ }
215
+
216
+ const stableSerialize = (value: unknown) => {
217
+ const serialized = EJSON.serialize(value, { relaxed: false })
218
+ return JSON.stringify(toStableValue(serialized))
219
+ }
220
+
221
+ const getWatchPermissionContext = (user: Record<string, any>) => ({
222
+ role: user.role,
223
+ roles: user.roles,
224
+ data: user.data,
225
+ custom_data: user.custom_data,
226
+ user_data: user.user_data
227
+ })
228
+
229
+ const resolveWatchStream = (
230
+ database: string,
231
+ collection: string,
232
+ watchArgs: Record<string, unknown>,
233
+ user: Record<string, any>
234
+ ) => {
235
+ const keys = Object.keys(watchArgs)
236
+ const hasOnlyAllowedKeys = keys.every((key) => key === 'filter' || key === 'ids')
237
+ if (!hasOnlyAllowedKeys) {
238
+ throw new Error('watch options support only "filter" or "ids"')
239
+ }
240
+
241
+ const extraFilter = parseWatchFilter(watchArgs)
242
+ const ids = watchArgs.ids
243
+ if (extraFilter && typeof ids !== 'undefined') {
244
+ throw new Error('watch options cannot include both "ids" and "filter"')
245
+ }
246
+
247
+ const pipeline: Document[] = []
248
+ if (extraFilter) {
249
+ pipeline.push({ $match: mapWatchFilterToChangeStreamMatch(extraFilter) as Document })
250
+ }
251
+
252
+ if (typeof ids !== 'undefined') {
253
+ if (!Array.isArray(ids)) {
254
+ throw new Error('watch ids must be an array')
255
+ }
256
+ pipeline.push({
257
+ $match: {
258
+ $or: [
259
+ { 'documentKey._id': { $in: ids } },
260
+ { 'fullDocument._id': { $in: ids } }
261
+ ]
262
+ }
263
+ })
264
+ }
265
+
266
+ const options = { fullDocument: 'updateLookup' }
267
+ const streamKey = stableSerialize({
268
+ database,
269
+ collection,
270
+ pipeline,
271
+ options,
272
+ permissionContext: getWatchPermissionContext(user)
273
+ })
274
+
275
+ return { extraFilter, options, pipeline, streamKey }
276
+ }
277
+
278
+ const getWatchStats = () => {
279
+ let subscribers = 0
280
+ for (const hub of sharedWatchStreams.values()) {
281
+ subscribers += hub.subscribers.size
282
+ }
283
+ return {
284
+ hubs: sharedWatchStreams.size,
285
+ subscribers
286
+ }
287
+ }
288
+
289
+ const logWatchStats = (
290
+ event: string,
291
+ details?: Record<string, unknown>
292
+ ) => {
293
+ if (!debugWatchStreams) return
294
+ const stats = getWatchStats()
295
+ console.log('[watch-pool]', event, {
296
+ hubs: stats.hubs,
297
+ subscribers: stats.subscribers,
298
+ ...details
299
+ })
300
+ }
89
301
 
90
302
  const parseWatchFilter = (args: unknown): Document | undefined => {
91
303
  if (!isRecord(args)) return undefined
92
- const candidate =
93
- (isRecord(args.filter) ? args.filter : undefined) ??
94
- (isRecord(args.query) ? args.query : undefined)
95
- return candidate ? (candidate as Document) : undefined
304
+ const candidate = isRecord(args.filter) ? args.filter : undefined
305
+ if (!candidate) return undefined
306
+ if ('$match' in candidate) {
307
+ throw new Error('watch filter must be a query object, not a $match stage')
308
+ }
309
+ return candidate as Document
96
310
  }
97
311
 
98
312
  const isReadableDocumentResult = (value: unknown) =>
@@ -224,7 +438,8 @@ export const functionsController: FunctionController = async (
224
438
  )
225
439
  const config = EJSON.deserialize(decodedConfig) as Base64Function
226
440
 
227
- const [{ database, collection, ...watchArgs }] = config.arguments
441
+ const [{ database, collection, ...watchArgsInput }] = config.arguments
442
+ const watchArgs = isRecord(watchArgsInput) ? watchArgsInput : {}
228
443
 
229
444
  const headers = {
230
445
  'Content-Type': 'text/event-stream',
@@ -235,21 +450,33 @@ export const functionsController: FunctionController = async (
235
450
  "access-control-allow-headers": "X-Stitch-Location, X-Baas-Location, Location",
236
451
  };
237
452
 
238
- res.raw.writeHead(200, headers)
239
- res.raw.flushHeaders();
240
-
241
- const streamKey = `${database}::${collection}`
242
453
  const subscriberId = `${Date.now()}-${watchSubscriberCounter++}`
243
- const extraFilter = parseWatchFilter(watchArgs)
244
- const mongoClient = app.mongo.client as unknown as {
245
- db: (name: string) => { collection: (name: string) => { watch: (...args: any[]) => any } }
246
- }
454
+ const {
455
+ streamKey,
456
+ extraFilter,
457
+ options: watchOptions,
458
+ pipeline: watchPipeline
459
+ } = resolveWatchStream(database, collection, watchArgs, user)
247
460
 
248
461
  let hub = sharedWatchStreams.get(streamKey)
249
462
  if (!hub) {
250
- const stream = mongoClient.db(database).collection(collection).watch([], {
251
- fullDocument: 'whenAvailable'
463
+ if (sharedWatchStreams.size >= maxSharedWatchStreams) {
464
+ res.status(503)
465
+ return JSON.stringify({
466
+ error: JSON.stringify({
467
+ message: 'Watch stream limit reached',
468
+ name: 'WatchStreamLimitError'
469
+ }),
470
+ error_code: 'WatchStreamLimitError'
471
+ })
472
+ }
473
+ const stream = services['mongodb-atlas'](app, {
474
+ user,
475
+ rules
252
476
  })
477
+ .db(database)
478
+ .collection(collection)
479
+ .watch(watchPipeline, watchOptions)
253
480
  hub = {
254
481
  database,
255
482
  collection,
@@ -257,8 +484,14 @@ export const functionsController: FunctionController = async (
257
484
  subscribers: new Map<string, WatchSubscriber>()
258
485
  }
259
486
  sharedWatchStreams.set(streamKey, hub)
487
+ logWatchStats('hub-created', { streamKey, database, collection })
488
+ } else {
489
+ logWatchStats('hub-reused', { streamKey, database, collection })
260
490
  }
261
491
 
492
+ res.raw.writeHead(200, headers)
493
+ res.raw.flushHeaders();
494
+
262
495
  const ensureHubListeners = (currentHub: SharedWatchStream) => {
263
496
  if ((currentHub as SharedWatchStream & { listenersBound?: boolean }).listenersBound) {
264
497
  return
@@ -268,6 +501,7 @@ export const functionsController: FunctionController = async (
268
501
  currentHub.stream.off('change', onHubChange)
269
502
  currentHub.stream.off('error', onHubError)
270
503
  sharedWatchStreams.delete(streamKey)
504
+ logWatchStats('hub-closed', { streamKey, database, collection })
271
505
  try {
272
506
  await currentHub.stream.close()
273
507
  } catch {
@@ -281,6 +515,7 @@ export const functionsController: FunctionController = async (
281
515
  const subscriberRes = subscriber.response
282
516
  if (subscriberRes.writableEnded || subscriberRes.destroyed) {
283
517
  currentHub.subscribers.delete(subscriber.id)
518
+ logWatchStats('subscriber-auto-removed', { streamKey, subscriberId: subscriber.id })
284
519
  return
285
520
  }
286
521
 
@@ -289,8 +524,8 @@ export const functionsController: FunctionController = async (
289
524
  (change as { fullDocument?: { _id?: unknown } })?.fullDocument?._id
290
525
  if (typeof docId === 'undefined') return
291
526
 
292
- const readQuery = subscriber.extraFilter
293
- ? ({ $and: [subscriber.extraFilter, { _id: docId }] } as Document)
527
+ const readQuery = subscriber.documentFilter
528
+ ? ({ $and: [subscriber.documentFilter, { _id: docId }] } as Document)
294
529
  : ({ _id: docId } as Document)
295
530
 
296
531
  try {
@@ -308,6 +543,7 @@ export const functionsController: FunctionController = async (
308
543
  subscriberRes.write(`event: error\ndata: ${formatFunctionExecutionError(error)}\n\n`)
309
544
  subscriberRes.end()
310
545
  currentHub.subscribers.delete(subscriber.id)
546
+ logWatchStats('subscriber-error-removed', { streamKey, subscriberId: subscriber.id })
311
547
  }
312
548
  }))
313
549
 
@@ -339,17 +575,25 @@ export const functionsController: FunctionController = async (
339
575
  id: subscriberId,
340
576
  user,
341
577
  response: res.raw,
342
- extraFilter
578
+ documentFilter: (() => {
579
+ if (!extraFilter) return undefined
580
+ const mapped = mapWatchFilterToDocumentQuery(extraFilter)
581
+ if (!isRecord(mapped) || Object.keys(mapped).length === 0) return undefined
582
+ return mapped as Document
583
+ })()
343
584
  }
344
585
  hub.subscribers.set(subscriberId, subscriber)
586
+ logWatchStats('subscriber-added', { streamKey, subscriberId })
345
587
 
346
588
  req.raw.on('close', () => {
347
589
  const currentHub = sharedWatchStreams.get(streamKey)
348
590
  if (!currentHub) return
349
591
  currentHub.subscribers.delete(subscriberId)
592
+ logWatchStats('subscriber-closed', { streamKey, subscriberId })
350
593
  if (!currentHub.subscribers.size) {
351
594
  void currentHub.stream.close()
352
595
  sharedWatchStreams.delete(streamKey)
596
+ logWatchStats('hub-empty-closed', { streamKey })
353
597
  }
354
598
  })
355
599
  })