@airoom/nextmin-node 2.0.2 → 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 (37) hide show
  1. package/README.md +151 -0
  2. package/dist/api/apiRouter.d.ts +20 -0
  3. package/dist/api/apiRouter.js +122 -7
  4. package/dist/api/router/mountCrudRoutes.js +69 -18
  5. package/dist/api/router/setupAuthRoutes.js +475 -38
  6. package/dist/api/router/setupChatWidgetRoutes.d.ts +3 -0
  7. package/dist/api/router/setupChatWidgetRoutes.js +207 -0
  8. package/dist/api/router/setupFileRoutes.js +221 -16
  9. package/dist/api/router/utils.d.ts +2 -1
  10. package/dist/api/router/utils.js +8 -6
  11. package/dist/cli.d.ts +1 -0
  12. package/dist/cli.js +145 -64
  13. package/dist/database/DatabaseAdapter.d.ts +1 -0
  14. package/dist/database/NMAdapter.d.ts +6 -0
  15. package/dist/database/NMAdapter.js +437 -86
  16. package/dist/database/QueryEngine.js +7 -4
  17. package/dist/files/FileStorageAdapter.d.ts +1 -0
  18. package/dist/files/LocalFileStorageAdapter.d.ts +1 -0
  19. package/dist/files/LocalFileStorageAdapter.js +25 -3
  20. package/dist/files/S3FileStorageAdapter.d.ts +3 -1
  21. package/dist/files/S3FileStorageAdapter.js +72 -11
  22. package/dist/files/filename.js +6 -4
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +3 -1
  25. package/dist/models/BaseModel.d.ts +4 -0
  26. package/dist/policy/authorize.d.ts +1 -1
  27. package/dist/policy/authorize.js +64 -13
  28. package/dist/schemas/Users.json +20 -10
  29. package/dist/services/IndexingService.d.ts +24 -0
  30. package/dist/services/IndexingService.js +555 -0
  31. package/dist/services/LocalAIService.d.ts +34 -0
  32. package/dist/services/LocalAIService.js +471 -0
  33. package/dist/services/OpenAIService.d.ts +16 -0
  34. package/dist/services/OpenAIService.js +586 -0
  35. package/dist/utils/SchemaLoader.d.ts +1 -1
  36. package/dist/utils/SchemaLoader.js +40 -4
  37. 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.
