@airoom/nextmin-node 0.1.6 → 0.1.8

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.
@@ -134,12 +134,19 @@ function mountCrudRoutes(ctx, modelNameLC) {
134
134
  const childToCreate = { ...childPayload, baseId: baseCreated.id };
135
135
  const childCreated = await model.create(childToCreate);
136
136
  let resultDoc = { ...baseCreated, ...childCreated };
137
+ resultDoc.exId = String(baseCreated.id);
138
+ resultDoc.id = String(childCreated.id);
139
+ resultDoc._id = resultDoc.id;
140
+ delete resultDoc.baseId;
137
141
  delete resultDoc.baseId;
138
142
  if (cdec.exposePrivate && childCreated?.id) {
139
143
  const [refChild] = await model.read({ id: childCreated.id }, 1, 0, true);
140
144
  const [refBase] = await baseModel.read({ id: baseCreated.id }, 1, 0, true);
141
145
  if (refChild && refBase) {
142
146
  resultDoc = { ...refBase, ...refChild };
147
+ resultDoc.exId = String(refBase.id ?? refBase._id);
148
+ resultDoc.id = String(refChild.id ?? refChild._id);
149
+ resultDoc._id = resultDoc.id;
143
150
  delete resultDoc.baseId;
144
151
  }
145
152
  }
@@ -370,10 +377,9 @@ function mountCrudRoutes(ctx, modelNameLC) {
370
377
  childRows = await model.read(childFilter, 0, 0, needPrivateForHydrate);
371
378
  }
372
379
  else {
373
- childRows = await model.read(childFilter, limit, page * limit, needPrivateForHydrate, {
374
- sort: Object.keys(childSort).length ? childSort : undefined,
375
- });
380
+ childRows = await model.read(childFilter, limit, page * limit, needPrivateForHydrate, { sort: Object.keys(childSort).length ? childSort : undefined });
376
381
  }
382
+ // build base map
377
383
  const baseIds = Array.from(new Set(childRows
378
384
  .map((r) => (0, utils_1.toIdString)(r?.baseId))
379
385
  .filter((s) => !!s)));
@@ -381,13 +387,21 @@ function mountCrudRoutes(ctx, modelNameLC) {
381
387
  ? await baseModel.read({ id: { $in: baseIds } }, baseIds.length, 0, !!rdec.exposePrivate)
382
388
  : [];
383
389
  const baseMap = new Map(baseDocs.map((b) => [String(b.id), b]));
390
+ // merge, but force merged id to BASE id (single entity identity)
384
391
  let merged = childRows.map((row) => {
385
392
  const bid = (0, utils_1.toIdString)(row?.baseId);
386
- const b = bid ? baseMap.get(bid) : null;
387
- const m = b ? { ...b, ...row } : { ...row };
393
+ const base = bid ? baseMap.get(bid) : null;
394
+ const m = base ? { ...base, ...row } : { ...row };
395
+ // expose base id as exId, keep child's id as id
396
+ m.exId = String(base?.id ?? base?._id ?? bid ?? '');
397
+ m.id = String(row?.id ?? row?._id);
398
+ m._id = m.id;
399
+ // optional: keep for trace/debug
400
+ m.__childId = m.id;
388
401
  delete m.baseId;
389
402
  return m;
390
403
  });
404
+ // apply base-level filters/sort, then DEDUPE by canonical id
391
405
  if (Object.keys(extBaseFilter).length) {
392
406
  merged = merged.filter((m) => (0, utils_1.matchDoc)(m, extBaseFilter));
393
407
  }
@@ -395,9 +409,12 @@ function mountCrudRoutes(ctx, modelNameLC) {
395
409
  if (Object.keys(combinedSort).length) {
396
410
  merged = (0, utils_1.sortInMemory)(merged, combinedSort);
397
411
  }
412
+ // **dedupe**: one row per base entity
413
+ merged = (0, utils_1.dedupeBy)(merged, (r) => String(r.exId || r.id || r._id || ''));
398
414
  if (currentUserId && isUsersModel) {
399
415
  merged = merged.filter((r) => String(r?.id) !== String(currentUserId));
400
416
  }
417
+ // pagination + totals
401
418
  let totalRows;
402
419
  let paged;
403
420
  if (requiresBaseProcessing) {
@@ -406,7 +423,9 @@ function mountCrudRoutes(ctx, modelNameLC) {
406
423
  paged = merged.slice(start, start + limit);
407
424
  }
408
425
  else {
409
- totalRows = await model.count(childFilter);
426
+ // If your model supports it, prefer a distinct count on baseId:
427
+ // totalRows = await (model as any).countDistinct?.('baseId', childFilter) ?? merged.length;
428
+ totalRows = merged.length; // fallback keeps UI consistent with deduped list
410
429
  paged = merged.slice(0, limit);
411
430
  }
412
431
  const data = rdec.exposePrivate
@@ -515,6 +534,10 @@ function mountCrudRoutes(ctx, modelNameLC) {
515
534
  const [baseDoc] = await baseModel.read({ id: baseIdStr }, 1, 0, !!dec.exposePrivate);
516
535
  if (baseDoc) {
517
536
  toReturn = { ...baseDoc, ...doc };
537
+ toReturn.exId = String(baseDoc.id ?? baseDoc._id ?? baseIdStr);
538
+ toReturn.id = String(doc.id ?? doc._id);
539
+ toReturn._id = toReturn.id;
540
+ delete toReturn.baseId;
518
541
  }
519
542
  }
520
543
  if (toReturn)
@@ -647,8 +670,12 @@ function mountCrudRoutes(ctx, modelNameLC) {
647
670
  const [refChild] = await model.read({ id: String(updatedChild.id) }, 1, 0, true);
648
671
  const [refBase] = await baseModel.read({ id: baseId }, 1, 0, true);
649
672
  let responseDoc = refChild && refBase ? { ...refBase, ...refChild } : updatedChild;
650
- if (responseDoc)
673
+ if (responseDoc) {
674
+ responseDoc.exId = String(baseId);
675
+ responseDoc.id = String((refChild?.id ?? updatedChild?.id));
676
+ responseDoc._id = responseDoc.id;
651
677
  delete responseDoc.baseId;
678
+ }
652
679
  const masked = udec.exposePrivate
653
680
  ? (0, authorize_1.applyReadMaskOne)(responseDoc, udec.sensitiveMask)
654
681
  : (0, authorize_1.applyReadMaskOne)(responseDoc, udec.readMask);
@@ -719,7 +746,7 @@ function mountCrudRoutes(ctx, modelNameLC) {
719
746
  catch { }
720
747
  }
721
748
  const merged = baseId
722
- ? { ...deletedChild, baseId: undefined }
749
+ ? { ...deletedChild, exId: String(baseId), baseId: undefined }
723
750
  : deletedChild;
724
751
  const masked = ddec.exposePrivate
725
752
  ? (0, authorize_1.applyReadMaskOne)(merged, ddec.sensitiveMask)
@@ -1,4 +1,4 @@
1
- import type { Request } from "express";
1
+ import type { Request } from 'express';
2
2
  export type AnyRec = Record<string, any>;
3
3
  export type SortDir = 1 | -1;
4
4
  export type SortSpec = Record<string, SortDir>;
@@ -61,3 +61,4 @@ export declare function refInfoFromAttr(attr: any): {
61
61
  ref: string;
62
62
  isArray: boolean;
63
63
  } | null;
64
+ export declare const dedupeBy: <T, K extends string | number>(rows: T[], key: (r: T) => K) => T[];
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.splitCSV = exports.isPlainObject = void 0;
3
+ exports.dedupeBy = exports.splitCSV = exports.isPlainObject = void 0;
4
4
  exports.normalizeAttrType = normalizeAttrType;
5
5
  exports.toIdString = toIdString;
6
6
  exports.splitFilterForExtended = splitFilterForExtended;
@@ -12,38 +12,41 @@ exports.parseSort = parseSort;
12
12
  exports.parseQuery = parseQuery;
13
13
  exports.extractIds = extractIds;
14
14
  exports.refInfoFromAttr = refInfoFromAttr;
15
- const isPlainObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
15
+ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
16
16
  exports.isPlainObject = isPlainObject;
17
- const splitCSV = (raw) => raw.split(",").map(s => s.trim()).filter(Boolean);
17
+ const splitCSV = (raw) => raw
18
+ .split(',')
19
+ .map((s) => s.trim())
20
+ .filter(Boolean);
18
21
  exports.splitCSV = splitCSV;
19
22
  function normalizeAttrType(attr) {
20
23
  const a = Array.isArray(attr) ? attr?.[0] : attr;
21
24
  let t = a?.type ?? a;
22
- if (typeof t === "function" && t.name)
25
+ if (typeof t === 'function' && t.name)
23
26
  t = t.name;
24
- if (t && typeof t === "object" && "name" in t)
27
+ if (t && typeof t === 'object' && 'name' in t)
25
28
  t = t.name;
26
- if (typeof t === "string")
29
+ if (typeof t === 'string')
27
30
  t = t.toLowerCase();
28
- if (t === "bool")
29
- t = "boolean";
30
- if (t === "objectid" || t === "oid" || t === "ref")
31
- t = "objectid";
32
- return String(t || "");
31
+ if (t === 'bool')
32
+ t = 'boolean';
33
+ if (t === 'objectid' || t === 'oid' || t === 'ref')
34
+ t = 'objectid';
35
+ return String(t || '');
33
36
  }
34
37
  function toIdString(v) {
35
38
  if (!v)
36
39
  return null;
37
- if (typeof v === "string")
40
+ if (typeof v === 'string')
38
41
  return v;
39
- if (typeof v === "number")
42
+ if (typeof v === 'number')
40
43
  return String(v);
41
- if (typeof v === "object") {
42
- if (typeof v.id === "string")
44
+ if (typeof v === 'object') {
45
+ if (typeof v.id === 'string')
43
46
  return v.id;
44
- if (v._id && typeof v._id.toString === "function")
47
+ if (v._id && typeof v._id.toString === 'function')
45
48
  return v._id.toString();
46
- if (typeof v._id === "string")
49
+ if (typeof v._id === 'string')
47
50
  return v._id;
48
51
  }
49
52
  return null;
@@ -55,7 +58,7 @@ function splitFilterForExtended(filter, baseKeys) {
55
58
  const outChild = {};
56
59
  const outBase = {};
57
60
  for (const [k, v] of Object.entries(node)) {
58
- if (k === "$and" || k === "$or" || k === "$nor") {
61
+ if (k === '$and' || k === '$or' || k === '$nor') {
59
62
  if (!Array.isArray(v))
60
63
  continue;
61
64
  const childArr = [];
@@ -102,8 +105,8 @@ function sortInMemory(rows, sort) {
102
105
  const dir = sort[k];
103
106
  const av = a?.[k];
104
107
  const bv = b?.[k];
105
- const ax = av instanceof Date ? +av : (av ?? "");
106
- const bx = bv instanceof Date ? +bv : (bv ?? "");
108
+ const ax = av instanceof Date ? +av : (av ?? '');
109
+ const bx = bv instanceof Date ? +bv : (bv ?? '');
107
110
  if (ax > bx)
108
111
  return dir;
109
112
  if (ax < bx)
@@ -115,23 +118,23 @@ function sortInMemory(rows, sort) {
115
118
  function matchDoc(doc, filter) {
116
119
  const evalNode = (node) => {
117
120
  for (const [k, v] of Object.entries(node)) {
118
- if (k === "$and" && Array.isArray(v))
121
+ if (k === '$and' && Array.isArray(v))
119
122
  return v.every((n) => evalNode(n));
120
- if (k === "$or" && Array.isArray(v))
123
+ if (k === '$or' && Array.isArray(v))
121
124
  return v.some((n) => evalNode(n));
122
- if (k === "$nor" && Array.isArray(v))
125
+ if (k === '$nor' && Array.isArray(v))
123
126
  return !v.some((n) => evalNode(n));
124
127
  const dv = doc[k];
125
128
  if ((0, exports.isPlainObject)(v)) {
126
- if ("$in" in v && !v.$in.includes(dv))
129
+ if ('$in' in v && !v.$in.includes(dv))
127
130
  return false;
128
- if ("$gte" in v && !(dv >= v.$gte))
131
+ if ('$gte' in v && !(dv >= v.$gte))
129
132
  return false;
130
- if ("$lte" in v && !(dv <= v.$lte))
133
+ if ('$lte' in v && !(dv <= v.$lte))
131
134
  return false;
132
- if ("$regex" in v) {
133
- const re = new RegExp(v.$regex, v.$options || "");
134
- if (!re.test(String(dv ?? "")))
135
+ if ('$regex' in v) {
136
+ const re = new RegExp(v.$regex, v.$options || '');
137
+ if (!re.test(String(dv ?? '')))
135
138
  return false;
136
139
  }
137
140
  }
@@ -150,24 +153,42 @@ function buildPredicateForField(field, attr, raw) {
150
153
  const attrType = normalizeAttrType(base);
151
154
  const tokens = (0, exports.splitCSV)(raw);
152
155
  switch (attrType) {
153
- case "string":
154
- return isArray ? { [field]: { $in: tokens.length ? tokens : [raw] } } : { [field]: { $regex: raw, $options: "i" } };
155
- case "number": {
156
+ case 'string':
157
+ return isArray
158
+ ? { [field]: { $in: tokens.length ? tokens : [raw] } }
159
+ : { [field]: { $regex: raw, $options: 'i' } };
160
+ case 'number': {
156
161
  const nums = tokens.map(Number).filter((n) => !Number.isNaN(n));
157
- return isArray ? (nums.length ? { [field]: { $in: nums } } : null) : (nums.length ? { [field]: nums[0] } : null);
162
+ return isArray
163
+ ? nums.length
164
+ ? { [field]: { $in: nums } }
165
+ : null
166
+ : nums.length
167
+ ? { [field]: nums[0] }
168
+ : null;
158
169
  }
159
- case "boolean": {
160
- const toBool = (t) => /^(true|1|yes)$/i.test(t) ? true : /^(false|0|no)$/i.test(t) ? false : null;
170
+ case 'boolean': {
171
+ const toBool = (t) => /^(true|1|yes)$/i.test(t)
172
+ ? true
173
+ : /^(false|0|no)$/i.test(t)
174
+ ? false
175
+ : null;
161
176
  if (isArray) {
162
- const bools = tokens.map(toBool).filter((v) => v !== null);
177
+ const bools = tokens
178
+ .map(toBool)
179
+ .filter((v) => v !== null);
163
180
  return bools.length ? { [field]: { $in: bools } } : null;
164
181
  }
165
182
  const b = toBool(raw);
166
183
  return b === null ? null : { [field]: b };
167
184
  }
168
- case "objectid":
169
- return isArray || tokens.length > 1 ? { [field]: { $in: tokens } } : (raw ? { [field]: raw } : null);
170
- case "date": {
185
+ case 'objectid':
186
+ return isArray || tokens.length > 1
187
+ ? { [field]: { $in: tokens } }
188
+ : raw
189
+ ? { [field]: raw }
190
+ : null;
191
+ case 'date': {
171
192
  const toDate = (t) => {
172
193
  const d = new Date(t);
173
194
  return Number.isNaN(+d) ? null : d;
@@ -180,17 +201,22 @@ function buildPredicateForField(field, attr, raw) {
180
201
  return d ? { [field]: d } : null;
181
202
  }
182
203
  default:
183
- return isArray ? { [field]: { $in: tokens.length ? tokens : [raw] } } : { [field]: raw };
204
+ return isArray
205
+ ? { [field]: { $in: tokens.length ? tokens : [raw] } }
206
+ : { [field]: raw };
184
207
  }
185
208
  }
186
209
  function parseSort(expr) {
187
210
  if (!expr)
188
211
  return;
189
212
  const out = {};
190
- for (const raw of expr.split(",").map((s) => s.trim()).filter(Boolean)) {
191
- if (raw.startsWith("-"))
213
+ for (const raw of expr
214
+ .split(',')
215
+ .map((s) => s.trim())
216
+ .filter(Boolean)) {
217
+ if (raw.startsWith('-'))
192
218
  out[raw.slice(1)] = -1;
193
- else if (raw.startsWith("+"))
219
+ else if (raw.startsWith('+'))
194
220
  out[raw.slice(1)] = 1;
195
221
  else
196
222
  out[raw] = 1;
@@ -198,15 +224,17 @@ function parseSort(expr) {
198
224
  return Object.keys(out).length ? out : undefined;
199
225
  }
200
226
  function parseQuery(req) {
201
- const limit = Math.min(parseInt(String(req.query.limit ?? "12"), 10) || 12, 100);
202
- const page = Math.max(parseInt(String(req.query.page ?? "1"), 10) || 1, 1);
227
+ const limit = Math.min(parseInt(String(req.query.limit ?? '12'), 10) || 12, 100);
228
+ const page = Math.max(parseInt(String(req.query.page ?? '1'), 10) || 1, 1);
203
229
  const skip = (page - 1) * limit;
204
- const fields = String(req.query.fields ?? "")
205
- .split(",")
230
+ const fields = String(req.query.fields ?? '')
231
+ .split(',')
206
232
  .map((s) => s.trim())
207
233
  .filter(Boolean);
208
- const projection = fields.length ? Object.fromEntries(fields.map((f) => [f, 1])) : undefined;
209
- const sort = parseSort(String(req.query.sort ?? "-createdAt"));
234
+ const projection = fields.length
235
+ ? Object.fromEntries(fields.map((f) => [f, 1]))
236
+ : undefined;
237
+ const sort = parseSort(String(req.query.sort ?? '-createdAt'));
210
238
  return { limit, page, skip, projection, sort };
211
239
  }
212
240
  function extractIds(val) {
@@ -216,16 +244,16 @@ function extractIds(val) {
216
244
  const toId = (v) => {
217
245
  if (!v)
218
246
  return null;
219
- if (typeof v === "string")
247
+ if (typeof v === 'string')
220
248
  return v;
221
- if (typeof v === "number")
249
+ if (typeof v === 'number')
222
250
  return String(v);
223
- if (typeof v === "object") {
224
- if (typeof v.id === "string")
251
+ if (typeof v === 'object') {
252
+ if (typeof v.id === 'string')
225
253
  return v.id;
226
- if (v._id && typeof v._id === "string")
254
+ if (v._id && typeof v._id === 'string')
227
255
  return v._id;
228
- if (v._id && typeof v._id.toString === "function")
256
+ if (v._id && typeof v._id.toString === 'function')
229
257
  return v._id.toString();
230
258
  }
231
259
  return null;
@@ -239,9 +267,16 @@ function refInfoFromAttr(attr) {
239
267
  return { ref: String(attr[0].ref), isArray: true };
240
268
  }
241
269
  const a = Array.isArray(attr) ? attr?.[0] : attr;
242
- const t = (typeof a?.type === "string" ? a.type : String(a?.type || "")).toLowerCase();
243
- if (a?.ref && (t === "objectid" || t === "ref")) {
270
+ const t = (typeof a?.type === 'string' ? a.type : String(a?.type || '')).toLowerCase();
271
+ if (a?.ref && (t === 'objectid' || t === 'ref')) {
244
272
  return { ref: String(a.ref), isArray: false };
245
273
  }
246
274
  return null;
247
275
  }
276
+ const dedupeBy = (rows, key) => {
277
+ const map = new Map();
278
+ for (const r of rows)
279
+ map.set(key(r), r);
280
+ return Array.from(map.values());
281
+ };
282
+ exports.dedupeBy = dedupeBy;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airoom/nextmin-node",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",