@yrest/cli 0.8.0 → 0.9.0

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.
package/dist/index.mjs CHANGED
@@ -17,6 +17,56 @@ var init_esm_shims = __esm({
17
17
  }
18
18
  });
19
19
 
20
+ // src/storage/parseRelations.ts
21
+ function parseRelations(raw) {
22
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
23
+ const result = {};
24
+ for (const [collection, fields] of Object.entries(raw)) {
25
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
26
+ result[collection] = {};
27
+ for (const [key, value] of Object.entries(fields)) {
28
+ const def = normaliseRelationDef(key, value);
29
+ if (def) result[collection][key] = def;
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+ function normaliseRelationDef(key, value) {
35
+ if (typeof value === "string") {
36
+ return { type: "many2one", target: value };
37
+ }
38
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
39
+ const v = value;
40
+ const type = v["type"];
41
+ const nested = v["nested"] === true ? true : void 0;
42
+ if (type === "many2one" || type === void 0) {
43
+ const target = v["target"];
44
+ if (typeof target !== "string") return null;
45
+ return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
46
+ }
47
+ if (type === "one2one") {
48
+ const target = v["target"];
49
+ if (typeof target !== "string") return null;
50
+ return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
51
+ }
52
+ if (type === "many2many") {
53
+ const target = typeof v["target"] === "string" ? v["target"] : key;
54
+ const through = v["through"];
55
+ const foreignKey = v["foreignKey"];
56
+ const otherKey = v["otherKey"];
57
+ if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
58
+ return null;
59
+ return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
60
+ }
61
+ return null;
62
+ }
63
+ var init_parseRelations = __esm({
64
+ "src/storage/parseRelations.ts"() {
65
+ "use strict";
66
+ init_esm_shims();
67
+ }
68
+ });
69
+
20
70
  // src/utils/deepCopy.ts
21
71
  function deepCopyData(source) {
22
72
  return Object.fromEntries(
@@ -42,7 +92,7 @@ import { parse as parse2, stringify } from "yaml";
42
92
  function createYrestStorage(filePath) {
43
93
  const absPath = resolve(filePath);
44
94
  const raw = parse2(readFileSync2(absPath, "utf8")) ?? {};
45
- const relations = raw["_rel"] ?? {};
95
+ const relations = parseRelations(raw["_rel"]);
46
96
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
47
97
  const data = Object.fromEntries(
48
98
  Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
@@ -79,7 +129,7 @@ function createYrestStorage(filePath) {
79
129
  },
80
130
  reload() {
81
131
  const fresh = parse2(readFileSync2(absPath, "utf8")) ?? {};
82
- const freshRelations = fresh["_rel"] ?? {};
132
+ const freshRelations = parseRelations(fresh["_rel"]);
83
133
  const freshData = Object.fromEntries(
84
134
  Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
85
135
  );
@@ -112,6 +162,7 @@ var init_yrestStorage = __esm({
112
162
  "src/storage/yrestStorage.ts"() {
113
163
  "use strict";
114
164
  init_esm_shims();
165
+ init_parseRelations();
115
166
  init_deepCopy();
116
167
  }
117
168
  });
@@ -163,13 +214,21 @@ function dedent(str) {
163
214
 
164
215
  // src/api/yrestServer.ts
165
216
  init_esm_shims();
217
+ init_parseRelations();
166
218
  import { resolve as resolve2 } from "path";
167
219
 
168
220
  // src/utils/handlers.ts
169
221
  init_esm_shims();
170
222
  import { existsSync } from "fs";
223
+ var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs"];
171
224
  async function loadHandlers(filePath) {
172
225
  if (!existsSync(filePath)) return /* @__PURE__ */ new Map();
226
+ if (!ALLOWED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
227
+ console.error(
228
+ ` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs and .cjs files are allowed\x1B[0m`
229
+ );
230
+ return /* @__PURE__ */ new Map();
231
+ }
173
232
  try {
174
233
  const mod = await import(filePath);
175
234
  const map = /* @__PURE__ */ new Map();
@@ -210,6 +269,9 @@ import { readFileSync } from "fs";
210
269
  import { dirname, join } from "path";
211
270
  import { fileURLToPath as fileURLToPath2 } from "url";
212
271
 
272
+ // src/router/templates/about.helpers.ts
273
+ init_esm_shims();
274
+
213
275
  // src/utils/interpolate.ts
214
276
  init_esm_shims();
215
277
  import { randomUUID } from "crypto";
@@ -257,16 +319,7 @@ function hasTemplates(value) {
257
319
  return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
258
320
  }
259
321
 
260
- // src/router/templates/about.template.ts
261
- var _dir = dirname(fileURLToPath2(import.meta.url));
262
- var LOGO_SRC = (() => {
263
- try {
264
- const buf = readFileSync(join(_dir, "../../assets/logo-color.png"));
265
- return `data:image/png;base64,${buf.toString("base64")}`;
266
- } catch {
267
- return "";
268
- }
269
- })();
322
+ // src/router/templates/about.helpers.ts
270
323
  var METHOD_COLOR = {
271
324
  GET: "#3fb950",
272
325
  POST: "#58a6ff",
@@ -275,8 +328,11 @@ var METHOD_COLOR = {
275
328
  DELETE: "#f85149",
276
329
  fn: "#f0883e"
277
330
  };
331
+ function escapeHtml(str) {
332
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
333
+ }
278
334
  function badge(label, color, bg) {
279
- return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
335
+ return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${escapeHtml(label)}</span>`;
280
336
  }
281
337
  function methodBadge(method) {
282
338
  const color = METHOD_COLOR[method] ?? "#7d8590";
@@ -286,7 +342,7 @@ function endpointRow(method, path2, desc) {
286
342
  return `
287
343
  <tr>
288
344
  <td class="method-cell">${methodBadge(method)}</td>
289
- <td class="path-cell"><code>${path2}</code></td>
345
+ <td class="path-cell"><code>${escapeHtml(path2)}</code></td>
290
346
  <td class="desc-cell">${desc}</td>
291
347
  </tr>`;
292
348
  }
@@ -320,7 +376,7 @@ function resourceAccordion(name, base, isOpen) {
320
376
  return `
321
377
  <details class="resource-card" ${isOpen ? "open" : ""}>
322
378
  <summary>
323
- <span class="resource-name">/${name}</span>
379
+ <span class="resource-name">/${escapeHtml(name)}</span>
324
380
  <span class="route-count">6 routes</span>
325
381
  </summary>
326
382
  <table>
@@ -328,6 +384,139 @@ function resourceAccordion(name, base, isOpen) {
328
384
  </table>
329
385
  </details>`;
330
386
  }
387
+ function nestedRoutesAccordion(relations, base) {
388
+ const rows = [];
389
+ for (const [source, fields] of Object.entries(relations)) {
390
+ for (const [key, def] of Object.entries(fields)) {
391
+ const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
392
+ if (def.type === "many2many") {
393
+ const singular = source.endsWith("s") ? source.slice(0, -1) : source;
394
+ const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
395
+ rows.push(
396
+ endpointRow(
397
+ "GET",
398
+ `${base}/${source}/:id/${key}`,
399
+ `List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
400
+ )
401
+ );
402
+ const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
403
+ rows.push(
404
+ endpointRow(
405
+ "GET",
406
+ `${base}/${def.target}/:id/${source}`,
407
+ `List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
408
+ )
409
+ );
410
+ } else {
411
+ const path2 = `${base}/${def.target}/:id/${source}`;
412
+ const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
413
+ const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
414
+ rows.push(
415
+ endpointRow(
416
+ "GET",
417
+ path2,
418
+ `${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
419
+ )
420
+ );
421
+ }
422
+ }
423
+ }
424
+ if (!rows.length) return "";
425
+ return `
426
+ <details class="resource-card nested-card">
427
+ <summary>
428
+ <span class="resource-name">Nested routes</span>
429
+ <span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
430
+ </summary>
431
+ <table><tbody>${rows.join("")}</tbody></table>
432
+ </details>`;
433
+ }
434
+ function snapshotAccordion() {
435
+ return `
436
+ <details class="resource-card nested-card">
437
+ <summary>
438
+ <span class="resource-name">/_snapshot</span>
439
+ <span class="route-count">3 routes</span>
440
+ </summary>
441
+ <table><tbody>
442
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
443
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
444
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
445
+ </tbody></table>
446
+ </details>`;
447
+ }
448
+ function customRoutesAccordion(routes, base, handlers) {
449
+ if (!routes.length) return "";
450
+ const rows = routes.map((r) => {
451
+ const fullPath = `${base}${r.path}`;
452
+ const tags = [];
453
+ if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
454
+ if (r.delay && r.delay > 0)
455
+ tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
456
+ if (r.scenarios?.length) {
457
+ const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
458
+ tags.push(
459
+ `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
460
+ );
461
+ }
462
+ if (r.otherwise)
463
+ tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
464
+ let desc;
465
+ if (r.error) {
466
+ desc = `Error injection \u2014 <code>${r.error}</code>`;
467
+ } else if (r.handler) {
468
+ const found = handlers.has(r.handler);
469
+ const handlerName = escapeHtml(r.handler);
470
+ desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
471
+ } else if (r.scenarios?.length) {
472
+ const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
473
+ desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
474
+ } else if (r.response?.body != null && hasTemplates(r.response.body)) {
475
+ desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
476
+ } else {
477
+ const status = r.response?.status ?? 200;
478
+ desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
479
+ }
480
+ if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
481
+ return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
482
+ });
483
+ return `
484
+ <details class="resource-card nested-card">
485
+ <summary>
486
+ <span class="resource-name">Custom routes</span>
487
+ <span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
488
+ </summary>
489
+ <table><tbody>
490
+ ${rows.join("")}
491
+ </tbody></table>
492
+ </details>`;
493
+ }
494
+ function handlersAccordion(handlers, routes, base) {
495
+ if (!handlers.size) return "";
496
+ const routesByHandler = /* @__PURE__ */ new Map();
497
+ for (const r of routes) {
498
+ if (r.handler) {
499
+ const list = routesByHandler.get(r.handler) ?? [];
500
+ list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
501
+ routesByHandler.set(r.handler, list);
502
+ }
503
+ }
504
+ const rows = [...handlers.keys()].map((name) => {
505
+ const linked = routesByHandler.get(name);
506
+ const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
507
+ return endpointRow("fn", name + "()", routeDesc);
508
+ });
509
+ return `
510
+ <details class="resource-card nested-card">
511
+ <summary>
512
+ <span class="resource-name">Handlers</span>
513
+ <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
514
+ </summary>
515
+ <table><tbody>
516
+ ${rows.join("")}
517
+ </tbody></table>
518
+ </details>`;
519
+ }
331
520
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
332
521
  const examples = [];
333
522
  const firstCol = collections[0];
@@ -358,35 +547,36 @@ curl -X DELETE ${p}/1`
358
547
  const firstRel = Object.entries(relations)[0];
359
548
  if (firstRel) {
360
549
  const [child, fields] = firstRel;
361
- const fk = Object.keys(fields)[0];
362
- const expandKey = fk.replace(/Id$/i, "");
363
- examples.push(
364
- `# Embed parent with ?_expand
365
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
366
- );
367
- }
368
- const firstRelEntry = Object.entries(relations)[0];
369
- if (firstRelEntry) {
370
- const [child, fields] = firstRelEntry;
371
- const parent = Object.values(fields)[0];
372
- if (parent) {
373
- examples.push(`# Nested resource
374
- curl ${host}${base}/${parent}/1/${child}`);
550
+ const firstField = Object.entries(fields)[0];
551
+ if (firstField) {
552
+ const [fk, def] = firstField;
553
+ if (def.type !== "many2many") {
554
+ const expandKey = fk.replace(/Id$/i, "");
555
+ examples.push(
556
+ `# Embed parent with ?_expand
557
+ curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
558
+ `# Nested resource
559
+ curl ${host}${base}/${def.target}/1/${child}`
560
+ );
561
+ } else {
562
+ examples.push(`# Many-to-many embed
563
+ curl "${host}${base}/${child}/1/${fk}"`);
564
+ }
375
565
  }
376
566
  }
377
567
  if (options.pageable.enabled && firstCol) {
378
568
  examples.push(`# Pageable envelope
379
569
  curl "${host}${base}/${firstCol}?_page=2"`);
380
570
  }
381
- const firstParentRel = Object.entries(relations).find(
382
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
383
- );
384
571
  if (firstCol) {
385
572
  examples.push(
386
573
  `# Project fields with ?_fields
387
574
  curl "${host}${base}/${firstCol}?_fields=id,name"`
388
575
  );
389
576
  }
577
+ const firstParentRel = Object.entries(relations).find(
578
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
579
+ );
390
580
  if (firstParentRel && firstCol) {
391
581
  const [childName] = firstParentRel;
392
582
  examples.push(
@@ -404,7 +594,7 @@ curl -X POST ${host}/_snapshot/reset`
404
594
  }
405
595
  if (firstCustomRoute) {
406
596
  const method = firstCustomRoute.method?.toUpperCase() ?? "GET";
407
- const fullPath = `${host}${base}${firstCustomRoute.path}`;
597
+ const fullPath = `${host}${base}${escapeHtml(firstCustomRoute.path ?? "")}`;
408
598
  const curlFlag = method === "GET" ? "" : `-X ${method} `;
409
599
  examples.push(`# Custom route
410
600
  curl ${curlFlag}${fullPath}`);
@@ -412,9 +602,21 @@ curl ${curlFlag}${fullPath}`);
412
602
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
413
603
  return `<pre>${highlighted}</pre>`;
414
604
  }
605
+
606
+ // src/router/templates/about.template.ts
607
+ var _dir = dirname(fileURLToPath2(import.meta.url));
608
+ var LOGO_SRC = (() => {
609
+ try {
610
+ const buf = readFileSync(join(_dir, "../../assets/logo-color.png"));
611
+ return `data:image/png;base64,${buf.toString("base64")}`;
612
+ } catch {
613
+ return "";
614
+ }
615
+ })();
415
616
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
416
617
  const collections = Object.keys(storage.getData());
417
618
  const relations = storage.getRelations();
619
+ const customRoutes = storage.getRoutes();
418
620
  const base = options.base;
419
621
  const host = `http://${options.host}:${options.port}`;
420
622
  const modes = [];
@@ -428,104 +630,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
428
630
  if (options.idStrategy !== "increment")
429
631
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
430
632
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
431
- const nestedRows = [];
432
- for (const [child, fields] of Object.entries(relations)) {
433
- for (const [, parent] of Object.entries(fields)) {
434
- const nestedPath = `${base}/${parent}/:id/${child}`;
435
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
436
- nestedRows.push(
437
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
438
- );
439
- }
440
- }
441
- const nestedAccordion = nestedRows.length ? `
442
- <details class="resource-card nested-card">
443
- <summary>
444
- <span class="resource-name">Nested routes</span>
445
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
446
- </summary>
447
- <table><tbody>${nestedRows.join("")}</tbody></table>
448
- </details>` : "";
449
- const snapshotAccordion = options.snapshot ? `
450
- <details class="resource-card nested-card">
451
- <summary>
452
- <span class="resource-name">/_snapshot</span>
453
- <span class="route-count">3 routes</span>
454
- </summary>
455
- <table><tbody>
456
- ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
457
- ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
458
- ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
459
- </tbody></table>
460
- </details>` : "";
461
- const customRoutes = storage.getRoutes();
462
- const customRoutesAccordion = customRoutes.length ? `
463
- <details class="resource-card nested-card">
464
- <summary>
465
- <span class="resource-name">Custom routes</span>
466
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
467
- </summary>
468
- <table><tbody>
469
- ${customRoutes.map((r) => {
470
- const fullPath = `${base}${r.path}`;
471
- const tags = [];
472
- if (r.error) {
473
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
474
- }
475
- if (r.delay && r.delay > 0) {
476
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
477
- }
478
- if (r.scenarios?.length) {
479
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
480
- tags.push(
481
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
482
- );
483
- }
484
- if (r.otherwise) {
485
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
486
- }
487
- let desc;
488
- if (r.error) {
489
- desc = `Error injection \u2014 <code>${r.error}</code>`;
490
- } else if (r.handler) {
491
- const found = handlers.has(r.handler);
492
- desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
493
- } else if (r.scenarios?.length) {
494
- const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
495
- desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
496
- } else if (r.response?.body != null && hasTemplates(r.response.body)) {
497
- desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
498
- } else {
499
- const status = r.response?.status ?? 200;
500
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
501
- }
502
- if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
503
- return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
504
- }).join("")}
505
- </tbody></table>
506
- </details>` : "";
507
- const routesByHandler = /* @__PURE__ */ new Map();
508
- for (const r of customRoutes) {
509
- if (r.handler) {
510
- const list = routesByHandler.get(r.handler) ?? [];
511
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
512
- routesByHandler.set(r.handler, list);
513
- }
514
- }
515
- const handlersAccordion = handlers.size > 0 ? `
516
- <details class="resource-card nested-card">
517
- <summary>
518
- <span class="resource-name">Handlers</span>
519
- <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
520
- </summary>
521
- <table><tbody>
522
- ${[...handlers.keys()].map((name) => {
523
- const routes = routesByHandler.get(name);
524
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
525
- return endpointRow("fn", name + "()", routeDesc);
526
- }).join("")}
527
- </tbody></table>
528
- </details>` : "";
529
633
  const paginationDesc = options.pageable.enabled ? `Pageable mode active \u2014 default limit <code>${options.pageable.limit}</code>. Response wrapped in <code>{ data, pagination }</code>.` : `Returns the requested slice. <code>X-Total-Count</code> header reflects the total before pagination.`;
530
634
  return `<!DOCTYPE html>
531
635
  <html lang="en">
@@ -673,10 +777,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
673
777
  <h2>Endpoints</h2>
674
778
  <div class="endpoints-grid">
675
779
  ${accordions}
676
- ${nestedAccordion}
677
- ${snapshotAccordion}
678
- ${customRoutesAccordion}
679
- ${handlersAccordion}
780
+ ${nestedRoutesAccordion(relations, base)}
781
+ ${options.snapshot ? snapshotAccordion() : ""}
782
+ ${customRoutesAccordion(customRoutes, base, handlers)}
783
+ ${handlersAccordion(handlers, customRoutes, base)}
680
784
  </div>
681
785
 
682
786
  <h2>Query Parameters</h2>
@@ -767,6 +871,7 @@ function applyOperator(itemValue, op, filterValue) {
767
871
  case "_start":
768
872
  return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
769
873
  case "_regex": {
874
+ if (filterValue.length > 200) return false;
770
875
  try {
771
876
  return new RegExp(filterValue, "i").test(strItem);
772
877
  } catch {
@@ -882,10 +987,11 @@ function expandItems(input, query, resource, storage) {
882
987
  const resourceRelations = storage.getRelations()[resource] ?? {};
883
988
  const expansions = /* @__PURE__ */ new Map();
884
989
  for (const expandKey of keys) {
885
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
990
+ for (const [field, def] of Object.entries(resourceRelations)) {
991
+ if (def.type === "many2many") continue;
886
992
  const derivedKey = field.replace(/Id$/i, "");
887
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
888
- expansions.set(expandKey, { field, parentCollection });
993
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
994
+ expansions.set(expandKey, { field, parentCollection: def.target });
889
995
  break;
890
996
  }
891
997
  }
@@ -914,10 +1020,24 @@ function embedItems(input, query, resource, storage) {
914
1020
  const relations = storage.getRelations();
915
1021
  const embeds = /* @__PURE__ */ new Map();
916
1022
  for (const embedKey of keys) {
1023
+ const ownRelations = relations[resource] ?? {};
1024
+ if (embedKey in ownRelations) {
1025
+ const def = ownRelations[embedKey];
1026
+ if (def.type === "many2many") {
1027
+ embeds.set(embedKey, {
1028
+ kind: "many2many",
1029
+ target: def.target,
1030
+ through: def.through,
1031
+ foreignKey: def.foreignKey,
1032
+ otherKey: def.otherKey
1033
+ });
1034
+ continue;
1035
+ }
1036
+ }
917
1037
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
918
- for (const [fkField, parentCollection] of Object.entries(fields)) {
919
- if (parentCollection === resource && childCollection === embedKey) {
920
- embeds.set(embedKey, { childCollection, fkField });
1038
+ for (const [fkField, def] of Object.entries(fields)) {
1039
+ if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
1040
+ embeds.set(embedKey, { kind: def.type, childCollection, fkField });
921
1041
  break outer;
922
1042
  }
923
1043
  }
@@ -926,10 +1046,57 @@ function embedItems(input, query, resource, storage) {
926
1046
  if (embeds.size === 0) return isArray ? items : input;
927
1047
  const result = items.map((item) => {
928
1048
  const out = { ...item };
929
- for (const [embedKey, { childCollection, fkField }] of embeds) {
930
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
931
- (child) => String(child[fkField]) === String(item["id"])
932
- );
1049
+ for (const [embedKey, spec] of embeds) {
1050
+ if (spec.kind === "many2many") {
1051
+ const pivot = storage.getCollection(spec.through) ?? [];
1052
+ const matchingIds = new Set(
1053
+ pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
1054
+ );
1055
+ out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
1056
+ (t) => matchingIds.has(String(t["id"]))
1057
+ );
1058
+ } else if (spec.kind === "one2one") {
1059
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
1060
+ (child) => String(child[spec.fkField]) === String(item["id"])
1061
+ ) ?? null;
1062
+ } else {
1063
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
1064
+ (child) => String(child[spec.fkField]) === String(item["id"])
1065
+ );
1066
+ }
1067
+ }
1068
+ return out;
1069
+ });
1070
+ return isArray ? result : result[0];
1071
+ }
1072
+ function applyNested(input, resource, storage) {
1073
+ const isArray = Array.isArray(input);
1074
+ const items = isArray ? input : [input];
1075
+ const resourceRelations = storage.getRelations()[resource] ?? {};
1076
+ const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
1077
+ if (nestedDefs.length === 0) return input;
1078
+ const result = items.map((item) => {
1079
+ const out = { ...item };
1080
+ for (const [key, def] of nestedDefs) {
1081
+ if (def.type === "many2many") {
1082
+ const pivot = storage.getCollection(def.through) ?? [];
1083
+ const matchingIds = new Set(
1084
+ pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
1085
+ );
1086
+ out[key] = (storage.getCollection(def.target) ?? []).filter(
1087
+ (t) => matchingIds.has(String(t["id"]))
1088
+ );
1089
+ } else {
1090
+ const foreignKeyValue = item[key];
1091
+ if (foreignKeyValue === void 0) continue;
1092
+ const parent = (storage.getCollection(def.target) ?? []).find(
1093
+ (p) => String(p["id"]) === String(foreignKeyValue)
1094
+ );
1095
+ if (parent !== void 0) {
1096
+ const embedKey = key.replace(/Id$/i, "");
1097
+ out[embedKey] = parent;
1098
+ }
1099
+ }
933
1100
  }
934
1101
  return out;
935
1102
  });
@@ -969,7 +1136,12 @@ var CollectionRouteCommand = class {
969
1136
  const totalPages = Math.ceil(totalItems / limit) || 1;
970
1137
  const data = projectFields(
971
1138
  embedItems(
972
- expandItems(paginate(sorted, page, limit), req.query, this.resource, this.storage),
1139
+ expandItems(
1140
+ applyNested(paginate(sorted, page, limit), this.resource, this.storage),
1141
+ req.query,
1142
+ this.resource,
1143
+ this.storage
1144
+ ),
973
1145
  req.query,
974
1146
  this.resource,
975
1147
  this.storage
@@ -1001,7 +1173,12 @@ var CollectionRouteCommand = class {
1001
1173
  }
1002
1174
  return projectFields(
1003
1175
  embedItems(
1004
- expandItems(result, req.query, this.resource, this.storage),
1176
+ expandItems(
1177
+ applyNested(result, this.resource, this.storage),
1178
+ req.query,
1179
+ this.resource,
1180
+ this.storage
1181
+ ),
1005
1182
  req.query,
1006
1183
  this.resource,
1007
1184
  this.storage
@@ -1187,7 +1364,12 @@ var ItemRouteCommand = class {
1187
1364
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1188
1365
  return projectFields(
1189
1366
  embedItems(
1190
- expandItems(item, req.query, this.resource, this.storage),
1367
+ expandItems(
1368
+ applyNested(item, this.resource, this.storage),
1369
+ req.query,
1370
+ this.resource,
1371
+ this.storage
1372
+ ),
1191
1373
  req.query,
1192
1374
  this.resource,
1193
1375
  this.storage
@@ -1231,32 +1413,68 @@ var NestedRouteCommand = class {
1231
1413
  relations;
1232
1414
  base;
1233
1415
  register(server) {
1234
- for (const [child, fields] of Object.entries(this.relations)) {
1235
- for (const [field, parent] of Object.entries(fields)) {
1236
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1237
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1238
- server.get(collectionPath, (req, reply) => {
1239
- const parentCollection = this.storage.getCollection(parent) ?? [];
1240
- const parentItem = findById(parentCollection, req.params.id);
1241
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1242
- const children = (this.storage.getCollection(child) ?? []).filter(
1243
- (item) => String(item[field]) === req.params.id
1244
- );
1245
- return children;
1246
- });
1247
- server.get(itemPath, (req, reply) => {
1248
- const parentCollection = this.storage.getCollection(parent) ?? [];
1249
- const parentItem = findById(parentCollection, req.params.id);
1250
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1251
- const childItem = (this.storage.getCollection(child) ?? []).find(
1252
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1253
- );
1254
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1255
- return childItem;
1256
- });
1416
+ for (const [source, fields] of Object.entries(this.relations)) {
1417
+ for (const [key, def] of Object.entries(fields)) {
1418
+ if (def.type === "many2many") {
1419
+ this.registerMany2Many(server, source, key, def);
1420
+ } else {
1421
+ this.registerFkRelation(server, source, key, def.target, def.type);
1422
+ }
1257
1423
  }
1258
1424
  }
1259
1425
  }
1426
+ registerFkRelation(server, child, fkField, parent, type) {
1427
+ const collectionPath = `${this.base}/${parent}/:id/${child}`;
1428
+ const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1429
+ server.get(collectionPath, (req, reply) => {
1430
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1431
+ const parentItem = findById(parentCollection, req.params.id);
1432
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1433
+ const all = (this.storage.getCollection(child) ?? []).filter(
1434
+ (item) => String(item[fkField]) === req.params.id
1435
+ );
1436
+ if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
1437
+ return all;
1438
+ });
1439
+ if (type === "many2one") {
1440
+ server.get(itemPath, (req, reply) => {
1441
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1442
+ const parentItem = findById(parentCollection, req.params.id);
1443
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1444
+ const childItem = (this.storage.getCollection(child) ?? []).find(
1445
+ (item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
1446
+ );
1447
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
1448
+ return childItem;
1449
+ });
1450
+ }
1451
+ }
1452
+ registerMany2Many(server, source, alias, def) {
1453
+ server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
1454
+ const sourceCollection = this.storage.getCollection(source) ?? [];
1455
+ const sourceItem = findById(sourceCollection, req.params.id);
1456
+ if (!sourceItem) return reply.status(404).send({ error: "Not found" });
1457
+ const pivot = this.storage.getCollection(def.through) ?? [];
1458
+ const matchingIds = new Set(
1459
+ pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
1460
+ );
1461
+ return (this.storage.getCollection(def.target) ?? []).filter(
1462
+ (t) => matchingIds.has(String(t["id"]))
1463
+ );
1464
+ });
1465
+ server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
1466
+ const targetCollection = this.storage.getCollection(def.target) ?? [];
1467
+ const targetItem = findById(targetCollection, req.params.id);
1468
+ if (!targetItem) return reply.status(404).send({ error: "Not found" });
1469
+ const pivot = this.storage.getCollection(def.through) ?? [];
1470
+ const matchingIds = new Set(
1471
+ pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
1472
+ );
1473
+ return (this.storage.getCollection(source) ?? []).filter(
1474
+ (t) => matchingIds.has(String(t["id"]))
1475
+ );
1476
+ });
1477
+ }
1260
1478
  };
1261
1479
 
1262
1480
  // src/router/routes/snapshot.routes.ts
@@ -1419,7 +1637,7 @@ function buildOptions(opts) {
1419
1637
  }
1420
1638
  function createInMemoryStorage(data) {
1421
1639
  const raw = data;
1422
- const relations = raw["_rel"] ?? {};
1640
+ const relations = parseRelations(raw["_rel"]);
1423
1641
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
1424
1642
  const collections = Object.fromEntries(
1425
1643
  Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")