@airoom/nextmin-node 1.4.6 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +48 -5
  2. package/dist/api/apiRouter.d.ts +2 -0
  3. package/dist/api/apiRouter.js +67 -21
  4. package/dist/api/router/mountCrudRoutes.js +207 -220
  5. package/dist/api/router/mountFindRoutes.js +2 -49
  6. package/dist/api/router/mountSearchRoutes.js +10 -52
  7. package/dist/api/router/mountSearchRoutes_extended.js +7 -48
  8. package/dist/api/router/utils.js +20 -7
  9. package/dist/cli.d.ts +1 -0
  10. package/dist/cli.js +83 -0
  11. package/dist/database/DatabaseAdapter.d.ts +7 -0
  12. package/dist/database/NMAdapter.d.ts +41 -0
  13. package/dist/database/NMAdapter.js +979 -0
  14. package/dist/database/QueryEngine.d.ts +14 -0
  15. package/dist/database/QueryEngine.js +215 -0
  16. package/dist/database/utils.d.ts +2 -0
  17. package/dist/database/utils.js +21 -0
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.js +11 -5
  20. package/dist/models/BaseModel.d.ts +16 -0
  21. package/dist/models/BaseModel.js +32 -4
  22. package/dist/policy/authorize.js +95 -38
  23. package/dist/schemas/Users.json +66 -30
  24. package/dist/services/RealtimeService.d.ts +20 -0
  25. package/dist/services/RealtimeService.js +93 -0
  26. package/dist/services/SchemaService.d.ts +3 -0
  27. package/dist/services/SchemaService.js +6 -2
  28. package/dist/utils/DefaultDataInitializer.js +10 -2
  29. package/dist/utils/Events.d.ts +34 -0
  30. package/dist/utils/Events.js +55 -0
  31. package/dist/utils/Logger.js +12 -10
  32. package/dist/utils/QueryCache.d.ts +16 -0
  33. package/dist/utils/QueryCache.js +106 -0
  34. package/dist/utils/SchemaLoader.d.ts +5 -0
  35. package/dist/utils/SchemaLoader.js +45 -3
  36. package/package.json +19 -4
  37. package/dist/database/InMemoryAdapter.d.ts +0 -15
  38. package/dist/database/InMemoryAdapter.js +0 -71
  39. package/dist/database/MongoAdapter.d.ts +0 -52
  40. 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,9 +295,6 @@ 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 || {});
347
299
  const isUsersModel = modelNameLC === 'user' ||
348
300
  modelNameLC === 'users' ||
@@ -355,40 +307,31 @@ function mountCrudRoutes(ctx, modelNameLC) {
355
307
  const baseSchema = ctx.getSchema(baseLC);
356
308
  const baseModel = ctx.getModel(baseLC);
357
309
  const baseAttrKeys = new Set(Object.keys(baseSchema.attributes || {}));
310
+ const childAttrs = schema.attributes || {};
311
+ for (const k of Object.keys(childAttrs)) {
312
+ const cHead = Array.isArray(childAttrs[k]) ? childAttrs[k][0] : childAttrs[k];
313
+ if (cHead && !cHead.inherited) {
314
+ baseAttrKeys.delete(k);
315
+ }
316
+ }
358
317
  const { child: childFilter, base: extBaseFilter } = (0, utils_1.splitFilterForExtended)(finalFilter, baseAttrKeys);
359
- const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(baseSort, baseAttrKeys);
318
+ const { child: childSort, base: extBaseSort } = (0, utils_1.splitSortForExtended)(sort, baseAttrKeys);
360
319
  const needPrivateForHydrate = true;
361
320
  const requiresBaseProcessing = Object.keys(extBaseFilter).length > 0 ||
362
321
  Object.keys(extBaseSort).length > 0;
363
- let childRows = [];
322
+ let dataRows = [];
364
323
  if (requiresBaseProcessing) {
365
- childRows = await model.read(childFilter, 0, 0, needPrivateForHydrate);
324
+ // If we need to filter/sort by base fields, read ALL child rows (already hydrated by adapter)
325
+ dataRows = await model.read(childFilter, 0, 0, needPrivateForHydrate, { projection });
366
326
  }
367
327
  else {
368
- childRows = await model.read(childFilter, limit, page * limit, needPrivateForHydrate, { sort: Object.keys(childSort).length ? childSort : undefined });
328
+ // Normal paginated read (already hydrated by adapter)
329
+ dataRows = await model.read(childFilter, limit, skip, needPrivateForHydrate, {
330
+ sort: Object.keys(childSort).length ? childSort : undefined,
331
+ projection,
332
+ });
369
333
  }
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
- });
334
+ let merged = dataRows;
392
335
  // apply base-level filters/sort, then DEDUPE by canonical id
