@livequery/mongodb 2.0.0 → 2.0.148
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/README.md +82 -1
- package/build/src/Cursor.d.ts +2 -3
- package/build/src/MongoDatasource.d.ts +4 -20
- package/build/src/MongoDatasource.js +37 -34
- package/build/src/MongoQuery.d.ts +1 -1
- package/build/src/MongoQuery.js +102 -25
- package/build/src/MongodbRealtime.d.ts +17 -0
- package/build/src/MongodbRealtime.js +206 -0
- package/build/src/SmartCache.d.ts +1 -1
- package/build/src/SmartCache.js +2 -2
- package/build/src/index.d.ts +1 -1
- package/build/src/index.js +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -2
- package/build/src/types.d.ts +0 -46
- package/build/src/types.js +0 -1
package/README.md
CHANGED
|
@@ -24,6 +24,7 @@ For local development in this workspace, `@livequery/core` is installed as a dev
|
|
|
24
24
|
```ts
|
|
25
25
|
export * from './MongoDatasource.js'
|
|
26
26
|
export * from './DataChangePayload.js'
|
|
27
|
+
export * from './MongodbRealtime.js'
|
|
27
28
|
export * from './types.js'
|
|
28
29
|
```
|
|
29
30
|
|
|
@@ -305,6 +306,86 @@ Returns:
|
|
|
305
306
|
|
|
306
307
|
The collection cache key includes connection, database, and collection name to avoid reusing collection handles across tenants or connections.
|
|
307
308
|
|
|
309
|
+
### `MongodbRealtime`
|
|
310
|
+
|
|
311
|
+
MongoDB change stream watcher for realtime Livequery updates. This replaces the need to use the separate `@livequery/mongodb-mapper` package in native MongoDB projects.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import { WebsocketGateway } from '@livequery/core'
|
|
315
|
+
import { MongoDatasource, MongodbRealtime } from '@livequery/mongodb'
|
|
316
|
+
|
|
317
|
+
const datasource = new MongoDatasource({
|
|
318
|
+
connections: { default: client },
|
|
319
|
+
databases: ['main'],
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
await datasource.init([
|
|
323
|
+
{
|
|
324
|
+
method: 'GET',
|
|
325
|
+
path: '/products',
|
|
326
|
+
collection: 'products',
|
|
327
|
+
realtime: true,
|
|
328
|
+
},
|
|
329
|
+
])
|
|
330
|
+
|
|
331
|
+
const websocketGateway = new WebsocketGateway(server)
|
|
332
|
+
|
|
333
|
+
new MongodbRealtime()
|
|
334
|
+
.watch(datasource.config, [
|
|
335
|
+
{
|
|
336
|
+
// LivequeryRequestParser.parse(...).schema — document-id segment already stripped
|
|
337
|
+
schema: 'products',
|
|
338
|
+
options: { collection: 'products', realtime: true },
|
|
339
|
+
},
|
|
340
|
+
])
|
|
341
|
+
.subscribe(websocketGateway)
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`MongoRealtimeRoute`:
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
type MongoRealtimeRoute = {
|
|
348
|
+
schema: string // parsed route path from @livequery/core, e.g. 'users/:userId/posts'
|
|
349
|
+
options: RouteOptions
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Realtime route requirements:
|
|
354
|
+
|
|
355
|
+
- `realtime` must be `true`.
|
|
356
|
+
- `schema` is the parsed route path (`LivequeryRequestParser.parse(...).schema`), so the document-id segment is already stripped and each `:param` names the document field holding the parent value.
|
|
357
|
+
- `collection` must be a static string. Dynamic collection, database, or connection resolver functions are skipped because database watchers must be known up front.
|
|
358
|
+
- When watching a `MongoClient`, `db` or `config.databases` decides which database names to watch. When watching a `Db`, that database is used directly.
|
|
359
|
+
|
|
360
|
+
By default, `MongodbRealtime` enables MongoDB pre/post images with `collMod` and watches with `fullDocument: 'updateLookup'` and `fullDocumentBeforeChange: 'whenAvailable'`.
|
|
361
|
+
|
|
362
|
+
Disable the `collMod` call when your deployment manages pre/post images separately:
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
new MongodbRealtime({ enablePreAndPostImages: false })
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
For nested collection refs, name the route param after the document field that holds the parent value:
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
{
|
|
372
|
+
schema: 'users/:userId/posts',
|
|
373
|
+
options: { collection: 'posts', realtime: true },
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
If the document field is an array (one document belongs to many parents), the change is fanned out to one ref per array element, and array membership changes emit `added`/`removed` per ref.
|
|
378
|
+
|
|
379
|
+
An inserted `{ _id: 'post1', userId: 'user1', title: 'Hello' }` emits:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
{
|
|
383
|
+
ref: 'users/user1/posts',
|
|
384
|
+
type: 'added',
|
|
385
|
+
data: { id: 'post1', userId: 'user1', title: 'Hello' },
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
308
389
|
### `DataChangePayload<T>`
|
|
309
390
|
|
|
310
391
|
Type-only realtime/change payload contract.
|
|
@@ -360,7 +441,7 @@ type RouteOptions = {
|
|
|
360
441
|
|
|
361
442
|
Fields:
|
|
362
443
|
|
|
363
|
-
- `realtime`:
|
|
444
|
+
- `realtime`: marks a static collection route for `MongodbRealtime.watch()`. Query execution itself is unchanged.
|
|
364
445
|
- `collection`: required collection name or resolver function.
|
|
365
446
|
- `db`: optional database name or resolver function.
|
|
366
447
|
- `connection`: optional connection name or resolver function.
|
package/build/src/Cursor.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type { LivequeryBaseEntity, QueryOption } from "./types.js";
|
|
2
1
|
export declare class Cursor {
|
|
3
|
-
static caculate
|
|
4
|
-
static parse
|
|
2
|
+
static caculate(item: Record<string, any>, options: Record<string, any>): string;
|
|
3
|
+
static parse(cursor: string): any;
|
|
5
4
|
}
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import type { LivequeryContext, LivequeryDatasource as CoreLivequeryDatasource, LivequeryDatasourceInitConfig } from '@livequery/core';
|
|
2
|
-
import type { LivequeryRequest, LivequeryBaseEntity,
|
|
2
|
+
import type { LivequeryRequest, LivequeryBaseEntity, UpdatedData } from '@livequery/core';
|
|
3
3
|
import type { Db, MongoClient } from 'mongodb';
|
|
4
4
|
import { Subject } from 'rxjs';
|
|
5
|
-
export type LivequeryDatasource<Config, RouteOptions> = Subject<WebsocketSyncPayload<LivequeryBaseEntity>> & {
|
|
6
|
-
init(config: Config, routes: Array<{
|
|
7
|
-
path: string;
|
|
8
|
-
method: number;
|
|
9
|
-
options: RouteOptions;
|
|
10
|
-
}>): Promise<void>;
|
|
11
|
-
query: (query: LivequeryRequest, options: RouteOptions) => Promise<any>;
|
|
12
|
-
};
|
|
13
5
|
export type MongoConnection = MongoClient | Db;
|
|
14
6
|
export type MongoDatasourceConfig = {
|
|
15
7
|
connections: {
|
|
@@ -17,12 +9,6 @@ export type MongoDatasourceConfig = {
|
|
|
17
9
|
};
|
|
18
10
|
databases?: string[];
|
|
19
11
|
};
|
|
20
|
-
type LegacyRouteConfig<RouteOptions> = {
|
|
21
|
-
path: string;
|
|
22
|
-
method: number | string;
|
|
23
|
-
options?: RouteOptions;
|
|
24
|
-
config?: RouteOptions;
|
|
25
|
-
};
|
|
26
12
|
export type RouteOptions = {
|
|
27
13
|
realtime?: boolean;
|
|
28
14
|
collection: string | ((req: LivequeryRequest) => Promise<string> | string);
|
|
@@ -30,16 +16,15 @@ export type RouteOptions = {
|
|
|
30
16
|
connection?: string | ((req: LivequeryRequest) => Promise<string> | string);
|
|
31
17
|
objectIdFields?: string[];
|
|
32
18
|
};
|
|
33
|
-
export declare class MongoDatasource extends Subject<
|
|
19
|
+
export declare class MongoDatasource extends Subject<UpdatedData<LivequeryBaseEntity>> implements CoreLivequeryDatasource<RouteOptions> {
|
|
34
20
|
#private;
|
|
35
21
|
readonly refs: Map<string, Set<string>>;
|
|
36
22
|
config: MongoDatasourceConfig;
|
|
37
23
|
routes: Map<string, RouteOptions>;
|
|
38
24
|
constructor(config?: MongoDatasourceConfig);
|
|
39
25
|
init(routes: Array<LivequeryDatasourceInitConfig<RouteOptions>>): Promise<void>;
|
|
40
|
-
init(config: MongoDatasourceConfig, routes: Array<LegacyRouteConfig<RouteOptions>>): Promise<void>;
|
|
41
26
|
handle(ctx: LivequeryContext): Promise<{}>;
|
|
42
|
-
query(req: LivequeryRequest, options: RouteOptions): Promise<
|
|
27
|
+
query(req: LivequeryRequest, options: RouteOptions): Promise<{
|
|
43
28
|
items: any[];
|
|
44
29
|
summary: any;
|
|
45
30
|
cursor: {
|
|
@@ -62,6 +47,5 @@ export declare class MongoDatasource extends Subject<WebsocketSyncPayload<Livequ
|
|
|
62
47
|
};
|
|
63
48
|
} | {
|
|
64
49
|
item: any;
|
|
65
|
-
}
|
|
50
|
+
}>;
|
|
66
51
|
}
|
|
67
|
-
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Cursor } from './Cursor.js';
|
|
2
2
|
import { MongoQuery } from "./MongoQuery.js";
|
|
3
|
-
import { ObjectId } from '
|
|
3
|
+
import { ObjectId } from 'mongodb';
|
|
4
4
|
import { SmartCache } from './SmartCache.js';
|
|
5
5
|
import { Subject } from 'rxjs';
|
|
6
6
|
export class MongoDatasource extends Subject {
|
|
@@ -14,23 +14,18 @@ export class MongoDatasource extends Subject {
|
|
|
14
14
|
this.config = config;
|
|
15
15
|
this.routes = new Map();
|
|
16
16
|
}
|
|
17
|
-
async init(
|
|
18
|
-
const routes = Array.isArray(configOrRoutes)
|
|
19
|
-
? configOrRoutes
|
|
20
|
-
: maybeRoutes || [];
|
|
21
|
-
if (!Array.isArray(configOrRoutes)) {
|
|
22
|
-
this.config = configOrRoutes;
|
|
23
|
-
}
|
|
17
|
+
async init(routes) {
|
|
24
18
|
this.routes = routes.reduce((p, c) => {
|
|
25
|
-
const options =
|
|
26
|
-
|
|
19
|
+
const { method, path, ...options } = c;
|
|
20
|
+
const routeOptions = options;
|
|
21
|
+
if (!routeOptions.collection)
|
|
27
22
|
return p;
|
|
28
23
|
const key = this.#routeKey(c.method, c.path);
|
|
29
24
|
const set = p.get(key) || p.get(c.path);
|
|
30
|
-
if (set && set.collection !=
|
|
25
|
+
if (set && set.collection != routeOptions.collection)
|
|
31
26
|
throw new Error('Collection mismatch for route path "' + c.path + '"');
|
|
32
|
-
p.set(key,
|
|
33
|
-
p.set(c.path,
|
|
27
|
+
p.set(key, routeOptions);
|
|
28
|
+
p.set(c.path, routeOptions);
|
|
34
29
|
return p;
|
|
35
30
|
}, new Map());
|
|
36
31
|
}
|
|
@@ -59,15 +54,6 @@ export class MongoDatasource extends Subject {
|
|
|
59
54
|
return this.#del(query, collection);
|
|
60
55
|
throw { status: 500, code: 'INVAILD_METHOD', message: 'Invaild method' };
|
|
61
56
|
}
|
|
62
|
-
#getRouteOptions(route) {
|
|
63
|
-
const legacy = route;
|
|
64
|
-
if (legacy.options)
|
|
65
|
-
return legacy.options;
|
|
66
|
-
if (legacy.config)
|
|
67
|
-
return legacy.config;
|
|
68
|
-
const { method, path, ...options } = route;
|
|
69
|
-
return options;
|
|
70
|
-
}
|
|
71
57
|
#getOptions(ctx) {
|
|
72
58
|
const routePath = ctx.request.ref || ctx.request.path;
|
|
73
59
|
const options = this.routes.get(this.#routeKey(ctx.request.method, routePath)) || this.routes.get(routePath);
|
|
@@ -87,23 +73,20 @@ export class MongoDatasource extends Subject {
|
|
|
87
73
|
is_collection: !livequery.document_id,
|
|
88
74
|
collection_ref: livequery.collection_ref,
|
|
89
75
|
schema_collection_ref: livequery.schema_collection_ref,
|
|
90
|
-
|
|
76
|
+
document_id: livequery.document_id,
|
|
91
77
|
keys: livequery.keys || {},
|
|
92
78
|
query: livequery.query || {},
|
|
93
|
-
options: livequery.query || {},
|
|
94
79
|
method: livequery.method?.toLowerCase(),
|
|
95
80
|
body: livequery.body,
|
|
96
81
|
};
|
|
97
82
|
}
|
|
98
83
|
#normalizeRequest(req) {
|
|
99
|
-
const
|
|
84
|
+
const query = req.query || {};
|
|
100
85
|
return {
|
|
101
86
|
...req,
|
|
102
87
|
keys: req.keys || {},
|
|
103
|
-
query
|
|
104
|
-
options,
|
|
88
|
+
query,
|
|
105
89
|
is_collection: typeof req.is_collection == 'boolean' ? req.is_collection : !req.document_id,
|
|
106
|
-
doc_id: req.doc_id || req.document_id,
|
|
107
90
|
method: req.method?.toLowerCase(),
|
|
108
91
|
};
|
|
109
92
|
}
|
|
@@ -128,8 +111,8 @@ export class MongoDatasource extends Subject {
|
|
|
128
111
|
const total = current + count.next + count.prev;
|
|
129
112
|
const paging = {
|
|
130
113
|
cursor: {
|
|
131
|
-
last: Cursor.caculate(items[items.length - 1], req.
|
|
132
|
-
first: Cursor.caculate(items[0], req.
|
|
114
|
+
last: Cursor.caculate(items[items.length - 1], req.query),
|
|
115
|
+
first: Cursor.caculate(items[0], req.query)
|
|
133
116
|
},
|
|
134
117
|
has,
|
|
135
118
|
count: {
|
|
@@ -186,26 +169,46 @@ export class MongoDatasource extends Subject {
|
|
|
186
169
|
};
|
|
187
170
|
}
|
|
188
171
|
async #put(req, collection) {
|
|
189
|
-
|
|
172
|
+
await collection.updateOne(this.#keys(req), this.#update(req.body));
|
|
173
|
+
return { item: this.#writtenItem(req) };
|
|
190
174
|
}
|
|
191
175
|
async #patch(req, collection) {
|
|
192
|
-
|
|
176
|
+
await collection.updateOne(this.#keys(req), this.#update(req.body));
|
|
177
|
+
return { item: this.#writtenItem(req) };
|
|
193
178
|
}
|
|
194
179
|
async #del(req, collection) {
|
|
195
|
-
|
|
180
|
+
await collection.deleteOne(this.#keys(req));
|
|
181
|
+
return { item: this.#writtenItem(req) };
|
|
182
|
+
}
|
|
183
|
+
#writtenItem(req) {
|
|
184
|
+
const keys = req.keys || {};
|
|
185
|
+
const isPlainBody = req.body && typeof req.body === 'object'
|
|
186
|
+
&& !Object.keys(req.body).some(k => k.startsWith('$'));
|
|
187
|
+
const id = keys.id ?? req.document_id;
|
|
188
|
+
return {
|
|
189
|
+
...keys,
|
|
190
|
+
...isPlainBody ? req.body : {},
|
|
191
|
+
...id ? { id } : {}
|
|
192
|
+
};
|
|
196
193
|
}
|
|
197
194
|
#keys(req) {
|
|
198
195
|
return Object.entries(req.keys).reduce((p, [k, c]) => {
|
|
199
196
|
return {
|
|
200
197
|
...p,
|
|
201
198
|
...k == 'id' ? {
|
|
202
|
-
_id:
|
|
199
|
+
_id: this.#objectId('id', req.keys.id)
|
|
203
200
|
} : {
|
|
204
201
|
[k]: c
|
|
205
202
|
}
|
|
206
203
|
};
|
|
207
204
|
}, {});
|
|
208
205
|
}
|
|
206
|
+
#objectId(field, value) {
|
|
207
|
+
if (typeof value != 'string' || !ObjectId.isValid(value)) {
|
|
208
|
+
throw { status: 400, code: 'INVALID_OBJECT_ID', message: `Invalid ObjectId for field "${field}": ${JSON.stringify(value)}` };
|
|
209
|
+
}
|
|
210
|
+
return ObjectId.createFromHexString(value);
|
|
211
|
+
}
|
|
209
212
|
#update(body) {
|
|
210
213
|
if (!body || Object.keys(body).some(key => key.startsWith('$')))
|
|
211
214
|
return body;
|
package/build/src/MongoQuery.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Cursor } from "./Cursor.js";
|
|
2
|
-
import { ObjectId } from "
|
|
2
|
+
import { ObjectId } from "mongodb";
|
|
3
3
|
export class MongoQuery {
|
|
4
4
|
static #is_operator(c) {
|
|
5
5
|
return ['+', '-', '*', '/', '(', ')', '~'].indexOf(c) !== -1;
|
|
@@ -91,6 +91,23 @@ export class MongoQuery {
|
|
|
91
91
|
}
|
|
92
92
|
return stack.pop();
|
|
93
93
|
}
|
|
94
|
+
static #escape_regex(value) {
|
|
95
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
96
|
+
}
|
|
97
|
+
static #objectId(field, value) {
|
|
98
|
+
if (typeof value != 'string' || !ObjectId.isValid(value)) {
|
|
99
|
+
throw { status: 400, code: 'INVALID_OBJECT_ID', message: `Invalid ObjectId for field "${field}": ${JSON.stringify(value)}` };
|
|
100
|
+
}
|
|
101
|
+
return ObjectId.createFromHexString(value);
|
|
102
|
+
}
|
|
103
|
+
static #parse_cursor(token) {
|
|
104
|
+
try {
|
|
105
|
+
return Cursor.parse(token);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw { status: 400, code: 'INVALID_CURSOR', message: 'Invalid pagination cursor' };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
94
111
|
static #parse_array(value) {
|
|
95
112
|
if (Array.isArray(value))
|
|
96
113
|
return value;
|
|
@@ -106,7 +123,7 @@ export class MongoQuery {
|
|
|
106
123
|
}
|
|
107
124
|
static #parse_summary(req) {
|
|
108
125
|
const parsed = Object
|
|
109
|
-
.entries(req.
|
|
126
|
+
.entries(req.query)
|
|
110
127
|
.map(([key, v], index) => {
|
|
111
128
|
if (!key.startsWith('::'))
|
|
112
129
|
return [];
|
|
@@ -201,19 +218,25 @@ export class MongoQuery {
|
|
|
201
218
|
};
|
|
202
219
|
}
|
|
203
220
|
static #parse_conditions(filters) {
|
|
221
|
+
const $match = this.#build_match(filters);
|
|
222
|
+
return Object.keys($match).length > 0 ? [{ $match }] : [];
|
|
223
|
+
}
|
|
224
|
+
static #build_match(filters) {
|
|
204
225
|
if (!filters)
|
|
205
|
-
return
|
|
226
|
+
return {};
|
|
206
227
|
const { ':and': and, ':or': or, ':not': not, ...rest } = filters;
|
|
207
228
|
const $or = Object.entries(rest).filter(([k]) => k.endsWith(':like')).map(([k, v]) => {
|
|
208
229
|
const key = k.split(':like')[0];
|
|
209
230
|
const value = `${v}`;
|
|
210
231
|
return {
|
|
211
|
-
[key]: { $regex: value }
|
|
232
|
+
[key]: { $regex: this.#escape_regex(value), $options: 'i' }
|
|
212
233
|
};
|
|
213
234
|
});
|
|
214
|
-
const
|
|
235
|
+
const fields = Object.entries(rest).reduce((p, [k, value]) => {
|
|
215
236
|
if (k.startsWith('::'))
|
|
216
237
|
return p;
|
|
238
|
+
if (k.endsWith(':like'))
|
|
239
|
+
return p;
|
|
217
240
|
const [key, expression] = k.split(':');
|
|
218
241
|
const map = {
|
|
219
242
|
eq: () => ({ $eq: value }),
|
|
@@ -245,20 +268,35 @@ export class MongoQuery {
|
|
|
245
268
|
...fn()
|
|
246
269
|
}
|
|
247
270
|
};
|
|
248
|
-
},
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
271
|
+
}, {});
|
|
272
|
+
const clauses = [];
|
|
273
|
+
if (Object.keys(fields).length > 0)
|
|
274
|
+
clauses.push(fields);
|
|
275
|
+
if ($or.length > 0)
|
|
276
|
+
clauses.push({ $or });
|
|
277
|
+
const andMatch = and ? this.#build_match(and) : {};
|
|
278
|
+
if (Object.keys(andMatch).length > 0)
|
|
279
|
+
clauses.push(andMatch);
|
|
280
|
+
const orMatch = or ? this.#build_match(or) : {};
|
|
281
|
+
if (Object.keys(orMatch).length > 0) {
|
|
282
|
+
clauses.push({ $or: Object.entries(orMatch).map(([k, v]) => ({ [k]: v })) });
|
|
283
|
+
}
|
|
284
|
+
const notMatch = not ? this.#build_match(not) : {};
|
|
285
|
+
if (Object.keys(notMatch).length > 0) {
|
|
286
|
+
clauses.push({ $nor: Object.entries(notMatch).map(([k, v]) => ({ [k]: v })) });
|
|
287
|
+
}
|
|
288
|
+
if (clauses.length === 0)
|
|
289
|
+
return {};
|
|
290
|
+
if (clauses.length === 1)
|
|
291
|
+
return clauses[0];
|
|
292
|
+
return { $and: clauses };
|
|
255
293
|
}
|
|
256
294
|
static #build_search_query(req) {
|
|
257
|
-
const search = req.
|
|
295
|
+
const search = req.query[":search"];
|
|
258
296
|
return search ? [{ $match: { $text: { $search: `${search}` } } }] : [];
|
|
259
297
|
}
|
|
260
298
|
static #get_limit(req) {
|
|
261
|
-
const l = Number(req.
|
|
299
|
+
const l = Number(req.query[':limit']);
|
|
262
300
|
if (isNaN(l))
|
|
263
301
|
return 10;
|
|
264
302
|
if (l < 1)
|
|
@@ -276,13 +314,19 @@ export class MongoQuery {
|
|
|
276
314
|
}
|
|
277
315
|
];
|
|
278
316
|
}
|
|
317
|
+
static #topn_sorter($sort) {
|
|
318
|
+
return Object.entries($sort).reduce((p, [key, order]) => ({
|
|
319
|
+
...p,
|
|
320
|
+
[key == '_id' ? 'id' : key]: order
|
|
321
|
+
}), {});
|
|
322
|
+
}
|
|
279
323
|
static #build_cursor_query($sort, req, reverse = false) {
|
|
280
324
|
const limit = this.#get_limit(req);
|
|
281
|
-
const after = req.
|
|
282
|
-
const before = req.
|
|
283
|
-
const around = req.
|
|
325
|
+
const after = req.query[':after'];
|
|
326
|
+
const before = req.query[':before'];
|
|
327
|
+
const around = req.query[':around'];
|
|
284
328
|
const pagination_token = around || before || after;
|
|
285
|
-
const cursor = pagination_token ?
|
|
329
|
+
const cursor = pagination_token ? this.#parse_cursor(pagination_token) : (reverse ? null : {});
|
|
286
330
|
if (!cursor)
|
|
287
331
|
return [{ $limit: 1 }, { $match: { _id: 0 } }];
|
|
288
332
|
const $or = Object.entries({ ...$sort, _id: $sort._id || -1 }).map(([key, order], index, arr) => {
|
|
@@ -317,7 +361,7 @@ export class MongoQuery {
|
|
|
317
361
|
items: {
|
|
318
362
|
[reverse ? '$bottomN' : '$topN']: {
|
|
319
363
|
n: limit,
|
|
320
|
-
sortBy:
|
|
364
|
+
sortBy: this.#topn_sorter($sort),
|
|
321
365
|
output: "$$ROOT"
|
|
322
366
|
}
|
|
323
367
|
}
|
|
@@ -336,7 +380,7 @@ export class MongoQuery {
|
|
|
336
380
|
];
|
|
337
381
|
}
|
|
338
382
|
static #build_cursor_paging($sort, req) {
|
|
339
|
-
if (req.
|
|
383
|
+
if (req.query[':after'] || req.query[':before'] || req.query[':around']) {
|
|
340
384
|
}
|
|
341
385
|
const { pipelines, summary } = this.#parse_summary(req);
|
|
342
386
|
const limit = this.#get_limit(req);
|
|
@@ -384,15 +428,48 @@ export class MongoQuery {
|
|
|
384
428
|
];
|
|
385
429
|
}
|
|
386
430
|
static #build_offset_paging(req) {
|
|
387
|
-
|
|
431
|
+
const limit = this.#get_limit(req);
|
|
432
|
+
const page = Math.max(1, Math.floor(Number(req.query[':page'])) || 1);
|
|
433
|
+
const skip = (page - 1) * limit;
|
|
434
|
+
const { pipelines, summary } = this.#parse_summary(req);
|
|
435
|
+
return [
|
|
436
|
+
{
|
|
437
|
+
$facet: {
|
|
438
|
+
...pipelines,
|
|
439
|
+
items: [{ $skip: skip }, { $limit: limit }],
|
|
440
|
+
total: [{ $count: 'count' }],
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
$project: {
|
|
445
|
+
summary,
|
|
446
|
+
items: 1,
|
|
447
|
+
total: { $ifNull: [{ $arrayElemAt: ['$total.count', 0] }, 0] },
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
$project: {
|
|
452
|
+
summary: 1,
|
|
453
|
+
items: 1,
|
|
454
|
+
has: {
|
|
455
|
+
prev: { $gt: [skip, 0] },
|
|
456
|
+
next: { $gt: ['$total', skip + limit] },
|
|
457
|
+
},
|
|
458
|
+
count: {
|
|
459
|
+
prev: { $literal: skip },
|
|
460
|
+
next: { $max: [{ $subtract: ['$total', skip + limit] }, 0] },
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
];
|
|
388
465
|
}
|
|
389
466
|
static #build_query_filter(req) {
|
|
390
|
-
const { ":after": after, ":before": before, ':around': around, ":limit": _limit, ":page": _page, ":search": search, ...rest } = req.
|
|
467
|
+
const { ":after": after, ":before": before, ':around': around, ":limit": _limit, ":page": _page, ":search": search, ...rest } = req.query;
|
|
391
468
|
return this.#parse_conditions({ ...rest, ...req.keys });
|
|
392
469
|
}
|
|
393
470
|
static #get_sorter(req) {
|
|
394
471
|
let default_sort = -1;
|
|
395
|
-
const $sort = Object.entries(req.
|
|
472
|
+
const $sort = Object.entries(req.query).reduce((p, [k, order]) => {
|
|
396
473
|
if (!k.endsWith(':sort'))
|
|
397
474
|
return p;
|
|
398
475
|
const by = k.split(':sort')[0];
|
|
@@ -417,7 +494,7 @@ export class MongoQuery {
|
|
|
417
494
|
{
|
|
418
495
|
$match: {
|
|
419
496
|
...req.keys,
|
|
420
|
-
...req.keys.id ? { id: undefined, _id:
|
|
497
|
+
...req.keys.id ? { id: undefined, _id: this.#objectId('id', req.keys.id) } : {}
|
|
421
498
|
}
|
|
422
499
|
},
|
|
423
500
|
...this.#rename_id(),
|
|
@@ -436,7 +513,7 @@ export class MongoQuery {
|
|
|
436
513
|
summary: {}
|
|
437
514
|
};
|
|
438
515
|
}
|
|
439
|
-
const is_cursor_paging = req.
|
|
516
|
+
const is_cursor_paging = req.query[':after'] || req.query[':before'] || req.query[':around'] || !req.query[':page'];
|
|
440
517
|
const $sort = this.#get_sorter(req);
|
|
441
518
|
const pipelines = [
|
|
442
519
|
{ $sort },
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Observable } from 'rxjs';
|
|
2
|
+
import type { UpdatedData } from '@livequery/core';
|
|
3
|
+
import type { MongoDatasourceConfig, RouteOptions } from './MongoDatasource.js';
|
|
4
|
+
export type MongoRealtimeChangeType = 'added' | 'modified' | 'removed';
|
|
5
|
+
export type MongoRealtimeOptions = {
|
|
6
|
+
enablePreAndPostImages?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export type MongoRealtimeRoute = {
|
|
9
|
+
schema: string;
|
|
10
|
+
options: RouteOptions;
|
|
11
|
+
};
|
|
12
|
+
export declare class MongodbRealtime {
|
|
13
|
+
#private;
|
|
14
|
+
private options;
|
|
15
|
+
constructor(options?: MongoRealtimeOptions);
|
|
16
|
+
watch(config: MongoDatasourceConfig, routes: MongoRealtimeRoute[]): Observable<UpdatedData<any>>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { EMPTY, Observable, from, map, mergeAll, mergeMap, retry } from 'rxjs';
|
|
2
|
+
const changeTypes = {
|
|
3
|
+
insert: 'added',
|
|
4
|
+
update: 'modified',
|
|
5
|
+
replace: 'modified',
|
|
6
|
+
delete: 'removed',
|
|
7
|
+
};
|
|
8
|
+
export class MongodbRealtime {
|
|
9
|
+
options;
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
}
|
|
13
|
+
#reformatId(obj) {
|
|
14
|
+
if (!obj)
|
|
15
|
+
return undefined;
|
|
16
|
+
const { _id, __v, id, ...rest } = obj;
|
|
17
|
+
return {
|
|
18
|
+
id: _id ? String(_id) : id || '#',
|
|
19
|
+
...Object.entries(rest).reduce((acc, [key, value]) => {
|
|
20
|
+
if (key.startsWith('_'))
|
|
21
|
+
return acc;
|
|
22
|
+
return { ...acc, [key]: value };
|
|
23
|
+
}, {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
#isDb(connection) {
|
|
27
|
+
return typeof connection.collection == 'function';
|
|
28
|
+
}
|
|
29
|
+
#collection(source) {
|
|
30
|
+
if (this.#isDb(source.connection))
|
|
31
|
+
return source.connection.collection(source.collection);
|
|
32
|
+
return source.connection.db(source.dbName).collection(source.collection);
|
|
33
|
+
}
|
|
34
|
+
#watchSources(config, routes) {
|
|
35
|
+
const sources = new Map();
|
|
36
|
+
for (const route of routes) {
|
|
37
|
+
const options = route.options;
|
|
38
|
+
if (!options?.realtime)
|
|
39
|
+
continue;
|
|
40
|
+
if (typeof options.collection != 'string')
|
|
41
|
+
continue;
|
|
42
|
+
if (typeof options.connection == 'function' || typeof options.db == 'function')
|
|
43
|
+
continue;
|
|
44
|
+
const connectionName = options.connection || Object.keys(config.connections)[0] || 'default';
|
|
45
|
+
const connection = config.connections[connectionName];
|
|
46
|
+
if (!connection)
|
|
47
|
+
continue;
|
|
48
|
+
const dbNames = this.#isDb(connection)
|
|
49
|
+
? [undefined]
|
|
50
|
+
: options.db ? [options.db] : config.databases || ['main'];
|
|
51
|
+
for (const dbName of dbNames) {
|
|
52
|
+
const key = `${connectionName}|${dbName || ''}|${options.collection}`;
|
|
53
|
+
sources.set(key, { connection, dbName, collection: options.collection });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return [...sources.values()];
|
|
57
|
+
}
|
|
58
|
+
#listenRawChanges(config, routes) {
|
|
59
|
+
const sources = this.#watchSources(config, routes);
|
|
60
|
+
if (sources.length === 0)
|
|
61
|
+
return EMPTY;
|
|
62
|
+
return from(sources).pipe(mergeMap(async (source) => {
|
|
63
|
+
const collection = this.#collection(source);
|
|
64
|
+
if (this.options.enablePreAndPostImages !== false) {
|
|
65
|
+
await collection.db.command({
|
|
66
|
+
collMod: source.collection,
|
|
67
|
+
changeStreamPreAndPostImages: { enabled: true },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return new Observable(observer => {
|
|
71
|
+
const stream = collection.watch([], {
|
|
72
|
+
fullDocument: 'updateLookup',
|
|
73
|
+
fullDocumentBeforeChange: 'whenAvailable',
|
|
74
|
+
});
|
|
75
|
+
stream
|
|
76
|
+
.on('error', error => observer.error(error))
|
|
77
|
+
.on('change', (change) => {
|
|
78
|
+
const type = changeTypes[change.operationType];
|
|
79
|
+
if (!type)
|
|
80
|
+
return;
|
|
81
|
+
const fields = new Set(type == 'modified'
|
|
82
|
+
? [
|
|
83
|
+
...Object.keys(change.updateDescription?.updatedFields || {}).map(field => field.split('.')[0]),
|
|
84
|
+
...(change.updateDescription?.removedFields || []).map(field => field.split('.')[0]),
|
|
85
|
+
].filter(field => !field.startsWith('_'))
|
|
86
|
+
: []);
|
|
87
|
+
observer.next({
|
|
88
|
+
table: change.ns.coll,
|
|
89
|
+
type,
|
|
90
|
+
new_data: this.#reformatId(change.fullDocument),
|
|
91
|
+
old_data: this.#reformatId(change.fullDocumentBeforeChange ?? change.documentKey),
|
|
92
|
+
fields,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
return () => {
|
|
96
|
+
void stream.close();
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}), mergeMap(stream => stream), retry());
|
|
100
|
+
}
|
|
101
|
+
#routeRefMetadata(route) {
|
|
102
|
+
const options = route.options;
|
|
103
|
+
if (!options?.realtime)
|
|
104
|
+
return;
|
|
105
|
+
if (typeof options.collection != 'string')
|
|
106
|
+
return;
|
|
107
|
+
const segments = route.schema.split('/').filter(Boolean);
|
|
108
|
+
const ref = segments.filter(segment => !segment.startsWith(':')).join('/');
|
|
109
|
+
if (!ref)
|
|
110
|
+
return;
|
|
111
|
+
const metadata = segments
|
|
112
|
+
.map((collection, index) => {
|
|
113
|
+
if (index % 2 === 1)
|
|
114
|
+
return [];
|
|
115
|
+
const param = segments[index + 1];
|
|
116
|
+
if (!param?.startsWith(':'))
|
|
117
|
+
return [{ collection }];
|
|
118
|
+
const field = param.slice(1);
|
|
119
|
+
return [{ collection, field: field == 'id' ? '_id' : field }];
|
|
120
|
+
})
|
|
121
|
+
.flat();
|
|
122
|
+
return [ref, metadata];
|
|
123
|
+
}
|
|
124
|
+
#paths(routes) {
|
|
125
|
+
return routes.reduce((paths, route) => {
|
|
126
|
+
const options = route.options;
|
|
127
|
+
const entry = this.#routeRefMetadata(route);
|
|
128
|
+
if (!options || !entry || typeof options.collection != 'string')
|
|
129
|
+
return paths;
|
|
130
|
+
const [ref, metadata] = entry;
|
|
131
|
+
const refs = paths.get(options.collection) || new Map();
|
|
132
|
+
refs.set(ref, metadata);
|
|
133
|
+
paths.set(options.collection, refs);
|
|
134
|
+
return paths;
|
|
135
|
+
}, new Map());
|
|
136
|
+
}
|
|
137
|
+
#format(paths, event) {
|
|
138
|
+
const refs = paths.get(event.table);
|
|
139
|
+
if (!refs)
|
|
140
|
+
return [];
|
|
141
|
+
const merged = {
|
|
142
|
+
...event.old_data || {},
|
|
143
|
+
...event.new_data || {},
|
|
144
|
+
};
|
|
145
|
+
const changes = Object.keys(merged)
|
|
146
|
+
.filter(key => event.fields.has(key))
|
|
147
|
+
.reduce((acc, key) => ({ ...acc, [key]: event.new_data?.[key] }), { id: merged.id });
|
|
148
|
+
const typeForField = (field, pathValue) => {
|
|
149
|
+
const oldValue = event.old_data?.[field];
|
|
150
|
+
const newValue = event.new_data?.[field];
|
|
151
|
+
const value = String(pathValue);
|
|
152
|
+
if (Array.isArray(oldValue) || Array.isArray(newValue)) {
|
|
153
|
+
const oldArray = (Array.isArray(oldValue) ? oldValue : []).map(item => String(item));
|
|
154
|
+
const newArray = (Array.isArray(newValue) ? newValue : []).map(item => String(item));
|
|
155
|
+
if (oldArray.includes(value) && !newArray.includes(value))
|
|
156
|
+
return 'removed';
|
|
157
|
+
if (!oldArray.includes(value) && newArray.includes(value))
|
|
158
|
+
return 'added';
|
|
159
|
+
return 'modified';
|
|
160
|
+
}
|
|
161
|
+
if (String(newValue) == value && String(oldValue) != value)
|
|
162
|
+
return 'added';
|
|
163
|
+
if (String(oldValue) == value && String(newValue) != value)
|
|
164
|
+
return 'removed';
|
|
165
|
+
return 'modified';
|
|
166
|
+
};
|
|
167
|
+
const buildRefs = ([{ collection, field }, ...fields]) => {
|
|
168
|
+
if (fields.length === 0 || !field)
|
|
169
|
+
return [{ refs: [collection], type: event.type }];
|
|
170
|
+
const oldValues = event.old_data?.[field];
|
|
171
|
+
const newValues = event.new_data?.[field];
|
|
172
|
+
const values = Array.isArray(oldValues) || Array.isArray(newValues)
|
|
173
|
+
? [...new Set([
|
|
174
|
+
...(Array.isArray(oldValues) ? oldValues : []).map((item) => String(item)),
|
|
175
|
+
...(Array.isArray(newValues) ? newValues : []).map((item) => String(item)),
|
|
176
|
+
])]
|
|
177
|
+
: [merged[field] ?? '-'];
|
|
178
|
+
return values.flatMap(value => {
|
|
179
|
+
return buildRefs(fields).map(next => ({
|
|
180
|
+
type: event.type == 'added' || event.type == 'removed'
|
|
181
|
+
? event.type
|
|
182
|
+
: next.type == 'added' || next.type == 'removed'
|
|
183
|
+
? next.type
|
|
184
|
+
: typeForField(field, value),
|
|
185
|
+
refs: [collection, String(value), ...next.refs],
|
|
186
|
+
}));
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
return [...refs.values()].flatMap(metadata => {
|
|
190
|
+
return buildRefs(metadata).map(({ refs, type }) => ({
|
|
191
|
+
ref: refs.join('/'),
|
|
192
|
+
type,
|
|
193
|
+
data: type == 'added' ? merged : {
|
|
194
|
+
...type == 'modified' ? changes : {},
|
|
195
|
+
id: merged.id,
|
|
196
|
+
},
|
|
197
|
+
}));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
watch(config, routes) {
|
|
201
|
+
const paths = this.#paths(routes);
|
|
202
|
+
if (paths.size === 0)
|
|
203
|
+
return EMPTY;
|
|
204
|
+
return this.#listenRawChanges(config, routes).pipe(map(event => this.#format(paths, event)), mergeAll());
|
|
205
|
+
}
|
|
206
|
+
}
|
package/build/src/SmartCache.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export class SmartCache {
|
|
2
2
|
#storage = new Map();
|
|
3
|
-
async get(key,
|
|
3
|
+
async get(key, resolver) {
|
|
4
4
|
const cache = this.#storage.get(key);
|
|
5
5
|
if (cache)
|
|
6
6
|
return await cache;
|
|
7
|
-
const value =
|
|
7
|
+
const value = resolver();
|
|
8
8
|
this.#storage.set(key, value);
|
|
9
9
|
return await value;
|
|
10
10
|
}
|
package/build/src/index.d.ts
CHANGED
package/build/src/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["../src/cursor.ts","../src/datachangepayload.ts","../src/mongodatasource.ts","../src/mongoquery.ts","../src/
|
|
1
|
+
{"root":["../src/cursor.ts","../src/datachangepayload.ts","../src/mongodatasource.ts","../src/mongoquery.ts","../src/mongodbrealtime.ts","../src/smartcache.ts","../src/index.ts"],"version":"5.9.3"}
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "git@github.com:livequery/mongodb.git"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.0.
|
|
9
|
+
"version": "2.0.148",
|
|
10
10
|
"description": "MongoDB datasource mapping for @livequery ecosystem",
|
|
11
11
|
"main": "./build/src/index.js",
|
|
12
12
|
"types": "./build/src/index.d.ts",
|
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
"deploy": "rm -rf build && yarn build; git add .; git commit -m \"Update\"; git push origin master; npm publish --access public"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"bson": "*",
|
|
38
37
|
"mongodb": "^6.20.0"
|
|
39
38
|
}
|
|
40
39
|
}
|
package/build/src/types.d.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import type { LivequeryRequest as CoreLivequeryRequest } from '@livequery/core';
|
|
2
|
-
export type LivequeryBaseEntity = {
|
|
3
|
-
id?: string;
|
|
4
|
-
[key: string]: any;
|
|
5
|
-
};
|
|
6
|
-
export type QueryOption<T extends LivequeryBaseEntity = LivequeryBaseEntity> = Record<string, any>;
|
|
7
|
-
export type FilterConditions<T extends LivequeryBaseEntity = LivequeryBaseEntity> = Record<string, any>;
|
|
8
|
-
export type Paging = {
|
|
9
|
-
cursor: {
|
|
10
|
-
last: string | null;
|
|
11
|
-
first: string | null;
|
|
12
|
-
};
|
|
13
|
-
has: {
|
|
14
|
-
prev: boolean;
|
|
15
|
-
next: boolean;
|
|
16
|
-
};
|
|
17
|
-
count: {
|
|
18
|
-
prev: number;
|
|
19
|
-
next: number;
|
|
20
|
-
current: number;
|
|
21
|
-
total: number;
|
|
22
|
-
};
|
|
23
|
-
page: {
|
|
24
|
-
current: number;
|
|
25
|
-
total: number;
|
|
26
|
-
};
|
|
27
|
-
};
|
|
28
|
-
export type WebsocketSyncPayload<T extends LivequeryBaseEntity = LivequeryBaseEntity> = {
|
|
29
|
-
ref?: string;
|
|
30
|
-
type?: string;
|
|
31
|
-
data?: T;
|
|
32
|
-
[key: string]: any;
|
|
33
|
-
};
|
|
34
|
-
export type LivequeryRequest<T = any> = Partial<CoreLivequeryRequest<T>> & {
|
|
35
|
-
keys: Record<string, any>;
|
|
36
|
-
ref: string;
|
|
37
|
-
collection_ref?: string;
|
|
38
|
-
schema_collection_ref?: string;
|
|
39
|
-
method: string;
|
|
40
|
-
body?: T;
|
|
41
|
-
query?: Record<string, any>;
|
|
42
|
-
options: Record<string, any>;
|
|
43
|
-
is_collection: boolean;
|
|
44
|
-
doc_id?: string;
|
|
45
|
-
document_id?: string;
|
|
46
|
-
};
|
package/build/src/types.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|