@airoom/nextmin-node 0.1.5 → 0.1.7

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.
@@ -1,7 +1,7 @@
1
- import express from "express";
2
- import type { Server as HttpServer } from "http";
3
- import { DatabaseAdapter } from "../database/DatabaseAdapter";
4
- import type { FileStorageAdapter } from "../files/FileStorageAdapter";
1
+ import express from 'express';
2
+ import type { Server as HttpServer } from 'http';
3
+ import { DatabaseAdapter } from '../database/DatabaseAdapter';
4
+ import type { FileStorageAdapter } from '../files/FileStorageAdapter';
5
5
  export interface APIRouterOptions {
6
6
  dbAdapter: DatabaseAdapter;
7
7
  server?: HttpServer;
@@ -22,6 +22,7 @@ export declare class APIRouter {
22
22
  private liveSchemas;
23
23
  private notFoundHandler?;
24
24
  private fileStorage?;
25
+ private fileRoutesMounted;
25
26
  constructor(options: APIRouterOptions);
26
27
  getRouter(): express.Router;
27
28
  private wireSchemaHotReload;
@@ -32,6 +33,7 @@ export declare class APIRouter {
32
33
  private mountSchemasEndpointOnce;
33
34
  private mountRoutes;
34
35
  private mountFindRoutes;
36
+ private mountFileRoutes;
35
37
  private createCtx;
36
38
  private getSchema;
37
39
  private getModel;
@@ -12,6 +12,7 @@ const SchemaLoader_1 = require("../utils/SchemaLoader");
12
12
  const InMemoryAdapter_1 = require("../database/InMemoryAdapter");
13
13
  const DefaultDataInitializer_1 = require("../utils/DefaultDataInitializer");
14
14
  const SchemaService_1 = require("../services/SchemaService");
15
+ const setupFileRoutes_1 = require("./router/setupFileRoutes");
15
16
  const setupAuthRoutes_1 = require("./router/setupAuthRoutes");
16
17
  const mountCrudRoutes_1 = require("./router/mountCrudRoutes");
17
18
  const mountFindRoutes_1 = require("./router/mountFindRoutes");
@@ -23,12 +24,13 @@ class APIRouter {
23
24
  this.schemasRouteRegistered = false;
24
25
  this.registeredModels = new Set();
25
26
  this.liveSchemas = {};
27
+ this.fileRoutesMounted = false;
26
28
  // ---------- Live lookups ----------
27
29
  this.getSchema = (name) => {
28
30
  const s = this.liveSchemas[name];
29
31
  if (!s) {
30
32
  const err = new Error(`Model '${name}' removed`);
31
- err.code = "MODEL_REMOVED";
33
+ err.code = 'MODEL_REMOVED';
32
34
  throw err;
33
35
  }
34
36
  return s;
@@ -38,7 +40,7 @@ class APIRouter {
38
40
  const m = this.models[name];
39
41
  if (!m) {
40
42
  const err = new Error(`Model '${name}' unavailable`);
41
- err.code = "MODEL_UNAVAILABLE";
43
+ err.code = 'MODEL_UNAVAILABLE';
42
44
  throw err;
43
45
  }
44
46
  return m;
@@ -49,7 +51,7 @@ class APIRouter {
49
51
  const authHeader = req.headers.authorization;
50
52
  if (!authHeader)
51
53
  return next();
52
- const token = authHeader.split(" ")[1];
54
+ const token = authHeader.split(' ')[1];
53
55
  if (!token)
54
56
  return next();
55
57
  try {
@@ -62,7 +64,7 @@ class APIRouter {
62
64
  });
63
65
  };
64
66
  this.normalizeRoleName = async (value) => {
65
- const rolesModel = this.getModel("roles");
67
+ const rolesModel = this.getModel('roles');
66
68
  const isHex24 = (s) => /^[0-9a-fA-F]{24}$/.test(s);
67
69
  const fromString = async (s) => {
68
70
  if (!s)
@@ -70,17 +72,17 @@ class APIRouter {
70
72
  if (isHex24(s) && rolesModel) {
71
73
  const docs = await rolesModel.read({ id: s }, 1, 0, true);
72
74
  const n = docs?.[0]?.name;
73
- return typeof n === "string" ? n : null;
75
+ return typeof n === 'string' ? n : null;
74
76
  }
75
77
  return s;
76
78
  };
77
- if (typeof value === "string")
79
+ if (typeof value === 'string')
78
80
  return (await fromString(value))?.toLowerCase() ?? null;
79
81
  if (Array.isArray(value)) {
80
82
  for (const v of value) {
81
- const n = typeof v === "string"
83
+ const n = typeof v === 'string'
82
84
  ? await fromString(v)
83
- : v && typeof v === "object" && typeof v.name === "string"
85
+ : v && typeof v === 'object' && typeof v.name === 'string'
84
86
  ? v.name
85
87
  : null;
86
88
  if (n)
@@ -88,11 +90,11 @@ class APIRouter {
88
90
  }
89
91
  return null;
90
92
  }
91
- if (value && typeof value === "object") {
93
+ if (value && typeof value === 'object') {
92
94
  const o = value;
93
- if (typeof o.name === "string")
95
+ if (typeof o.name === 'string')
94
96
  return o.name.toLowerCase();
95
- if (typeof o.id === "string") {
97
+ if (typeof o.id === 'string') {
96
98
  const n = await fromString(o.id);
97
99
  return n ? n.toLowerCase() : null;
98
100
  }
@@ -101,9 +103,9 @@ class APIRouter {
101
103
  };
102
104
  // ---------- auth middlewares ----------
103
105
  this.apiKeyMiddleware = (req, res, next) => {
104
- const apiKey = req.headers["x-api-key"];
105
- if (typeof apiKey !== "string" || apiKey !== this.trustedApiKey) {
106
- return res.status(401).json({ error: "Invalid or missing API key" });
106
+ const apiKey = req.headers['x-api-key'];
107
+ if (typeof apiKey !== 'string' || apiKey !== this.trustedApiKey) {
108
+ return res.status(401).json({ error: 'Invalid or missing API key' });
107
109
  }
108
110
  next();
109
111
  };
@@ -111,10 +113,10 @@ class APIRouter {
111
113
  this.apiKeyMiddleware(req, res, () => {
112
114
  const authHeader = req.headers.authorization;
113
115
  if (!authHeader)
114
- return res.status(401).json({ error: "Authorization header missing" });
115
- const token = authHeader.split(" ")[1];
116
+ return res.status(401).json({ error: 'Authorization header missing' });
117
+ const token = authHeader.split(' ')[1];
116
118
  if (!token)
117
- return res.status(401).json({ error: "API Token missing." });
119
+ return res.status(401).json({ error: 'API Token missing.' });
118
120
  try {
119
121
  const decoded = jsonwebtoken_1.default.verify(token, this.jwtSecret);
120
122
  // @ts-ignore
@@ -122,14 +124,16 @@ class APIRouter {
122
124
  next();
123
125
  }
124
126
  catch {
125
- res.status(401).json({ error: "Invalid or expired token" });
127
+ res.status(401).json({ error: 'Invalid or expired token' });
126
128
  }
127
129
  });
128
130
  };
129
131
  // ---------- helpers ----------
130
- this.validateRequiredFields = (schema, payload, mode = "create") => {
132
+ this.validateRequiredFields = (schema, payload, mode = 'create') => {
131
133
  const missing = [];
132
- const isEmpty = (v) => v === undefined || v === null || (typeof v === "string" && v.trim() === "");
134
+ const isEmpty = (v) => v === undefined ||
135
+ v === null ||
136
+ (typeof v === 'string' && v.trim() === '');
133
137
  let baseKeys = null;
134
138
  const baseName = schema?.extends;
135
139
  if (baseName) {
@@ -147,76 +151,77 @@ class APIRouter {
147
151
  continue;
148
152
  if (attribute?.private)
149
153
  continue;
150
- if (baseKeys && key !== "baseId" && baseKeys.has(key))
154
+ if (baseKeys && key !== 'baseId' && baseKeys.has(key))
151
155
  continue;
152
156
  const reqd = Boolean(attribute?.required);
153
157
  if (!reqd)
154
158
  continue;
155
- if (mode === "create") {
159
+ if (mode === 'create') {
156
160
  if (isEmpty(payload[key]))
157
161
  missing.push(key);
158
162
  }
159
163
  else {
160
- if (Object.prototype.hasOwnProperty.call(payload, key) && isEmpty(payload[key])) {
164
+ if (Object.prototype.hasOwnProperty.call(payload, key) &&
165
+ isEmpty(payload[key])) {
161
166
  missing.push(key);
162
167
  }
163
168
  }
164
169
  }
165
170
  return missing;
166
171
  };
167
- this.isDevelopment = process.env.APP_MODE !== "production";
172
+ this.isDevelopment = process.env.APP_MODE !== 'production';
168
173
  this.router = express_1.default.Router();
169
174
  this.fileStorage = options.fileStorageAdapter;
170
175
  this.dbAdapter = options.dbAdapter || new InMemoryAdapter_1.InMemoryAdapter();
171
- this.jwtSecret = process.env.JWT_SECRET || "default_jwt_secret";
176
+ this.jwtSecret = process.env.JWT_SECRET || 'default_jwt_secret';
172
177
  if (this.isDevelopment && options.server) {
173
178
  (0, SchemaService_1.startSchemaService)(options.server, {
174
179
  getApiKey: () => this.trustedApiKey,
175
180
  });
176
- options.server.on("listening", () => {
181
+ options.server.on('listening', () => {
177
182
  // @ts-ignore
178
183
  const addr = options.server.address();
179
- Logger_1.default.info("SchemaService", `[schema-service] started at /__nextmin__/schema ns /schema on ${typeof addr === "string" ? addr : `${addr?.address}:${addr?.port}`}`);
184
+ Logger_1.default.info('SchemaService', `[schema-service] started at /__nextmin__/schema ns /schema on ${typeof addr === 'string' ? addr : `${addr?.address}:${addr?.port}`}`);
180
185
  });
181
186
  }
182
- this.schemaLoader = SchemaLoader_1.SchemaLoader.getInstance?.() ?? new SchemaLoader_1.SchemaLoader();
187
+ this.schemaLoader =
188
+ SchemaLoader_1.SchemaLoader.getInstance?.() ?? new SchemaLoader_1.SchemaLoader();
183
189
  const initialSchemas = this.schemaLoader.getSchemas();
184
190
  this.setLiveSchemas(initialSchemas);
185
191
  const finishBoot = async () => {
186
- if (typeof this.dbAdapter.registerSchemas === "function") {
192
+ if (typeof this.dbAdapter.registerSchemas === 'function') {
187
193
  await this.dbAdapter.registerSchemas(initialSchemas);
188
194
  }
189
195
  this.rebuildModels(Object.values(initialSchemas));
190
196
  this.mountSchemasEndpointOnce();
191
197
  this.mountRoutes(initialSchemas);
192
198
  this.mountFindRoutes();
199
+ this.mountFileRoutes();
193
200
  await this.syncAllIndexes(initialSchemas);
194
201
  const initializer = new DefaultDataInitializer_1.DefaultDataInitializer(this.dbAdapter, this.models);
195
202
  try {
196
203
  await initializer.initialize();
197
- this.trustedApiKey = initializer.getApiKey() || "";
198
- Logger_1.default.info("APIRouter", `Trusted API key set: ${this.trustedApiKey ? "[hidden]" : "none"}`);
204
+ this.trustedApiKey = initializer.getApiKey() || '';
205
+ Logger_1.default.info('APIRouter', `Trusted API key set: ${this.trustedApiKey ? '[hidden]' : 'none'}`);
199
206
  }
200
207
  catch (err) {
201
- Logger_1.default.error("APIRouter", "Failed to initialize default data", err);
208
+ Logger_1.default.error('APIRouter', 'Failed to initialize default data', err);
202
209
  }
203
210
  this.setupNotFoundMiddleware();
204
211
  this.wireSchemaHotReload();
205
212
  };
206
- if (typeof this.dbAdapter.registerSchemas === "function") {
213
+ if (typeof this.dbAdapter.registerSchemas === 'function') {
207
214
  const res = this.dbAdapter.registerSchemas(initialSchemas);
208
215
  if (res instanceof Promise) {
209
- res
210
- .then(finishBoot)
211
- .catch((err) => {
212
- Logger_1.default.error("APIRouter", "registerSchemas failed", err);
216
+ res.then(finishBoot).catch((err) => {
217
+ Logger_1.default.error('APIRouter', 'registerSchemas failed', err);
213
218
  this.setupNotFoundMiddleware();
214
219
  });
215
220
  return;
216
221
  }
217
222
  }
218
223
  finishBoot().catch((err) => {
219
- Logger_1.default.error("APIRouter", "finishBoot error", err);
224
+ Logger_1.default.error('APIRouter', 'finishBoot error', err);
220
225
  this.setupNotFoundMiddleware();
221
226
  });
222
227
  }
@@ -226,15 +231,17 @@ class APIRouter {
226
231
  // ---------- Hot reload ----------
227
232
  wireSchemaHotReload() {
228
233
  const anyLoader = this.schemaLoader;
229
- if (typeof anyLoader.on !== "function")
234
+ if (typeof anyLoader.on !== 'function')
230
235
  return;
231
- anyLoader.on("schemasChanged", async (newSchemas) => {
236
+ anyLoader.on('schemasChanged', async (newSchemas) => {
232
237
  try {
233
238
  const removed = this.diffRemovedModels(this.liveSchemas, newSchemas);
234
- if (removed.length && typeof this.dbAdapter.unregisterSchemas === "function") {
239
+ if (removed.length &&
240
+ typeof this.dbAdapter.unregisterSchemas === 'function') {
235
241
  await this.dbAdapter.unregisterSchemas(removed);
236
242
  }
237
- else if (removed.length && typeof this.dbAdapter.dropModel === "function") {
243
+ else if (removed.length &&
244
+ typeof this.dbAdapter.dropModel === 'function') {
238
245
  for (const name of removed) {
239
246
  try {
240
247
  await this.dbAdapter.dropModel(name);
@@ -242,22 +249,22 @@ class APIRouter {
242
249
  catch { }
243
250
  }
244
251
  }
245
- if (typeof this.dbAdapter.registerSchemas === "function") {
252
+ if (typeof this.dbAdapter.registerSchemas === 'function') {
246
253
  await this.dbAdapter.registerSchemas(newSchemas);
247
254
  }
248
255
  this.setLiveSchemas(newSchemas);
249
256
  this.rebuildModels(Object.values(newSchemas));
250
257
  this.mountRoutes(newSchemas);
251
258
  await this.syncAllIndexes(newSchemas);
252
- Logger_1.default.info("APIRouter", `Schemas reloaded (added/updated: ${Object.keys(newSchemas).length}, removed: ${removed.join(", ") || "none"})`);
259
+ Logger_1.default.info('APIRouter', `Schemas reloaded (added/updated: ${Object.keys(newSchemas).length}, removed: ${removed.join(', ') || 'none'})`);
253
260
  }
254
261
  catch (err) {
255
- Logger_1.default.error("APIRouter", "Failed to refresh after schemasChanged", err);
262
+ Logger_1.default.error('APIRouter', 'Failed to refresh after schemasChanged', err);
256
263
  }
257
264
  });
258
265
  }
259
266
  async syncAllIndexes(schemas) {
260
- if (typeof this.dbAdapter.syncIndexes !== "function")
267
+ if (typeof this.dbAdapter.syncIndexes !== 'function')
261
268
  return;
262
269
  const plan = this.schemaLoader.getIndexPlan();
263
270
  for (const s of Object.values(schemas)) {
@@ -268,7 +275,7 @@ class APIRouter {
268
275
  await this.dbAdapter.syncIndexes(modelName, spec);
269
276
  }
270
277
  catch (err) {
271
- Logger_1.default.warn("APIRouter", `Index sync failed for ${modelName}: ${err?.message || err}`);
278
+ Logger_1.default.warn('APIRouter', `Index sync failed for ${modelName}: ${err?.message || err}`);
272
279
  }
273
280
  }
274
281
  }
@@ -280,7 +287,7 @@ class APIRouter {
280
287
  if (!next.has(name.toLowerCase()))
281
288
  removed.push(name.toLowerCase());
282
289
  }
283
- return removed.filter((n) => n !== "users" && n !== "roles");
290
+ return removed.filter((n) => n !== 'users' && n !== 'roles');
284
291
  }
285
292
  setLiveSchemas(schemas) {
286
293
  const idx = {};
@@ -299,7 +306,7 @@ class APIRouter {
299
306
  if (this.schemasRouteRegistered)
300
307
  return;
301
308
  this.schemasRouteRegistered = true;
302
- this.router.get("/_schemas", this.apiKeyMiddleware, (_req, res) => {
309
+ this.router.get('/_schemas', this.apiKeyMiddleware, (_req, res) => {
303
310
  res.json({
304
311
  success: true,
305
312
  data: this.schemaLoader.getPublicSchemaList(),
@@ -307,7 +314,7 @@ class APIRouter {
307
314
  });
308
315
  }
309
316
  mountRoutes(schemas) {
310
- if (!this.authRoutesInitialized && this.liveSchemas["users"]) {
317
+ if (!this.authRoutesInitialized && this.liveSchemas['users']) {
311
318
  (0, setupAuthRoutes_1.setupAuthRoutes)(this.createCtx());
312
319
  this.authRoutesInitialized = true;
313
320
  }
@@ -327,6 +334,15 @@ class APIRouter {
327
334
  (0, mountFindRoutes_1.mountFindRoutes)(this.createCtx());
328
335
  this.ensureNotFoundLast();
329
336
  }
337
+ mountFileRoutes() {
338
+ if (this.fileRoutesMounted)
339
+ return;
340
+ if (this.fileStorage) {
341
+ (0, setupFileRoutes_1.setupFileRoutes)(this.createCtx());
342
+ this.fileRoutesMounted = true;
343
+ this.ensureNotFoundLast();
344
+ }
345
+ }
330
346
  // ---------- Context builder for modules ----------
331
347
  createCtx() {
332
348
  return {
@@ -359,35 +375,45 @@ class APIRouter {
359
375
  return this.optionalAuthMiddleware;
360
376
  if (publicRule === false)
361
377
  return this.authenticateMiddleware;
362
- return action === "read" ? this.optionalAuthMiddleware : this.authenticateMiddleware;
378
+ return action === 'read'
379
+ ? this.optionalAuthMiddleware
380
+ : this.authenticateMiddleware;
363
381
  }
364
382
  catch {
365
- return action === "read" ? this.optionalAuthMiddleware : this.authenticateMiddleware;
383
+ return action === 'read'
384
+ ? this.optionalAuthMiddleware
385
+ : this.authenticateMiddleware;
366
386
  }
367
387
  }
368
388
  getUserRoleFromReq(req) {
369
389
  const r = req.user?.role;
370
- return typeof r === "string" ? r : typeof r?.name === "string" ? r.name : null;
390
+ return typeof r === 'string'
391
+ ? r
392
+ : typeof r?.name === 'string'
393
+ ? r.name
394
+ : null;
371
395
  }
372
396
  handleWriteError(error, res) {
373
- if (error?.code === "MODEL_REMOVED") {
397
+ if (error?.code === 'MODEL_REMOVED') {
374
398
  res.status(410).json({ error: true, message: error.message });
375
399
  return;
376
400
  }
377
401
  if (error?.code === 11000) {
378
402
  const field = Object.keys(error.keyPattern || {})[0];
379
- res.status(400).json({ error: true, message: `Duplicate value for field: ${field}` });
403
+ res
404
+ .status(400)
405
+ .json({ error: true, message: `Duplicate value for field: ${field}` });
380
406
  }
381
407
  else {
382
- res.status(400).json({ error: true, message: error?.message || "Error" });
408
+ res.status(400).json({ error: true, message: error?.message || 'Error' });
383
409
  }
384
410
  }
385
411
  setupNotFoundMiddleware() {
386
412
  if (!this.notFoundHandler) {
387
413
  this.notFoundHandler = (req, res) => {
388
- Logger_1.default.warn("apiRouter", `API route not found: ${req.originalUrl}`);
414
+ Logger_1.default.warn('apiRouter', `API route not found: ${req.originalUrl}`);
389
415
  res.status(404).json({
390
- error: "API route not found",
416
+ error: 'API route not found',
391
417
  path: req.originalUrl,
392
418
  method: req.method,
393
419
  });
@@ -400,7 +426,7 @@ class APIRouter {
400
426
  return;
401
427
  // @ts-ignore
402
428
  this.router.stack = this.router.stack.filter((layer) => layer?.handle !== this.notFoundHandler);
403
- this.router.use("*", this.notFoundHandler);
429
+ this.router.use('*', this.notFoundHandler);
404
430
  }
405
431
  async checkUniqueFields(schema, data, excludeId) {
406
432
  const uniqueFields = Object.entries(schema.attributes)
@@ -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.5",
3
+ "version": "0.1.7",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",