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

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,kBAuRjC,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,9 @@ 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 : {};
362
+ console.log("🚀 ~ functionsController ~ watchArgs:", watchArgs);
180
363
  const headers = {
181
364
  'Content-Type': 'text/event-stream',
182
365
  'Cache-Control': 'no-cache',
@@ -185,17 +368,27 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
185
368
  "access-control-allow-origin": '*',
186
369
  "access-control-allow-headers": "X-Stitch-Location, X-Baas-Location, Location",
187
370
  };
188
- res.raw.writeHead(200, headers);
189
- res.raw.flushHeaders();
190
- const streamKey = `${database}::${collection}`;
191
371
  const subscriberId = `${Date.now()}-${watchSubscriberCounter++}`;
192
- const extraFilter = parseWatchFilter(watchArgs);
193
- const mongoClient = app.mongo.client;
372
+ const { streamKey, extraFilter, options: watchOptions, pipeline: watchPipeline } = resolveWatchStream(database, collection, watchArgs, user);
194
373
  let hub = sharedWatchStreams.get(streamKey);
195
374
  if (!hub) {
196
- const stream = mongoClient.db(database).collection(collection).watch([], {
197
- fullDocument: 'whenAvailable'
198
- });
375
+ if (sharedWatchStreams.size >= maxSharedWatchStreams) {
376
+ res.status(503);
377
+ return JSON.stringify({
378
+ error: JSON.stringify({
379
+ message: 'Watch stream limit reached',
380
+ name: 'WatchStreamLimitError'
381
+ }),
382
+ error_code: 'WatchStreamLimitError'
383
+ });
384
+ }
385
+ const stream = services_1.services['mongodb-atlas'](app, {
386
+ user,
387
+ rules
388
+ })
389
+ .db(database)
390
+ .collection(collection)
391
+ .watch(watchPipeline, watchOptions);
199
392
  hub = {
200
393
  database,
201
394
  collection,
@@ -203,7 +396,13 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
203
396
  subscribers: new Map()
204
397
  };
205
398
  sharedWatchStreams.set(streamKey, hub);
399
+ logWatchStats('hub-created', { streamKey, database, collection });
400
+ }
401
+ else {
402
+ logWatchStats('hub-reused', { streamKey, database, collection });
206
403
  }
404
+ res.raw.writeHead(200, headers);
405
+ res.raw.flushHeaders();
207
406
  const ensureHubListeners = (currentHub) => {
208
407
  if (currentHub.listenersBound) {
209
408
  return;
@@ -212,6 +411,7 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
212
411
  currentHub.stream.off('change', onHubChange);
213
412
  currentHub.stream.off('error', onHubError);
214
413
  sharedWatchStreams.delete(streamKey);
414
+ logWatchStats('hub-closed', { streamKey, database, collection });
215
415
  try {
216
416
  yield currentHub.stream.close();
217
417
  }
@@ -220,19 +420,21 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
220
420
  }
221
421
  });
