@anteros/core 0.0.1-alpha.1

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.
Files changed (55) hide show
  1. package/README.md +143 -0
  2. package/database/collection.ts +160 -0
  3. package/database/decorator.ts +172 -0
  4. package/database/file.ts +93 -0
  5. package/database/mongodbadapter.ts +1128 -0
  6. package/database/rest.ts +14 -0
  7. package/database/schema.ts +160 -0
  8. package/database/tenant.ts +37 -0
  9. package/database/workflow.ts +384 -0
  10. package/index.ts +28 -0
  11. package/lib/asyncContextStorage.ts +68 -0
  12. package/lib/define.ts +114 -0
  13. package/lib/error.ts +21 -0
  14. package/lib/files.ts +459 -0
  15. package/lib/middleware.ts +66 -0
  16. package/lib/routes.ts +44 -0
  17. package/lib/scripts.ts +47 -0
  18. package/lib/services.ts +45 -0
  19. package/lib/sockets.ts +44 -0
  20. package/lib/workflow.ts +60 -0
  21. package/package.json +31 -0
  22. package/server/api.ts +789 -0
  23. package/server/boot.ts +101 -0
  24. package/server/config.ts +107 -0
  25. package/server/env.ts +16 -0
  26. package/server/hono.ts +176 -0
  27. package/server/io.ts +15 -0
  28. package/server/routes.ts +48 -0
  29. package/server/security.ts +138 -0
  30. package/tests/api.test.ts +281 -0
  31. package/tsconfig.json +36 -0
  32. package/types/activity.d.ts +45 -0
  33. package/types/api.d.ts +85 -0
  34. package/types/collection.d.ts +82 -0
  35. package/types/config.d.ts +55 -0
  36. package/types/field.d.ts +72 -0
  37. package/types/file.d.ts +120 -0
  38. package/types/hook.d.ts +30 -0
  39. package/types/middleware.d.ts +18 -0
  40. package/types/mongo.d.ts +61 -0
  41. package/types/options.d.ts +7 -0
  42. package/types/rest.d.ts +18 -0
  43. package/types/route.d.ts +19 -0
  44. package/types/schema.d.ts +0 -0
  45. package/types/scripts.d.ts +10 -0
  46. package/types/service.d.ts +37 -0
  47. package/types/task.d.ts +12 -0
  48. package/types/tenant.d.ts +16 -0
  49. package/types/token.d.ts +14 -0
  50. package/types/websocket.d.ts +15 -0
  51. package/types/workflow.d.ts +91 -0
  52. package/utils/cache.ts +96 -0
  53. package/utils/crypto.ts +226 -0
  54. package/utils/func.ts +1037 -0
  55. package/utils/index.ts +17 -0