393
336
  if (Object.keys(extBaseFilter).length) {
394
337
  merged = merged.filter((m) => (0, utils_1.matchDoc)(m, extBaseFilter));
@@ -396,10 +339,18 @@ function mountCrudRoutes(ctx, modelNameLC) {
396
339
  const combinedSort = { ...extBaseSort, ...childSort };
397
340
  if (Object.keys(combinedSort).length) {
398
341
  merged = merged.sort((a, b) => {
342
+ const getField = (obj, k) => {
343
+ if (obj?.[k] !== undefined)
344
+ return obj[k];
345
+ if (obj?.baseId && typeof obj.baseId === 'object' && obj.baseId[k] !== undefined) {
346
+ return obj.baseId[k];
347
+ }
348
+ return undefined;
349
+ };
399
350
  for (const k of Object.keys(combinedSort)) {
400
351
  const dir = combinedSort[k];
401
- const av = a?.[k];
402
- const bv = b?.[k];
352
+ const av = getField(a, k);
353
+ const bv = getField(b, k);
403
354
  const ax = av instanceof Date ? +av : av;
404
355
  const bx = bv instanceof Date ? +bv : bv;
405
356
  if (ax == null && bx == null)
@@ -426,7 +377,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
426
377
  let paged;
427
378
  if (requiresBaseProcessing) {
428
379
  totalRows = merged.length;
429
- const start = page * limit;
380
+ const start = skip;
430
381
  paged = merged.slice(start, start + limit);
431
382
  }
432
383
  else {
@@ -437,6 +388,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
437
388
  const data = rdec.exposePrivate
438
389
  ? (0, authorize_1.applyReadMaskMany)(paged, rdec.sensitiveMask)
439
390
  : (0, authorize_1.applyReadMaskMany)(paged, rdec.readMask);
391
+ await injectActionRelatedData(ctx, schema, data);
440
392
  return res.status(200).json({
441
393
  success: true,
442
394
  message: `Data fetched for ${schema.modelName}`,
@@ -446,15 +398,14 @@ function mountCrudRoutes(ctx, modelNameLC) {
446
398
  });
447
399
  }
448
400
  // ---------- NON-EXTENDED ----------
449
- const sort = baseSort;
450
401
  const totalRows = await model.count(finalFilter);
451
402
  let rawRows = [];
452
403
  const exposePrivateForRead = !!rdec.exposePrivate;
453
404
  try {
454
- rawRows = await model.read(finalFilter, limit + 1, page * limit, exposePrivateForRead, { sort });
405
+ rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead, { sort, projection });
455
406
  }
456
407
  catch {
457
- rawRows = await model.read(finalFilter, limit + 1, page * limit, exposePrivateForRead);
408
+ rawRows = await model.read(finalFilter, limit + 1, skip, exposePrivateForRead);
458
409
  if (sort && Object.keys(sort).length) {
459
410
  const orderKeys = Object.keys(sort);
460
411
  rawRows.sort((a, b) => {
@@ -487,6 +438,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
487
438
  const data = rdec.exposePrivate
488
439
  ? (0, authorize_1.applyReadMaskMany)(rows, rdec.sensitiveMask)
489
440
  : (0, authorize_1.applyReadMaskMany)(rows, rdec.readMask);
441
+ await injectActionRelatedData(ctx, schema, data);
490
442
  return res.status(200).json({
491
443
  success: true,
492
444
  message: `Data fetched for ${schema.modelName}`,
@@ -512,6 +464,8 @@ function mountCrudRoutes(ctx, modelNameLC) {
512
464
  .json({ error: true, message: 'Method not allowed' });
513
465
  }
514
466
  const model = ctx.getModel(modelNameLC);
467
+ const baseKeys = new Set(Object.keys(schema.attributes || {}));
468
+ const { projection } = (0, utils_1.parseQuery)(req, baseKeys);
515
469
  const pctx = ctxFromReq(req);
516
470
  const schemaPolicy = {
517
471
  allowedMethods: schema.allowedMethods,
@@ -520,7 +474,16 @@ function mountCrudRoutes(ctx, modelNameLC) {
520
474
  const decForInclude = (0, authorize_1.authorize)(modelNameLC, 'read', schemaPolicy, pctx);
521
475
  const needPrivateForHydrate = !!schema.extends;
522
476
  const exposePrivateForRead = needPrivateForHydrate || !!decForInclude.exposePrivate;
523
- const recordArr = await model.read({ id: req.params.id }, 1, 0, exposePrivateForRead);
477
+ const recordArr = await model.read({ id: req.params.id }, 1, 0, exposePrivateForRead, {
478
+ projection: (() => {
479
+ if (!projection)
480
+ return undefined;
481
+ if (!schema.extends)
482
+ return projection;
483
+ // For extended schemas, ensure baseId is included for hydration
484
+ return { ...projection, baseId: 1 };
485
+ })()
486
+ });
524
487
  const doc = recordArr?.[0];
525
488
  if (!doc) {
526
489
  return res
@@ -532,22 +495,20 @@ function mountCrudRoutes(ctx, modelNameLC) {
532
495
  return res.status(403).json({ error: true, message: 'forbidden' });
533
496
  let toReturn = doc;
534
497
  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;
498
+ // Hydration is handled by adapter.
499
+ // We just ensure exId and baseId consistency if hydrated.
500
+ if (doc) {
501
+ toReturn = { ...doc };
502
+ if (!toReturn.exId && toReturn.baseId) {
503
+ toReturn.exId = (0, utils_1.toIdString)(toReturn.baseId);
547
504
  }
505
+ if (toReturn.id)
506
+ toReturn.id = String(toReturn.id);
507
+ if (toReturn._id)
508
+ toReturn._id = String(toReturn._id);
509
+ if (toReturn.baseId)
510
+ toReturn.baseId = String(toReturn.baseId);
548
511
  }
549
- if (toReturn)
550
- delete toReturn.baseId;
551
512
  }
552
513
  const data = dec.exposePrivate
553
514
  ? (0, authorize_1.applyReadMaskOne)(toReturn, dec.sensitiveMask)
@@ -614,25 +575,36 @@ function mountCrudRoutes(ctx, modelNameLC) {
614
575
  const baseLC = baseName.toLowerCase();
615
576
  const baseSchema = ctx.getSchema(baseLC);
616
577
  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
- }
578
+ // Determine target baseId: prefer payload if changed, otherwise current
579
+ const targetBaseIdRaw = upd.baseId !== undefined ? upd.baseId : before?.baseId;
580
+ const targetBaseId = (0, utils_1.toIdString)(targetBaseIdRaw);
625
581
  const baseUpd = {};
626
582
  const childUpd = {};
627
583
  for (const [k, v] of Object.entries(upd)) {
628
- if (k === 'baseId')
584
+ if (k === 'baseId') {
585
+ childUpd[k] = v;
629
586
  continue;
630
- if (baseSchema.attributes[k])
587
+ }
588
+ const baseAttr = baseSchema.attributes[k];
589
+ const childAttr = schema.attributes[k];
590
+ const bHead = Array.isArray(baseAttr) ? baseAttr[0] : baseAttr;
591
+ const cHead = Array.isArray(childAttr) ? childAttr[0] : childAttr;
592
+ if (bHead?.safe === true) {
631
593
  baseUpd[k] = v;
632
- else
594
+ }
595
+ else if (cHead && !cHead.inherited) {
633
596
  childUpd[k] = v;
597
+ }
598
+ else if (baseAttr) {
599
+ baseUpd[k] = v;
600
+ }
601
+ else {
602
+ childUpd[k] = v;
603
+ }
634
604
  }
635
- const baseConflicts = await ctx.checkUniqueFields(baseSchema, baseUpd, baseId);
605
+ const baseConflicts = targetBaseId
606
+ ? await ctx.checkUniqueFields(baseSchema, baseUpd, targetBaseId || undefined)
607
+ : null;
636
608
  if (baseConflicts && baseConflicts.length) {
637
609
  return res.status(400).json({
638
610
  error: true,
@@ -670,17 +642,32 @@ function mountCrudRoutes(ctx, modelNameLC) {
670
642
  if (Object.keys(childUpd).length) {
671
643
  updatedChild = await model.update(req.params.id, childUpd);
672
644
  }
673
- if (Object.keys(baseUpd).length) {
674
- await baseModel.update(baseId, baseUpd);
645
+ if (targetBaseId && Object.keys(baseUpd).length) {
646
+ await baseModel.update(targetBaseId, baseUpd);
647
+ }
648
+ const [refChild] = await model.read({ id: String(req.params.id) }, 1, 0, true);
649
+ const refBase = targetBaseId
650
+ ? (await baseModel.read({ id: targetBaseId }, 1, 0, true))?.[0]
651
+ : null;
652
+ let responseDoc = refChild ? { ...refChild } : { ...updatedChild, id: req.params.id };
653
+ if (refChild && refBase) {
654
+ // Overlap logic: prefer child value if it's set/not-empty
655
+ responseDoc = { ...refBase };
656
+ for (const k of Object.keys(refChild)) {
657
+ const val = refChild[k];
658
+ const hasValue = val != null && (!Array.isArray(val) || val.length > 0);
659
+ if (hasValue) {
660
+ responseDoc[k] = val;
661
+ }
662
+ }
675
663
  }
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
664
  if (responseDoc) {
680
- responseDoc.exId = String(baseId);
681
- responseDoc.id = String((refChild?.id ?? updatedChild?.id));
665
+ responseDoc.exId = String(targetBaseId || '');
666
+ responseDoc.id = String(req.params.id);
682
667
  responseDoc._id = responseDoc.id;
683
- delete responseDoc.baseId;
668
+ if (responseDoc.baseId)
669
+ responseDoc.baseId = String(responseDoc.baseId);
670
+ // delete (responseDoc as any).baseId;
684
671
  }
685
672
  const masked = udec.exposePrivate
686
673
  ? (0, authorize_1.applyReadMaskOne)(responseDoc, udec.sensitiveMask)
@@ -83,31 +83,7 @@ function mountFindRoutes(ctx) {
83
83
  else {
84
84
  items = await ctx.getModel(targetLC).read({ id: { $in: ids } }, limit, skip, !!tDec.exposePrivate);
85
85
  }
86
- // extended hydration if needed
87
- if (targetSchema.extends && Array.isArray(items) && items.length) {
88
- const baseName = String(targetSchema.extends);
89
- const baseLC = baseName.toLowerCase();
90
- const baseModel = ctx.getModel(baseLC);
91
- const baseIds = Array.from(new Set(items.map((it) => it?.baseId).filter(Boolean)));
92
- if (baseIds.length) {
93
- const baseDocs = await baseModel.read({ id: { $in: baseIds } }, baseIds.length, 0, !!tDec.exposePrivate);
94
- const baseMap = new Map(baseDocs.map((b) => [String(b?.id ?? b?._id), b]));
95
- items = items.map((it) => {
96
- const bid = String(it?.baseId || "");
97
- const b = bid ? baseMap.get(bid) : null;
98
- const merged = b ? { ...b, ...it } : { ...it };
99
- delete merged.baseId;
100
- return merged;
101
- });
102
- }
103
- else {
104
- items = items.map((it) => {
105
- const copy = { ...it };
106
- delete copy.baseId;
107
- return copy;
108
- });
109
- }
110
- }
86
+ // extended hydration is handled by adapter.read/findMany
111
87
  const masked = tDec.exposePrivate ? (0, authorize_1.applyReadMaskMany)(items, tDec.sensitiveMask) : (0, authorize_1.applyReadMaskMany)(items, tDec.readMask);
112
88
  const totalRows = ids.length;
113
89
  return res.status(200).json({
@@ -163,30 +139,7 @@ function mountFindRoutes(ctx) {
163
139
  totalRows = all.length;
164
140
  items = all.slice(skip, skip + limit);
165
141
  }
166
- if (targetSchema.extends && Array.isArray(items) && items.length) {
167
- const baseName = String(targetSchema.extends);
168
- const baseLC = baseName.toLowerCase();
169
- const baseModel = ctx.getModel(baseLC);
170
- const baseIds = Array.from(new Set(items.map((it) => it?.baseId).filter(Boolean)));
171
- if (baseIds.length) {
172
- const baseDocs = await baseModel.read({ id: { $in: baseIds } }, baseIds.length, 0, !!tDec.exposePrivate);
173
- const baseMap = new Map(baseDocs.map((b) => [String(b?.id ?? b?._id), b]));
174
- items = items.map((it) => {
175
- const bid = String(it?.baseId || "");
176
- const b = bid ? baseMap.get(bid) : null;
177
- const merged = b ? { ...b, ...it } : { ...it };
178
- delete merged.baseId;
179
- return merged;
180
- });
181
- }
182
- else {
183
- items = items.map((it) => {
184
- const copy = { ...it };
185
- delete copy.baseId;
186
- return copy;
187
- });
188
- }
189
- }
142
+ // extended hydration is handled by adapter.read/findMany
190
143
  const masked = tDec.exposePrivate ? (0, authorize_1.applyReadMaskMany)(items, tDec.sensitiveMask) : (0, authorize_1.applyReadMaskMany)(items, tDec.readMask);
191
144
  return res.status(200).json({
192
145
  success: true,