@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 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`: optional marker for realtime routes. The current adapter does not use it for query execution.
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.
@@ -1,5 +1,4 @@
1
- import type { LivequeryBaseEntity, QueryOption } from "./types.js";
2
1
  export declare class Cursor {
3
- static caculate<T extends LivequeryBaseEntity>(item: T, options: QueryOption<T>): string;
4
- static parse<T extends LivequeryBaseEntity>(cursor: string): any;
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, WebsocketSyncPayload } from './types.js';
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<WebsocketSyncPayload<LivequeryBaseEntity>> implements LivequeryDatasource<MongoDatasourceConfig, RouteOptions>, CoreLivequeryDatasource<RouteOptions> {
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<import("mongodb").DeleteResult | {
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
- } | import("mongodb").UpdateResult<any>>;
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 'bson';
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(configOrRoutes, maybeRoutes) {
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 = this.#getRouteOptions(c);
26
- if (!options.collection)
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 != options.collection)
25
+ if (set && set.collection != routeOptions.collection)
31
26
  throw new Error('Collection mismatch for route path "' + c.path + '"');
32
- p.set(key, options);
33
- p.set(c.path, options);
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
- doc_id: livequery.document_id,
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 options = req.options || req.query || {};
84
+ const query = req.query || {};
100
85
  return {
101
86
  ...req,
102
87
  keys: req.keys || {},
103
- query: req.query || options,
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.options),
132
- first: Cursor.caculate(items[0], req.options)
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
- return await collection.updateOne(this.#keys(req), this.#update(req.body));
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
- return await collection.updateOne(this.#keys(req), this.#update(req.body));
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
- return await collection.deleteOne(this.#keys(req));
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: ObjectId.createFromHexString(req.keys.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;
@@ -1,4 +1,4 @@
1
- import type { LivequeryBaseEntity, LivequeryRequest } from "./types.js";
1
+ import type { LivequeryBaseEntity, LivequeryRequest } from "@livequery/core";
2
2
  import type { Collection } from "mongodb";
3
3
  export declare class MongoQuery {
4
4
  #private;
@@ -1,5 +1,5 @@
1
1
  import { Cursor } from "./Cursor.js";
2
- import { ObjectId } from "bson";
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.options)
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 $match = Object.entries(rest).reduce((p, [k, value]) => {
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
- }, $or.length > 0 ? { $or } : {});
249
- return [
250
- ...Object.keys($match).length > 0 ? [{ $match }] : [],
251
- ...and && Object.keys(and).length > 0 ? [{ $expr: { $and: this.#parse_conditions(and) } }] : [],
252
- ...or && Object.keys(and).length > 0 ? [{ $expr: { $or: this.#parse_conditions(or) } }] : [],
253
- ...not && Object.keys(and).length > 0 ? [{ $not: { $and: this.#parse_conditions(not) } }] : [],
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.options[":search"];
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.options[':limit']);
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.options[':after'];
282
- const before = req.options[':before'];
283
- const around = req.options[':around'];
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 ? Cursor.parse(pagination_token) : (reverse ? null : {});
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.options[':after'] || req.options[':before'] || req.options[':around']) {
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
- return [];
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.options;
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.options).reduce((p, [k, order]) => {
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: ObjectId.createFromHexString(req.keys.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.options[':after'] || req.options[':before'] || req.options[':around'] || !req.options['page'];
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
+ }
@@ -1,4 +1,4 @@
1
1
  export declare class SmartCache {
2
2
  #private;
3
- get<T>(key: any, reslover: () => Promise<T>): Promise<any>;
3
+ get<T>(key: any, resolver: () => Promise<T>): Promise<any>;
4
4
  }
@@ -1,10 +1,10 @@
1
1
  export class SmartCache {
2
2
  #storage = new Map();
3
- async get(key, reslover) {
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 = reslover();
7
+ const value = resolver();
8
8
  this.#storage.set(key, value);
9
9
  return await value;
10
10
  }
@@ -1,3 +1,3 @@
1
1
  export * from './MongoDatasource.js';
2
2
  export * from './DataChangePayload.js';
3
- export * from './types.js';
3
+ export * from './MongodbRealtime.js';
@@ -1,3 +1,3 @@
1
1
  export * from './MongoDatasource.js';
2
2
  export * from './DataChangePayload.js';
3
- export * from './types.js';
3
+ export * from './MongodbRealtime.js';
@@ -1 +1 @@
1
- {"root":["../src/cursor.ts","../src/datachangepayload.ts","../src/mongodatasource.ts","../src/mongoquery.ts","../src/smartcache.ts","../src/index.ts","../src/types.ts"],"version":"5.9.3"}
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.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
  }
@@ -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
- };
@@ -1 +0,0 @@
1
- export {};