@@ -20,6 +21,7 @@ class NMAdapter {
20
21
  this.entitySchemas = new Map();
21
22
  this.registeredSchemas = {};
22
23
  this.imageProxyOptions = {};
24
+ this.queryCache = new QueryCache_1.QueryCache();
23
25
  const isValidUrl = (url) => typeof url === 'string' && url.startsWith('http');
24
26
  let imageProxy = options.imageProxy;
25
27
  if (!imageProxy || Object.keys(imageProxy).length === 0) {
@@ -107,8 +109,10 @@ class NMAdapter {
107
109
  // 3) Sync Indexes
108
110
  for (const [name, def] of Object.entries(schemas)) {
109
111
  const indexes = {};
110
- for (const [key, attr] of Object.entries(def.attributes)) {
111
- 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') {
112
116
  indexes[key] = 1;
113
117
  }
114
118
  }
@@ -116,7 +120,9 @@ class NMAdapter {
116
120
  await this.syncIndexes(name, indexes);
117
121
  }
118
122
  }
119
- // 4) Cache repositories
123
+ // 4) Validate refPath configurations
124
+ this.validateRefPathSchemas();
125
+ // 5) Cache repositories
120
126
  for (const name of Object.keys(schemas)) {
121
127
  const lcName = name.toLowerCase();
122
128
  const schema = this.entitySchemas.get(lcName);
@@ -125,6 +131,44 @@ class NMAdapter {
125
131
  }
126
132
  }
127
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
+ }
128
172
  async syncIndexes(modelName, spec) {
129
173
  const repo = this.repositories.get(modelName.toLowerCase());
130
174
  if (!repo)
@@ -150,6 +194,8 @@ class NMAdapter {
150
194
  }
151
195
  buildEntitySchema(def) {
152
196
  const isMongo = this.dataSource.options.type === 'mongodb';
197
+ const isPostgres = this.dataSource.options.type === 'postgres';
198
+ const dateTimeType = isPostgres ? 'timestamp' : 'datetime';
153
199
  const columns = {
154
200
  id: {
155
201
  primary: true,
@@ -161,13 +207,13 @@ class NMAdapter {
161
207
  generated: isMongo ? true : 'increment',
162
208
  },
163
209
  createdAt: {
164
- type: 'datetime',
210
+ type: dateTimeType,
165
211
  createDate: true,
166
212
  default: () => 'CURRENT_TIMESTAMP',
167
213
  nullable: true,
168
214
  },
169
215
  updatedAt: {
170
- type: 'datetime',
216
+ type: dateTimeType,
171
217
  updateDate: true,
172
218
  default: () => 'CURRENT_TIMESTAMP',
173
219
  nullable: true,
@@ -216,7 +262,7 @@ class NMAdapter {
216
262
  sparse: true,
217
263
  });
218
264
  }
219
- if (typeORMType === 'datetime' && attr.default === 'now') {
265
+ if ((typeORMType === 'datetime' || typeORMType === 'timestamp') && attr.default === 'now') {
220
266
  columns[key].default = () => 'CURRENT_TIMESTAMP';
221
267
  }
222
268
  }
@@ -232,12 +278,13 @@ class NMAdapter {
232
278
  return 'simple-json';
233
279
  }
234
280
  const type = String(attr?.type || 'string').toLowerCase();
281
+ const isPostgres = this.dataSource.options.type === 'postgres';
235
282
  switch (type) {
236
283
  case 'string': return 'varchar';
237
284
  case 'number': return 'float';
238
285
  case 'boolean': return 'boolean';
239
286
  case 'date':
240
- case 'datetime': return 'datetime';
287
+ case 'datetime': return isPostgres ? 'timestamp' : 'datetime';
241
288
  case 'json':
242
289
  case 'object': return 'json';
243
290
  case 'array': return 'simple-json';
@@ -253,9 +300,120 @@ class NMAdapter {
253
300
  }
254
301
  return repo;
255
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
+ }
256
412
  async create(collection, data, schemaDefinition) {
413
+ await this.queryCache.invalidateCollection(collection);
257
414
  const repo = this.getRepository(collection);
258
- const coerced = this.coercePayloadForStorage(data, schemaDefinition);
415
+ const cleanedData = this.cleanPayloadForStorage(data);
416
+ const coerced = this.coercePayloadForStorage(cleanedData, schemaDefinition);
259
417
  // Ensure timestamps are set if not provided (especially for MongoDB)
260
418
  const now = new Date();
261
419
  if (!coerced.createdAt)
@@ -266,38 +424,81 @@ class NMAdapter {
266
424
  return this.prepareResult(created);
267
425
  }
268
426
  coercePayloadForStorage(payload, schema) {
269
- if (!payload || !schema || this.dataSource.options.type !== 'mongodb')
427
+ if (!payload || !schema)
270
428
  return payload;
429
+ const isMongo = this.dataSource.options.type === 'mongodb';
271
430
  const coerced = { ...payload };
272
431
  for (const [key, attr] of Object.entries(schema.attributes)) {
273
432
  const a = Array.isArray(attr) ? attr[0] : attr;
274
433
  const isObjectIdField = String(a?.type || '').toLowerCase() === 'objectid';
275
- // For MongoDB, we remove null values to satisfy sparse indexes
276
- if (coerced[key] === null) {
277
- delete coerced[key];
278
- continue;
279
- }
434
+ // Flatten nested objects/relations to their IDs
280
435
  if (isObjectIdField && coerced[key] != null) {
281
436
  if (Array.isArray(coerced[key])) {
282
- 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
+ });
283
443
  }
284
- else {
285
- 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
+ }
286
475
  }
287
476
  }
288
477
  }
289
478
  return coerced;
290
479
  }
291
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
+ }
292
488
  const repo = this.getRepository(collection);
293
489
  const isMongo = this.dataSource.options.type === 'mongodb';
294
490
  const resolvedQuery = await this.resolveNestedSearches(query);
295
491
  const where = this.transformQuery(resolvedQuery, schemaDefinition);
492
+ let order = options?.sort;
493
+ if (order && !isMongo) {
494
+ order = { ...order };
495
+ delete order._id;
496
+ }
296
497
  const findOptions = {
297
498
  where,
298
499
  take: limit,
299
500
  skip: skip,
300
- order: options?.sort,
501
+ order,
301
502
  };
302
503
  if (options?.projection) {
303
504
  const hasRootProj = this.isExclusiveProjection(options.projection, schemaDefinition);
@@ -388,6 +589,7 @@ class NMAdapter {
388
589
  if (schemaDefinition) {
389
590
  await this.hydrateRelations(mappedResults, schemaDefinition, options?.depth ?? 0, options);
390
591
  }
592
+ await this.queryCache.set(cacheKey, mappedResults);
391
593
  return mappedResults;
392
594
  }
393
595
  prepareResult(item) {
@@ -405,37 +607,32 @@ class NMAdapter {
405
607
  if (item.id != null)
406
608
  item.id = String(item.id);
407
609
  }
408
- // Apply image URL proxying/transformation if configured
409
- if (this.imageProxyOptions.originalBase && this.imageProxyOptions.proxyBase) {
410
- this.transformUrls(item);
411
- }
610
+ // Apply image URL proxying/transformation
611
+ this.transformUrls(item);
412
612
  return item;
413
613
  }
414
614
  transformUrls(obj) {
415
615
  if (!obj || typeof obj !== 'object')
416
616
  return;
417
- const { originalBase, proxyBase } = this.imageProxyOptions;
418
- if (!originalBase || !proxyBase)
419
- return;
420
617
  for (const key in obj) {
421
- const val = obj[key];
422
- if (typeof val === 'string') {
423
- if (val.startsWith(originalBase)) {
424
- obj[key] = val.replace(originalBase, proxyBase);
618
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
619
+ const val = obj[key];
620
+ if (typeof val === 'string') {
621
+ obj[key] = this.formatUrlForRead(val);
425
622
  }
426
- }
427
- else if (Array.isArray(val)) {
428
- for (let i = 0; i < val.length; i++) {
429
- if (typeof val[i] === 'string' && val[i].startsWith(originalBase)) {
430
- val[i] = val[i].replace(originalBase, proxyBase);
431
- }
432
- else if (val[i] && typeof val[i] === 'object') {
433
- this.transformUrls(val[i]);
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
+ }
434
631
  }
435
632
  }
436
- }
437
- else if (val && typeof val === 'object') {
438
- this.transformUrls(val);
633
+ else if (val && typeof val === 'object') {
634
+ this.transformUrls(val);
635
+ }
439
636
  }
440
637
  }
441
638
  }
@@ -662,8 +859,31 @@ class NMAdapter {
662
859
  if (key === 'baseId')
663
860
  return false;
664
861
  const a = Array.isArray(attr) ? attr[0] : attr;
665
- 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);
666
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
+ };
667
887
  for (const [key, attr] of relFields) {
668
888
  hydrationPromises.push((async () => {
669
889
  const hasRootProj = this.isExclusiveProjection(projection, schema);
@@ -671,27 +891,87 @@ class NMAdapter {
671
891
  if (projection && hasRootProj && !isTargeted)
672
892
  return;
673
893
  const isArray = Array.isArray(attr);
674
- const refModelName = (isArray ? attr[0] : attr).ref;
675
- if (!refModelName)
676
- return;
677
- const refLc = refModelName.toLowerCase();
678
- const refRepo = this.repositories.get(refLc);
679
- if (!refRepo)
680
- return;
681
- 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
+ };
682
920
  for (const item of results) {
683
- const val = item[key];
921
+ let val = item[key];
684
922
  if (val == null)
685
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);
686
946
  if (isArray && Array.isArray(val)) {
687
- val.forEach(v => { if (v != null)
688
- 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
+ });
689
967
  }
690
968
  else {
691
- idsToFetch.add(val);
969
+ if (!isHydratedObject(val)) {
970
+ group.push({ item, idVal: val });
971
+ }
692
972
  }
693
973
  }
694
- if (idsToFetch.size === 0)
974
+ if (itemsByModel.size === 0)
695
975
  return;
696
976
  let subProjection = options?._populate?.[key];
697
977
  if (subProjection) {
@@ -707,25 +987,44 @@ class NMAdapter {
707
987
  }
708
988
  }
709
989
  }
710
- const fetchedDocs = await this.read(refModelName, { id: { $in: Array.from(idsToFetch) } }, idsToFetch.size, 0, this.registeredSchemas[refModelName], true, {
711
- depth: depth + 1,
712
- projection: subProjection
713
- });
714
- const docMap = new Map();
715
- fetchedDocs.forEach(d => {
716
- const sid = this.idToString(d.id);
717
- if (sid)
718
- docMap.set(sid, d);
719
- });
720
- for (const item of results) {
721
- const val = item[key];
722
- 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)
723
994
  continue;
724
- if (Array.isArray(val)) {
725
- item[key] = val.map(v => docMap.get(this.idToString(v)) || v);
726
- }
727
- else {
728
- 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
+ }
729
1028
  }
730
1029
  }
731
1030
  })());
