@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.
- package/dist/features/functions/controller.d.ts +2 -0
- package/dist/features/functions/controller.d.ts.map +1 -1
- package/dist/features/functions/controller.js +227 -16
- package/package.json +1 -1
- package/src/features/functions/__tests__/watch-filter.test.ts +116 -0
- package/src/features/functions/controller.ts +263 -19
|
@@ -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;
|
|
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 =
|
|
78
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
197
|
-
|
|
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.
|
|
235
|
-
? { $and: [subscriber.
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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, ...
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
293
|
-
? ({ $and: [subscriber.
|
|
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
|
-
|
|
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
|
})
|