222
422
  const onHubChange = (change) => __awaiter(void 0, void 0, void 0, function* () {
423
+ console.log("🚀 ~ onHubChange ~ change:", change);
223
424
  const subscribers = Array.from(currentHub.subscribers.values());
224
425
  yield Promise.all(subscribers.map((subscriber) => __awaiter(void 0, void 0, void 0, function* () {
225
426
  var _a, _b, _c;
226
427
  const subscriberRes = subscriber.response;
227
428
  if (subscriberRes.writableEnded || subscriberRes.destroyed) {
228
429
  currentHub.subscribers.delete(subscriber.id);
430
+ logWatchStats('subscriber-auto-removed', { streamKey, subscriberId: subscriber.id });
229
431
  return;
230
432
  }
231
433
  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
434
  if (typeof docId === 'undefined')
233
435
  return;
234
- const readQuery = subscriber.extraFilter
235
- ? { $and: [subscriber.extraFilter, { _id: docId }] }
436
+ const readQuery = subscriber.documentFilter
437
+ ? { $and: [subscriber.documentFilter, { _id: docId }] }
236
438
  : { _id: docId };
237
439
  try {
238
440
  const readableDoc = yield services_1.services['mongodb-atlas'](app, {
@@ -242,6 +444,7 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
242
444
  .db(currentHub.database)
243
445
  .collection(currentHub.collection)
244
446
  .findOne(readQuery);
447
+ console.log("🚀 ~ onHubChange ~ readableDoc:", readableDoc);
245
448
  if (!isReadableDocumentResult(readableDoc))
246
449
  return;
247
450
  subscriberRes.write(`data: ${serializeEjson(change)}\n\n`);
@@ -250,6 +453,7 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
250
453
  subscriberRes.write(`event: error\ndata: ${formatFunctionExecutionError(error)}\n\n`);
251
454
  subscriberRes.end();
252
455
  currentHub.subscribers.delete(subscriber.id);
456
+ logWatchStats('subscriber-error-removed', { streamKey, subscriberId: subscriber.id });
253
457
  }
254
458
  })));
255
459
  if (!currentHub.subscribers.size) {
@@ -276,17 +480,27 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
276
480
  id: subscriberId,
277
481
  user,
278
482
  response: res.raw,
279
- extraFilter
483
+ documentFilter: (() => {
484
+ if (!extraFilter)
485
+ return undefined;
486
+ const mapped = (0, exports.mapWatchFilterToDocumentQuery)(extraFilter);
487
+ if (!isRecord(mapped) || Object.keys(mapped).length === 0)
488
+ return undefined;
489
+ return mapped;
490
+ })()
280
491
  };
281
492
  hub.subscribers.set(subscriberId, subscriber);
493
+ logWatchStats('subscriber-added', { streamKey, subscriberId });
282
494
  req.raw.on('close', () => {
283
495
  const currentHub = sharedWatchStreams.get(streamKey);
284
496
  if (!currentHub)
285
497
  return;
286
498
  currentHub.subscribers.delete(subscriberId);
499
+ logWatchStats('subscriber-closed', { streamKey, subscriberId });
287
500
  if (!currentHub.subscribers.size) {
288
501
  void currentHub.stream.close();
289
502
  sharedWatchStreams.delete(streamKey);
503
+ logWatchStats('hub-empty-closed', { streamKey });
290
504
  }
291
505
  });
292
506
  }));
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.4",
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,9 @@ 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 : {}
443
+ console.log("🚀 ~ functionsController ~ watchArgs:", watchArgs)
228
444
 
229
445
  const headers = {
230
446
  'Content-Type': 'text/event-stream',
@@ -235,21 +451,33 @@ export const functionsController: FunctionController = async (
235
451
  "access-control-allow-headers": "X-Stitch-Location, X-Baas-Location, Location",
236
452
  };
237
453
 
238
- res.raw.writeHead(200, headers)
239
- res.raw.flushHeaders();
240
-
241
- const streamKey = `${database}::${collection}`
242
454
  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
- }
455
+ const {
456
+ streamKey,
457
+ extraFilter,
458
+ options: watchOptions,
459
+ pipeline: watchPipeline
460
+ } = resolveWatchStream(database, collection, watchArgs, user)
247
461
 
248
462
  let hub = sharedWatchStreams.get(streamKey)
249
463
  if (!hub) {
250
- const stream = mongoClient.db(database).collection(collection).watch([], {
251
- fullDocument: 'whenAvailable'
464
+ if (sharedWatchStreams.size >= maxSharedWatchStreams) {
465
+ res.status(503)
466
+ return JSON.stringify({
467
+ error: JSON.stringify({
468
+ message: 'Watch stream limit reached',
469
+ name: 'WatchStreamLimitError'
470
+ }),
471
+ error_code: 'WatchStreamLimitError'
472
+ })
473
+ }
474
+ const stream = services['mongodb-atlas'](app, {
475
+ user,
476
+ rules
252
477
  })
478
+ .db(database)
479
+ .collection(collection)
480
+ .watch(watchPipeline, watchOptions)
253
481
  hub = {
254
482
  database,
255
483
  collection,
@@ -257,8 +485,14 @@ export const functionsController: FunctionController = async (
257
485
  subscribers: new Map<string, WatchSubscriber>()
258
486
  }
259
487
  sharedWatchStreams.set(streamKey, hub)
488
+ logWatchStats('hub-created', { streamKey, database, collection })
489
+ } else {
490
+ logWatchStats('hub-reused', { streamKey, database, collection })
260
491
  }
261
492
 
493
+ res.raw.writeHead(200, headers)
494
+ res.raw.flushHeaders();
495
+
262
496
  const ensureHubListeners = (currentHub: SharedWatchStream) => {
263
497
  if ((currentHub as SharedWatchStream & { listenersBound?: boolean }).listenersBound) {
264
498
  return
@@ -268,6 +502,7 @@ export const functionsController: FunctionController = async (
268
502
  currentHub.stream.off('change', onHubChange)
269
503
  currentHub.stream.off('error', onHubError)
270
504
  sharedWatchStreams.delete(streamKey)
505
+ logWatchStats('hub-closed', { streamKey, database, collection })
271
506
  try {
272
507
  await currentHub.stream.close()
273
508
  } catch {
@@ -276,11 +511,13 @@ export const functionsController: FunctionController = async (
276
511
  }
277
512
 
278
513
  const onHubChange = async (change: Document) => {
514
+ console.log("🚀 ~ onHubChange ~ change:", change)
279
515
  const subscribers = Array.from(currentHub.subscribers.values())
280
516
  await Promise.all(subscribers.map(async (subscriber) => {
281
517
  const subscriberRes = subscriber.response
282
518
  if (subscriberRes.writableEnded || subscriberRes.destroyed) {
283
519
  currentHub.subscribers.delete(subscriber.id)
520
+ logWatchStats('subscriber-auto-removed', { streamKey, subscriberId: subscriber.id })
284
521
  return
285
522
  }
286
523
 
@@ -289,8 +526,8 @@ export const functionsController: FunctionController = async (
289
526
  (change as { fullDocument?: { _id?: unknown } })?.fullDocument?._id
290
527
  if (typeof docId === 'undefined') return
291
528
 
292
- const readQuery = subscriber.extraFilter
293
- ? ({ $and: [subscriber.extraFilter, { _id: docId }] } as Document)
529
+ const readQuery = subscriber.documentFilter
530
+ ? ({ $and: [subscriber.documentFilter, { _id: docId }] } as Document)
294
531
  : ({ _id: docId } as Document)
295
532
 
296
533
  try {
@@ -301,6 +538,7 @@ export const functionsController: FunctionController = async (
301
538
  .db(currentHub.database)
302
539
  .collection(currentHub.collection)
303
540
  .findOne(readQuery)
541
+ console.log("🚀 ~ onHubChange ~ readableDoc:", readableDoc)
304
542
 
305
543
  if (!isReadableDocumentResult(readableDoc)) return
306
544
  subscriberRes.write(`data: ${serializeEjson(change)}\n\n`)
@@ -308,6 +546,7 @@ export const functionsController: FunctionController = async (
308
546
  subscriberRes.write(`event: error\ndata: ${formatFunctionExecutionError(error)}\n\n`)
309
547
  subscriberRes.end()
310
548
  currentHub.subscribers.delete(subscriber.id)
549
+ logWatchStats('subscriber-error-removed', { streamKey, subscriberId: subscriber.id })
311
550
  }
312
551
  }))
313
552
 
@@ -339,17 +578,25 @@ export const functionsController: FunctionController = async (
339
578
  id: subscriberId,
340
579
  user,
341
580
  response: res.raw,
342
- extraFilter
581
+ documentFilter: (() => {
582
+ if (!extraFilter) return undefined
583
+ const mapped = mapWatchFilterToDocumentQuery(extraFilter)
584
+ if (!isRecord(mapped) || Object.keys(mapped).length === 0) return undefined
585
+ return mapped as Document
586
+ })()
343
587
  }
344
588
  hub.subscribers.set(subscriberId, subscriber)
589
+ logWatchStats('subscriber-added', { streamKey, subscriberId })
345
590
 
346
591
  req.raw.on('close', () => {
347
592
  const currentHub = sharedWatchStreams.get(streamKey)
348
593
  if (!currentHub) return
349
594
  currentHub.subscribers.delete(subscriberId)
595
+ logWatchStats('subscriber-closed', { streamKey, subscriberId })
350
596
  if (!currentHub.subscribers.size) {
351
597
  void currentHub.stream.close()
352
598
  sharedWatchStreams.delete(streamKey)
599
+ logWatchStats('hub-empty-closed', { streamKey })
353
600
  }
354
601
  })
355
602
  })