@flowerforce/flowerbase 1.7.6-beta.1 → 1.7.6-beta.2
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 +7 -1
- package/dist/services/mongodb-atlas/index.d.ts +3 -0
- package/dist/services/mongodb-atlas/index.d.ts.map +1 -1
- package/dist/services/mongodb-atlas/index.js +88 -10
- package/package.json +1 -1
- package/src/features/functions/__tests__/watch-filter.test.ts +11 -1
- package/src/features/functions/controller.ts +8 -0
- package/src/services/mongodb-atlas/__tests__/watch-filter.test.ts +78 -0
- package/src/services/mongodb-atlas/index.ts +92 -6
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import type { Document } from 'mongodb';
|
|
1
2
|
import { FunctionController } from './interface';
|
|
2
3
|
export declare const mapWatchFilterToChangeStreamMatch: (value: unknown) => unknown;
|
|
3
4
|
export declare const mapWatchFilterToDocumentQuery: (value: unknown) => unknown;
|
|
5
|
+
export declare const shouldSkipReadabilityLookupForChange: (change: Document) => boolean;
|
|
4
6
|
/**
|
|
5
7
|
* > Creates a pre handler for every query
|
|
6
8
|
* @param app -> the fastify instance
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../../src/features/functions/controller.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../../../src/features/functions/controller.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAIvC,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,eAAO,MAAM,oCAAoC,GAAI,QAAQ,QAAQ,YAClC,CAAA;AAEnC;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,EAAE,kBAyRjC,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 = exports.mapWatchFilterToDocumentQuery = exports.mapWatchFilterToChangeStreamMatch = void 0;
|
|
23
|
+
exports.functionsController = exports.shouldSkipReadabilityLookupForChange = 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");
|
|
@@ -262,6 +262,8 @@ const isReadableDocumentResult = (value) => !!value &&
|
|
|
262
262
|
typeof value === 'object' &&
|
|
263
263
|
!Array.isArray(value) &&
|
|
264
264
|
Object.keys(value).length > 0;
|
|
265
|
+
const shouldSkipReadabilityLookupForChange = (change) => change.operationType === 'delete';
|
|
266
|
+
exports.shouldSkipReadabilityLookupForChange = shouldSkipReadabilityLookupForChange;
|
|
265
267
|
/**
|
|
266
268
|
* > Creates a pre handler for every query
|
|
267
269
|
* @param app -> the fastify instance
|
|
@@ -431,6 +433,10 @@ const functionsController = (app_1, _a) => __awaiter(void 0, [app_1, _a], void 0
|
|
|
431
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;
|
|
432
434
|
if (typeof docId === 'undefined')
|
|
433
435
|
return;
|
|
436
|
+
if ((0, exports.shouldSkipReadabilityLookupForChange)(change)) {
|
|
437
|
+
subscriberRes.write(`data: ${serializeEjson(change)}\n\n`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
434
440
|
const readQuery = subscriber.documentFilter
|
|
435
441
|
? { $and: [subscriber.documentFilter, { _id: docId }] }
|
|
436
442
|
: { _id: docId };
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { Document } from 'mongodb';
|
|
1
2
|
import { MongodbAtlasFunction } from './model';
|
|
3
|
+
export declare const toWatchMatchFilter: (value: unknown) => unknown;
|
|
4
|
+
export declare const watchPipelineRequestsDelete: (pipeline: Document[]) => boolean;
|
|
2
5
|
declare const MongodbAtlas: MongodbAtlasFunction;
|
|
3
6
|
export default MongodbAtlas;
|
|
4
7
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAIL,QAAQ,EAQT,MAAM,SAAS,CAAA;AAOhB,OAAO,EAGL,oBAAoB,EAErB,MAAM,SAAS,CAAA;AAgJhB,eAAO,MAAM,kBAAkB,GAAI,OAAO,OAAO,KAAG,OA0BnD,CAAA;AA4BD,eAAO,MAAM,2BAA2B,GAAI,UAAU,QAAQ,EAAE,YAK5D,CAAA;AAgtCJ,QAAA,MAAM,YAAY,EAAE,oBAwBlB,CAAA;AAEF,eAAe,YAAY,CAAA"}
|
|
@@ -23,6 +23,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
23
23
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
24
24
|
};
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.watchPipelineRequestsDelete = exports.toWatchMatchFilter = void 0;
|
|
26
27
|
const cloneDeep_1 = __importDefault(require("lodash/cloneDeep"));
|
|
27
28
|
const get_1 = __importDefault(require("lodash/get"));
|
|
28
29
|
const isEqual_1 = __importDefault(require("lodash/isEqual"));
|
|
@@ -72,6 +73,12 @@ const findOptionKeys = new Set([
|
|
|
72
73
|
'projection'
|
|
73
74
|
]);
|
|
74
75
|
const isPlainObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
76
|
+
const isTraversablePlainObject = (value) => {
|
|
77
|
+
if (!isPlainObject(value))
|
|
78
|
+
return false;
|
|
79
|
+
const prototype = Object.getPrototypeOf(value);
|
|
80
|
+
return prototype === Object.prototype || prototype === null;
|
|
81
|
+
};
|
|
75
82
|
const looksLikeFindOptions = (value) => {
|
|
76
83
|
if (!isPlainObject(value))
|
|
77
84
|
return false;
|
|
@@ -114,21 +121,81 @@ const normalizeFindOneAndUpdateOptions = (options) => {
|
|
|
114
121
|
return Object.assign(Object.assign({}, rest), { returnDocument: returnNewDocument ? 'after' : 'before' });
|
|
115
122
|
};
|
|
116
123
|
const buildAndQuery = (clauses) => clauses.length ? { $and: clauses } : {};
|
|
124
|
+
const watchChangeEventRootKeys = new Set([
|
|
125
|
+
'_id',
|
|
126
|
+
'operationType',
|
|
127
|
+
'clusterTime',
|
|
128
|
+
'txnNumber',
|
|
129
|
+
'lsid',
|
|
130
|
+
'ns',
|
|
131
|
+
'documentKey',
|
|
132
|
+
'fullDocument',
|
|
133
|
+
'updateDescription'
|
|
134
|
+
]);
|
|
135
|
+
const isWatchChangeEventPath = (key) => {
|
|
136
|
+
if (watchChangeEventRootKeys.has(key))
|
|
137
|
+
return true;
|
|
138
|
+
return (key.startsWith('ns.') ||
|
|
139
|
+
key.startsWith('documentKey.') ||
|
|
140
|
+
key.startsWith('fullDocument.') ||
|
|
141
|
+
key.startsWith('updateDescription.'));
|
|
142
|
+
};
|
|
143
|
+
const isWatchOpaqueChangeEventObjectKey = (key) => key === 'ns' || key === 'documentKey' || key === 'fullDocument' || key === 'updateDescription';
|
|
117
144
|
const toWatchMatchFilter = (value) => {
|
|
118
145
|
if (Array.isArray(value)) {
|
|
119
|
-
return value.map((item) => toWatchMatchFilter(item));
|
|
146
|
+
return value.map((item) => (0, exports.toWatchMatchFilter)(item));
|
|
120
147
|
}
|
|
121
|
-
if (!
|
|
148
|
+
if (!isTraversablePlainObject(value))
|
|
122
149
|
return value;
|
|
123
150
|
return Object.entries(value).reduce((acc, [key, current]) => {
|
|
124
151
|
if (key.startsWith('$')) {
|
|
125
|
-
acc[key] = toWatchMatchFilter(current);
|
|
152
|
+
acc[key] = (0, exports.toWatchMatchFilter)(current);
|
|
126
153
|
return acc;
|
|
127
154
|
}
|
|
128
|
-
|
|
155
|
+
if (isWatchOpaqueChangeEventObjectKey(key)) {
|
|
156
|
+
acc[key] = current;
|
|
157
|
+
return acc;
|
|
158
|
+
}
|
|
159
|
+
if (isWatchChangeEventPath(key)) {
|
|
160
|
+
acc[key] = (0, exports.toWatchMatchFilter)(current);
|
|
161
|
+
return acc;
|
|
162
|
+
}
|
|
163
|
+
acc[`fullDocument.${key}`] = (0, exports.toWatchMatchFilter)(current);
|
|
129
164
|
return acc;
|
|
130
165
|
}, {});
|
|
131
166
|
};
|
|
167
|
+
exports.toWatchMatchFilter = toWatchMatchFilter;
|
|
168
|
+
const isDeleteOperationValue = (value) => {
|
|
169
|
+
if (typeof value === 'string')
|
|
170
|
+
return value.toLowerCase() === 'delete';
|
|
171
|
+
if (isPlainObject(value) && Array.isArray(value.$in)) {
|
|
172
|
+
return value.$in.some((entry) => isDeleteOperationValue(entry));
|
|
173
|
+
}
|
|
174
|
+
if (Array.isArray(value)) {
|
|
175
|
+
return value.some((entry) => isDeleteOperationValue(entry));
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
};
|
|
179
|
+
const hasDeleteOperationType = (value) => {
|
|
180
|
+
if (Array.isArray(value)) {
|
|
181
|
+
return value.some((entry) => hasDeleteOperationType(entry));
|
|
182
|
+
}
|
|
183
|
+
if (!isTraversablePlainObject(value))
|
|
184
|
+
return false;
|
|
185
|
+
return Object.entries(value).some(([key, current]) => {
|
|
186
|
+
if (key === 'operationType') {
|
|
187
|
+
return isDeleteOperationValue(current);
|
|
188
|
+
}
|
|
189
|
+
return hasDeleteOperationType(current);
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
const watchPipelineRequestsDelete = (pipeline) => pipeline.some((stage) => {
|
|
193
|
+
if (!isTraversablePlainObject(stage))
|
|
194
|
+
return false;
|
|
195
|
+
const match = stage.$match;
|
|
196
|
+
return hasDeleteOperationType(match);
|
|
197
|
+
});
|
|
198
|
+
exports.watchPipelineRequestsDelete = watchPipelineRequestsDelete;
|
|
132
199
|
const resolveWatchArgs = (pipelineOrOptions, options) => {
|
|
133
200
|
var _a;
|
|
134
201
|
const inputPipeline = Array.isArray(pipelineOrOptions) ? pipelineOrOptions : [];
|
|
@@ -143,7 +210,7 @@ const resolveWatchArgs = (pipelineOrOptions, options) => {
|
|
|
143
210
|
const _b = rawOptions, { filter: watchFilter, ids } = _b, watchOptions = __rest(_b, ["filter", "ids"]);
|
|
144
211
|
const extraMatches = [];
|
|
145
212
|
if (typeof watchFilter !== 'undefined') {
|
|
146
|
-
extraMatches.push({ $match: toWatchMatchFilter(watchFilter) });
|
|
213
|
+
extraMatches.push({ $match: (0, exports.toWatchMatchFilter)(watchFilter) });
|
|
147
214
|
}
|
|
148
215
|
if (Array.isArray(ids)) {
|
|
149
216
|
extraMatches.push({
|
|
@@ -861,15 +928,26 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
|
|
|
861
928
|
(0, utils_3.checkDenyOperation)(normalizedRules, collection.collectionName, model_1.CRUD_OPERATIONS.READ);
|
|
862
929
|
// Apply access filters to initial change stream pipeline
|
|
863
930
|
const formattedQuery = (0, utils_3.getFormattedQuery)(filters, {}, user);
|
|
864
|
-
const watchFormattedQuery = formattedQuery.map((condition) => toWatchMatchFilter(condition));
|
|
931
|
+
const watchFormattedQuery = formattedQuery.map((condition) => (0, exports.toWatchMatchFilter)(condition));
|
|
932
|
+
const requestedPipeline = [...extraMatches, ...pipeline];
|
|
933
|
+
const allowDeleteBypass = (0, exports.watchPipelineRequestsDelete)(requestedPipeline);
|
|
865
934
|
const firstStep = watchFormattedQuery.length
|
|
866
935
|
? {
|
|
867
|
-
$match:
|
|
868
|
-
|
|
869
|
-
|
|
936
|
+
$match: allowDeleteBypass
|
|
937
|
+
? {
|
|
938
|
+
$or: [
|
|
939
|
+
{
|
|
940
|
+
$and: watchFormattedQuery
|
|
941
|
+
},
|
|
942
|
+
{ operationType: 'delete' }
|
|
943
|
+
]
|
|
944
|
+
}
|
|
945
|
+
: {
|
|
946
|
+
$and: watchFormattedQuery
|
|
947
|
+
}
|
|
870
948
|
}
|
|
871
949
|
: undefined;
|
|
872
|
-
const formattedPipeline = [firstStep, ...
|
|
950
|
+
const formattedPipeline = [firstStep, ...requestedPipeline].filter(Boolean);
|
|
873
951
|
const result = changestreamCollection.watch(formattedPipeline, watchOptions);
|
|
874
952
|
const originalOn = result.on.bind(result);
|
|
875
953
|
/**
|
package/package.json
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { ObjectId } from 'mongodb'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
mapWatchFilterToChangeStreamMatch,
|
|
4
|
+
mapWatchFilterToDocumentQuery,
|
|
5
|
+
shouldSkipReadabilityLookupForChange
|
|
6
|
+
} from '../controller'
|
|
3
7
|
|
|
4
8
|
describe('watch filter mapping', () => {
|
|
5
9
|
it('keeps change-event fields untouched and prefixes only document fields', () => {
|
|
@@ -113,4 +117,10 @@ describe('watch filter mapping', () => {
|
|
|
113
117
|
expect(documentQuery._id).toEqual(id)
|
|
114
118
|
expect(documentQuery.operationType).toBeUndefined()
|
|
115
119
|
})
|
|
120
|
+
|
|
121
|
+
it('skips readability lookup only for delete change events', () => {
|
|
122
|
+
expect(shouldSkipReadabilityLookupForChange({ operationType: 'delete' } as any)).toBe(true)
|
|
123
|
+
expect(shouldSkipReadabilityLookupForChange({ operationType: 'update' } as any)).toBe(false)
|
|
124
|
+
expect(shouldSkipReadabilityLookupForChange({ operationType: 'insert' } as any)).toBe(false)
|
|
125
|
+
})
|
|
116
126
|
})
|
|
@@ -315,6 +315,9 @@ const isReadableDocumentResult = (value: unknown) =>
|
|
|
315
315
|
!Array.isArray(value) &&
|
|
316
316
|
Object.keys(value as Record<string, unknown>).length > 0
|
|
317
317
|
|
|
318
|
+
export const shouldSkipReadabilityLookupForChange = (change: Document) =>
|
|
319
|
+
change.operationType === 'delete'
|
|
320
|
+
|
|
318
321
|
/**
|
|
319
322
|
* > Creates a pre handler for every query
|
|
320
323
|
* @param app -> the fastify instance
|
|
@@ -524,6 +527,11 @@ export const functionsController: FunctionController = async (
|
|
|
524
527
|
(change as { fullDocument?: { _id?: unknown } })?.fullDocument?._id
|
|
525
528
|
if (typeof docId === 'undefined') return
|
|
526
529
|
|
|
530
|
+
if (shouldSkipReadabilityLookupForChange(change)) {
|
|
531
|
+
subscriberRes.write(`data: ${serializeEjson(change)}\n\n`)
|
|
532
|
+
return
|
|
533
|
+
}
|
|
534
|
+
|
|
527
535
|
const readQuery = subscriber.documentFilter
|
|
528
536
|
? ({ $and: [subscriber.documentFilter, { _id: docId }] } as Document)
|
|
529
537
|
: ({ _id: docId } as Document)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { ObjectId } from 'mongodb'
|
|
2
|
+
import { toWatchMatchFilter, watchPipelineRequestsDelete } from '../index'
|
|
3
|
+
|
|
4
|
+
describe('mongodb-atlas watch filter mapping', () => {
|
|
5
|
+
it('keeps change-event keys untouched and prefixes only document keys', () => {
|
|
6
|
+
const input = {
|
|
7
|
+
accountId: '699efbc09729e3b79f79e9b4',
|
|
8
|
+
operationType: 'delete',
|
|
9
|
+
$or: [
|
|
10
|
+
{ requestId: '69a282a75cd849c244e001ca' },
|
|
11
|
+
{ 'documentKey._id': '69a282a75cd849c244e001ca' }
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const output = toWatchMatchFilter(input)
|
|
16
|
+
expect(output).toEqual({
|
|
17
|
+
'fullDocument.accountId': '699efbc09729e3b79f79e9b4',
|
|
18
|
+
operationType: 'delete',
|
|
19
|
+
$or: [
|
|
20
|
+
{ 'fullDocument.requestId': '69a282a75cd849c244e001ca' },
|
|
21
|
+
{ 'documentKey._id': '69a282a75cd849c244e001ca' }
|
|
22
|
+
]
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const outputJson = JSON.stringify(output)
|
|
26
|
+
expect(outputJson).not.toContain('fullDocument.operationType')
|
|
27
|
+
expect(outputJson).not.toContain('fullDocument.documentKey.')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('preserves ObjectId values in watch filter mapping', () => {
|
|
31
|
+
const id = new ObjectId('69a282a75cd849c244e001ca')
|
|
32
|
+
const input = {
|
|
33
|
+
operationType: 'update',
|
|
34
|
+
'documentKey._id': id,
|
|
35
|
+
requestId: id
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const output = toWatchMatchFilter(input) as Record<string, unknown>
|
|
39
|
+
expect(output.operationType).toBe('update')
|
|
40
|
+
expect(output['documentKey._id']).toEqual(id)
|
|
41
|
+
expect(output['fullDocument.requestId']).toEqual(id)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('detects delete operation requests in watch pipeline matches', () => {
|
|
45
|
+
expect(
|
|
46
|
+
watchPipelineRequestsDelete([
|
|
47
|
+
{
|
|
48
|
+
$match: {
|
|
49
|
+
$or: [
|
|
50
|
+
{ operationType: 'insert' },
|
|
51
|
+
{ operationType: 'delete' }
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
] as any)
|
|
56
|
+
).toBe(true)
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
watchPipelineRequestsDelete([
|
|
60
|
+
{
|
|
61
|
+
$match: {
|
|
62
|
+
operationType: { $in: ['insert', 'replace'] }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
] as any)
|
|
66
|
+
).toBe(false)
|
|
67
|
+
|
|
68
|
+
expect(
|
|
69
|
+
watchPipelineRequestsDelete([
|
|
70
|
+
{
|
|
71
|
+
$project: {
|
|
72
|
+
operationType: 1
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
] as any)
|
|
76
|
+
).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -79,6 +79,12 @@ const findOptionKeys = new Set([
|
|
|
79
79
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
80
80
|
!!value && typeof value === 'object' && !Array.isArray(value)
|
|
81
81
|
|
|
82
|
+
const isTraversablePlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
83
|
+
if (!isPlainObject(value)) return false
|
|
84
|
+
const prototype = Object.getPrototypeOf(value)
|
|
85
|
+
return prototype === Object.prototype || prototype === null
|
|
86
|
+
}
|
|
87
|
+
|
|
82
88
|
const looksLikeFindOptions = (value: unknown) => {
|
|
83
89
|
if (!isPlainObject(value)) return false
|
|
84
90
|
return Object.keys(value).some((key) => findOptionKeys.has(key))
|
|
@@ -140,23 +146,92 @@ const normalizeFindOneAndUpdateOptions = (
|
|
|
140
146
|
const buildAndQuery = (clauses: MongoFilter<Document>[]): MongoFilter<Document> =>
|
|
141
147
|
clauses.length ? { $and: clauses } : {}
|
|
142
148
|
|
|
143
|
-
const
|
|
149
|
+
const watchChangeEventRootKeys = new Set([
|
|
150
|
+
'_id',
|
|
151
|
+
'operationType',
|
|
152
|
+
'clusterTime',
|
|
153
|
+
'txnNumber',
|
|
154
|
+
'lsid',
|
|
155
|
+
'ns',
|
|
156
|
+
'documentKey',
|
|
157
|
+
'fullDocument',
|
|
158
|
+
'updateDescription'
|
|
159
|
+
])
|
|
160
|
+
|
|
161
|
+
const isWatchChangeEventPath = (key: string) => {
|
|
162
|
+
if (watchChangeEventRootKeys.has(key)) return true
|
|
163
|
+
return (
|
|
164
|
+
key.startsWith('ns.') ||
|
|
165
|
+
key.startsWith('documentKey.') ||
|
|
166
|
+
key.startsWith('fullDocument.') ||
|
|
167
|
+
key.startsWith('updateDescription.')
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const isWatchOpaqueChangeEventObjectKey = (key: string) =>
|
|
172
|
+
key === 'ns' || key === 'documentKey' || key === 'fullDocument' || key === 'updateDescription'
|
|
173
|
+
|
|
174
|
+
export const toWatchMatchFilter = (value: unknown): unknown => {
|
|
144
175
|
if (Array.isArray(value)) {
|
|
145
176
|
return value.map((item) => toWatchMatchFilter(item))
|
|
146
177
|
}
|
|
147
178
|
|
|
148
|
-
if (!
|
|
179
|
+
if (!isTraversablePlainObject(value)) return value
|
|
149
180
|
|
|
150
181
|
return Object.entries(value).reduce<Record<string, unknown>>((acc, [key, current]) => {
|
|
151
182
|
if (key.startsWith('$')) {
|
|
152
183
|
acc[key] = toWatchMatchFilter(current)
|
|
153
184
|
return acc
|
|
154
185
|
}
|
|
186
|
+
|
|
187
|
+
if (isWatchOpaqueChangeEventObjectKey(key)) {
|
|
188
|
+
acc[key] = current
|
|
189
|
+
return acc
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (isWatchChangeEventPath(key)) {
|
|
193
|
+
acc[key] = toWatchMatchFilter(current)
|
|
194
|
+
return acc
|
|
195
|
+
}
|
|
196
|
+
|
|
155
197
|
acc[`fullDocument.${key}`] = toWatchMatchFilter(current)
|
|
156
198
|
return acc
|
|
157
199
|
}, {})
|
|
158
200
|
}
|
|
159
201
|
|
|
202
|
+
const isDeleteOperationValue = (value: unknown): boolean => {
|
|
203
|
+
if (typeof value === 'string') return value.toLowerCase() === 'delete'
|
|
204
|
+
if (isPlainObject(value) && Array.isArray((value as { $in?: unknown[] }).$in)) {
|
|
205
|
+
return (value as { $in: unknown[] }).$in.some((entry) => isDeleteOperationValue(entry))
|
|
206
|
+
}
|
|
207
|
+
if (Array.isArray(value)) {
|
|
208
|
+
return value.some((entry) => isDeleteOperationValue(entry))
|
|
209
|
+
}
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const hasDeleteOperationType = (value: unknown): boolean => {
|
|
214
|
+
if (Array.isArray(value)) {
|
|
215
|
+
return value.some((entry) => hasDeleteOperationType(entry))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!isTraversablePlainObject(value)) return false
|
|
219
|
+
|
|
220
|
+
return Object.entries(value).some(([key, current]) => {
|
|
221
|
+
if (key === 'operationType') {
|
|
222
|
+
return isDeleteOperationValue(current)
|
|
223
|
+
}
|
|
224
|
+
return hasDeleteOperationType(current)
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const watchPipelineRequestsDelete = (pipeline: Document[]) =>
|
|
229
|
+
pipeline.some((stage) => {
|
|
230
|
+
if (!isTraversablePlainObject(stage)) return false
|
|
231
|
+
const match = (stage as { $match?: unknown }).$match
|
|
232
|
+
return hasDeleteOperationType(match)
|
|
233
|
+
})
|
|
234
|
+
|
|
160
235
|
type RealmCompatibleWatchOptions = Document & {
|
|
161
236
|
filter?: MongoFilter<Document>
|
|
162
237
|
ids?: unknown[]
|
|
@@ -1017,15 +1092,26 @@ const getOperators: GetOperatorsFunction = (
|
|
|
1017
1092
|
(condition) => toWatchMatchFilter(condition) as MongoFilter<Document>
|
|
1018
1093
|
)
|
|
1019
1094
|
|
|
1095
|
+
const requestedPipeline = [...extraMatches, ...pipeline]
|
|
1096
|
+
const allowDeleteBypass = watchPipelineRequestsDelete(requestedPipeline)
|
|
1020
1097
|
const firstStep = watchFormattedQuery.length
|
|
1021
1098
|
? {
|
|
1022
|
-
$match:
|
|
1023
|
-
|
|
1024
|
-
|
|
1099
|
+
$match: allowDeleteBypass
|
|
1100
|
+
? {
|
|
1101
|
+
$or: [
|
|
1102
|
+
{
|
|
1103
|
+
$and: watchFormattedQuery
|
|
1104
|
+
},
|
|
1105
|
+
{ operationType: 'delete' }
|
|
1106
|
+
]
|
|
1107
|
+
}
|
|
1108
|
+
: {
|
|
1109
|
+
$and: watchFormattedQuery
|
|
1110
|
+
}
|
|
1025
1111
|
}
|
|
1026
1112
|
: undefined
|
|
1027
1113
|
|
|
1028
|
-
const formattedPipeline = [firstStep, ...
|
|
1114
|
+
const formattedPipeline = [firstStep, ...requestedPipeline].filter(Boolean) as Document[]
|
|
1029
1115
|
|
|
1030
1116
|
const result = changestreamCollection.watch(formattedPipeline, watchOptions)
|
|
1031
1117
|
const originalOn = result.on.bind(result)
|