@@ -0,0 +1,1128 @@
1
+ import type { Tenant } from "../types/tenant";
2
+ import type { MongoClientOptions, Db, ClientSession, UpdateOptions, UpdateFilter, Document, DeleteResult, ChangeStreamOptions, ChangeStream, ChangeStreamDocument, AnyBulkWriteOperation, BulkWriteOptions, BulkWriteResult, UpdateResult } from "mongodb";
3
+ import { MongoClient, ObjectId, AggregationCursor } from "mongodb";
4
+ import type { FindOptions, findOneOptions } from "../types/mongo";
5
+ import { sessionCtxStorage, asyncContextStorage, requestCtxStorage } from "../lib/asyncContextStorage";
6
+ import * as func from "../utils/func"
7
+ import { AppError, fn } from "../lib/error";
8
+ import { createWorkflow } from "./workflow";
9
+ import { getTenant } from "./tenant";
10
+ import { getCollection, getCollectionKeys } from "./collection"
11
+ import type { RestActions, BulkUpdateOperation, RestOptions } from "../types/rest";
12
+ import type Joi from "joi";
13
+ import type { Collection } from "../types/collection";
14
+ import { CheckBulkWriteOperations, CheckIfCollectionExists, CheckIfId, CheckInsertData, CheckIfArrayOfIds, CheckFilter } from "./decorator";
15
+ import type { ActionsApiList, updateApiOptions } from "../types/api";
16
+ import type { ActivityInput } from "../types/activity";
17
+ import * as os from 'node:os';
18
+ import { cfg } from '../server/config';
19
+ import { io } from '../server/io';
20
+ import type { Service } from '../types/service';
21
+
22
+ class MongoRest {
23
+ client!: MongoClient;
24
+ #isConnected: boolean = false;
25
+ #internal: boolean = true;
26
+ db!: Db;
27
+ tenant_id!: string;
28
+ #tenant!: Tenant;
29
+ session!: ClientSession | undefined;
30
+ #database?: {
31
+ uri: string;
32
+ options?: MongoClientOptions;
33
+ };
34
+ #useHook: boolean;
35
+ #useCustomApi: boolean;
36
+ constructor(options: RestOptions) {
37
+ this.#internal = options.internal ?? true;
38
+ this.#database = options.database;
39
+ this.#useHook = options.useHook ?? true;
40
+ this.#useCustomApi = options.useCustomApi ?? true;
41
+ if (options.session) {
42
+ this.session = options.session;
43
+ }
44
+ let tenant = getTenant(options?.tenant_id ?? '-')
45
+ if (options.tenant_id && tenant) {
46
+ this.#tenant = tenant;
47
+ this.tenant_id = tenant.id;
48
+ this.db = tenant.database.db as Db;
49
+ this.client = tenant.database.client as MongoClient;
50
+ }
51
+
52
+ }
53
+
54
+
55
+ private _validate(options: { collection: string, action?: ActionsApiList, data?: any, update?: UpdateFilter<any> }) {
56
+ let col = getCollection(options.collection, this.#tenant.id)
57
+ let schema = col?._schema_
58
+ let partialSchema = col?._schemaPartial_
59
+ let validationResult: Joi.ValidationResult<any> | undefined = undefined
60
+
61
+
62
+ if (!col) throw new AppError(`collection '${options?.collection || ''}' not found`, {
63
+ code: 'COLLECTION_NOT_FOUND',
64
+ status: 500
65
+ })
66
+
67
+
68
+ if (!options.action) throw new AppError(`action '${options?.action || ''}' not found or required`, {
69
+ code: 'ACTION_REQUIRED',
70
+ status: 500
71
+ })
72
+
73
+
74
+ //let unauthorizedKeys = func.unauthorizedKeys(options.data, getCollectionKeys(col))
75
+ /* if (unauthorizedKeys.length > 0) {
76
+ throw new AppError(`Unauthorized keys in "${options.collection}" : ${unauthorizedKeys.join(', ')}`, {
77
+ code: 'UNAUTHORIZED_KEYS',
78
+ status: 400
79
+ })
80
+ } */
81
+
82
+
83
+
84
+ if (schema && partialSchema) {
85
+
86
+
87
+ if (options?.data) {
88
+ if (options.action == 'insertMany') {
89
+ options.data.map(d => {
90
+ validationResult = schema.validate(d, {
91
+ allowUnknown: false,
92
+ })
93
+ if (validationResult && validationResult.error) {
94
+ throw new AppError(validationResult.error.message, {
95
+ code: 'VALIDATION_ERROR',
96
+ status: 400
97
+ })
98
+ }
99
+ })
100
+ }
101
+
102
+ if (options.action == 'insertOne') {
103
+ validationResult = schema.validate(options.data, {
104
+ allowUnknown: false,
105
+ })
106
+ }
107
+ }
108
+
109
+
110
+ if (options?.update) {
111
+ if (options.action == 'insertOne' && options.update?.$set) {
112
+ validationResult = partialSchema.validate(options.update?.$set, {
113
+ allowUnknown: false,
114
+ })
115
+ }
116
+
117
+ if (options.action == 'insertMany' && options.update?.$set) {
118
+ validationResult = partialSchema.validate(options.update?.$set, {
119
+ allowUnknown: false,
120
+ })
121
+ }
122
+
123
+ if (options.action == "findOneAndUpdate" && options.update.$set) {
124
+ validationResult = partialSchema.validate(options.update.$set, {
125
+ allowUnknown: false,
126
+ })
127
+ }
128
+
129
+ if (options.action == "findOneAndUpdate" && options.update.$setOnInsert) {
130
+ validationResult = partialSchema.validate(options.update.$setOnInsert, {
131
+ allowUnknown: false,
132
+ })
133
+ }
134
+ }
135
+
136
+ if (validationResult?.error) {
137
+ throw new AppError(validationResult.error.message, {
138
+ code: 'VALIDATION_ERROR',
139
+ status: 400
140
+ })
141
+ }
142
+
143
+ }
144
+
145
+
146
+
147
+ }
148
+
149
+ private async #logActivity(data: {
150
+ action: string,
151
+ collection: string,
152
+ input?: any,
153
+ result?: any,
154
+ error?: { message: string, code: string },
155
+ duration: number,
156
+ }) {
157
+ const ctxMeta = requestCtxStorage.get<Record<string, any>>('meta')
158
+ const traceCtx = requestCtxStorage.get<{ id: string; comment?: string; tag?: string; version?: string }>('trace')
159
+ const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
160
+
161
+ const traceId = traceCtx?.id ?? crypto.randomUUID()
162
+ const trace: ActivityInput['trace'] = {
163
+ id: traceId,
164
+ comment: traceCtx?.comment,
165
+ tag: traceCtx?.tag,
166
+ version: traceCtx?.version,
167
+ }
168
+
169
+ const { request: requestMeta, ...restMeta } = ctxMeta ?? {}
170
+
171
+ const meta: Record<string, any> = {
172
+ ...restMeta,
173
+ platform: restMeta?.platform ?? os.platform(),
174
+ core_version: cfg.version ?? restMeta?.core_version,
175
+ }
176
+
177
+ // Determine collection type
178
+ const colMeta = cfg.collections?.find(c => c.slug === data.collection && c._tenant_ === this.tenant_id)
179
+ ?? cfg.fileCollections?.find(c => c.slug === data.collection && c._tenant_ === this.tenant_id)
180
+ const collectionType = colMeta && 'type' in colMeta ? colMeta.type : colMeta ? 'file' : undefined
181
+
182
+ const activity: ActivityInput = {
183
+ internal: requestCtxStorage.get<boolean>('internal') ?? this.#internal,
184
+ trace,
185
+ request: requestMeta,
186
+ meta,
187
+ operation: {
188
+ tenant: this.tenant_id,
189
+ action: data.action,
190
+ collection: data.collection,
191
+ collectionType,
192
+ status: data.error ? 'error' : 'success',
193
+ input: data.input,
194
+ result: data.result,
195
+ error: data.error ?? null,
196
+ duration: data.duration,
197
+ transaction: this.session?.inTransaction?.() ?? false,
198
+ token: token ? { decoded: token.decoded, value: null, provided: token.provided ?? true, expired: token.expired ?? false } : undefined,
199
+ },
200
+ ts: new Date(),
201
+ }
202
+
203
+ await this.audit.addActivities([activity])
204
+ }
205
+
206
+ private async #executeWithAudit<T>(
207
+ action: string,
208
+ collection: string,
209
+ input: any,
210
+ fn: () => Promise<T>
211
+ ): Promise<T> {
212
+ const start = Date.now()
213
+ try {
214
+ const result = await fn()
215
+ const duration = Date.now() - start
216
+ this.#logActivity({ action, collection, input, result, duration })
217
+ return result
218
+ } catch (err: any) {
219
+ const duration = Date.now() - start
220
+ this.#logActivity({ action, collection, input, error: { message: err?.message, code: err?.code || 'INTERNAL_API_ERROR' }, duration })
221
+ throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
222
+ }
223
+ }
224
+
225
+ async connect(options?: {
226
+ uri?: string;
227
+ options?: MongoClientOptions;
228
+ }): Promise<{ client: MongoClient, db: Db }> {
229
+ try {
230
+
231
+ if (!this.#isConnected && this.#database) {
232
+ this.client = new MongoClient(options?.uri ?? this.#database.uri, {
233
+ ...options?.options || this.#database.options,
234
+ })
235
+ await this.client.connect()
236
+ this.db = this.client.db();
237
+ this.#isConnected = true;
238
+ }
239
+ return {
240
+ client: this.client,
241
+ db: this.db,
242
+ }
243
+ } catch (err: any) {
244
+ console.error('MongoDB connection failed', err?.message);
245
+ throw new Error(err?.message);
246
+ }
247
+ }
248
+
249
+ @CheckIfCollectionExists()
250
+ async watch(collection: string, pipeline: any[], options: ChangeStreamOptions): Promise<ChangeStream> {
251
+ const col = getCollection(collection, this.#tenant.id) as Collection
252
+ const isSafe = func.isSafeAggregatePipeline(pipeline);
253
+ if (!isSafe.isSafe) {
254
+ throw isSafe.error;
255
+ }
256
+ pipeline = await func.buildInput(pipeline)
257
+ pipeline = func.toBson(pipeline, { col })
258
+ return await this.db.collection(collection).watch(pipeline, {
259
+ allowDiskUse: true,
260
+ session: this.session,
261
+ ...options
262
+ })
263
+ }
264
+
265
+ @CheckIfCollectionExists()
266
+ async aggregate(collection: string, pipeline: any[]): Promise<Document[]> {
267
+ const action = 'aggregate' as ActionsApiList
268
+ try {
269
+
270
+ const col = getCollection(collection, this.#tenant.id) as Collection
271
+ const isSafe = func.isSafeAggregatePipeline(pipeline);
272
+ if (!isSafe.isSafe) {
273
+ throw isSafe.error;
274
+ }
275
+ pipeline = func.toBson(pipeline, { col })
276
+ if (col.hooks?.beforeOperation) {
277
+ await col.hooks.beforeOperation({
278
+ rest: this,
279
+ io,
280
+ action: action,
281
+ meta: {
282
+ action,
283
+ collection: collection,
284
+ pipeline: pipeline,
285
+ }
286
+ })
287
+ }
288
+
289
+ let result = await this.db.collection(collection).aggregate(pipeline, {
290
+ session: this.session,
291
+ allowDiskUse: true,
292
+ }).toArray()
293
+ result = func.toJson(result)
294
+
295
+ await col.hooks?.afterOperation?.({
296
+ rest: this,
297
+ io,
298
+ action: action,
299
+ meta: {
300
+ action,
301
+ collection: collection,
302
+ pipeline: pipeline,
303
+ result: result,
304
+ }
305
+ })
306
+
307
+ return result as any[]
308
+ } catch (err: any) {
309
+ throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
310
+ }
311
+ }
312
+
313
+
314
+ @CheckIfCollectionExists()
315
+ async find(collection: string, params: FindOptions = {}, options = {}): Promise<Document[]> {
316
+ const action = 'find' as ActionsApiList
317
+ try {
318
+ const col = getCollection(collection, this.#tenant.id) as Collection
319
+ let pipeline = func.buildPipeline(params, { col: col })
320
+ pipeline = await func.buildInput(pipeline, { rest: this })
321
+ pipeline = func.toBson(pipeline, { col })
322
+
323
+ if (col.hooks?.beforeOperation) {
324
+ await col.hooks.beforeOperation({
325
+ rest: this,
326
+ io,
327
+ action: action,
328
+ meta: {
329
+ action: action,
330
+ collection: collection,
331
+ params: func.clone(params),
332
+ options: func.clone(options),
333
+ }
334
+ })
335
+ }
336
+
337
+ let result = await this.db.collection(collection).aggregate(pipeline, {
338
+ session: this.session,
339
+ allowDiskUse: true
340
+ }).toArray()
341
+ result = func.toJson(result)
342
+
343
+ if (col.hooks?.afterOperation) {
344
+ await col.hooks.afterOperation({
345
+ rest: this,
346
+ io,
347
+ action: action,
348
+ meta: {
349
+ action: action,
350
+ collection: collection,
351
+ params: func.clone(params),
352
+ options: func.clone(options),
353
+ result: func.clone(result),
354
+ }
355
+ })
356
+ }
357
+
358
+ return result as any[]
359
+ } catch (err: any) {
360
+ throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
361
+ }
362
+ }
363
+
364
+ @CheckIfCollectionExists()
365
+ async findOne(collection: string, _id: string, params?: findOneOptions): Promise<any> {
366
+ const action = 'findOne' as ActionsApiList
367
+ try {
368
+ const col = getCollection(collection, this.#tenant.id) as Collection
369
+ let pipeline = func.buildPipeline({
370
+ ...params,
371
+ $match: { _id },
372
+ $limit: 1
373
+ }, { col: col })
374
+ pipeline = await func.buildInput(pipeline, { rest: this })
375
+ pipeline = func.toBson(pipeline, { col })
376
+
377
+ if (col.hooks?.beforeOperation) {
378
+ await col.hooks.beforeOperation({
379
+ rest: this,
380
+ io,
381
+ action: action,
382
+ meta: {
383
+ action: action,
384
+ collection: collection,
385
+ params: func.clone(params),
386
+ id: _id,
387
+ }
388
+ })
389
+ }
390
+
391
+ let result = (await this.db.collection(collection).aggregate(pipeline, {
392
+ session: this.session,
393
+ allowDiskUse: true
394
+ }).toArray()).at(0) ?? null
395
+ result = func.toJson(result)
396
+
397
+ if (col.hooks?.afterOperation) {
398
+ await col.hooks.afterOperation({
399
+ rest: this,
400
+ io,
401
+ action: action,
402
+ meta: {
403
+ action: action,
404
+ collection: collection,
405
+ result: func.clone(result),
406
+ params: func.clone(params),
407
+ id: _id,
408
+ }
409
+ })
410
+ }
411
+
412
+ return result
413
+ } catch (err: any) {
414
+ throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
415
+ }
416
+ }
417
+
418
+ @CheckIfCollectionExists()
419
+ @CheckInsertData('insertOne')
420
+ async insertOne<T>(collection: string, data: T): Promise<T & { _id: string }> {
421
+ const action = 'insertOne' as ActionsApiList
422
+ return this.#executeWithAudit(action, collection, { data }, async () => {
423
+ const col = getCollection(collection, this.#tenant.id) as Collection
424
+
425
+ data = await func.buildInput(data as T, {
426
+ action: action, col: col, rest: this
427
+ })
428
+ this._validate({ collection, action: action, data: data })
429
+
430
+ if (col.hooks?.beforeOperation) {
431
+ await col.hooks.beforeOperation({
432
+ rest: this,
433
+ io,
434
+ action,
435
+ meta: {
436
+ action,
437
+ collection,
438
+ data: func.clone(data),
439
+ }
440
+ })
441
+ }
442
+
443
+ await this.db.collection(collection).insertOne(func.toBson(data, { col }) as any, {
444
+ session: this.session,
445
+ })
446
+
447
+ const result: T & { _id: string } = func.toJson(data as T & { _id: string })
448
+ if (col?.hooks?.afterOperation) {
449
+ await col.hooks.afterOperation({
450
+ rest: this,
451
+ io,
452
+ action: action,
453
+ meta: {
454
+ action: action,
455
+ collection: collection,
456
+ data: func.clone(data),
457
+ result: func.clone(result),
458
+ }
459
+ })
460
+ }
461
+
462
+ return result
463
+ })
464
+ }
465
+
466
+ @CheckIfCollectionExists()
467
+ @CheckInsertData('insertMany')
468
+ async insertMany<T>(collection: string, data: T[]): Promise<(T & { _id: string })[]> {
469
+ const action = 'insertMany' as ActionsApiList
470
+ return this.#executeWithAudit(action, collection, { count: data.length }, async () => {
471
+ const col = getCollection(collection, this.#tenant.id) as Collection
472
+ let dataInput: T[] = []
473
+
474
+ for (let d of data) {
475
+ d = await func.buildInput(d as T, {
476
+ action: action,
477
+ col,
478
+ rest: this
479
+ })
480
+ dataInput.push(d)
481
+ }
482
+
483
+ this._validate({ collection, action: action, data: dataInput })
484
+
485
+ if (col.hooks?.beforeOperation) {
486
+ await col.hooks.beforeOperation({
487
+ rest: this,
488
+ io,
489
+ action: action,
490
+ meta: {
491
+ action: action,
492
+ collection: collection,
493
+ data: func.clone(dataInput),
494
+ }
495
+ })
496
+ }
497
+
498
+ await this.db.collection(collection).insertMany(func.toBson(dataInput, { col }) as any, {
499
+ session: this.session,
500
+ })
501
+
502
+ const result: (T & { _id: string })[] = func.toJson(dataInput as (T & { _id: string })[])
503
+
504
+ if (col.hooks?.afterOperation) {
505
+ await col.hooks.afterOperation({
506
+ rest: this,
507
+ io,
508
+ action: action,
509
+ meta: {
510
+ action: action,
511
+ collection: collection,
512
+ result: func.clone(result),
513
+ }
514
+ })
515
+ }
516
+
517
+ return result as (T & { _id: string })[]
518
+ })
519
+ }
520
+
521
+ @CheckIfCollectionExists()
522
+ @CheckIfId('updateOne')
523
+ async updateOne(collection: string, _id: string, update: UpdateFilter<any>) {
524
+ const action = 'updateOne' as ActionsApiList
525
+ return this.#executeWithAudit(action, collection, { id: _id, update }, async () => {
526
+ const col = getCollection(collection, this.#tenant.id) as Collection
527
+
528
+ if (update.$set) {
529
+ update.$set = await func.buildInput(update.$set, { action: action, col: col })
530
+ }
531
+
532
+ update = await func.buildInput(update)
533
+ this._validate({ collection, action: action, update: update })
534
+ update = func.toBson(update, { col })
535
+
536
+ if (col.hooks?.beforeOperation) {
537
+ await col.hooks.beforeOperation({
538
+ rest: this,
539
+ io,
540
+ action: action,
541
+ meta: {
542
+ action: action,
543
+ update: func.clone(update),
544
+ collection: collection,
545
+ id: _id,
546
+ }
547
+ })
548
+ }
549
+
550
+ const data = await this.db.collection(collection).findOneAndUpdate(
551
+ { _id: new ObjectId(_id) },
552
+ { ...(update as any) },
553
+ { session: this.session, returnDocument: 'after' }
554
+ )
555
+
556
+ const result = func.toJson(data)
557
+
558
+ if (col.hooks?.afterOperation) {
559
+ await col.hooks.afterOperation({
560
+ rest: this,
561
+ io,
562
+ action: action,
563
+ meta: {
564
+ action: action,
565
+ collection: collection,
566
+ update: func.clone(update),
567
+ id: _id,
568
+ result: func.clone(result),
569
+ }
570
+ })
571
+ }
572
+
573
+ return result
574
+ })
575
+ }
576
+
577
+ @CheckIfCollectionExists()
578
+ @CheckFilter()
579
+ async findOneAndUpdate(collection: string, filter: Document, update: UpdateFilter<any>, options?: updateApiOptions): Promise<Document | null> {
580
+ const action = 'findOneAndUpdate' as ActionsApiList
581
+ return this.#executeWithAudit(action, collection, { filter, update, options }, async () => {
582
+ const col = getCollection(collection, this.#tenant.id) as Collection
583
+
584
+
585
+
586
+ if (update.$set) {
587
+ update.$set = await func.buildInput(update.$set, { action: action, col: col })
588
+ }
589
+
590
+ update.$setOnInsert = await func.buildInput(update.$setOnInsert || {}, {
591
+ action: 'insertOne',
592
+ col: col
593
+ })
594
+
595
+ update.$set = func.omit(update.$set, ['updatedAt'])
596
+ update.$setOnInsert = func.omit(update.$setOnInsert, ['updatedAt'])
597
+
598
+ update.$currentDate = {
599
+ updatedAt: true,
600
+ }
601
+
602
+
603
+ // clean Deep on filter
604
+ if (options?.cleanDeep) {
605
+ filter = func.cleanDeep(filter)
606
+ }
607
+
608
+ // if filter is empty, throw error
609
+ if (func.isEmpty(filter)) {
610
+ throw new AppError('Filter is empty', { code: 'FILTER_EMPTY', status: 400 })
611
+ }
612
+
613
+
614
+ this._validate({ collection, action: action, update: update.$set })
615
+ this._validate({ collection, action: action, update: update.$setOnInsert })
616
+ update = func.toBson(update, { col })
617
+ filter = func.toBson(filter, { col })
618
+
619
+ if (col.hooks?.beforeOperation) {
620
+ await col.hooks.beforeOperation({
621
+ rest: this,
622
+ io,
623
+ action: action,
624
+ meta: {
625
+ action: action,
626
+ collection: collection,
627
+ filter: func.clone(filter),
628
+ update: func.clone(update),
629
+ }
630
+ })
631
+ }
632
+
633
+
634
+
635
+
636
+
637
+ const data = await this.db.collection(collection).findOneAndUpdate(
638
+ filter,
639
+ {
640
+ ...(update as any),
641
+
642
+ },
643
+ { session: this.session, returnDocument: 'after', upsert: options?.upsert ?? false }
644
+ )
645
+
646
+ const result = func.toJson(data)
647
+
648
+ if (col.hooks?.afterOperation) {
649
+ await col.hooks.afterOperation({
650
+ rest: this,
651
+ io,
652
+ action: action,
653
+ meta: {
654
+ action: action,
655
+ collection: collection,
656
+ filter: func.clone(filter),
657
+ update: func.clone(update),
658
+ result: func.clone(result),
659
+ }
660
+ })
661
+ }
662
+
663
+ return result as Document | null
664
+ })
665
+ }
666
+
667
+ @CheckIfCollectionExists()
668
+ @CheckIfArrayOfIds('updateMany')
669
+ async updateMany(collection: string, _ids: string[], update: UpdateFilter<any>): Promise<UpdateResult> {
670
+ const action = 'updateMany' as ActionsApiList
671
+ return this.#executeWithAudit(action, collection, { ids: _ids, update }, async () => {
672
+ const col = getCollection(collection, this.#tenant.id) as Collection
673
+
674
+ if (update.$set) {
675
+ update.$set = await func.buildInput(update.$set, { action: action, col: col })
676
+ }
677
+ update = await func.buildInput(update)
678
+ this._validate({ collection, action: action, update: update })
679
+ update = func.toBson(update, { col })
680
+
681
+ if (col.hooks?.beforeOperation) {
682
+ await col.hooks.beforeOperation({
683
+ rest: this,
684
+ io,
685
+ action: action,
686
+ meta: {
687
+ action: action,
688
+ collection: collection,
689
+ update: func.clone(update),
690
+ ids: _ids,
691
+ }
692
+ })
693
+ }
694
+
695
+ const result = await this.db.collection(collection).updateMany(
696
+ { _id: { $in: _ids.map(id => new ObjectId(id)) } },
697
+ { ...(update as any) },
698
+ { session: this.session }
699
+ )
700
+
701
+ if (col.hooks?.afterOperation) {
702
+ await col.hooks.afterOperation({
703
+ rest: this,
704
+ io,
705
+ action: action,
706
+ meta: {
707
+ action: action,
708
+ collection: collection,
709
+ update: func.clone(update),
710
+ ids: _ids,
711
+ result: func.clone(result),
712
+ }
713
+ })
714
+ }
715
+
716
+ return result as UpdateResult
717
+ })
718
+ }
719
+
720
+ @CheckIfCollectionExists()
721
+ @CheckIfId('deleteOne')
722
+ async deleteOne(collection: string, _id: string) {
723
+ const action = 'deleteOne' as ActionsApiList
724
+ return this.#executeWithAudit(action, collection, { id: _id }, async () => {
725
+ const col = getCollection(collection, this.#tenant.id) as Collection
726
+
727
+ if (col.hooks?.beforeOperation) {
728
+ await col.hooks.beforeOperation({
729
+ rest: this,
730
+ io,
731
+ action: action,
732
+ meta: { action: action, collection: collection, id: _id }
733
+ })
734
+ }
735
+
736
+ let result = await this.db.collection(collection).findOneAndDelete(
737
+ { _id: new ObjectId(_id) },
738
+ { session: this.session }
739
+ )
740
+ result = func.toJson(result)
741
+
742
+ if (col.hooks?.afterOperation) {
743
+ await col.hooks.afterOperation({
744
+ rest: this,
745
+ io,
746
+ action: action,
747
+ meta: { action: action, collection: collection, id: _id, result: func.clone(result) }
748
+ })
749
+ }
750
+
751
+ return result
752
+ })
753
+ }
754
+
755
+
756
+ @CheckIfCollectionExists()
757
+ @CheckIfArrayOfIds('deleteMany')
758
+ async deleteMany(collection: string, _ids: string[]): Promise<DeleteResult> {
759
+ const action = 'deleteMany' as ActionsApiList
760
+ return this.#executeWithAudit(action, collection, { ids: _ids }, async () => {
761
+ const col = getCollection(collection, this.#tenant.id) as Collection
762
+
763
+ if (col.hooks?.beforeOperation) {
764
+ await col.hooks.beforeOperation({
765
+ rest: this,
766
+ io,
767
+ action: action,
768
+ meta: { action: action, collection: collection, ids: _ids }
769
+ })
770
+ }
771
+
772
+ let result: DeleteResult = await this.db.collection(collection).deleteMany(
773
+ { _id: { $in: _ids.map(id => new ObjectId(id)) } },
774
+ { session: this.session }
775
+ )
776
+ result = func.toJson(result) as DeleteResult
777
+
778
+ if (col.hooks?.afterOperation) {
779
+ await col.hooks.afterOperation({
780
+ rest: this,
781
+ io,
782
+ action: action,
783
+ meta: { action: action, collection: collection, ids: _ids, result: func.clone(result) }
784
+ })
785
+ }
786
+
787
+ return result
788
+ })
789
+ }
790
+
791
+
792
+ @CheckIfCollectionExists()
793
+ @CheckBulkWriteOperations()
794
+ async bulkWrite(collection: string, operations: AnyBulkWriteOperation[], options?: BulkWriteOptions): Promise<BulkWriteResult> {
795
+ const action = 'bulkWrite' as ActionsApiList
796
+ return this.#executeWithAudit(action, collection, { count: operations.length }, async () => {
797
+ for (let operation of operations) {
798
+ const col = getCollection(collection, this.#tenant.id) as Collection
799
+
800
+ if ('insertOne' in operation) {
801
+ operation.insertOne.document = func.toBson(
802
+ await func.buildInput(operation.insertOne.document, { action: 'insertOne', col }),
803
+ { col },
804
+ )
805
+ }
806
+ if ('updateOne' in operation) {
807
+ operation.updateOne.filter = func.toBson(
808
+ await func.buildInput(operation.updateOne.filter, { action: 'updateOne', col }),
809
+ { col },
810
+ )
811
+ const updateOneDoc = operation.updateOne.update as UpdateFilter<Document>
812
+ if (updateOneDoc.$set) {
813
+ updateOneDoc.$set = await func.buildInput(updateOneDoc.$set, { action: 'updateOne', col })
814
+ }
815
+ operation.updateOne.update = func.toBson(operation.updateOne.update, { col })
816
+ }
817
+ if ('updateMany' in operation) {
818
+ operation.updateMany.filter = func.toBson(
819
+ await func.buildInput(operation.updateMany.filter, { action: 'updateMany', col }),
820
+ { col },
821
+ )
822
+ const updateManyDoc = operation.updateMany.update as UpdateFilter<Document>
823
+ if (updateManyDoc.$set) {
824
+ updateManyDoc.$set = await func.buildInput(updateManyDoc.$set, { action: 'updateMany', col })
825
+ }
826
+ operation.updateMany.update = func.toBson(operation.updateMany.update, { col })
827
+ }
828
+ if ('replaceOne' in operation) {
829
+ operation.replaceOne.replacement = func.toBson(
830
+ await func.buildInput(operation.replaceOne.replacement, { action: 'replaceOne', col }),
831
+ { col },
832
+ )
833
+ }
834
+ if ('deleteOne' in operation) {
835
+ operation.deleteOne.filter = func.toBson(
836
+ await func.buildInput(operation.deleteOne.filter, { action: 'deleteOne', col }),
837
+ { col },
838
+ )
839
+ }
840
+ if ('deleteMany' in operation) {
841
+ operation.deleteMany.filter = func.toBson(
842
+ await func.buildInput(operation.deleteMany.filter, { action: 'deleteMany', col }),
843
+ { col },
844
+ )
845
+ }
846
+ }
847
+
848
+ const result = await this.db.collection(collection).bulkWrite(operations, {
849
+ session: this.session,
850
+ ...options,
851
+ })
852
+
853
+ return result
854
+ })
855
+ }
856
+
857
+
858
+ @CheckIfCollectionExists()
859
+ async bulkUpdate(collection: string, operations: BulkUpdateOperation[], options?: BulkWriteOptions): Promise<BulkWriteResult> {
860
+ const action = 'bulkUpdate' as ActionsApiList
861
+ return this.#executeWithAudit(action, collection, { count: operations.length }, async () => {
862
+ const col = getCollection(collection, this.#tenant.id) as Collection
863
+
864
+ for (const operation of operations) {
865
+ if ('updateOne' in operation) {
866
+ const update = operation.updateOne.update as UpdateFilter<Document>
867
+ if (update.$set) {
868
+ update.$set = await func.buildInput(update.$set, { action: 'updateOne', col })
869
+ }
870
+ operation.updateOne.update = func.toBson(operation.updateOne.update, { col })
871
+ }
872
+ if ('updateMany' in operation) {
873
+ const update = operation.updateMany.update as UpdateFilter<Document>
874
+ if (update.$set) {
875
+ update.$set = await func.buildInput(update.$set, { action: 'updateMany', col })
876
+ }
877
+ operation.updateMany.update = func.toBson(operation.updateMany.update, { col })
878
+ }
879
+ }
880
+
881
+ const result = await this.db.collection(collection).bulkWrite(operations, {
882
+ session: this.session,
883
+ ...options,
884
+ })
885
+
886
+ return result
887
+ })
888
+ }
889
+
890
+
891
+
892
+ @CheckIfCollectionExists()
893
+ async countDocuments(collection: string, query: any = {}): Promise<number> {
894
+ const action = 'countDocuments' as ActionsApiList
895
+ return this.#executeWithAudit(action, collection, { query }, async () => {
896
+ return await this.db.collection(collection).countDocuments()
897
+ })
898
+ }
899
+
900
+ @CheckIfCollectionExists()
901
+ async dropCollection(collection: string) {
902
+ const action = 'dropCollection' as ActionsApiList
903
+ return this.#executeWithAudit(action, collection, undefined, async () => {
904
+ return await this.db.collection(collection).drop()
905
+ })
906
+ }
907
+
908
+ @CheckIfCollectionExists()
909
+ async dropIndex(collection: string, index: string) {
910
+ const action = 'dropIndex' as ActionsApiList
911
+ return this.#executeWithAudit(action, collection, { index }, async () => {
912
+ return await this.db.collection(collection).dropIndex(index)
913
+ })
914
+ }
915
+
916
+ @CheckIfCollectionExists()
917
+ async dropIndexes(collection: string) {
918
+ const action = 'dropIndexes' as ActionsApiList
919
+ return this.#executeWithAudit(action, collection, undefined, async () => {
920
+ return await this.db.collection(collection).dropIndexes()
921
+ })
922
+ }
923
+
924
+ async runAction<T = any>(collection: string, action: string, data?: any): Promise<T> {
925
+ const col = getCollection(collection, this.#tenant.id)
926
+ if (!col) {
927
+ throw new AppError(`Collection '${collection}' not found`, { code: 'COLLECTION_NOT_FOUND', status: 500 })
928
+ }
929
+
930
+ if (!Object.hasOwn(col?.actions ?? {}, action)) {
931
+ throw new AppError(`Action '${action}' not found on collection '${collection}'`, { code: 'ACTION_NOT_FOUND', status: 500 })
932
+ }
933
+
934
+ const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
935
+
936
+ return await col.actions?.[action]({
937
+ rest: this,
938
+ io,
939
+ data,
940
+ error: fn.error,
941
+ jwt: func.jwt,
942
+ token: token ?? { value: null, decoded: null },
943
+ })
944
+ }
945
+
946
+ async runService<T = any>(service: string, action: string, data?: any): Promise<T> {
947
+ const serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === this.tenant_id) as Service | undefined
948
+ if (!serviceInstance) {
949
+ throw new AppError(`Service '${service}' not found`, { code: 'SERVICE_NOT_FOUND', status: 500 })
950
+ }
951
+ if (!serviceInstance.enabled) {
952
+ throw new AppError(`Service '${service}' is not enabled`, { code: 'SERVICE_NOT_ENABLED', status: 500 })
953
+ }
954
+ if (!Object.hasOwn(serviceInstance.actions, action) || !serviceInstance.actions?.[action]) {
955
+ throw new AppError(`Action '${action}' not found on service '${service}'`, { code: 'SERVICE_ACTION_NOT_FOUND', status: 500 })
956
+ }
957
+
958
+ const token = requestCtxStorage.get<{ value: string | null, decoded: Record<string, unknown> | null, provided: boolean, expired: boolean }>('token')
959
+
960
+ return await serviceInstance.actions?.[action]({
961
+ data,
962
+ error: fn.error,
963
+ io,
964
+ jwt: func.jwt,
965
+ token: token ?? { value: null, decoded: null, provided: false, expired: false },
966
+ rest: this,
967
+ io,
968
+ })
969
+ }
970
+
971
+ async stats() {
972
+ try {
973
+ return await this.db.stats()
974
+ } catch (err: any) {
975
+ throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
976
+ }
977
+ }
978
+
979
+ /**
980
+ * List workflow runs from the _workflows_ collection with filtering.
981
+ * Works like find() but targets the _workflows_ collection by default.
982
+ */
983
+ async workflows(params: FindOptions = {}): Promise<Document[]> {
984
+ try {
985
+ let pipeline = func.buildPipeline(params)
986
+ pipeline = await func.buildInput(pipeline, { rest: this })
987
+ pipeline = func.toBson(pipeline)
988
+
989
+ let result = await this.db.collection('_workflows_').aggregate(pipeline, {
990
+ session: this.session,
991
+ allowDiskUse: true,
992
+ }).toArray()
993
+ result = func.toJson(result)
994
+ return result as any[]
995
+ } catch (err: any) {
996
+ throw err instanceof AppError ? err : new AppError(err?.message || 'Internal server error', { code: 'INTERNAL_API_ERROR', status: 500 })
997
+ }
998
+ }
999
+
1000
+ startTransaction() {
1001
+ if (!this.session) this.session = this.startSession()
1002
+ this.session.startTransaction()
1003
+ }
1004
+
1005
+ async commitTransaction() {
1006
+ if (this.session) {
1007
+ await this.session.commitTransaction()
1008
+ await this.session.endSession()
1009
+ this.session = undefined as any
1010
+ }
1011
+ }
1012
+
1013
+ async abortTransaction() {
1014
+ if (this.session) {
1015
+ const session = this.session
1016
+ try {
1017
+ await session.abortTransaction()
1018
+ } finally {
1019
+ await session.endSession()
1020
+ this.session = undefined as any
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+
1026
+ startSession(): ClientSession {
1027
+ let session = this.client.startSession()
1028
+ this.session = session
1029
+ return session
1030
+ }
1031
+
1032
+ async endSession(): Promise<void> {
1033
+ if (this.session) {
1034
+ await this.session.endSession()
1035
+ this.session = undefined as any
1036
+ }
1037
+ }
1038
+
1039
+ startTrace(traceId?: string, extra?: { comment?: string; tag?: string; version?: string }) {
1040
+ requestCtxStorage.set('trace', {
1041
+ id: traceId ?? crypto.randomUUID(),
1042
+ comment: extra?.comment,
1043
+ tag: extra?.tag,
1044
+ version: extra?.version,
1045
+ })
1046
+ }
1047
+
1048
+ unsetTrace() {
1049
+ requestCtxStorage.delete('trace')
1050
+ }
1051
+
1052
+ get audit() {
1053
+ return {
1054
+ addActivities: async (activities: ActivityInput[]) => {
1055
+ await this.db.collection('_activities_').insertMany(activities)
1056
+ .then(e => e)
1057
+ .catch(err => {
1058
+ console.error('Error inserting activities', err)
1059
+ })
1060
+ },
1061
+ getActivities: async (opts = {
1062
+ $match: {},
1063
+ $limit: 100
1064
+ }) => {
1065
+
1066
+ let pipeline = func.buildPipeline(opts)
1067
+ return await this.db.collection('_activities_').aggregate(pipeline).toArray()
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ /**
1073
+ * Acquire a distributed lock for this tenant.
1074
+ * Uses MongoDB atomic findOneAndUpdate with upsert.
1075
+ * Only one node in a cluster can hold the lock at a time.
1076
+ * Returns true if the lock was acquired, false otherwise.
1077
+ */
1078
+ async lock(name: string, ttlMs = 300_000): Promise<void> {
1079
+ const now = Date.now();
1080
+ const expiresAt = now + ttlMs;
1081
+ const id = `${this.tenant_id}:${name}`;
1082
+
1083
+ try {
1084
+ const result = await this.db.collection('_locks_').findOneAndUpdate(
1085
+ { _id: id, expiresAt: { $lt: now } },
1086
+ { $set: { _id: id, tid: this.tenant_id, name, acquiredAt: now, expiresAt } },
1087
+ { upsert: true, returnDocument: 'after' }
1088
+ );
1089
+ if (!result) {
1090
+ throw new AppError(`Lock '${name}' is already held by another node`, {
1091
+ code: 'LOCK_ACQUISITION_FAILED',
1092
+ status: 409
1093
+ });
1094
+ }
1095
+ } catch (err: any) {
1096
+ if (err instanceof AppError) throw err;
1097
+ // E11000 duplicate key = another node holds a valid lock
1098
+ if (err?.code === 11000) {
1099
+ throw new AppError(`Lock '${name}' is already held by another node`, {
1100
+ code: 'LOCK_ACQUISITION_FAILED',
1101
+ status: 409
1102
+ });
1103
+ }
1104
+ throw err;
1105
+ }
1106
+ }
1107
+
1108
+ /**
1109
+ * Release a distributed lock.
1110
+ */
1111
+ async unlock(name: string): Promise<void> {
1112
+ await this.db.collection('_locks_').deleteOne({
1113
+ tid: this.tenant_id,
1114
+ name,
1115
+ });
1116
+ }
1117
+
1118
+ /**
1119
+ * Workflow engine — run, pause, resume, cancel workflows.
1120
+ * Usage: rest.workflow.run('transfert', data)
1121
+ */
1122
+ get workflow() {
1123
+ return createWorkflow(this.tenant_id);
1124
+ }
1125
+
1126
+ }
1127
+
1128
+ export { MongoRest }