@livequery/mongodb 2.0.0

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 ADDED
@@ -0,0 +1,537 @@
1
+ # @livequery/mongodb
2
+
3
+ Native MongoDB datasource adapter for the `@livequery` ecosystem.
4
+
5
+ This package translates Livequery request shapes into MongoDB native driver operations. It is intended for projects that want to use `@livequery/core` with plain `mongodb` collections, without Mongoose models or schema introspection.
6
+
7
+ The adapter supports two integration styles:
8
+
9
+ - Core style: `new MongoDatasource(config)`, `init(routes)`, then `handle(ctx)`.
10
+ - Legacy style: `new MongoDatasource()`, `init(config, routes)`, then direct `query(req, options)`.
11
+
12
+ Reads are executed with MongoDB aggregation pipelines through `Collection.aggregate(...).toArray()`. Writes use native collection methods such as `insertOne`, `updateOne`, and `deleteOne`.
13
+
14
+ ## Installation
15
+
16
+ ```sh
17
+ bun add @livequery/mongodb mongodb bson rxjs
18
+ ```
19
+
20
+ For local development in this workspace, `@livequery/core` is installed as a dev dependency from `file:../core`. Runtime JavaScript does not import `@livequery/core`; the generated declaration files use core types.
21
+
22
+ ## Exports
23
+
24
+ ```ts
25
+ export * from './MongoDatasource.js'
26
+ export * from './DataChangePayload.js'
27
+ export * from './types.js'
28
+ ```
29
+
30
+ ## Project Meaning
31
+
32
+ `@livequery/mongodb` is the MongoDB datasource layer for Livequery.
33
+
34
+ Its job is not to parse HTTP requests. That belongs to `@livequery/core`, usually through `LivequeryRequestParser`. Its job is also not to provide Mongoose-style schemas, validation, hooks, virtuals, or `populate()`. This package receives a parsed Livequery request, resolves which MongoDB collection should handle it, and runs the corresponding native MongoDB operation.
35
+
36
+ Typical request flow with `@livequery/core`:
37
+
38
+ 1. A framework adapter creates a `LivequeryContext`.
39
+ 2. `LivequeryRequestParser` reads `ctx.request` and writes `ctx.livequery`.
40
+ 3. `MongoDatasource.handle(ctx)` resolves route options from `ctx.request.method` and `ctx.request.ref`.
41
+ 4. `MongoDatasource` converts `ctx.livequery.query` into adapter `req.options`.
42
+ 5. Reads are delegated to `MongoQuery`; writes go directly to the native collection.
43
+ 6. The result is assigned to `ctx.response`.
44
+
45
+ ## Main Classes And Types
46
+
47
+ ### `MongoDatasource`
48
+
49
+ Main adapter class.
50
+
51
+ ```ts
52
+ class MongoDatasource extends Subject<WebsocketSyncPayload<LivequeryBaseEntity>>
53
+ ```
54
+
55
+ Responsibilities:
56
+
57
+ - Store datasource config and route options.
58
+ - Support core-style and legacy-style initialization.
59
+ - Resolve connection, database, and collection for each request.
60
+ - Normalize configured ObjectId fields.
61
+ - Execute reads, inserts, updates, and deletes.
62
+ - Implement `handle(ctx)` for `@livequery/core`.
63
+
64
+ #### `constructor(config?)`
65
+
66
+ Creates a datasource.
67
+
68
+ Parameters:
69
+
70
+ - `config?: MongoDatasourceConfig`: optional database configuration. Use this for core-style initialization.
71
+
72
+ Example:
73
+
74
+ ```ts
75
+ const datasource = new MongoDatasource({
76
+ connections: { default: client },
77
+ databases: ['main'],
78
+ })
79
+ ```
80
+
81
+ If `config` is omitted, call `init(config, routes)` later.
82
+
83
+ #### `init(routes)`
84
+
85
+ Core-style initialization.
86
+
87
+ Parameters:
88
+
89
+ - `routes: Array<LivequeryDatasourceInitConfig<RouteOptions>>`: route entries. Each entry includes `method`, `path`, and route options such as `collection`, `db`, `connection`, and `objectIdFields`.
90
+
91
+ Example:
92
+
93
+ ```ts
94
+ await datasource.init([
95
+ {
96
+ method: 'GET',
97
+ path: '/products',
98
+ collection: 'products',
99
+ },
100
+ ])
101
+ ```
102
+
103
+ Route lookup uses `METHOD path`, for example `GET /products`. A path-only fallback is also stored for compatibility.
104
+
105
+ #### `init(config, routes)`
106
+
107
+ Legacy-style initialization.
108
+
109
+ Parameters:
110
+
111
+ - `config: MongoDatasourceConfig`: MongoDB connection configuration.
112
+ - `routes: Array<{ method; path; options }>`: legacy route entries. The datasource options are nested under `options`.
113
+
114
+ Example:
115
+
116
+ ```ts
117
+ await datasource.init(
118
+ {
119
+ connections: { default: client },
120
+ databases: ['main'],
121
+ },
122
+ [
123
+ {
124
+ method: 'GET',
125
+ path: '/products',
126
+ options: {
127
+ collection: 'products',
128
+ },
129
+ },
130
+ ]
131
+ )
132
+ ```
133
+
134
+ #### `handle(ctx)`
135
+
136
+ Core handler entry point.
137
+
138
+ Parameters:
139
+
140
+ - `ctx: LivequeryContext`: context created by `@livequery/core` or a framework adapter. `ctx.livequery` must already be populated, usually by `LivequeryRequestParser`.
141
+
142
+ Behavior:
143
+
144
+ - Throws `INVALID_LIVEQUERY_REQUEST` if `ctx.livequery` is missing.
145
+ - Resolves route options from `ctx.request.method` and `ctx.request.ref || ctx.request.path`.
146
+ - Calls `query(req, options)`.
147
+ - Assigns the result to `ctx.response`.
148
+ - Returns `ctx.response`.
149
+
150
+ Example:
151
+
152
+ ```ts
153
+ new LivequeryRequestParser().handle(ctx)
154
+ await datasource.handle(ctx)
155
+ console.log(ctx.response)
156
+ ```
157
+
158
+ Important: for dynamic routes such as `/products/:id`, `ctx.request.ref` should be the route pattern, not the concrete URL. Example: `ref: '/products/:id'`, `path: '/products/507f1f77bcf86cd799439011'`.
159
+
160
+ #### `query(req, options)`
161
+
162
+ Executes a parsed Livequery request against one MongoDB collection.
163
+
164
+ Parameters:
165
+
166
+ - `req: LivequeryRequest`: adapter request. Core requests use `query`; this adapter normalizes it to `options`. Legacy callers can pass `options` directly.
167
+ - `options: RouteOptions`: route configuration that tells the adapter which collection, database, connection, and ObjectId fields to use.
168
+
169
+ Supported `req.method` values:
170
+
171
+ - `get`: read collection or document.
172
+ - `post`: insert one document.
173
+ - `put`: update one document.
174
+ - `patch`: update one document.
175
+ - `delete`: delete one document.
176
+
177
+ Write behavior:
178
+
179
+ - `post` merges `req.keys` and `req.body`, then calls `insertOne`.
180
+ - `put` and `patch` call `updateOne`.
181
+ - Plain update bodies are wrapped in `$set`.
182
+ - Bodies that already contain MongoDB update operators, such as `$set` or `$inc`, are passed through unchanged.
183
+ - `delete` calls `deleteOne`.
184
+
185
+ Example:
186
+
187
+ ```ts
188
+ const response = await datasource.query(
189
+ {
190
+ method: 'get',
191
+ ref: 'products',
192
+ is_collection: true,
193
+ keys: {},
194
+ options: { ':limit': 10 },
195
+ },
196
+ {
197
+ collection: 'products',
198
+ }
199
+ )
200
+ ```
201
+
202
+ ### `MongoQuery`
203
+
204
+ Static read query builder.
205
+
206
+ Responsibilities:
207
+
208
+ - Convert Livequery filters into MongoDB aggregation stages.
209
+ - Build sort stages from `field:sort` options.
210
+ - Build cursor paging stages from `:after`, `:before`, and `:around`.
211
+ - Convert Mongo `_id` into response `id`.
212
+ - Parse summary aggregation options beginning with `::`.
213
+ - Execute `collection.aggregate(pipeline).toArray()`.
214
+
215
+ #### `MongoQuery.query(req, collection)`
216
+
217
+ Parameters:
218
+
219
+ - `req: LivequeryRequest`: normalized adapter request. Reads `req.keys`, `req.options`, and `req.is_collection`.
220
+ - `collection: Collection<T>`: native MongoDB collection.
221
+
222
+ Behavior:
223
+
224
+ - For document reads, matches by `req.keys`, converts `req.keys.id` to `_id`, renames `_id` to `id`, and returns a one-item result shape.
225
+ - For collection reads, builds an aggregation pipeline with sort, filter, search, id rename, cursor paging, and summary facets.
226
+
227
+ Known behavior:
228
+
229
+ - `:limit` defaults to `10`.
230
+ - Minimum `:limit` is `1`.
231
+ - Maximum `:limit` is `100`.
232
+ - Cursor paging is implemented.
233
+ - Offset paging with `page` is not implemented yet.
234
+
235
+ Filter examples:
236
+
237
+ ```ts
238
+ {
239
+ 'status': 'active',
240
+ 'price:gte': 10,
241
+ 'price:lte': 100,
242
+ 'categoryId:eq-oid': '507f1f77bcf86cd799439011',
243
+ 'name:like': 'phone',
244
+ ':limit': 20,
245
+ 'price:sort': 'asc',
246
+ }
247
+ ```
248
+
249
+ Summary example:
250
+
251
+ ```ts
252
+ {
253
+ 'category:sort': 'asc',
254
+ '::totals': 'category|sum(price)|avg(price)|count()',
255
+ }
256
+ ```
257
+
258
+ ### `Cursor`
259
+
260
+ Cursor pagination helper.
261
+
262
+ #### `Cursor.caculate(item, options)`
263
+
264
+ Builds a cursor from a response item and active sort options.
265
+
266
+ Parameters:
267
+
268
+ - `item: LivequeryBaseEntity`: response item. Must contain `id`.
269
+ - `options: QueryOption`: request options. Sort options ending with `:sort` are included in the cursor.
270
+
271
+ Returns:
272
+
273
+ - Hex-encoded JSON cursor string.
274
+ - `null` when `item` is missing.
275
+
276
+ The method name is intentionally spelled `caculate` for compatibility.
277
+
278
+ #### `Cursor.parse(cursor)`
279
+
280
+ Decodes a cursor.
281
+
282
+ Parameters:
283
+
284
+ - `cursor: string`: hex-encoded JSON cursor created by `Cursor.caculate`.
285
+
286
+ Returns:
287
+
288
+ - Parsed cursor object.
289
+ - `null` when the input is empty.
290
+
291
+ ### `SmartCache`
292
+
293
+ Small async promise cache used for native collection handles.
294
+
295
+ #### `get(key, resolver)`
296
+
297
+ Parameters:
298
+
299
+ - `key: any`: cache key.
300
+ - `resolver: () => Promise<T>`: async function used when the key is not already cached.
301
+
302
+ Returns:
303
+
304
+ - The cached promise result.
305
+
306
+ The collection cache key includes connection, database, and collection name to avoid reusing collection handles across tenants or connections.
307
+
308
+ ### `DataChangePayload<T>`
309
+
310
+ Type-only realtime/change payload contract.
311
+
312
+ ```ts
313
+ type DataChangePayload<T = any> = {
314
+ id: string
315
+ type: 'added' | 'modified' | 'removed'
316
+ data: T
317
+ refs: Array<{ ref: string, old_ref: string }>
318
+ new_doc: T
319
+ }
320
+ ```
321
+
322
+ ## Configuration Types
323
+
324
+ ### `MongoDatasourceConfig`
325
+
326
+ ```ts
327
+ import type { Db, MongoClient } from 'mongodb'
328
+
329
+ type MongoConnection = MongoClient | Db
330
+
331
+ type MongoDatasourceConfig = {
332
+ connections: { [key: string]: MongoConnection }
333
+ databases?: string[]
334
+ }
335
+ ```
336
+
337
+ Fields:
338
+
339
+ - `connections`: map of connection names to either `MongoClient` or `Db`.
340
+ - `databases`: optional list of database names. This is metadata for consumers; collection resolution uses route `db`, `process.env.DB_NAME`, or `"main"`.
341
+
342
+ Default resolution:
343
+
344
+ - Connection defaults to the first configured connection name, then `"default"`.
345
+ - Database defaults to route `db`, then `process.env.DB_NAME`, then `"main"`.
346
+ - If the connection is a `MongoClient`, the datasource calls `client.db(dbName)`.
347
+ - If the connection is already a `Db`, that `Db` is used directly.
348
+
349
+ ### `RouteOptions`
350
+
351
+ ```ts
352
+ type RouteOptions = {
353
+ realtime?: boolean
354
+ collection: string | ((req: LivequeryRequest) => Promise<string> | string)
355
+ db?: string | ((req: LivequeryRequest) => Promise<string> | string)
356
+ connection?: string | ((req: LivequeryRequest) => Promise<string> | string)
357
+ objectIdFields?: string[]
358
+ }
359
+ ```
360
+
361
+ Fields:
362
+
363
+ - `realtime`: optional marker for realtime routes. The current adapter does not use it for query execution.
364
+ - `collection`: required collection name or resolver function.
365
+ - `db`: optional database name or resolver function.
366
+ - `connection`: optional connection name or resolver function.
367
+ - `objectIdFields`: top-level request fields that should be converted from valid string ids to `ObjectId`.
368
+
369
+ Use function values when tenant, database, or collection depends on request keys.
370
+
371
+ ## Core Usage Example
372
+
373
+ ```ts
374
+ import { MongoClient } from 'mongodb'
375
+ import { LivequeryRequestParser, type LivequeryContext } from '@livequery/core'
376
+ import { MongoDatasource } from '@livequery/mongodb'
377
+
378
+ const client = new MongoClient(process.env.MONGO_URL!)
379
+ await client.connect()
380
+
381
+ const datasource = new MongoDatasource({
382
+ connections: { default: client },
383
+ databases: ['main'],
384
+ })
385
+
386
+ await datasource.init([
387
+ {
388
+ method: 'GET',
389
+ path: '/products',
390
+ collection: 'products',
391
+ },
392
+ {
393
+ method: 'GET',
394
+ path: '/products/:id',
395
+ collection: 'products',
396
+ },
397
+ ])
398
+
399
+ const ctx: LivequeryContext = {
400
+ request: {
401
+ method: 'GET',
402
+ path: '/products',
403
+ ref: '/products',
404
+ params: {},
405
+ query: { ':limit': 20, 'price:sort': 'desc' },
406
+ headers: new Map(),
407
+ },
408
+ }
409
+
410
+ new LivequeryRequestParser().handle(ctx)
411
+ await datasource.handle(ctx)
412
+
413
+ console.log(ctx.response)
414
+ ```
415
+
416
+ ## Core Document Route Example
417
+
418
+ ```ts
419
+ const ctx: LivequeryContext = {
420
+ request: {
421
+ method: 'GET',
422
+ path: '/products/507f1f77bcf86cd799439011',
423
+ ref: '/products/:id',
424
+ params: { id: '507f1f77bcf86cd799439011' },
425
+ query: {},
426
+ headers: new Map(),
427
+ },
428
+ }
429
+
430
+ new LivequeryRequestParser().handle(ctx)
431
+ await datasource.handle(ctx)
432
+ ```
433
+
434
+ `LivequeryRequestParser` will set `ctx.livequery.document_id` and `ctx.livequery.keys.id`. The datasource converts `id` to Mongo `_id` for document reads and writes.
435
+
436
+ ## Legacy Usage Example
437
+
438
+ ```ts
439
+ import { MongoClient } from 'mongodb'
440
+ import { MongoDatasource } from '@livequery/mongodb'
441
+
442
+ const client = new MongoClient(process.env.MONGO_URL!)
443
+ await client.connect()
444
+
445
+ const datasource = new MongoDatasource()
446
+
447
+ await datasource.init(
448
+ {
449
+ connections: { default: client },
450
+ databases: ['main'],
451
+ },
452
+ [
453
+ {
454
+ method: 'GET',
455
+ path: '/products',
456
+ options: {
457
+ collection: 'products',
458
+ },
459
+ },
460
+ ]
461
+ )
462
+
463
+ const response = await datasource.query(
464
+ {
465
+ method: 'get',
466
+ ref: 'products',
467
+ is_collection: true,
468
+ collection_ref: 'products',
469
+ schema_collection_ref: 'products',
470
+ keys: {},
471
+ options: { ':limit': 10 },
472
+ },
473
+ {
474
+ collection: 'products',
475
+ }
476
+ )
477
+
478
+ console.log(response.items)
479
+ ```
480
+
481
+ ## Dynamic Tenant Example
482
+
483
+ ```ts
484
+ await datasource.init([
485
+ {
486
+ method: 'GET',
487
+ path: '/tenant/:tenantId/products',
488
+ connection: req => req.keys.tenantId,
489
+ db: req => `tenant_${req.keys.tenantId}`,
490
+ collection: req => `products_${req.keys.tenantId}`,
491
+ objectIdFields: ['ownerId', 'categoryId'],
492
+ },
493
+ ])
494
+ ```
495
+
496
+ This lets one datasource choose connection, database, and collection per request.
497
+
498
+ ## ObjectId Handling
499
+
500
+ This package does not use Mongoose schema introspection. Configure ObjectId conversion explicitly.
501
+
502
+ Use `objectIdFields` for top-level keys and write bodies:
503
+
504
+ ```ts
505
+ await datasource.init([
506
+ {
507
+ method: 'PATCH',
508
+ path: '/products/:id',
509
+ collection: 'products',
510
+ objectIdFields: ['ownerId', 'categoryId'],
511
+ },
512
+ ])
513
+ ```
514
+
515
+ Use query suffixes for filter values:
516
+
517
+ ```ts
518
+ {
519
+ 'ownerId:eq-oid': '507f1f77bcf86cd799439011',
520
+ }
521
+ ```
522
+
523
+ ## Build And Verification
524
+
525
+ ```sh
526
+ npm run build
527
+ npm test
528
+ ```
529
+
530
+ `npm test` runs the Bun test suite. The tests use mocked MongoDB collections, so they do not require a real MongoDB server.
531
+
532
+ ## Notes
533
+
534
+ - This package is ESM and uses TypeScript `NodeNext`.
535
+ - Local imports in source files should include `.js` extensions.
536
+ - Do not add Mongoose dependencies here. Mongoose-specific behavior belongs in `@livequery/mongoose`.
537
+ - `@livequery/core` is used for types and core handler integration.
@@ -0,0 +1,5 @@
1
+ import type { LivequeryBaseEntity, QueryOption } from "./types.js";
2
+ 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;
5
+ }
@@ -0,0 +1,31 @@
1
+ export class Cursor {
2
+ static caculate(item, options) {
3
+ if (!item)
4
+ return null;
5
+ const map = (Object
6
+ .entries(options)
7
+ .filter(([k, v]) => k.endsWith(':sort'))
8
+ .map(([k, v]) => {
9
+ const key = k.split(':sort')[0];
10
+ return {
11
+ key: key == '_id' ? 'id' : key,
12
+ value: v
13
+ };
14
+ })
15
+ .reduce((p, { key, value }) => {
16
+ const name = key.split(':sort')[0];
17
+ return {
18
+ ...p,
19
+ [name]: item[name]
20
+ };
21
+ }, {
22
+ id: item.id
23
+ }));
24
+ return Buffer.from(JSON.stringify(map), 'utf8').toString('hex');
25
+ }
26
+ static parse(cursor) {
27
+ if (!cursor)
28
+ return null;
29
+ return JSON.parse(Buffer.from(cursor, 'hex').toString('utf8'));
30
+ }
31
+ }
@@ -0,0 +1,10 @@
1
+ export type DataChangePayload<T = any> = {
2
+ id: string;
3
+ type: 'added' | 'modified' | 'removed';
4
+ data: T;
5
+ refs: Array<{
6
+ ref: string;
7
+ old_ref: string;
8
+ }>;
9
+ new_doc: T;
10
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import type { LivequeryContext, LivequeryDatasource as CoreLivequeryDatasource, LivequeryDatasourceInitConfig } from '@livequery/core';
2
+ import type { LivequeryRequest, LivequeryBaseEntity, WebsocketSyncPayload } from './types.js';
3
+ import type { Db, MongoClient } from 'mongodb';
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
+ export type MongoConnection = MongoClient | Db;
14
+ export type MongoDatasourceConfig = {
15
+ connections: {
16
+ [key: string]: MongoConnection;
17
+ };
18
+ databases?: string[];
19
+ };
20
+ type LegacyRouteConfig<RouteOptions> = {
21
+ path: string;
22
+ method: number | string;
23
+ options?: RouteOptions;
24
+ config?: RouteOptions;
25
+ };
26
+ export type RouteOptions = {
27
+ realtime?: boolean;
28
+ collection: string | ((req: LivequeryRequest) => Promise<string> | string);
29
+ db?: string | ((req: LivequeryRequest) => Promise<string> | string);
30
+ connection?: string | ((req: LivequeryRequest) => Promise<string> | string);
31
+ objectIdFields?: string[];
32
+ };
33
+ export declare class MongoDatasource extends Subject<WebsocketSyncPayload<LivequeryBaseEntity>> implements LivequeryDatasource<MongoDatasourceConfig, RouteOptions>, CoreLivequeryDatasource<RouteOptions> {
34
+ #private;
35
+ readonly refs: Map<string, Set<string>>;
36
+ config: MongoDatasourceConfig;
37
+ routes: Map<string, RouteOptions>;
38
+ constructor(config?: MongoDatasourceConfig);
39
+ init(routes: Array<LivequeryDatasourceInitConfig<RouteOptions>>): Promise<void>;
40
+ init(config: MongoDatasourceConfig, routes: Array<LegacyRouteConfig<RouteOptions>>): Promise<void>;
41
+ handle(ctx: LivequeryContext): Promise<{}>;
42
+ query(req: LivequeryRequest, options: RouteOptions): Promise<import("mongodb").DeleteResult | {
43
+ items: any[];
44
+ summary: any;
45
+ cursor: {
46
+ last: string | null;
47
+ first: string | null;
48
+ };
49
+ has: {
50
+ prev: boolean;
51
+ next: boolean;
52
+ };
53
+ count: {
54
+ prev: number;
55
+ next: number;
56
+ current: number;
57
+ total: number;
58
+ };
59
+ page: {
60
+ current: number;
61
+ total: number;
62
+ };
63
+ } | {
64
+ item: any;
65
+ } | import("mongodb").UpdateResult<any>>;
66
+ }
67
+ export {};