@airoom/nextmin-node 1.4.6 → 2.0.2

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 (52) hide show
  1. package/README.md +48 -5
  2. package/dist/api/apiRouter.d.ts +10 -1
  3. package/dist/api/apiRouter.js +83 -22
  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 +218 -223
  7. package/dist/api/router/mountFindRoutes.js +2 -49
  8. package/dist/api/router/mountSearchRoutes.js +10 -52
  9. package/dist/api/router/mountSearchRoutes_extended.js +7 -48
  10. package/dist/api/router/setupFileRoutes.js +41 -0
  11. package/dist/api/router/utils.d.ts +2 -0
  12. package/dist/api/router/utils.js +37 -8
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +83 -0
  15. package/dist/database/DatabaseAdapter.d.ts +8 -0
  16. package/dist/database/NMAdapter.d.ts +46 -0
  17. package/dist/database/NMAdapter.js +1243 -0
  18. package/dist/database/QueryEngine.d.ts +14 -0
  19. package/dist/database/QueryEngine.js +215 -0
  20. package/dist/database/utils.d.ts +2 -0
  21. package/dist/database/utils.js +21 -0
  22. package/dist/files/FileStorageAdapter.d.ts +5 -0
  23. package/dist/files/LocalFileStorageAdapter.d.ts +5 -0
  24. package/dist/files/LocalFileStorageAdapter.js +14 -0
  25. package/dist/files/S3FileStorageAdapter.d.ts +5 -0
  26. package/dist/files/S3FileStorageAdapter.js +14 -0
  27. package/dist/index.d.ts +4 -1
  28. package/dist/index.js +11 -5
  29. package/dist/models/BaseModel.d.ts +31 -2
  30. package/dist/models/BaseModel.js +33 -5
  31. package/dist/policy/authorize.js +95 -38
  32. package/dist/schemas/Users.json +66 -30
  33. package/dist/services/AggregateService.d.ts +8 -0
  34. package/dist/services/AggregateService.js +87 -0
  35. package/dist/services/RealtimeService.d.ts +20 -0
  36. package/dist/services/RealtimeService.js +93 -0
  37. package/dist/services/SchemaService.d.ts +3 -0
  38. package/dist/services/SchemaService.js +6 -2
  39. package/dist/utils/DefaultDataInitializer.js +10 -2
  40. package/dist/utils/Events.d.ts +34 -0
  41. package/dist/utils/Events.js +55 -0
  42. package/dist/utils/Logger.d.ts +2 -1
  43. package/dist/utils/Logger.js +20 -9
  44. package/dist/utils/QueryCache.d.ts +16 -0
  45. package/dist/utils/QueryCache.js +106 -0
  46. package/dist/utils/SchemaLoader.d.ts +6 -1
  47. package/dist/utils/SchemaLoader.js +49 -5
  48. package/package.json +19 -4
  49. package/dist/database/InMemoryAdapter.d.ts +0 -15
  50. package/dist/database/InMemoryAdapter.js +0 -71
  51. package/dist/database/MongoAdapter.d.ts +0 -52
  52. package/dist/database/MongoAdapter.js +0 -410
@@ -8,6 +8,33 @@ const bcrypt_1 = __importDefault(require("bcrypt"));
8
8
  const Logger_1 = __importDefault(require("../../utils/Logger"));
9
9
  const fieldCodecs_1 = require("../../utils/fieldCodecs");
10
10
  const authorize_1 = require("../../policy/authorize");
11
+ const QueryEngine_1 = require("../../database/QueryEngine");
12
+ async function injectActionRelatedData(ctx, schema, rows) {
13
+ const actionsWithRelated = schema.actions?.filter((a) => a.relatedModel && a.relatedField);
14
+ if (!actionsWithRelated?.length || rows.length === 0)
15
+ return;
16
+ const ids = rows.map((r) => String(r.id));
17
+ for (const action of actionsWithRelated) {
18
+ try {
19
+ const model = ctx.getModel(action.relatedModel.toLowerCase());
20
+ if (model) {
21
+ // Precise grouping lookup instead of limited read()
22
+ const relatedMap = await model.findFirstRelatedIds(action.relatedField, ids);
23
+ for (const row of rows) {
24
+ if (!row._actionsData)
25
+ row._actionsData = {};
26
+ const matchedId = relatedMap.get(String(row.id));
27
+ if (matchedId) {
28
+ row._actionsData[action.relatedModel] = matchedId;
29
+ }
30
+ }
31
+ }
32
+ }
33
+ catch (err) {
34
+ Logger_1.default.error('mountCrudRoutes', `Error fetching related data for action ${action.label}:`, err);
35
+ }
36
+ }
37
+ }
11
38
  const utils_1 = require("./utils");
