@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.
- 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 +230 -16
- package/package.json +1 -1
- package/src/features/functions/__tests__/watch-filter.test.ts +116 -0
- package/src/features/functions/controller.ts +266 -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,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 =
|
|
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,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,
|
|
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 =
|
|
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
|
-
|
|
197
|
-
|
|
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.
|
|
235
|
-
? { $and: [subscriber.
|
|
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
|
-
|
|
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
|
@@ -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,9 @@ 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 : {}
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
293
|
-
? ({ $and: [subscriber.
|
|
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
|
-
|
|
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
|
})
|