@@ -767,6 +1066,7 @@ class NMAdapter {
767
1066
  await Promise.all(hydrationPromises);
768
1067
  }
769
1068
  async update(collection, targetId, data, schemaDefinition) {
1069
+ await this.queryCache.invalidateCollection(collection);
770
1070
  if (targetId == null || targetId === '' || targetId === 'undefined' || targetId === 'null') {
771
1071
  throw new Error(`[NMAdapter] Cannot perform update on '${collection}': targetId is required and cannot be null/undefined. This guard prevents accidental bulk updates.`);
772
1072
  }
@@ -775,9 +1075,10 @@ class NMAdapter {
775
1075
  throw new Error(`Repository for ${collection} not found.`);
776
1076
  const isMongo = this.dataSource.options.type === 'mongodb';
777
1077
  const resolvedTargetId = isMongo ? this.convertToObjectId(targetId) : targetId;
778
- const coerced = this.coercePayloadForStorage(data, schemaDefinition);
1078
+ const cleanedData = this.cleanPayloadForStorage(data);
1079
+ const coerced = this.coercePayloadForStorage(cleanedData, schemaDefinition);
779
1080
  // Strip ID fields from data to prevent primary key conflicts
780
- const { id: _unusedId, _id: _unusedRawId, ...updateData } = coerced;
1081
+ const { id: _unusedId, _id: _unusedRawId, exId: _unusedExId, ...updateData } = coerced;
781
1082
  const now = new Date();
782
1083
  updateData.updatedAt = now;
783
1084
  if (isMongo) {
@@ -786,8 +1087,8 @@ class NMAdapter {
786
1087
  const mongoRepo = this.dataSource.getMongoRepository(repo.metadata.name);
787
1088
  const setObj = {};
788
1089
  const unsetObj = {};
789
- // We look at the original data for nulls because coercePayloadForStorage strips them
790
- 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)) {
791
1092
  if (k === 'id' || k === '_id')
792
1093
  continue;
793
1094
  if (v === null) {
@@ -816,6 +1117,7 @@ class NMAdapter {
816
1117
  return updated[0];
817
1118
  }
818
1119
  async delete(collection, targetId, schemaDefinition) {
1120
+ await this.queryCache.invalidateCollection(collection);
819
1121
  if (targetId == null || targetId === '' || targetId === 'undefined' || targetId === 'null') {
820
1122
  throw new Error(`[NMAdapter] Cannot perform delete on '${collection}': targetId is required and cannot be null/undefined. This guard prevents accidental bulk deletions.`);
821
1123
  }
@@ -838,6 +1140,11 @@ class NMAdapter {
838
1140
  return existing[0] || null;
839
1141
  }
840
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
+ }
841
1148
  const repo = this.getRepository(collection);
842
1149
  const resolvedQuery = await this.resolveNestedSearches(query);
843
1150
  const where = this.transformQuery(resolvedQuery, schemaDefinition);
@@ -848,6 +1155,7 @@ class NMAdapter {
848
1155
  else {
849
1156
  total = await repo.count({ where });
850
1157
  }
1158
+ await this.queryCache.set(cacheKey, total);
851
1159
  return total;
852
1160
  }
853
1161
  async countBy(collection, field, query, schema) {
@@ -1043,6 +1351,16 @@ class NMAdapter {
1043
1351
  continue;
1044
1352
  }
1045
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
+ }
1046
1364
  const keys = Object.keys(value);
1047
1365
  if (keys.some(k => k.startsWith('$'))) {
1048
1366
  transformed[targetKey] = this.mapOperator(value, isMongo && isObjectIdField);
@@ -1062,24 +1380,47 @@ class NMAdapter {
1062
1380
  }
1063
1381
  }
1064
1382
  else {
1065
- // Coerce to number for SQL if it is a string numeric
1066
- if (Array.isArray(value)) {
1067
- transformed[targetKey] = (0, typeorm_1.In)(value.map(v => {
1068
- const n = Number(v);
1069
- return !isNaN(n) ? n : v;
1070
- }));
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
+ }
1071
1396
  }
1072
1397
  else {
1073
- const num = Number(value);
1074
- 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
+ }
1075
1409
  }
1076
1410
  }
1077
1411
  }
1078
1412
  else {
1079
1413
  // Coerce string numeric values for SQL, but keep as-is for Mongo (integrity for string fields)
1080
1414
  if (!isMongo) {
1081
- const num = Number(value);
1082
- 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
+ }
1083
1424
  }
1084
1425
  else {
1085
1426
  transformed[key] = value;
@@ -1087,6 +1428,16 @@ class NMAdapter {
1087
1428
  }
1088
1429
  }
1089
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
+ }
1090
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)
1091
1442
  if (!isMongo && transformed.$or && Array.isArray(transformed.$or)) {
1092
1443
  const orPart = transformed.$or;