12
39
  function mountCrudRoutes(ctx, modelNameLC) {
13
40
  const { router } = ctx;
@@ -99,25 +126,62 @@ function mountCrudRoutes(ctx, modelNameLC) {
99
126
  const basePayload = {};
100
127
  const childPayload = {};
101
128
  for (const [k, v] of Object.entries(payload)) {
102
- if (baseSchema.attributes[k])
129
+ if (k === 'baseId') {
130
+ childPayload[k] = v;
131
+ continue;
132
+ }
133
+ const baseAttr = baseSchema.attributes[k];
134
+ const childAttr = schema.attributes[k];
135
+ const bHead = Array.isArray(baseAttr) ? baseAttr[0] : baseAttr;
136
+ const cHead = Array.isArray(childAttr) ? childAttr[0] : childAttr;
137
+ if (bHead?.safe === true) {
103
138
  basePayload[k] = v;
104
- else if (k !== 'baseId')
139
+ }
140
+ else if (cHead && !cHead.inherited) {
141
+ childPayload[k] = v;
142
+ }
143
+ else if (baseAttr) {
144
+ basePayload[k] = v;
145
+ }
146
+ else {
105
147
  childPayload[k] = v;
148
+ }
106
149
  }
107
- if (baseLC === 'users' && basePayload.password && ctx.jwtSecret) {
108
- const salt = await bcrypt_1.default.genSalt(10);
109
- basePayload.password = await bcrypt_1.default.hash(String(basePayload.password) + ctx.jwtSecret, salt);
150
+ const isUserExt = baseLC === 'users';
151
+ const baseId = payload.baseId;
152
+ let baseCreated;
153
+ if (isUserExt && baseId) {
154
+ // Use existing user
155
+ const arr = await baseModel.read({ id: baseId }, 1, 0, true);
156
+ baseCreated = arr?.[0];
157
+ if (!baseCreated) {
158
+ return res.status(400).json({ error: true, message: 'Associate user account not found.' });
159
+ }
110
160
  }
111
- const baseConflicts = await ctx.checkUniqueFields(baseSchema, basePayload);
112
- if (baseConflicts && baseConflicts.length) {
113
- return res.status(400).json({
114
- error: true,
115
- fields: baseConflicts.map((field) => ({
116
- field,
117
- error: true,
118
- message: `You cannot use this ${field}. It's already been used.`,
119
- })),
120
- });
161
+ else {
162
+ // Normal flow: create base record if we have enough data
163
+ const baseMissing = ctx.validateRequiredFields(baseSchema, basePayload, 'create');
164
+ // Only attempt creation if at least one base field is provided or if it's not a UserExt?
165
+ // Actually, for UserExt, if baseId is missing and they didn't fill user fields (which are hidden),
166
+ // we should skip base creation and just create the child.
167
+ if (baseMissing.length === 0 && Object.keys(basePayload).length > 0) {
168
+ if (baseLC === 'users' && basePayload.password && ctx.jwtSecret) {
169
+ const salt = await bcrypt_1.default.genSalt(10);
170
+ basePayload.password = await bcrypt_1.default.hash(String(basePayload.password) + ctx.jwtSecret, salt);
171
+ }
172
+ const baseConflicts = await ctx.checkUniqueFields(baseSchema, basePayload);
173
+ if (baseConflicts && baseConflicts.length) {
174
+ return res.status(400).json({
175
+ error: true,
176
+ fields: baseConflicts.map((field) => ({
177
+ field,
178
+ error: true,
179
+ message: `You cannot use this ${field}. It's already been used.`,
180
+ })),
181
+ });
182
+ }
183
+ baseCreated = await baseModel.create(basePayload);
184
+ }
121
185
  }
122
186
  const childConflicts = await ctx.checkUniqueFields(schema, childPayload);
123
187
  if (childConflicts && childConflicts.length) {
@@ -130,24 +194,35 @@ function mountCrudRoutes(ctx, modelNameLC) {
130
194
  })),
131
195
  });
132
196
  }
