@airoom/nextmin-node 2.0.1 → 2.0.3

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 (45) hide show
  1. package/README.md +151 -0
  2. package/dist/api/apiRouter.d.ts +28 -1
  3. package/dist/api/apiRouter.js +138 -8
  4. package/dist/api/router/mountBatchRoutes.d.ts +2 -0
  5. package/dist/api/router/mountBatchRoutes.js +90 -0
  6. package/dist/api/router/mountCrudRoutes.js +79 -20
  7. package/dist/api/router/setupAuthRoutes.js +475 -38
  8. package/dist/api/router/setupChatWidgetRoutes.d.ts +3 -0
  9. package/dist/api/router/setupChatWidgetRoutes.js +207 -0
  10. package/dist/api/router/setupFileRoutes.js +258 -12
  11. package/dist/api/router/utils.d.ts +4 -1
  12. package/dist/api/router/utils.js +25 -7
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +145 -64
  15. package/dist/database/DatabaseAdapter.d.ts +2 -0
  16. package/dist/database/NMAdapter.d.ts +11 -0
  17. package/dist/database/NMAdapter.js +688 -73
  18. package/dist/database/QueryEngine.js +7 -4
  19. package/dist/files/FileStorageAdapter.d.ts +6 -0
  20. package/dist/files/LocalFileStorageAdapter.d.ts +6 -0
  21. package/dist/files/LocalFileStorageAdapter.js +39 -3
  22. package/dist/files/S3FileStorageAdapter.d.ts +8 -1
  23. package/dist/files/S3FileStorageAdapter.js +86 -11
  24. package/dist/files/filename.js +6 -4
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +3 -1
  27. package/dist/models/BaseModel.d.ts +19 -2
  28. package/dist/models/BaseModel.js +4 -4
  29. package/dist/policy/authorize.d.ts +1 -1
  30. package/dist/policy/authorize.js +64 -13
  31. package/dist/schemas/Users.json +20 -10
  32. package/dist/services/AggregateService.d.ts +8 -0
  33. package/dist/services/AggregateService.js +87 -0
  34. package/dist/services/IndexingService.d.ts +24 -0
  35. package/dist/services/IndexingService.js +555 -0
  36. package/dist/services/LocalAIService.d.ts +34 -0
  37. package/dist/services/LocalAIService.js +471 -0
  38. package/dist/services/OpenAIService.d.ts +16 -0
  39. package/dist/services/OpenAIService.js +586 -0
  40. package/dist/utils/Events.js +1 -1
  41. package/dist/utils/Logger.d.ts +2 -1
  42. package/dist/utils/Logger.js +15 -6
  43. package/dist/utils/SchemaLoader.d.ts +2 -2
  44. package/dist/utils/SchemaLoader.js +44 -6
  45. package/package.json +12 -4
@@ -9,6 +9,7 @@ const mongodb_1 = require("mongodb");
9
9
  const Logger_1 = __importDefault(require("../utils/Logger"));
10
10
  const utils_1 = require("../api/router/utils");
11
11
  const utils_2 = require("./utils");
12
+ const QueryCache_1 = require("../utils/QueryCache");
12
13
  /**
13
14
  * NMAdapter implements the DatabaseAdapter interface.
14
15
  * It supports SQL databases (PostgreSQL, MySQL, MariaDB, SQLite, MSSQL) and MongoDB.
@@ -19,6 +20,39 @@ class NMAdapter {
19
20
  this.repositories = new Map();
20
21
  this.entitySchemas = new Map();
21
22
  this.registeredSchemas = {};
23
+ this.imageProxyOptions = {};
24
+ this.queryCache = new QueryCache_1.QueryCache();
25
+ const isValidUrl = (url) => typeof url === 'string' && url.startsWith('http');
26
+ let imageProxy = options.imageProxy;
27
+ if (!imageProxy || Object.keys(imageProxy).length === 0) {
28
+ imageProxy = { proxyBase: '/images' };
29
+ }
30
+ if (imageProxy && imageProxy.proxyBase) {
31
+ this.imageProxyOptions = { ...imageProxy };
32
+ // Auto-detect originalBase if missing but s3/r2 config exists
33
+ if (!this.imageProxyOptions.originalBase) {
34
+ const s3 = options.s3 || {
35
+ bucket: process.env.S3_BUCKET,
36
+ region: process.env.S3_REGION,
37
+ endpoint: process.env.S3_ENDPOINT,
38
+ publicBaseUrl: process.env.S3_PUBLIC_BASE_URL
39
+ };
40
+ if (s3.publicBaseUrl) {
41
+ this.imageProxyOptions.originalBase = s3.publicBaseUrl.replace(/\/+$/, '');
42
+ }
43
+ else if (s3.bucket) {
44
+ if (isValidUrl(s3.endpoint)) {
45
+ this.imageProxyOptions.originalBase = `${s3.endpoint.replace(/\/+$/, '')}/${s3.bucket}`;
46
+ }
47
+ else if (s3.region) {
48
+ this.imageProxyOptions.originalBase = `https://${s3.bucket}.s3.${s3.region}.amazonaws.com`;
49
+ }
50
+ }
51
+ }
52
+ if (this.imageProxyOptions.originalBase && this.imageProxyOptions.proxyBase) {
53
+ Logger_1.default.info('NMAdapter', `Image proxy enabled: ${this.imageProxyOptions.originalBase} -> ${this.imageProxyOptions.proxyBase}`);
54
+ }
55
+ }
22
56
  this.dataSource = new typeorm_1.DataSource({
23
57
  ...options,
24
58
  entities: [], // Will be populated dynamically
@@ -75,8 +109,10 @@ class NMAdapter {
75
109
  // 3) Sync Indexes
76
110
  for (const [name, def] of Object.entries(schemas)) {
77
111
  const indexes = {};
78
- for (const [key, attr] of Object.entries(def.attributes)) {
79
- if (attr.index === true) {
112
+ for (const [key, attr] of Object.entries(def.attributes || {})) {
113
+ const a = Array.isArray(attr) ? attr[0] : attr;
114
+ const isRef = !!a?.ref || !!a?.refPath;
115
+ if (attr?.index === true || isRef || key === 'name' || key === 'title' || key === 'slug' || key === 'createdAt') {
80
116
  indexes[key] = 1;
81
117
  }
82
118
  }
@@ -84,7 +120,9 @@ class NMAdapter {
84
120
  await this.syncIndexes(name, indexes);
85
121
  }
86
122
  }
87
- // 4) Cache repositories
123
+ // 4) Validate refPath configurations
124
+ this.validateRefPathSchemas();
125
+ // 5) Cache repositories
88
126
  for (const name of Object.keys(schemas)) {
89
127
  const lcName = name.toLowerCase();
90
128
  const schema = this.entitySchemas.get(lcName);
@@ -93,6 +131,44 @@ class NMAdapter {
93
131
  }
94
132
  }
95
133
  }
134
+ validateRefPathSchemas() {
135
+ for (const [schemaName, schema] of Object.entries(this.registeredSchemas)) {
136
+ if (!schema?.attributes)
137
+ continue;
138
+ for (const [attrName, attr] of Object.entries(schema.attributes)) {
139
+ const a = Array.isArray(attr) ? attr[0] : attr;
140
+ if (!a?.refPath)
141
+ continue;
142
+ const refPathField = String(a.refPath);
143
+ // 1) Warning if both 'ref' and 'refPath' are defined
144
+ if (a.ref) {
145
+ Logger_1.default.warn('SchemaValidation', `Model "${schema.modelName}": Attribute "${attrName}" defines both "ref" ("${a.ref}") and "refPath" ("${refPathField}"). "refPath" takes precedence and "ref" will be ignored.`);
146
+ }
147
+ // 2) Warning if referenced refPath field does not exist in the same schema
148
+ const targetAttr = schema.attributes[refPathField];
149
+ if (!targetAttr) {
150
+ Logger_1.default.warn('SchemaValidation', `Model "${schema.modelName}": Attribute "${attrName}" specifies refPath "${refPathField}", but field "${refPathField}" does not exist in schema "${schema.modelName}".`);
151
+ continue;
152
+ }
153
+ // 3) Warning if target field is not an enum field with string items
154
+ const targetA = Array.isArray(targetAttr) ? targetAttr[0] : targetAttr;
155
+ const enumList = targetA?.enum;
156
+ if (!Array.isArray(enumList) || enumList.length === 0) {
157
+ Logger_1.default.warn('SchemaValidation', `Model "${schema.modelName}": Attribute "${attrName}" refPath "${refPathField}" references field "${refPathField}", which must be a string attribute with an "enum" array of target model names.`);
158
+ continue;
159
+ }
160
+ // 4) Warning for any enum value that is not a registered model
161
+ for (const enumVal of enumList) {
162
+ if (typeof enumVal !== 'string')
163
+ continue;
164
+ const exists = Object.values(this.registeredSchemas).some(s => s.modelName.toLowerCase() === enumVal.trim().toLowerCase());
165
+ if (!exists) {
166
+ Logger_1.default.warn('SchemaValidation', `Model "${schema.modelName}": Attribute "${attrName}" refPath "${refPathField}" enum contains "${enumVal}", but schema "${enumVal}" is not a registered model.`);
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
96
172
  async syncIndexes(modelName, spec) {
97
173
  const repo = this.repositories.get(modelName.toLowerCase());
98
174
  if (!repo)
@@ -118,6 +194,8 @@ class NMAdapter {
118
194
  }
119
195
  buildEntitySchema(def) {
120
196
  const isMongo = this.dataSource.options.type === 'mongodb';
197
+ const isPostgres = this.dataSource.options.type === 'postgres';
198
+ const dateTimeType = isPostgres ? 'timestamp' : 'datetime';
121
199
  const columns = {
122
200
  id: {
123
201
  primary: true,
@@ -129,12 +207,14 @@ class NMAdapter {
129
207
  generated: isMongo ? true : 'increment',
130
208
  },
131
209
  createdAt: {
132
- type: 'datetime',
210
+ type: dateTimeType,
211
+ createDate: true,
133
212
  default: () => 'CURRENT_TIMESTAMP',
134
213
  nullable: true,
135
214
  },
136
215
  updatedAt: {
137
- type: 'datetime',
216
+ type: dateTimeType,
217
+ updateDate: true,
138
218
  default: () => 'CURRENT_TIMESTAMP',
139
219
  nullable: true,
140
220
  },
@@ -159,6 +239,9 @@ class NMAdapter {
159
239
  continue; // Base model protects this field; skip column in child
160
240
  }
161
241
  }
242
+ const head = Array.isArray(attr) ? attr[0] : attr;
243
+ if (head?.type === 'virtual')
244
+ continue;
162
245
  const typeORMType = this.mapType(attr);
163
246
  const isUnique = attr.unique === true;
164
247
  const isSparse = attr.sparse === true;
@@ -179,7 +262,7 @@ class NMAdapter {
179
262
  sparse: true,
180
263
  });
181
264
  }
182
- if (typeORMType === 'datetime' && attr.default === 'now') {
265
+ if ((typeORMType === 'datetime' || typeORMType === 'timestamp') && attr.default === 'now') {
183
266
  columns[key].default = () => 'CURRENT_TIMESTAMP';
184
267
  }
185
268
  }
@@ -195,12 +278,13 @@ class NMAdapter {
195
278
  return 'simple-json';
196
279
  }
197
280
  const type = String(attr?.type || 'string').toLowerCase();
281
+ const isPostgres = this.dataSource.options.type === 'postgres';
198
282
  switch (type) {
199
283
  case 'string': return 'varchar';
200
284
  case 'number': return 'float';
201
285
  case 'boolean': return 'boolean';
202
286
  case 'date':
203
- case 'datetime': return 'datetime';
287
+ case 'datetime': return isPostgres ? 'timestamp' : 'datetime';
204
288
  case 'json':
205
289
  case 'object': return 'json';
206
290
  case 'array': return 'simple-json';
@@ -216,51 +300,220 @@ class NMAdapter {
216
300
  }
217
301
  return repo;
218
302
  }
303
+ getActiveBaseUrl() {
304
+ if (this.options.imageProxy && this.options.imageProxy.proxyBase) {
305
+ return this.options.imageProxy.proxyBase.replace(/\/+$/, '');
306
+ }
307
+ if (this.imageProxyOptions.originalBase) {
308
+ return this.imageProxyOptions.originalBase.replace(/\/+$/, '');
309
+ }
310
+ if (this.imageProxyOptions.proxyBase) {
311
+ return this.imageProxyOptions.proxyBase.replace(/\/+$/, '');
312
+ }
313
+ if (process.env.S3_PUBLIC_BASE_URL) {
314
+ return process.env.S3_PUBLIC_BASE_URL.replace(/\/+$/, '');
315
+ }
316
+ return '';
317
+ }
318
+ cleanUrlForStorage(val) {
319
+ if (typeof val !== 'string')
320
+ return val;
321
+ // If string has spaces or HTML tags, it is not a pure image URL/key
322
+ if (val.includes(' ') || val.includes('<') || val.includes('>')) {
323
+ return val;
324
+ }
325
+ const basesToStrip = [
326
+ this.imageProxyOptions.originalBase,
327
+ this.imageProxyOptions.proxyBase,
328
+ process.env.S3_PUBLIC_BASE_URL,
329
+ this.options.s3?.publicBaseUrl
330
+ ].filter((b) => typeof b === 'string' && b.length > 0);
331
+ let cleaned = val;
332
+ for (const base of basesToStrip) {
333
+ const cleanBase = base.replace(/\/+$/, '');
334
+ if (cleaned.startsWith(cleanBase)) {
335
+ cleaned = cleaned.substring(cleanBase.length);
336
+ }
337
+ }
338
+ const uploadsIdx = cleaned.indexOf('uploads/');
339
+ if (uploadsIdx !== -1) {
340
+ cleaned = cleaned.substring(uploadsIdx);
341
+ }
342
+ return cleaned.replace(/^\/+/, '');
343
+ }
344
+ cleanPayloadForStorage(obj) {
345
+ if (!obj || typeof obj !== 'object')
346
+ return obj;
347
+ if (obj instanceof Date || Buffer.isBuffer(obj)) {
348
+ return obj;
349
+ }
350
+ if (Array.isArray(obj)) {
351
+ return obj.map(item => this.cleanPayloadForStorage(item));
352
+ }
353
+ const cleaned = {};
354
+ for (const key in obj) {
355
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
356
+ const val = obj[key];
357
+ if (typeof val === 'string') {
358
+ cleaned[key] = this.cleanUrlForStorage(val);
359
+ }
360
+ else if (val && typeof val === 'object') {
361
+ cleaned[key] = this.cleanPayloadForStorage(val);
362
+ }
363
+ else {
364
+ cleaned[key] = val;
365
+ }
366
+ }
367
+ }
368
+ return cleaned;
369
+ }
370
+ formatUrlForRead(val) {
371
+ if (typeof val !== 'string')
372
+ return val;
373
+ // If string has spaces or HTML tags, it is not a pure image URL/key
374
+ if (val.includes(' ') || val.includes('<') || val.includes('>')) {
375
+ return val;
376
+ }
377
+ let cleaned = val;
378
+ let matched = false;
379
+ const uploadsIdx = cleaned.indexOf('uploads/');
380
+ if (uploadsIdx !== -1) {
381
+ cleaned = cleaned.substring(uploadsIdx);
382
+ matched = true;
383
+ }
384
+ else {
385
+ // Also check if we can strip known bases
386
+ const basesToStrip = [
387
+ this.imageProxyOptions.originalBase,
388
+ this.imageProxyOptions.proxyBase,
389
+ process.env.S3_PUBLIC_BASE_URL,
390
+ this.options.s3?.publicBaseUrl
391
+ ].filter((b) => typeof b === 'string' && b.length > 0);
392
+ for (const base of basesToStrip) {
393
+ const cleanBase = base.replace(/\/+$/, '');
394
+ if (cleaned.startsWith(cleanBase)) {
395
+ cleaned = cleaned.substring(cleanBase.length);
396
+ matched = true;
397
+ break;
398
+ }
399
+ }
400
+ }
401
+ cleaned = cleaned.replace(/^\/+/, '');
402
+ // If it was matched as a file URL/key, or if it is a clean storage path
403
+ if (matched || cleaned.startsWith('uploads/')) {
404
+ const activeBase = this.getActiveBaseUrl();
405
+ if (activeBase) {
406
+ return `${activeBase}/${cleaned}`;
407
+ }
408
+ return '/' + cleaned;
409
+ }
410
+ return val;
411
+ }
219
412
  async create(collection, data, schemaDefinition) {
413
+ await this.queryCache.invalidateCollection(collection);
220
414
  const repo = this.getRepository(collection);
221
- const coerced = this.coercePayloadForStorage(data, schemaDefinition);
415
+ const cleanedData = this.cleanPayloadForStorage(data);
416
+ const coerced = this.coercePayloadForStorage(cleanedData, schemaDefinition);
417
+ // Ensure timestamps are set if not provided (especially for MongoDB)
418
+ const now = new Date();
419
+ if (!coerced.createdAt)
420
+ coerced.createdAt = now;
421
+ if (!coerced.updatedAt)
422
+ coerced.updatedAt = now;
222
423
  const created = await repo.save(coerced);
223
424
  return this.prepareResult(created);
224
425
  }
225
426
  coercePayloadForStorage(payload, schema) {
226
- if (!payload || !schema || this.dataSource.options.type !== 'mongodb')
427
+ if (!payload || !schema)
227
428
  return payload;
429
+ const isMongo = this.dataSource.options.type === 'mongodb';
228
430
  const coerced = { ...payload };
229
431
  for (const [key, attr] of Object.entries(schema.attributes)) {
230
432
  const a = Array.isArray(attr) ? attr[0] : attr;
231
433
  const isObjectIdField = String(a?.type || '').toLowerCase() === 'objectid';
232
- // For MongoDB, we remove null values to satisfy sparse indexes
233
- if (coerced[key] === null) {
234
- delete coerced[key];
235
- continue;
236
- }
434
+ // Flatten nested objects/relations to their IDs
237
435
  if (isObjectIdField && coerced[key] != null) {
238
436
  if (Array.isArray(coerced[key])) {
239
- coerced[key] = coerced[key].map((v) => this.convertToObjectId(v));
437
+ coerced[key] = coerced[key].map((v) => {
438
+ if (v && typeof v === 'object') {
439
+ return v.id || v._id || v;
440
+ }
441
+ return v;
442
+ });
240
443
  }
241
- else {
242
- coerced[key] = this.convertToObjectId(coerced[key]);
444
+ else if (coerced[key] && typeof coerced[key] === 'object') {
445
+ coerced[key] = coerced[key].id || coerced[key]._id || coerced[key];
446
+ }
447
+ }
448
+ if (isMongo) {
449
+ // For MongoDB, we remove null values to satisfy sparse indexes
450
+ if (coerced[key] === null) {
451
+ delete coerced[key];
452
+ continue;
453
+ }
454
+ if (isObjectIdField && coerced[key] != null) {
455
+ if (Array.isArray(coerced[key])) {
456
+ coerced[key] = coerced[key].map((v) => this.convertToObjectId(v));
457
+ }
458
+ else {
459
+ coerced[key] = this.convertToObjectId(coerced[key]);
460
+ }
461
+ }
462
+ }
463
+ else {
464
+ // For SQL databases: single-value ObjectId relations are stored via simple-json.
465
+ // Coerce string numeric IDs to actual numbers so JSON.stringify produces '1' (not '"1"'),
466
+ // preventing the double-quote storage bug ("\"1\"" in the DB).
467
+ if (isObjectIdField && Array.isArray(attr) && !a?.multiple && coerced[key] != null) {
468
+ const val = coerced[key];
469
+ if (typeof val === 'string') {
470
+ const n = Number(val);
471
+ if (!isNaN(n) && val.trim() !== '') {
472
+ coerced[key] = n;
473
+ }
474
+ }
243
475
  }
244
476
  }
245
477
  }
246
478
  return coerced;
247
479
  }
248
480
  async read(collection, query, limit, skip, schemaDefinition, includePrivateFields, options) {
481
+ const cacheKey = await this.queryCache.generateKey(collection, 'read', {
482
+ query, limit, skip, schema: schemaDefinition?.modelName, includePrivateFields, options
483
+ });
484
+ const cached = await this.queryCache.get(cacheKey);
485
+ if (cached) {
486
+ return cached;
487
+ }
249
488
  const repo = this.getRepository(collection);
250
489
  const isMongo = this.dataSource.options.type === 'mongodb';
251
490
  const resolvedQuery = await this.resolveNestedSearches(query);
252
491
  const where = this.transformQuery(resolvedQuery, schemaDefinition);
492
+ let order = options?.sort;
493
+ if (order && !isMongo) {
494
+ order = { ...order };
495
+ delete order._id;
496
+ }
253
497
  const findOptions = {
254
498
  where,
255
499
  take: limit,
256
500
  skip: skip,
257
- order: options?.sort,
501
+ order,
258
502
  };
259
503
  if (options?.projection) {
260
504
  const hasRootProj = this.isExclusiveProjection(options.projection, schemaDefinition);
261
505
  if (hasRootProj) {
262
506
  const dbProjection = {};
507
+ // Always include ID for hydration purposes
508
+ if (isMongo)
509
+ dbProjection._id = true;
510
+ else
511
+ dbProjection.id = true;
263
512
  for (const [key, value] of Object.entries(options.projection)) {
513
+ // Skip dynamic aggregate fields like model[count] for the DB query
514
+ if (key.includes('[') && key.endsWith(']')) {
515
+ continue;
516
+ }
264
517
  if (key.includes('.') && schemaDefinition) {
265
518
  const root = key.split('.')[0];
266
519
  const attr = schemaDefinition.attributes[root];
@@ -288,7 +541,15 @@ class NMAdapter {
288
541
  }
289
542
  }
290
543
  let results;
291
- if (isMongo && findOptions.projection) {
544
+ if (isMongo && options?.sample) {
545
+ const mongoRepo = repo.manager.getMongoRepository(repo.metadata.name);
546
+ const aggregation = [{ $match: where }];
547
+ aggregation.push({ $sample: { size: options.sample } });
548
+ if (findOptions.projection)
549
+ aggregation.push({ $project: findOptions.projection });
550
+ results = await mongoRepo.aggregate(aggregation).toArray();
551
+ }
552
+ else if (isMongo && findOptions.projection) {
292
553
  const mongoRepo = repo.manager.getMongoRepository(repo.metadata.name);
293
554
  const cursor = mongoRepo.createCursor(where);
294
555
  cursor.project(findOptions.projection);
@@ -301,12 +562,34 @@ class NMAdapter {
301
562
  results = await cursor.toArray();
302
563
  }
303
564
  else {
304
- results = await repo.find(findOptions);
565
+ if (options?.sample && !isMongo) {
566
+ const qb = repo.createQueryBuilder('entity');
567
+ qb.where(where);
568
+ if (limit)
569
+ qb.take(limit);
570
+ if (skip)
571
+ qb.skip(skip);
572
+ const dbType = this.dataSource.options.type;
573
+ if (dbType === 'postgres' || dbType === 'sqlite') {
574
+ qb.orderBy('RANDOM()');
575
+ }
576
+ else if (dbType === 'mysql' || dbType === 'mariadb') {
577
+ qb.orderBy('RAND()');
578
+ }
579
+ else if (dbType === 'mssql') {
580
+ qb.orderBy('NEWID()');
581
+ }
582
+ results = await qb.getMany();
583
+ }
584
+ else {
585
+ results = await repo.find(findOptions);
586
+ }
305
587
  }
306
588
  const mappedResults = results.map(r => this.prepareResult(r));
307
589
  if (schemaDefinition) {
308
- await this.hydrateRelations(mappedResults, schemaDefinition, options?.depth ?? 0, options?.projection);
590
+ await this.hydrateRelations(mappedResults, schemaDefinition, options?.depth ?? 0, options);
309
591
  }
592
+ await this.queryCache.set(cacheKey, mappedResults);
310
593
  return mappedResults;
311
594
  }
312
595
  prepareResult(item) {
@@ -324,8 +607,35 @@ class NMAdapter {
324
607
  if (item.id != null)
325
608
  item.id = String(item.id);
326
609
  }
610
+ // Apply image URL proxying/transformation
611
+ this.transformUrls(item);
327
612
  return item;
328
613
  }
614
+ transformUrls(obj) {
615
+ if (!obj || typeof obj !== 'object')
616
+ return;
617
+ for (const key in obj) {
618
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
619
+ const val = obj[key];
620
+ if (typeof val === 'string') {
621
+ obj[key] = this.formatUrlForRead(val);
622
+ }
623
+ else if (Array.isArray(val)) {
624
+ for (let i = 0; i < val.length; i++) {
625
+ if (typeof val[i] === 'string') {
626
+ val[i] = this.formatUrlForRead(val[i]);
627
+ }
628
+ else if (val[i] && typeof val[i] === 'object') {
629
+ this.transformUrls(val[i]);
630
+ }
631
+ }
632
+ }
633
+ else if (val && typeof val === 'object') {
634
+ this.transformUrls(val);
635
+ }
636
+ }
637
+ }
638
+ }
329
639
  idToString(val) {
330
640
  if (val == null)
331
641
  return '';
@@ -358,8 +668,15 @@ class NMAdapter {
358
668
  return this.convertToObjectId(extractedId);
359
669
  }
360
670
  try {
361
- if (typeof val === 'string' && val.length === 24 && /^[0-9a-fA-F]{24}$/.test(val)) {
362
- return new mongodb_1.ObjectId(val);
671
+ if (this.dataSource.options.type === 'mongodb') {
672
+ if (typeof val === 'string' && val.length === 24 && /^[0-9a-fA-F]{24}$/.test(val)) {
673
+ return new mongodb_1.ObjectId(val);
674
+ }
675
+ }
676
+ else {
677
+ const n = Number(val);
678
+ if (!isNaN(n) && String(val).trim() !== '')
679
+ return n;
363
680
  }
364
681
  return val;
365
682
  }
@@ -367,9 +684,87 @@ class NMAdapter {
367
684
  return val;
368
685
  }
369
686
  }
370
- async hydrateRelations(results, schema, depth = 0, projection) {
687
+ discoverInverseRelation(targetModelName, sourceModelHint) {
688
+ const targetLc = targetModelName.toLowerCase();
689
+ const sourceLc = sourceModelHint.toLowerCase();
690
+ // 1. Find a schema that matches sourceModelHint (plural or singular)
691
+ let sourceSchema;
692
+ // Try exact match first
693
+ sourceSchema = this.registeredSchemas[sourceLc];
694
+ // Try common plural/singular variations
695
+ if (!sourceSchema) {
696
+ const variations = [
697
+ sourceLc + 's',
698
+ sourceLc + 'es',
699
+ sourceLc.endsWith('y') ? sourceLc.slice(0, -1) + 'ies' : sourceLc,
700
+ sourceLc.endsWith('ies') ? sourceLc.slice(0, -3) + 'y' : sourceLc,
701
+ sourceLc.endsWith('s') ? sourceLc.slice(0, -1) : sourceLc
702
+ ];
703
+ for (const v of variations) {
704
+ if (this.registeredSchemas[v]) {
705
+ sourceSchema = this.registeredSchemas[v];
706
+ break;
707
+ }
708
+ }
709
+ }
710
+ if (!sourceSchema)
711
+ return null;
712
+ // 2. Find which field in sourceSchema points to targetModelName
713
+ for (const [field, attr] of Object.entries(sourceSchema.attributes)) {
714
+ const a = Array.isArray(attr) ? attr[0] : attr;
715
+ if (a.ref?.toLowerCase() === targetLc) {
716
+ return { sourceModel: sourceSchema.modelName, foreignField: field };
717
+ }
718
+ }
719
+ return null;
720
+ }
721
+ async hydrateDynamicAggregates(results, schema, projection) {
722
+ const promises = [];
723
+ const idsToFetch = results.map(r => r.id).filter(v => v != null);
724
+ if (idsToFetch.length === 0)
725
+ return;
726
+ for (const [projKey, projVal] of Object.entries(projection)) {
727
+ if (projVal !== 1)
728
+ continue;
729
+ if (!projKey.includes('[') || !projKey.endsWith(']'))
730
+ continue;
731
+ const parts = projKey.split('[');
732
+ const modelHint = parts[0];
733
+ const op = parts[1].slice(0, -1);
734
+ if (op !== 'count')
735
+ continue;
736
+ const relation = this.discoverInverseRelation(schema.modelName, modelHint);
737
+ if (!relation) {
738
+ Logger_1.default.warn('NMAdapter', `No inverse relation found for ${modelHint} pointing to ${schema.modelName}`);
739
+ continue;
740
+ }
741
+ promises.push((async () => {
742
+ try {
743
+ const sourceSchema = this.registeredSchemas[relation.sourceModel.toLowerCase()];
744
+ const counts = await this.countBy(relation.sourceModel, relation.foreignField, { [relation.foreignField]: { $in: idsToFetch } }, sourceSchema);
745
+ const targetKey = `${modelHint}Count`;
746
+ results.forEach(r => {
747
+ const lid = this.idToString(r.id);
748
+ r[targetKey] = counts[lid] || 0;
749
+ });
750
+ }
751
+ catch (err) {
752
+ Logger_1.default.warn('NMAdapter', `Failed to fetch dynamic aggregate ${projKey} for ${schema.modelName}: ${err}`);
753
+ }
754
+ })());
755
+ }
756
+ if (promises.length > 0) {
757
+ await Promise.all(promises);
758
+ }
759
+ }
760
+ async hydrateRelations(results, schema, depth = 0, options) {
371
761
  if (!results.length || depth > 2)
372
762
  return;
763
+ const projection = options?.projection;
764
+ // --- Dynamic Aggregates (e.g. model[count]) ---
765
+ if (projection) {
766
+ await this.hydrateDynamicAggregates(results, schema, projection);
767
+ }
373
768
  // 1) Handle inheritance: if schema extends another, fetch base records
374
769
  if (schema.extends) {
375
770
  const baseLc = schema.extends.toLowerCase();
@@ -436,13 +831,59 @@ class NMAdapter {
436
831
  }
437
832
  // 2) Handle standard ObjectId hydration & 3) json-group in parallel
438
833
  const hydrationPromises = [];
834
+ // --- Virtual fields (Inverse relationship counts) ---
835
+ const virtualFields = Object.entries(schema.attributes).filter(([key, attr]) => {
836
+ const a = Array.isArray(attr) ? attr[0] : attr;
837
+ return a?.type === 'virtual' && a?.op === 'count';
838
+ });
839
+ for (const [key, attr] of virtualFields) {
840
+ hydrationPromises.push((async () => {
841
+ const a = Array.isArray(attr) ? attr[0] : attr;
842
+ const refModel = a.ref;
843
+ const foreignField = a.foreignField;
844
+ const localField = a.localField || 'id';
845
+ if (!refModel || !foreignField)
846
+ return;
847
+ const idsToFetch = results.map(r => r[localField]).filter(v => v != null);
848
+ if (idsToFetch.length === 0)
849
+ return;
850
+ const counts = await this.countBy(refModel, foreignField, { [foreignField]: { $in: idsToFetch } });
851
+ results.forEach(r => {
852
+ const lid = this.idToString(r[localField]);
853
+ r[key] = counts[lid] || 0;
854
+ });
855
+ })());
856
+ }
439
857
  // --- Standard Relations ---
440
858
  const relFields = Object.entries(schema.attributes).filter(([key, attr]) => {
441
859
  if (key === 'baseId')
442
860
  return false;
443
861
  const a = Array.isArray(attr) ? attr[0] : attr;
444
- return String(a?.type).toLowerCase() === 'objectid' && a?.ref;
862
+ const t = String(a?.type || '').toLowerCase();
863
+ return (t === 'objectid' || t === 'ref') && (a?.ref || a?.refPath);
445
864
  });
865
+ const isHydratedObject = (val) => {
866
+ if (val == null)
867
+ return false;
868
+ if (typeof val !== 'object')
869
+ return false;
870
+ if (Array.isArray(val))
871
+ return false;
872
+ if (val instanceof mongodb_1.ObjectId)
873
+ return false;
874
+ if (typeof val.toHexString === 'function')
875
+ return false;
876
+ if (val.constructor && val.constructor.name === 'ObjectId')
877
+ return false;
878
+ if (val instanceof Date)
879
+ return false;
880
+ if (Buffer.isBuffer(val))
881
+ return false;
882
+ const keys = Object.keys(val);
883
+ const hasId = keys.includes('id') || keys.includes('_id');
884
+ const hasOtherKeys = keys.some(k => k !== 'id' && k !== '_id');
885
+ return hasId && hasOtherKeys;
886
+ };
446
887
  for (const [key, attr] of relFields) {
447
888
  hydrationPromises.push((async () => {
448
889
  const hasRootProj = this.isExclusiveProjection(projection, schema);
@@ -450,58 +891,140 @@ class NMAdapter {
450
891
  if (projection && hasRootProj && !isTargeted)
451
892
  return;
452
893
  const isArray = Array.isArray(attr);
453
- const refModelName = (isArray ? attr[0] : attr).ref;
454
- if (!refModelName)
455
- return;
456
- const refLc = refModelName.toLowerCase();
457
- const refRepo = this.repositories.get(refLc);
458
- if (!refRepo)
459
- return;
460
- const idsToFetch = new Set();
894
+ const attrObj = isArray ? attr[0] : attr;
895
+ const refPathKey = attrObj.refPath ? String(attrObj.refPath) : undefined;
896
+ const staticRefModel = attrObj.ref ? String(attrObj.ref) : undefined;
897
+ // Group items by resolved target model name
898
+ const itemsByModel = new Map();
899
+ const resolveModelName = (raw) => {
900
+ // Strictly accept only string enum values (no objects or unknown types)
901
+ if (typeof raw !== 'string' || !raw.trim())
902
+ return null;
903
+ const nameStr = raw.trim();
904
+ // Verify that refPathKey refers to an existing field in the schema
905
+ const refPathAttr = schema.attributes[refPathKey];
906
+ if (!refPathAttr)
907
+ return null;
908
+ const targetA = Array.isArray(refPathAttr) ? refPathAttr[0] : refPathAttr;
909
+ const allowedEnums = targetA?.enum;
910
+ // Verify that value is listed in the refPath field's enum array
911
+ if (Array.isArray(allowedEnums) && allowedEnums.length > 0) {
912
+ const isAllowed = allowedEnums.some(e => typeof e === 'string' && e.trim().toLowerCase() === nameStr.toLowerCase());
913
+ if (!isAllowed)
914
+ return null;
915
+ }
916
+ // Match case-insensitively against registered schemas
917
+ const targetSchema = Object.values(this.registeredSchemas).find(s => s.modelName.toLowerCase() === nameStr.toLowerCase());
918
+ return targetSchema ? targetSchema.modelName : null;
919
+ };
461
920
  for (const item of results) {
462
- const val = item[key];
921
+ let val = item[key];
463
922
  if (val == null)
464
923
  continue;
924
+ // Determine target model for this specific item
925
+ const targetModel = refPathKey ? resolveModelName(item[refPathKey]) : staticRefModel;
926
+ if (!targetModel)
927
+ continue;
928
+ // Gracefully parse JSON-stringified relation data if stored in DB (as object or array)
929
+ if (typeof val === 'string') {
930
+ const trimmed = val.trim();
931
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
932
+ try {
933
+ const parsed = JSON.parse(trimmed);
934
+ if (parsed != null) {
935
+ item[key] = parsed;
936
+ val = parsed;
937
+ }
938
+ }
939
+ catch (e) { }
940
+ }
941
+ }
942
+ if (!itemsByModel.has(targetModel)) {
943
+ itemsByModel.set(targetModel, []);
944
+ }
945
+ const group = itemsByModel.get(targetModel);
465
946
  if (isArray && Array.isArray(val)) {
466
- val.forEach(v => { if (v != null)
467
- idsToFetch.add(v); });
947
+ item[key] = val.map(v => {
948
+ if (typeof v === 'string') {
949
+ const trimmed = v.trim();
950
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
951
+ try {
952
+ const parsed = JSON.parse(trimmed);
953
+ if (parsed != null)
954
+ return parsed;
955
+ }
956
+ catch (e) { }
957
+ }
958
+ }
959
+ return v;
960
+ });
961
+ const mappedVal = item[key];
962
+ mappedVal.forEach(v => {
963
+ if (v != null && !isHydratedObject(v)) {
964
+ group.push({ item, idVal: v });
965
+ }
966
+ });
468
967
  }
469
968
  else {
470
- idsToFetch.add(val);
969
+ if (!isHydratedObject(val)) {
970
+ group.push({ item, idVal: val });
971
+ }
471
972
  }
472
973
  }
473
- if (idsToFetch.size === 0)
974
+ if (itemsByModel.size === 0)
474
975
  return;
475
- let subProjection = undefined;
476
- if (projection) {
976
+ let subProjection = options?._populate?.[key];
977
+ if (subProjection) {
978
+ subProjection = { ...subProjection, id: 1 };
979
+ }
980
+ if (!subProjection && projection) {
477
981
  const prefix = `${key}.`;
478
982
  for (const [pk, pv] of Object.entries(projection)) {
479
983
  if (pk.startsWith(prefix)) {
480
984
  if (!subProjection)
481
- subProjection = {};
985
+ subProjection = { id: 1 };
482
986
  subProjection[pk.slice(prefix.length)] = pv;
483
987
  }
484
988
  }
485
989
  }
486
- const fetchedDocs = await this.read(refModelName, { id: { $in: Array.from(idsToFetch) } }, idsToFetch.size, 0, this.registeredSchemas[refModelName], true, {
487
- depth: depth + 1,
488
- projection: subProjection
489
- });
490
- const docMap = new Map();
491
- fetchedDocs.forEach(d => {
492
- const sid = this.idToString(d.id);
493
- if (sid)
494
- docMap.set(sid, d);
495
- });
496
- for (const item of results) {
497
- const val = item[key];
498
- if (val == null)
990
+ for (const [targetModelName, entries] of itemsByModel.entries()) {
991
+ const refLc = targetModelName.toLowerCase();
992
+ const refRepo = this.repositories.get(refLc);
993
+ if (!refRepo)
499
994
  continue;
500
- if (Array.isArray(val)) {
501
- item[key] = val.map(v => docMap.get(this.idToString(v)) || v);
502
- }
503
- else {
504
- item[key] = docMap.get(this.idToString(val)) || val;
995
+ const idsToFetch = new Set();
996
+ entries.forEach(e => idsToFetch.add(e.idVal));
997
+ if (idsToFetch.size === 0)
998
+ continue;
999
+ const fetchedDocs = await this.read(targetModelName, { id: { $in: Array.from(idsToFetch) } }, idsToFetch.size, 0, this.registeredSchemas[targetModelName], true, {
1000
+ depth: depth + 1,
1001
+ projection: subProjection
1002
+ });
1003
+ const docMap = new Map();
1004
+ fetchedDocs.forEach(d => {
1005
+ const sid = this.idToString(d.id);
1006
+ if (sid)
1007
+ docMap.set(sid, d);
1008
+ });
1009
+ for (const { item } of entries) {
1010
+ const val = item[key];
1011
+ if (val == null)
1012
+ continue;
1013
+ if (Array.isArray(val)) {
1014
+ item[key] = val.map(v => {
1015
+ if (isHydratedObject(v))
1016
+ return v;
1017
+ return docMap.get(this.idToString(v)) || v;
1018
+ });
1019
+ }
1020
+ else {
1021
+ if (isHydratedObject(val)) {
1022
+ // Keep as-is
1023
+ }
1024
+ else {
1025
+ item[key] = docMap.get(this.idToString(val)) || val;
1026
+ }
1027
+ }
505
1028
  }
506
1029
  }
507
1030
  })());
@@ -543,6 +1066,7 @@ class NMAdapter {
543
1066
  await Promise.all(hydrationPromises);
544
1067
  }
545
1068
  async update(collection, targetId, data, schemaDefinition) {
1069
+ await this.queryCache.invalidateCollection(collection);
546
1070
  if (targetId == null || targetId === '' || targetId === 'undefined' || targetId === 'null') {
547
1071
  throw new Error(`[NMAdapter] Cannot perform update on '${collection}': targetId is required and cannot be null/undefined. This guard prevents accidental bulk updates.`);
548
1072
  }
@@ -551,17 +1075,20 @@ class NMAdapter {
551
1075
  throw new Error(`Repository for ${collection} not found.`);
552
1076
  const isMongo = this.dataSource.options.type === 'mongodb';
553
1077
  const resolvedTargetId = isMongo ? this.convertToObjectId(targetId) : targetId;
554
- const coerced = this.coercePayloadForStorage(data, schemaDefinition);
1078
+ const cleanedData = this.cleanPayloadForStorage(data);
1079
+ const coerced = this.coercePayloadForStorage(cleanedData, schemaDefinition);
555
1080
  // Strip ID fields from data to prevent primary key conflicts
556
- const { id: _unusedId, _id: _unusedRawId, ...updateData } = coerced;
1081
+ const { id: _unusedId, _id: _unusedRawId, exId: _unusedExId, ...updateData } = coerced;
1082
+ const now = new Date();
1083
+ updateData.updatedAt = now;
557
1084
  if (isMongo) {
558
1085
  // Using explicit getMongoRepository and updateOne prevents accidental bulk updates
559
1086
  // that targetBy filter {} could cause in standard TypeORM .update()
560
1087
  const mongoRepo = this.dataSource.getMongoRepository(repo.metadata.name);
561
1088
  const setObj = {};
562
1089
  const unsetObj = {};
563
- // We look at the original data for nulls because coercePayloadForStorage strips them
564
- for (const [k, v] of Object.entries(data)) {
1090
+ // We look at the original cleaned data for nulls because coercePayloadForStorage strips them
1091
+ for (const [k, v] of Object.entries(cleanedData)) {
565
1092
  if (k === 'id' || k === '_id')
566
1093
  continue;
567
1094
  if (v === null) {
@@ -572,6 +1099,8 @@ class NMAdapter {
572
1099
  setObj[k] = updateData[k] !== undefined ? updateData[k] : v;
573
1100
  }
574
1101
  }
1102
+ // Always update the updatedAt field
1103
+ setObj.updatedAt = now;
575
1104
  const updatePayload = {};
576
1105
  if (Object.keys(setObj).length > 0)
577
1106
  updatePayload.$set = setObj;
@@ -588,6 +1117,7 @@ class NMAdapter {
588
1117
  return updated[0];
589
1118
  }
590
1119
  async delete(collection, targetId, schemaDefinition) {
1120
+ await this.queryCache.invalidateCollection(collection);
591
1121
  if (targetId == null || targetId === '' || targetId === 'undefined' || targetId === 'null') {
592
1122
  throw new Error(`[NMAdapter] Cannot perform delete on '${collection}': targetId is required and cannot be null/undefined. This guard prevents accidental bulk deletions.`);
593
1123
  }
@@ -610,6 +1140,11 @@ class NMAdapter {
610
1140
  return existing[0] || null;
611
1141
  }
612
1142
  async count(collection, query, schemaDefinition) {
1143
+ const cacheKey = await this.queryCache.generateKey(collection, 'count', { query, schema: schemaDefinition?.modelName });
1144
+ const cached = await this.queryCache.get(cacheKey);
1145
+ if (cached !== null) {
1146
+ return cached;
1147
+ }
613
1148
  const repo = this.getRepository(collection);
614
1149
  const resolvedQuery = await this.resolveNestedSearches(query);
615
1150
  const where = this.transformQuery(resolvedQuery, schemaDefinition);
@@ -620,8 +1155,45 @@ class NMAdapter {
620
1155
  else {
621
1156
  total = await repo.count({ where });
622
1157
  }
1158
+ await this.queryCache.set(cacheKey, total);
623
1159
  return total;
624
1160
  }
1161
+ async countBy(collection, field, query, schema) {
1162
+ const repo = this.getRepository(collection);
1163
+ const where = this.transformQuery(query, schema);
1164
+ const counts = {};
1165
+ if (this.dataSource.options.type === 'mongodb') {
1166
+ const mongoRepo = repo.manager.getMongoRepository(repo.metadata.name);
1167
+ // Determine if field is an array from schema to use $unwind
1168
+ const isArray = Array.isArray(schema?.attributes?.[field]);
1169
+ const pipeline = [{ $match: where }];
1170
+ if (isArray) {
1171
+ pipeline.push({ $unwind: `$${field}` });
1172
+ }
1173
+ pipeline.push({ $group: { _id: `$${field}`, count: { $sum: 1 } } });
1174
+ const results = await mongoRepo.aggregate(pipeline).toArray();
1175
+ results.forEach((r) => {
1176
+ if (r._id != null)
1177
+ counts[this.idToString(r._id)] = r.count;
1178
+ });
1179
+ }
1180
+ else {
1181
+ const results = await repo
1182
+ .createQueryBuilder('entity')
1183
+ .select(`entity.${field}`, 'key')
1184
+ .addSelect('COUNT(*)', 'count')
1185
+ .where(where)
1186
+ .groupBy(`entity.${field}`)
1187
+ .getRawMany();
1188
+ results.forEach((r) => {
1189
+ const k = r.key || r.KEY || r[`entity_${field}`];
1190
+ if (k != null) {
1191
+ counts[this.idToString(k)] = parseInt(r.count || r.COUNT || 0, 10);
1192
+ }
1193
+ });
1194
+ }
1195
+ return counts;
1196
+ }
625
1197
  async findFirstRelatedIds(collection, field, ids) {
626
1198
  const repo = this.getRepository(collection);
627
1199
  const map = new Map();
@@ -779,6 +1351,16 @@ class NMAdapter {
779
1351
  continue;
780
1352
  }
781
1353
  if ((0, utils_1.isPlainObject)(value)) {
1354
+ if (!isMongo && Array.isArray(attr) && value.$in !== undefined) {
1355
+ const vals = Array.isArray(value.$in) ? value.$in : [value.$in];
1356
+ transformed[targetKey] = (0, typeorm_1.Raw)(alias => {
1357
+ return vals.map((v, i) => `${alias} LIKE :val_${key}_${i}`).join(' OR ');
1358
+ }, vals.reduce((acc, v, i) => {
1359
+ acc[`val_${key}_${i}`] = `%"${v}"%`;
1360
+ return acc;
1361
+ }, {}));
1362
+ continue;
1363
+ }
782
1364
  const keys = Object.keys(value);
783
1365
  if (keys.some(k => k.startsWith('$'))) {
784
1366
  transformed[targetKey] = this.mapOperator(value, isMongo && isObjectIdField);
@@ -798,24 +1380,47 @@ class NMAdapter {
798
1380
  }
799
1381
  }
800
1382
  else {
801
- // Coerce to number for SQL if it is a string numeric
802
- if (Array.isArray(value)) {
803
- transformed[targetKey] = (0, typeorm_1.In)(value.map(v => {
804
- const n = Number(v);
805
- return !isNaN(n) ? n : v;
806
- }));
1383
+ // Check if it is a simple-json relation array attribute in the SQL database
1384
+ if (Array.isArray(attr)) {
1385
+ if (Array.isArray(value)) {
1386
+ transformed[targetKey] = (0, typeorm_1.Raw)(alias => {
1387
+ return value.map((v, i) => `${alias} LIKE :val_${key}_${i}`).join(' OR ');
1388
+ }, value.reduce((acc, v, i) => {
1389
+ acc[`val_${key}_${i}`] = `%"${v}"%`;
1390
+ return acc;
1391
+ }, {}));
1392
+ }
1393
+ else {
1394
+ transformed[targetKey] = (0, typeorm_1.Like)(`%"${value}"%`);
1395
+ }
807
1396
  }
808
1397
  else {
809
- const num = Number(value);
810
- transformed[targetKey] = !isNaN(num) ? num : value;
1398
+ // Coerce to number for SQL if it is a string numeric
1399
+ if (Array.isArray(value)) {
1400
+ transformed[targetKey] = (0, typeorm_1.In)(value.map(v => {
1401
+ const n = Number(v);
1402
+ return !isNaN(n) ? n : v;
1403
+ }));
1404
+ }
1405
+ else {
1406
+ const num = Number(value);
1407
+ transformed[targetKey] = !isNaN(num) ? num : value;
1408
+ }
811
1409
  }
812
1410
  }
813
1411
  }
814
1412
  else {
815
1413
  // Coerce string numeric values for SQL, but keep as-is for Mongo (integrity for string fields)
816
1414
  if (!isMongo) {
817
- const num = Number(value);
818
- transformed[key] = !isNaN(num) ? num : value;
1415
+ const attrType = a && String(a.type || '').toLowerCase();
1416
+ const isStringField = attrType === 'string' || attrType === 'text';
1417
+ if (isStringField) {
1418
+ transformed[key] = value;
1419
+ }
1420
+ else {
1421
+ const num = Number(value);
1422
+ transformed[key] = !isNaN(num) ? num : value;
1423
+ }
819
1424
  }
820
1425
  else {
821
1426
  transformed[key] = value;
@@ -823,6 +1428,16 @@ class NMAdapter {
823
1428
  }
824
1429
  }
825
1430
  }
1431
+ // Special handling for top-level $and in SQL (TypeORM expects flat object, not $and array)
1432
+ if (!isMongo && transformed.$and && Array.isArray(transformed.$and)) {
1433
+ const andPart = transformed.$and;
1434
+ delete transformed.$and;
1435
+ for (const sub of andPart) {
1436
+ if ((0, utils_1.isPlainObject)(sub)) {
1437
+ Object.assign(transformed, sub);
1438
+ }
1439
+ }
1440
+ }
826
1441
  // Special handling for top-level $or in SQL (TypeORM expects array for OR, but our $or needs to be flattened/merged if combined with other ANDs)
827
1442
  if (!isMongo && transformed.$or && Array.isArray(transformed.$or)) {
828
1443
  const orPart = transformed.$or;