133
- const baseCreated = await baseModel.create(basePayload);
134
- const childToCreate = { ...childPayload, baseId: baseCreated.id };
197
+ const childToCreate = { ...childPayload, baseId: baseCreated?.id || null };
135
198
  const childCreated = await model.create(childToCreate);
136
- let resultDoc = { ...baseCreated, ...childCreated };
137
- resultDoc.exId = String(baseCreated.id);
138
- resultDoc.id = String(childCreated.id);
199
+ let resultDoc = baseCreated
200
+ ? { ...baseCreated, ...childCreated }
201
+ : { ...childCreated };
202
+ if (baseCreated) {
203
+ resultDoc.exId = String(baseCreated.id || baseCreated._id || '');
204
+ }
205
+ resultDoc.id = String(childCreated?.id || childCreated?._id || resultDoc.id || '');
139
206
  resultDoc._id = resultDoc.id;
140
- delete resultDoc.baseId;
141
- delete resultDoc.baseId;
207
+ if (resultDoc.baseId)
208
+ resultDoc.baseId = String(resultDoc.baseId);
209
+ // delete resultDoc.baseId; // Preserve baseId for frontend use
142
210
  if (cdec.exposePrivate && childCreated?.id) {
143
211
  const [refChild] = await model.read({ id: childCreated.id }, 1, 0, true);
144
- const [refBase] = await baseModel.read({ id: baseCreated.id }, 1, 0, true);
145
- if (refChild && refBase) {
146
- resultDoc = { ...refBase, ...refChild };
147
- resultDoc.exId = String(refBase.id ?? refBase._id);
212
+ let refBase = null;
213
+ if (baseCreated) {
214
+ const [b] = await baseModel.read({ id: baseCreated.id }, 1, 0, true);
215
+ refBase = b;
216
+ }
217
+ if (refChild) {
218
+ resultDoc = refBase ? { ...refBase, ...refChild } : { ...refChild };
219
+ if (refBase)
220
+ resultDoc.exId = String(refBase.id ?? refBase._id);
148
221
  resultDoc.id = String(refChild.id ?? refChild._id);
149
222
  resultDoc._id = resultDoc.id;
150
- delete resultDoc.baseId;
223
+ if (resultDoc.baseId)
224
+ resultDoc.baseId = String(resultDoc.baseId);
225
+ // delete resultDoc.baseId; // Preserve baseId
151
226
  }
152
227
  }
153
228
  const masked = cdec.exposePrivate
@@ -199,8 +274,8 @@ function mountCrudRoutes(ctx, modelNameLC) {
199
274
  .json({ error: true, message: 'Method not allowed' });
200
275
  }
201
276
  const model = ctx.getModel(modelNameLC);
202
- const page = Number.parseInt(String(req.query.page ?? '0'), 10) || 0;
203
- const limit = Number.parseInt(String(req.query.limit ?? '10'), 10) || 10;
277
+ const baseKeys = new Set(Object.keys(schema.attributes || {}));
278
+ const { limit, page, skip, sort, projection } = (0, utils_1.parseQuery)(req, baseKeys);
204
279
  const q = String(req.query.q ?? '').trim();
205
280
  const searchKey = String(req.query.searchKey ?? '').trim();
206
281
  const searchKeysCSV = String(req.query.searchKeys ?? '').trim();
@@ -210,127 +285,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
210
285
  const dateFromStr = String(req.query.dateFrom ?? '').trim();
211
286
  const dateToStr = String(req.query.dateTo ?? '').trim();
212
287
  let dateKey = String(req.query.dateKey ?? 'createdAt').trim();
213
- const sortRaw = String(req.query.sort ?? '').trim();
214
- const sortTypeRaw = String(req.query.sortType ?? '').trim();
215
- const filter = {};
216
- // ---------- SEARCH ----------
217
- const ors = [];
218
- const ands = [];
219
- if (q && searchKey && schema.attributes?.[searchKey]) {
220
- const p = (0, utils_1.buildPredicateForField)(searchKey, schema.attributes[searchKey], q);
221
- if (p)
222
- ors.push(p);
223
- }
224
- if (q && searchKeysCSV) {
225
- const keys = (0, utils_1.splitCSV)(searchKeysCSV);
226
- const preds = keys
227
- .filter((k) => !!schema.attributes?.[k])
228
- .map((k) => (0, utils_1.buildPredicateForField)(k, schema.attributes[k], q))
229
- .filter(Boolean);
230
- if (preds.length) {
231
- if (searchMode === 'and')
232
- ands.push(...preds);
233
- else
234
- ors.push(...preds);
235
- }
236
- }
237
- if (ands.length && ors.length)
238
- filter.$and = [...ands, { $or: ors }];
239
- else if (ands.length)
240
- filter.$and = [...(filter.$and || []), ...ands];
241
- else if (ors.length)
242
- filter.$or = [...(filter.$or || []), ...ors];
243
- if (dateFromStr || dateToStr) {
244
- if (!schema.attributes?.[dateKey]) {
245
- if (schema.attributes?.createdAt)
246
- dateKey = 'createdAt';
247
- else if (schema.attributes?.updatedAt)
248
- dateKey = 'updatedAt';
249
- }
250
- if (dateKey && schema.attributes?.[dateKey]) {
251
- const range = {};
252
- if (dateFromStr) {
253
- const d = new Date(dateFromStr);
254
- if (!Number.isNaN(+d))
255
- range.$gte = d;
256
- }
257
- if (dateToStr) {
258
- const d = new Date(dateToStr);
259
- if (!Number.isNaN(+d))
260
- range.$lte = d;
261
- }
262
- if (Object.keys(range).length)
263
- filter[dateKey] = range;
264
- }
265
- }
266
- // ---------- Direct field filters from req.query ----------
267
- const RESERVED = new Set([
268
- 'page',
269
- 'limit',
270
- 'q',
271
- 'searchKey',
272
- 'searchKeys',
273
- 'searchMode',
274
- 'dateFrom',
275
- 'dateTo',
276
- 'dateKey',
277
- 'sort',
278
- 'sortType',
279
- ]);
280
- const attrMap = schema.attributes || {};
281
- const coerceQueryVal = (raw) => {
282
- const s = String(raw).trim();
283
- if (/^(true|false)$/i.test(s))
284
- return /^true$/i.test(s);
285
- if (/^-?\d+(\.\d+)?$/.test(s))
286
- return Number(s);
287
- if (/^null$/i.test(s))
288
- return null;
289
- if (/^undefined$/i.test(s))
290
- return undefined;
291
- return s;
292
- };
293
- for (const [k, vRaw] of Object.entries(req.query)) {
294
- if (RESERVED.has(k))
295
- continue;
296
- if (!Object.prototype.hasOwnProperty.call(attrMap, k))
297
- continue;
298
- const v = Array.isArray(vRaw)
299
- ? vRaw.map(String).join(',')
300
- : String(vRaw);
301
- if (!v?.length)
302
- continue;
303
- // Operators:
304
- // - "a,b,c" -> $in
305
- // - "!x" -> $ne
306
- // - "gt:10" -> {$gt:10}, "gte:10","lt:5","lte:5"
307
- // - default -> exact match
308
- const tryNumOrBool = (s) => coerceQueryVal(s);
309
- if (v.includes(',')) {
310
- const arr = v
311
- .split(',')
312
- .map((s) => s.trim())
313
- .filter(Boolean)
314
- .map(tryNumOrBool);
315
- filter[k] = { $in: arr };
316
- }
317
- else if (v.startsWith('!')) {
318
- filter[k] = { $ne: tryNumOrBool(v.slice(1)) };
319
- }
320
- else if (/^(gt|gte|lt|lte):/i.test(v)) {
321
- const [op, rest] = v.split(':', 2);
322
- const map = {
323
- gt: '$gt',
324
- gte: '$gte',
325
- lt: '$lt',
326
- lte: '$lte',
327
- };
328
- filter[k] = { [map[op.toLowerCase()]]: tryNumOrBool(rest) };
329
- }
330
- else {
331
- filter[k] = tryNumOrBool(v);
332
- }
333
- }
288
+ const filter = QueryEngine_1.QueryEngine.parse(req.query, schema);
334
289
  const pctx = ctxFromReq(req);
335
290
  const schemaPolicy = {
336
291
  allowedMethods: schema.allowedMethods,
@@ -340,10 +295,15 @@ function mountCrudRoutes(ctx, modelNameLC) {
340
295
  if (!rdec.allow) {
341
296
  return res.status(403).json({ error: true, message: 'forbidden' });
342
297
  }
343
- // ---------- SORT (updated; type-safe; honors client; allows virtual keys) ----------
344
- const baseKeys = new Set(Object.keys(schema.attributes || {}));
345
- const baseSort = (0, utils_1.parseSort)(sortRaw, sortTypeRaw, baseKeys);
346
298
  const finalFilter = (0, authorize_1.andFilter)(filter, rdec.queryFilter || {});
299
+ const countBy = String(req.query.countBy ?? '').trim();
300
+ if (countBy && schema.attributes[countBy]) {
301
+ const counts = await ctx.dbAdapter.countBy(schema.modelName, countBy, finalFilter, schema);
302
+ return res.status(200).json({
303
+ success: true,
304
+ counts,
305
+ });
306
+ }
347
307
  const isUsersModel = modelNameLC === 'user' ||
348
308
  modelNameLC === 'users' ||
349
309
  (schema?.modelName ?? '').toLowerCase() === 'users';
@@ -355,40 +315,31 @@ function mountCrudRoutes(ctx, modelNameLC) {
355
315
  const baseSchema = ctx.getSchema(baseLC);
356
316
  const baseModel = ctx.getModel(baseLC);
357
317
  const baseAttrKeys = new Set(Object.keys(baseSchema.attributes || {}));
318
+ const childAttrs = schema.attributes || {};
319
+ for (const k of Object.keys(childAttrs)) {
320
+ const cHead = Array.isArray(childAttrs[k]) ? childAttrs[k][0] : childAttrs[k];
321
+ if (cHead && !cHead.inherited) {
322
+ baseAttrKeys.delete(k);
323
+ }
324
+ }
358
325
  const { child: childFilter, base: extBaseFilter } = (0, utils_1.splitFilterForExtended)(finalFilter, baseAttrKeys);
359
- const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(baseSort, baseAttrKeys);
326
+ const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(sort, baseAttrKeys);
360
327
  const needPrivateForHydrate = true;
361
328
  const requiresBaseProcessing = Object.keys(extBaseFilter).length > 0 ||
362
329
  Object.keys(extBaseSort).length > 0;
363
- let childRows = [];
330
+ let dataRows = [];
364
331
  if (requiresBaseProcessing) {
365
- childRows = await model.read(childFilter, 0, 0, needPrivateForHydrate);
332
+ // If we need to filter/sort by base fields, read ALL child rows (already hydrated by adapter)
333
+ dataRows = await model.read(childFilter, 0, 0, needPrivateForHydrate, { projection });
366
334
  }
367
335
  else {
368
- childRows = await model.read(childFilter, limit, page * limit, needPrivateForHydrate, { sort: Object.keys(childSort).length ? childSort : undefined });
336
+ // Normal paginated read (already hydrated by adapter)
337
+ dataRows = await model.read(childFilter, limit, skip, needPrivateForHydrate, {
338
+ sort: Object.keys(childSort).length ? childSort : undefined,
339
+ projection,
340
+ });
369
341
  }
370
- // build base map
371
- const baseIds = Array.from(new Set(childRows
372
- .map((r) => (0, utils_1.toIdString)(r?.baseId))
373
- .filter((s) => !!s)));
374
- const baseDocs = baseIds.length
375
- ? await baseModel.read({ id: { $in: baseIds } }, baseIds.length, 0, !!rdec.exposePrivate)
376
- : [];
377
- const baseMap = new Map(baseDocs.map((b) => [String(b.id), b]));
378
- // merge, but force merged id to BASE id (single entity identity)
379
- let merged = childRows.map((row) => {
380
- const bid = (0, utils_1.toIdString)(row?.baseId);
381
- const base = bid ? baseMap.get(bid) : null;
382
- const m = base ? { ...base, ...row } : { ...row };
383
- // expose base id as exId, keep child's id as id
384
- m.exId = String(base?.id ?? base?._id ?? bid ?? '');
385
- m.id = String(row?.id ?? row?._id);
386
- m._id = m.id;
387
- // optional: keep for trace/debug
388
- m.__childId = m.id;
389
- delete m.baseId;
390
- return m;
391
- });
342
+ let merged = dataRows;
392
343
  // apply base-level filters/sort, then DEDUPE by canonical id
393
344
  if (Object.keys(extBaseFilter).length) {
394
345
  merged = merged.filter((m) => (0, utils_1.matchDoc)(m, extBaseFilter));
@@ -396,10 +347,18 @@ function mountCrudRoutes(ctx, modelNameLC) {
396
347
  const combinedSort = { ...extBaseSort, ...childSort };
397
348
  if (Object.keys(combinedSort).length) {
398
349
  merged = merged.sort((a, b) => {
350
+ const getField = (obj, k) => {
351
+ if (obj?.[k] !== undefined)
352
+ return obj[k];
353
+ if (obj?.baseId && typeof obj.baseId === 'object' && obj.baseId[k] !== undefined) {
354
+ return obj.baseId[k];
355
+ }
356
+ return undefined;
357
+ };
399
358
  for (const k of Object.keys(combinedSort)) {
400
359
  const dir = combinedSort[k];
401
- const av = a?.[k];
402
- const bv = b?.[k];
360
+ const av = getField(a, k);
361
+ const bv = getField(b, k);
403
362
  const ax = av instanceof Date ? +av : av;
404
363
  const bx = bv instanceof Date ? +bv : bv;
405
364
  if (ax == null && bx == null)
@@ -426,7 +385,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
426
385
  let paged;
427
386
  if (requiresBaseProcessing) {
428
387
  totalRows = merged.length;
429
- const start = page * limit;
388
+ const start = skip;
430
389
  paged = merged.slice(start, start + limit);
431
390
  }
432
391
  else {
@@ -437,6 +396,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
437
396
  const data = rdec.exposePrivate
438
397
  ? (0, authorize_1.applyReadMaskMany)(paged, rdec.sensitiveMask)
439
398
  : (0, authorize_1.applyReadMaskMany)(paged, rdec.readMask);
399
+ await injectActionRelatedData(ctx, schema, data);
440
400
  return res.status(200).json({
441
401
  success: true,
442
402
  message: `Data fetched for ${schema.modelName}`,
@@ -446,15 +406,14 @@ function mountCrudRoutes(ctx, modelNameLC) {
446
406
  });
447
407
  }
448
408
  // ---------- NON-EXTENDED ----------
449
- const sort = baseSort;
450
409
  const totalRows = await model.count(finalFilter);
451
410
  let rawRows = [];
452
411
  const exposePrivateForRead = !!rdec.exposePrivate;
453
412
  try {
454
- rawRows = await model.read(finalFilter, limit + 1, page * limit, exposePrivateForRead, { sort });
413
+ rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead, { sort, projection });
455
414
  }
456
415
  catch {
457
- rawRows = await model.read(finalFilter, limit + 1, page * limit, exposePrivateForRead);
416
+ rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead);
458
417
  if (sort && Object.keys(sort).length) {
459
418
  const orderKeys = Object.keys(sort);
460
419
  rawRows.sort((a, b) => {
@@ -487,6 +446,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
487
446
  const data = rdec.exposePrivate
488
447
  ? (0, authorize_1.applyReadMaskMany)(rows, rdec.sensitiveMask)
489
448
  : (0, authorize_1.applyReadMaskMany)(rows, rdec.readMask);
449
+ await injectActionRelatedData(ctx, schema, data);
490
450
  return res.status(200).json({
491
451
  success: true,
492
452
  message: `Data fetched for ${schema.modelName}`,
@@ -512,6 +472,8 @@ function mountCrudRoutes(ctx, modelNameLC) {
512
472
  .json({ error: true, message: 'Method not allowed' });
513
473
  }
514
474
  const model = ctx.getModel(modelNameLC);
475
+ const baseKeys = new Set(Object.keys(schema.attributes || {}));
476
+ const { projection } = (0, utils_1.parseQuery)(req, baseKeys);
515
477
  const pctx = ctxFromReq(req);
516
478
  const schemaPolicy = {
517
479
  allowedMethods: schema.allowedMethods,
@@ -520,7 +482,16 @@ function mountCrudRoutes(ctx, modelNameLC) {
520
482
  const decForInclude = (0, authorize_1.authorize)(modelNameLC, 'read', schemaPolicy, pctx);
521
483
  const needPrivateForHydrate = !!schema.extends;
522
484
  const exposePrivateForRead = needPrivateForHydrate || !!decForInclude.exposePrivate;
523
- const recordArr = await model.read({ id: req.params.id }, 1, 0, exposePrivateForRead);
485
+ const recordArr = await model.read({ id: req.params.id }, 1, 0, exposePrivateForRead, {
486
+ projection: (() => {
487
+ if (!projection)
488
+ return undefined;
489
+ if (!schema.extends)
490
+ return projection;
491
+ // For extended schemas, ensure baseId is included for hydration
492
+ return { ...projection, baseId: 1 };
493
+ })()
494
+ });
524
495
  const doc = recordArr?.[0];
525
496
  if (!doc) {
526
497
  return res
@@ -532,22 +503,20 @@ function mountCrudRoutes(ctx, modelNameLC) {
532
503
  return res.status(403).json({ error: true, message: 'forbidden' });
533
504
  let toReturn = doc;
534
505
  if (schema.extends) {
535
- const baseName = String(schema.extends);
536
- const baseLC = baseName.toLowerCase();
537
- const baseModel = ctx.getModel(baseLC);
538
- const baseIdStr = (0, utils_1.toIdString)(doc?.baseId);
539
- if (baseIdStr) {
540
- const [baseDoc] = await baseModel.read({ id: baseIdStr }, 1, 0, !!dec.exposePrivate);
541
- if (baseDoc) {
542
- toReturn = { ...baseDoc, ...doc };
543
- toReturn.exId = String(baseDoc.id ?? baseDoc._id ?? baseIdStr);
544
- toReturn.id = String(doc.id ?? doc._id);
545
- toReturn._id = toReturn.id;
546
- delete toReturn.baseId;
506
+ // Hydration is handled by adapter.
507
+ // We just ensure exId and baseId consistency if hydrated.
508
+ if (doc) {
509
+ toReturn = { ...doc };
510
+ if (!toReturn.exId && toReturn.baseId) {
511
+ toReturn.exId = (0, utils_1.toIdString)(toReturn.baseId);
547
512
  }
513
+ if (toReturn.id)
514
+ toReturn.id = String(toReturn.id);
515
+ if (toReturn._id)
516
+ toReturn._id = String(toReturn._id);
517
+ if (toReturn.baseId)
518
+ toReturn.baseId = String(toReturn.baseId);
548
519
  }
549
- if (toReturn)
550
- delete toReturn.baseId;
551
520
  }
552
521
  const data = dec.exposePrivate
553
522
  ? (0, authorize_1.applyReadMaskOne)(toReturn, dec.sensitiveMask)
@@ -614,25 +583,36 @@ function mountCrudRoutes(ctx, modelNameLC) {
614
583
  const baseLC = baseName.toLowerCase();
615
584
  const baseSchema = ctx.getSchema(baseLC);
616
585
  const baseModel = ctx.getModel(baseLC);
617
- const baseIdRaw = before?.baseId;
618
- const baseId = (0, utils_1.toIdString)(baseIdRaw);
619
- if (!baseId) {
620
- return res.status(400).json({
621
- error: true,
622
- message: 'Invalid extended record: missing baseId',
623
- });
624
- }
586
+ // Determine target baseId: prefer payload if changed, otherwise current
587
+ const targetBaseIdRaw = upd.baseId !== undefined ? upd.baseId : before?.baseId;
588
+ const targetBaseId = (0, utils_1.toIdString)(targetBaseIdRaw);
625
589
  const baseUpd = {};
626
590
  const childUpd = {};
627
591
  for (const [k, v] of Object.entries(upd)) {
628
- if (k === 'baseId')
592
+ if (k === 'baseId') {
593
+ childUpd[k] = v;
629
594
  continue;
630
- if (baseSchema.attributes[k])
595
+ }
596
+ const baseAttr = baseSchema.attributes[k];
597
+ const childAttr = schema.attributes[k];
598
+ const bHead = Array.isArray(baseAttr) ? baseAttr[0] : baseAttr;
599
+ const cHead = Array.isArray(childAttr) ? childAttr[0] : childAttr;
600
+ if (bHead?.safe === true) {
601
+ baseUpd[k] = v;
602
+ }
603
+ else if (cHead && !cHead.inherited) {
604
+ childUpd[k] = v;
605
+ }
606
+ else if (baseAttr) {
631
607
  baseUpd[k] = v;
632
- else
608
+ }
609
+ else {
633
610
  childUpd[k] = v;
611
+ }
634
612
  }
635
- const baseConflicts = await ctx.checkUniqueFields(baseSchema, baseUpd, baseId);
613
+ const baseConflicts = targetBaseId
614
+ ? await ctx.checkUniqueFields(baseSchema, baseUpd, targetBaseId || undefined)
615
+ : null;
636
616
  if (baseConflicts && baseConflicts.length) {
637
617
  return res.status(400).json({
638
618
  error: true,
@@ -668,19 +648,34 @@ function mountCrudRoutes(ctx, modelNameLC) {
668
648
  }
669
649
  let updatedChild = before;
670
650
  if (Object.keys(childUpd).length) {
671
- updatedChild = await model.update(req.params.id, childUpd);
672
- }
673
- if (Object.keys(baseUpd).length) {
674
- await baseModel.update(baseId, baseUpd);
651
+ updatedChild = await model.update(req.params.id, childUpd, false, before);
652
+ }
653
+ if (targetBaseId && Object.keys(baseUpd).length) {
654
+ await baseModel.update(targetBaseId, baseUpd);
655
+ }
656
+ const [refChild] = await model.read({ id: String(req.params.id) }, 1, 0, true);
657
+ const refBase = targetBaseId
658
+ ? (await baseModel.read({ id: targetBaseId }, 1, 0, true))?.[0]
659
+ : null;
660
+ let responseDoc = refChild ? { ...refChild } : { ...updatedChild, id: req.params.id };
661
+ if (refChild && refBase) {
662
+ // Overlap logic: prefer child value if it's set/not-empty
663
+ responseDoc = { ...refBase };
664
+ for (const k of Object.keys(refChild)) {
665
+ const val = refChild[k];
666
+ const hasValue = val != null && (!Array.isArray(val) || val.length > 0);
667
+ if (hasValue) {
668
+ responseDoc[k] = val;
669
+ }
670
+ }
675
671
  }
676
- const [refChild] = await model.read({ id: String(updatedChild.id) }, 1, 0, true);
677
- const [refBase] = await baseModel.read({ id: baseId }, 1, 0, true);
678
- let responseDoc = refChild && refBase ? { ...refBase, ...refChild } : updatedChild;
679
672
  if (responseDoc) {
680
- responseDoc.exId = String(baseId);
681
- responseDoc.id = String((refChild?.id ?? updatedChild?.id));
673
+ responseDoc.exId = String(targetBaseId || '');
674
+ responseDoc.id = String(req.params.id);
682
675
  responseDoc._id = responseDoc.id;
683
- delete responseDoc.baseId;
676
+ if (responseDoc.baseId)
677
+ responseDoc.baseId = String(responseDoc.baseId);
678
+ // delete (responseDoc as any).baseId;
684
679
  }
685
680
  const masked = udec.exposePrivate
686
681
  ? (0, authorize_1.applyReadMaskOne)(responseDoc, udec.sensitiveMask)
@@ -700,7 +695,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
700
695
  })),
701
696
  });
702
697
  }
703
- const updatedRecord = await model.update(req.params.id, upd);
698
+ const updatedRecord = await model.update(req.params.id, upd, false, before);
704
699
  let responseDoc = updatedRecord;
705
700
  if (udec.exposePrivate && updatedRecord?.id) {
706
701
  const refetched = await model.read({ id: updatedRecord.id }, 1, 0, true);