@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.js CHANGED
@@ -40,6 +40,56 @@ var init_cjs_shims = __esm({
40
40
  }
41
41
  });
42
42
 
43
+ // src/storage/parseRelations.ts
44
+ function parseRelations(raw) {
45
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
46
+ const result = {};
47
+ for (const [collection, fields] of Object.entries(raw)) {
48
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
49
+ result[collection] = {};
50
+ for (const [key, value] of Object.entries(fields)) {
51
+ const def = normaliseRelationDef(key, value);
52
+ if (def) result[collection][key] = def;
53
+ }
54
+ }
55
+ return result;
56
+ }
57
+ function normaliseRelationDef(key, value) {
58
+ if (typeof value === "string") {
59
+ return { type: "many2one", target: value };
60
+ }
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
62
+ const v = value;
63
+ const type = v["type"];
64
+ const nested = v["nested"] === true ? true : void 0;
65
+ if (type === "many2one" || type === void 0) {
66
+ const target = v["target"];
67
+ if (typeof target !== "string") return null;
68
+ return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
69
+ }
70
+ if (type === "one2one") {
71
+ const target = v["target"];
72
+ if (typeof target !== "string") return null;
73
+ return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
74
+ }
75
+ if (type === "many2many") {
76
+ const target = typeof v["target"] === "string" ? v["target"] : key;
77
+ const through = v["through"];
78
+ const foreignKey = v["foreignKey"];
79
+ const otherKey = v["otherKey"];
80
+ if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
81
+ return null;
82
+ return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
83
+ }
84
+ return null;
85
+ }
86
+ var init_parseRelations = __esm({
87
+ "src/storage/parseRelations.ts"() {
88
+ "use strict";
89
+ init_cjs_shims();
90
+ }
91
+ });
92
+
43
93
  // src/utils/deepCopy.ts
44
94
  function deepCopyData(source) {
45
95
  return Object.fromEntries(
@@ -61,7 +111,7 @@ __export(yrestStorage_exports, {
61
111
  function createYrestStorage(filePath) {
62
112
  const absPath = (0, import_node_path2.resolve)(filePath);
63
113
  const raw = (0, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
64
- const relations = raw["_rel"] ?? {};
114
+ const relations = parseRelations(raw["_rel"]);
65
115
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
66
116
  const data = Object.fromEntries(
67
117
  Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
@@ -98,7 +148,7 @@ function createYrestStorage(filePath) {
98
148
  },
99
149
  reload() {
100
150
  const fresh = (0, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
101
- const freshRelations = fresh["_rel"] ?? {};
151
+ const freshRelations = parseRelations(fresh["_rel"]);
102
152
  const freshData = Object.fromEntries(
103
153
  Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
104
154
  );
@@ -136,6 +186,7 @@ var init_yrestStorage = __esm({
136
186
  import_node_path2 = require("path");
137
187
  import_node_crypto2 = require("crypto");
138
188
  import_yaml2 = require("yaml");
189
+ init_parseRelations();
139
190
  init_deepCopy();
140
191
  }
141
192
  });
@@ -198,12 +249,20 @@ function dedent(str) {
198
249
  // src/api/yrestServer.ts
199
250
  init_cjs_shims();
200
251
  var import_node_path3 = require("path");
252
+ init_parseRelations();
201
253
 
202
254
  // src/utils/handlers.ts
203
255
  init_cjs_shims();
204
256
  var import_node_fs = require("fs");
257
+ var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs"];
205
258
  async function loadHandlers(filePath) {
206
259
  if (!(0, import_node_fs.existsSync)(filePath)) return /* @__PURE__ */ new Map();
260
+ if (!ALLOWED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
261
+ console.error(
262
+ ` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs and .cjs files are allowed\x1B[0m`
263
+ );
264
+ return /* @__PURE__ */ new Map();
265
+ }
207
266
  try {
208
267
  const mod = await import(filePath);
209
268
  const map = /* @__PURE__ */ new Map();
@@ -244,6 +303,9 @@ var import_node_fs2 = require("fs");
244
303
  var import_node_path = require("path");
245
304
  var import_node_url = require("url");
246
305
 
306
+ // src/router/templates/about.helpers.ts
307
+ init_cjs_shims();
308
+
247
309
  // src/utils/interpolate.ts
248
310
  init_cjs_shims();
249
311
  var import_node_crypto = require("crypto");
@@ -291,16 +353,7 @@ function hasTemplates(value) {
291
353
  return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
292
354
  }
293
355
 
294
- // src/router/templates/about.template.ts
295
- var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
296
- var LOGO_SRC = (() => {
297
- try {
298
- const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
299
- return `data:image/png;base64,${buf.toString("base64")}`;
300
- } catch {
301
- return "";
302
- }
303
- })();
356
+ // src/router/templates/about.helpers.ts
304
357
  var METHOD_COLOR = {
305
358
  GET: "#3fb950",
306
359
  POST: "#58a6ff",
@@ -309,8 +362,11 @@ var METHOD_COLOR = {
309
362
  DELETE: "#f85149",
310
363
  fn: "#f0883e"
311
364
  };
365
+ function escapeHtml(str) {
366
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
367
+ }
312
368
  function badge(label, color, bg) {
313
- return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
369
+ return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${escapeHtml(label)}</span>`;
314
370
  }
315
371
  function methodBadge(method) {
316
372
  const color = METHOD_COLOR[method] ?? "#7d8590";
@@ -320,7 +376,7 @@ function endpointRow(method, path, desc) {
320
376
  return `
321
377
  <tr>
322
378
  <td class="method-cell">${methodBadge(method)}</td>
323
- <td class="path-cell"><code>${path}</code></td>
379
+ <td class="path-cell"><code>${escapeHtml(path)}</code></td>
324
380
  <td class="desc-cell">${desc}</td>
325
381
  </tr>`;
326
382
  }
@@ -354,7 +410,7 @@ function resourceAccordion(name, base, isOpen) {
354
410
  return `
355
411
  <details class="resource-card" ${isOpen ? "open" : ""}>
356
412
  <summary>
357
- <span class="resource-name">/${name}</span>
413
+ <span class="resource-name">/${escapeHtml(name)}</span>
358
414
  <span class="route-count">6 routes</span>
359
415
  </summary>
360
416
  <table>
@@ -362,6 +418,139 @@ function resourceAccordion(name, base, isOpen) {
362
418
  </table>
363
419
  </details>`;
364
420
  }
421
+ function nestedRoutesAccordion(relations, base) {
422
+ const rows = [];
423
+ for (const [source, fields] of Object.entries(relations)) {
424
+ for (const [key, def] of Object.entries(fields)) {
425
+ const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
426
+ if (def.type === "many2many") {
427
+ const singular = source.endsWith("s") ? source.slice(0, -1) : source;
428
+ const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
429
+ rows.push(
430
+ endpointRow(
431
+ "GET",
432
+ `${base}/${source}/:id/${key}`,
433
+ `List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
434
+ )
435
+ );
436
+ const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
437
+ rows.push(
438
+ endpointRow(
439
+ "GET",
440
+ `${base}/${def.target}/:id/${source}`,
441
+ `List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
442
+ )
443
+ );
444
+ } else {
445
+ const path = `${base}/${def.target}/:id/${source}`;
446
+ const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
447
+ const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
448
+ rows.push(
449
+ endpointRow(
450
+ "GET",
451
+ path,
452
+ `${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
453
+ )
454
+ );
455
+ }
456
+ }
457
+ }
458
+ if (!rows.length) return "";
459
+ return `
460
+ <details class="resource-card nested-card">
461
+ <summary>
462
+ <span class="resource-name">Nested routes</span>
463
+ <span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
464
+ </summary>
465
+ <table><tbody>${rows.join("")}</tbody></table>
466
+ </details>`;
467
+ }
468
+ function snapshotAccordion() {
469
+ return `
470
+ <details class="resource-card nested-card">
471
+ <summary>
472
+ <span class="resource-name">/_snapshot</span>
473
+ <span class="route-count">3 routes</span>
474
+ </summary>
475
+ <table><tbody>
476
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
477
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
478
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
479
+ </tbody></table>
480
+ </details>`;
481
+ }
482
+ function customRoutesAccordion(routes, base, handlers) {
483
+ if (!routes.length) return "";
484
+ const rows = routes.map((r) => {
485
+ const fullPath = `${base}${r.path}`;
486
+ const tags = [];
487
+ if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
488
+ if (r.delay && r.delay > 0)
489
+ tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
490
+ if (r.scenarios?.length) {
491
+ const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
492
+ tags.push(
493
+ `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
494
+ );
495
+ }
496
+ if (r.otherwise)
497
+ tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
498
+ let desc;
499
+ if (r.error) {
500
+ desc = `Error injection \u2014 <code>${r.error}</code>`;
501
+ } else if (r.handler) {
502
+ const found = handlers.has(r.handler);
503
+ const handlerName = escapeHtml(r.handler);
504
+ desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
505
+ } else if (r.scenarios?.length) {
506
+ const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
507
+ desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
508
+ } else if (r.response?.body != null && hasTemplates(r.response.body)) {
509
+ desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
510
+ } else {
511
+ const status = r.response?.status ?? 200;
512
+ desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
513
+ }
514
+ if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
515
+ return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
516
+ });
517
+ return `
518
+ <details class="resource-card nested-card">
519
+ <summary>
520
+ <span class="resource-name">Custom routes</span>
521
+ <span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
522
+ </summary>
523
+ <table><tbody>
524
+ ${rows.join("")}
525
+ </tbody></table>
526
+ </details>`;
527
+ }
528
+ function handlersAccordion(handlers, routes, base) {
529
+ if (!handlers.size) return "";
530
+ const routesByHandler = /* @__PURE__ */ new Map();
531
+ for (const r of routes) {
532
+ if (r.handler) {
533
+ const list = routesByHandler.get(r.handler) ?? [];
534
+ list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
535
+ routesByHandler.set(r.handler, list);
536
+ }
537
+ }
538
+ const rows = [...handlers.keys()].map((name) => {
539
+ const linked = routesByHandler.get(name);
540
+ const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
541
+ return endpointRow("fn", name + "()", routeDesc);
542
+ });
543
+ return `
544
+ <details class="resource-card nested-card">
545
+ <summary>
546
+ <span class="resource-name">Handlers</span>
547
+ <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
548
+ </summary>
549
+ <table><tbody>
550
+ ${rows.join("")}
551
+ </tbody></table>
552
+ </details>`;
553
+ }
365
554
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
366
555
  const examples = [];
367
556
  const firstCol = collections[0];
@@ -392,35 +581,36 @@ curl -X DELETE ${p}/1`
392
581
  const firstRel = Object.entries(relations)[0];
393
582
  if (firstRel) {
394
583
  const [child, fields] = firstRel;
395
- const fk = Object.keys(fields)[0];
396
- const expandKey = fk.replace(/Id$/i, "");
397
- examples.push(
398
- `# Embed parent with ?_expand
399
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
400
- );
401
- }
402
- const firstRelEntry = Object.entries(relations)[0];
403
- if (firstRelEntry) {
404
- const [child, fields] = firstRelEntry;
405
- const parent = Object.values(fields)[0];
406
- if (parent) {
407
- examples.push(`# Nested resource
408
- curl ${host}${base}/${parent}/1/${child}`);
584
+ const firstField = Object.entries(fields)[0];
585
+ if (firstField) {
586
+ const [fk, def] = firstField;
587
+ if (def.type !== "many2many") {
588
+ const expandKey = fk.replace(/Id$/i, "");
589
+ examples.push(
590
+ `# Embed parent with ?_expand
591
+ curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
592
+ `# Nested resource
593
+ curl ${host}${base}/${def.target}/1/${child}`
594
+ );
595
+ } else {
596
+ examples.push(`# Many-to-many embed
597
+ curl "${host}${base}/${child}/1/${fk}"`);
598
+ }
409
599
  }
410
600
  }
411
601
  if (options.pageable.enabled && firstCol) {
412
602
  examples.push(`# Pageable envelope
413
603
  curl "${host}${base}/${firstCol}?_page=2"`);
414
604
  }
415
- const firstParentRel = Object.entries(relations).find(
416
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
417
- );
418
605
  if (firstCol) {
419
606
  examples.push(
420
607
  `# Project fields with ?_fields
421
608
  curl "${host}${base}/${firstCol}?_fields=id,name"`
422
609
  );
423
610
  }
611
+ const firstParentRel = Object.entries(relations).find(
612
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
613
+ );
424
614
  if (firstParentRel && firstCol) {
425
615
  const [childName] = firstParentRel;
426
616
  examples.push(
@@ -438,7 +628,7 @@ curl -X POST ${host}/_snapshot/reset`
438
628
  }
439
629
  if (firstCustomRoute) {
440
630
  const method = firstCustomRoute.method?.toUpperCase() ?? "GET";
441
- const fullPath = `${host}${base}${firstCustomRoute.path}`;
631
+ const fullPath = `${host}${base}${escapeHtml(firstCustomRoute.path ?? "")}`;
442
632
  const curlFlag = method === "GET" ? "" : `-X ${method} `;
443
633
  examples.push(`# Custom route
444
634
  curl ${curlFlag}${fullPath}`);
@@ -446,9 +636,21 @@ curl ${curlFlag}${fullPath}`);
446
636
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
447
637
  return `<pre>${highlighted}</pre>`;
448
638
  }
639
+
640
+ // src/router/templates/about.template.ts
641
+ var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
642
+ var LOGO_SRC = (() => {
643
+ try {
644
+ const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
645
+ return `data:image/png;base64,${buf.toString("base64")}`;
646
+ } catch {
647
+ return "";
648
+ }
649
+ })();
449
650
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
450
651
  const collections = Object.keys(storage.getData());
451
652
  const relations = storage.getRelations();
653
+ const customRoutes = storage.getRoutes();
452
654
  const base = options.base;
453
655
  const host = `http://${options.host}:${options.port}`;
454
656
  const modes = [];
@@ -462,104 +664,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
462
664
  if (options.idStrategy !== "increment")
463
665
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
464
666
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
465
- const nestedRows = [];
466
- for (const [child, fields] of Object.entries(relations)) {
467
- for (const [, parent] of Object.entries(fields)) {
468
- const nestedPath = `${base}/${parent}/:id/${child}`;
469
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
470
- nestedRows.push(
471
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
472
- );
473
- }
474
- }
475
- const nestedAccordion = nestedRows.length ? `
476
- <details class="resource-card nested-card">
477
- <summary>
478
- <span class="resource-name">Nested routes</span>
479
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
480
- </summary>
481
- <table><tbody>${nestedRows.join("")}</tbody></table>
482
- </details>` : "";
483
- const snapshotAccordion = options.snapshot ? `
484
- <details class="resource-card nested-card">
485
- <summary>
486
- <span class="resource-name">/_snapshot</span>
487
- <span class="route-count">3 routes</span>
488
- </summary>
489
- <table><tbody>
490
- ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
491
- ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
492
- ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
493
- </tbody></table>
494
- </details>` : "";
495
- const customRoutes = storage.getRoutes();
496
- const customRoutesAccordion = customRoutes.length ? `
497
- <details class="resource-card nested-card">
498
- <summary>
499
- <span class="resource-name">Custom routes</span>
500
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
501
- </summary>
502
- <table><tbody>
503
- ${customRoutes.map((r) => {
504
- const fullPath = `${base}${r.path}`;
505
- const tags = [];
506
- if (r.error) {
507
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
508
- }
509
- if (r.delay && r.delay > 0) {
510
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
511
- }
512
- if (r.scenarios?.length) {
513
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
514
- tags.push(
515
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
516
- );
517
- }
518
- if (r.otherwise) {
519
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
520
- }
521
- let desc;
522
- if (r.error) {
523
- desc = `Error injection \u2014 <code>${r.error}</code>`;
524
- } else if (r.handler) {
525
- const found = handlers.has(r.handler);
526
- desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
527
- } else if (r.scenarios?.length) {
528
- const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
529
- desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
530
- } else if (r.response?.body != null && hasTemplates(r.response.body)) {
531
- desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
532
- } else {
533
- const status = r.response?.status ?? 200;
534
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
535
- }
536
- if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
537
- return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
538
- }).join("")}
539
- </tbody></table>
540
- </details>` : "";
541
- const routesByHandler = /* @__PURE__ */ new Map();
542
- for (const r of customRoutes) {
543
- if (r.handler) {
544
- const list = routesByHandler.get(r.handler) ?? [];
545
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
546
- routesByHandler.set(r.handler, list);
547
- }
548
- }
549
- const handlersAccordion = handlers.size > 0 ? `
550
- <details class="resource-card nested-card">
551
- <summary>
552
- <span class="resource-name">Handlers</span>
553
- <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
554
- </summary>
555
- <table><tbody>
556
- ${[...handlers.keys()].map((name) => {
557
- const routes = routesByHandler.get(name);
558
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
559
- return endpointRow("fn", name + "()", routeDesc);
560
- }).join("")}
561
- </tbody></table>
562
- </details>` : "";
563
667
  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.`;
564
668
  return `<!DOCTYPE html>
565
669
  <html lang="en">
@@ -707,10 +811,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
707
811
  <h2>Endpoints</h2>
708
812
  <div class="endpoints-grid">
709
813
  ${accordions}
710
- ${nestedAccordion}
711
- ${snapshotAccordion}
712
- ${customRoutesAccordion}
713
- ${handlersAccordion}
814
+ ${nestedRoutesAccordion(relations, base)}
815
+ ${options.snapshot ? snapshotAccordion() : ""}
816
+ ${customRoutesAccordion(customRoutes, base, handlers)}
817
+ ${handlersAccordion(handlers, customRoutes, base)}
714
818
  </div>
715
819
 
716
820
  <h2>Query Parameters</h2>
@@ -801,6 +905,7 @@ function applyOperator(itemValue, op, filterValue) {
801
905
  case "_start":
802
906
  return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
803
907
  case "_regex": {
908
+ if (filterValue.length > 200) return false;
804
909
  try {
805
910
  return new RegExp(filterValue, "i").test(strItem);
806
911
  } catch {
@@ -916,10 +1021,11 @@ function expandItems(input, query, resource, storage) {
916
1021
  const resourceRelations = storage.getRelations()[resource] ?? {};
917
1022
  const expansions = /* @__PURE__ */ new Map();
918
1023
  for (const expandKey of keys) {
919
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
1024
+ for (const [field, def] of Object.entries(resourceRelations)) {
1025
+ if (def.type === "many2many") continue;
920
1026
  const derivedKey = field.replace(/Id$/i, "");
921
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
922
- expansions.set(expandKey, { field, parentCollection });
1027
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
1028
+ expansions.set(expandKey, { field, parentCollection: def.target });
923
1029
  break;
924
1030
  }
925
1031
  }
@@ -948,10 +1054,24 @@ function embedItems(input, query, resource, storage) {
948
1054
  const relations = storage.getRelations();
949
1055
  const embeds = /* @__PURE__ */ new Map();
950
1056
  for (const embedKey of keys) {
1057
+ const ownRelations = relations[resource] ?? {};
1058
+ if (embedKey in ownRelations) {
1059
+ const def = ownRelations[embedKey];
1060
+ if (def.type === "many2many") {
1061
+ embeds.set(embedKey, {
1062
+ kind: "many2many",
1063
+ target: def.target,
1064
+ through: def.through,
1065
+ foreignKey: def.foreignKey,
1066
+ otherKey: def.otherKey
1067
+ });
1068
+ continue;
1069
+ }
1070
+ }
951
1071
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
952
- for (const [fkField, parentCollection] of Object.entries(fields)) {
953
- if (parentCollection === resource && childCollection === embedKey) {
954
- embeds.set(embedKey, { childCollection, fkField });
1072
+ for (const [fkField, def] of Object.entries(fields)) {
1073
+ if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
1074
+ embeds.set(embedKey, { kind: def.type, childCollection, fkField });
955
1075
  break outer;
956
1076
  }
957
1077
  }
@@ -960,10 +1080,57 @@ function embedItems(input, query, resource, storage) {
960
1080
  if (embeds.size === 0) return isArray ? items : input;
961
1081
  const result = items.map((item) => {
962
1082
  const out = { ...item };
963
- for (const [embedKey, { childCollection, fkField }] of embeds) {
964
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
965
- (child) => String(child[fkField]) === String(item["id"])
966
- );
1083
+ for (const [embedKey, spec] of embeds) {
1084
+ if (spec.kind === "many2many") {
1085
+ const pivot = storage.getCollection(spec.through) ?? [];
1086
+ const matchingIds = new Set(
1087
+ pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
1088
+ );
1089
+ out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
1090
+ (t) => matchingIds.has(String(t["id"]))
1091
+ );
1092
+ } else if (spec.kind === "one2one") {
1093
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
1094
+ (child) => String(child[spec.fkField]) === String(item["id"])
1095
+ ) ?? null;
1096
+ } else {
1097
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
1098
+ (child) => String(child[spec.fkField]) === String(item["id"])
1099
+ );
1100
+ }
1101
+ }
1102
+ return out;
1103
+ });
1104
+ return isArray ? result : result[0];
1105
+ }
1106
+ function applyNested(input, resource, storage) {
1107
+ const isArray = Array.isArray(input);
1108
+ const items = isArray ? input : [input];
1109
+ const resourceRelations = storage.getRelations()[resource] ?? {};
1110
+ const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
1111
+ if (nestedDefs.length === 0) return input;
1112
+ const result = items.map((item) => {
1113
+ const out = { ...item };
1114
+ for (const [key, def] of nestedDefs) {
1115
+ if (def.type === "many2many") {
1116
+ const pivot = storage.getCollection(def.through) ?? [];
1117
+ const matchingIds = new Set(
1118
+ pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
1119
+ );
1120
+ out[key] = (storage.getCollection(def.target) ?? []).filter(
1121
+ (t) => matchingIds.has(String(t["id"]))
1122
+ );
1123
+ } else {
1124
+ const foreignKeyValue = item[key];
1125
+ if (foreignKeyValue === void 0) continue;
1126
+ const parent = (storage.getCollection(def.target) ?? []).find(
1127
+ (p) => String(p["id"]) === String(foreignKeyValue)
1128
+ );
1129
+ if (parent !== void 0) {
1130
+ const embedKey = key.replace(/Id$/i, "");
1131
+ out[embedKey] = parent;
1132
+ }
1133
+ }
967
1134
  }
968
1135
  return out;
969
1136
  });
@@ -1003,7 +1170,12 @@ var CollectionRouteCommand = class {
1003
1170
  const totalPages = Math.ceil(totalItems / limit) || 1;
1004
1171
  const data = projectFields(
1005
1172
  embedItems(
1006
- expandItems(paginate(sorted, page, limit), req.query, this.resource, this.storage),
1173
+ expandItems(
1174
+ applyNested(paginate(sorted, page, limit), this.resource, this.storage),
1175
+ req.query,
1176
+ this.resource,
1177
+ this.storage
1178
+ ),
1007
1179
  req.query,
1008
1180
  this.resource,
1009
1181
  this.storage
@@ -1035,7 +1207,12 @@ var CollectionRouteCommand = class {
1035
1207
  }
1036
1208
  return projectFields(
1037
1209
  embedItems(
1038
- expandItems(result, req.query, this.resource, this.storage),
1210
+ expandItems(
1211
+ applyNested(result, this.resource, this.storage),
1212
+ req.query,
1213
+ this.resource,
1214
+ this.storage
1215
+ ),
1039
1216
  req.query,
1040
1217
  this.resource,
1041
1218
  this.storage
@@ -1221,7 +1398,12 @@ var ItemRouteCommand = class {
1221
1398
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1222
1399
  return projectFields(
1223
1400
  embedItems(
1224
- expandItems(item, req.query, this.resource, this.storage),
1401
+ expandItems(
1402
+ applyNested(item, this.resource, this.storage),
1403
+ req.query,
1404
+ this.resource,
1405
+ this.storage
1406
+ ),
1225
1407
  req.query,
1226
1408
  this.resource,
1227
1409
  this.storage
@@ -1265,32 +1447,68 @@ var NestedRouteCommand = class {
1265
1447
  relations;
1266
1448
  base;
1267
1449
  register(server) {
1268
- for (const [child, fields] of Object.entries(this.relations)) {
1269
- for (const [field, parent] of Object.entries(fields)) {
1270
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1271
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1272
- server.get(collectionPath, (req, reply) => {
1273
- const parentCollection = this.storage.getCollection(parent) ?? [];
1274
- const parentItem = findById(parentCollection, req.params.id);
1275
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1276
- const children = (this.storage.getCollection(child) ?? []).filter(
1277
- (item) => String(item[field]) === req.params.id
1278
- );
1279
- return children;
1280
- });
1281
- server.get(itemPath, (req, reply) => {
1282
- const parentCollection = this.storage.getCollection(parent) ?? [];
1283
- const parentItem = findById(parentCollection, req.params.id);
1284
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1285
- const childItem = (this.storage.getCollection(child) ?? []).find(
1286
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1287
- );
1288
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1289
- return childItem;
1290
- });
1450
+ for (const [source, fields] of Object.entries(this.relations)) {
1451
+ for (const [key, def] of Object.entries(fields)) {
1452
+ if (def.type === "many2many") {
1453
+ this.registerMany2Many(server, source, key, def);
1454
+ } else {
1455
+ this.registerFkRelation(server, source, key, def.target, def.type);
1456
+ }
1291
1457
  }
1292
1458
  }
1293
1459
  }
1460
+ registerFkRelation(server, child, fkField, parent, type) {
1461
+ const collectionPath = `${this.base}/${parent}/:id/${child}`;
1462
+ const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1463
+ server.get(collectionPath, (req, reply) => {
1464
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1465
+ const parentItem = findById(parentCollection, req.params.id);
1466
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1467
+ const all = (this.storage.getCollection(child) ?? []).filter(
1468
+ (item) => String(item[fkField]) === req.params.id
1469
+ );
1470
+ if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
1471
+ return all;
1472
+ });
1473
+ if (type === "many2one") {
1474
+ server.get(itemPath, (req, reply) => {
1475
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1476
+ const parentItem = findById(parentCollection, req.params.id);
1477
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1478
+ const childItem = (this.storage.getCollection(child) ?? []).find(
1479
+ (item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
1480
+ );
1481
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
1482
+ return childItem;
1483
+ });
1484
+ }
1485
+ }
1486
+ registerMany2Many(server, source, alias, def) {
1487
+ server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
1488
+ const sourceCollection = this.storage.getCollection(source) ?? [];
1489
+ const sourceItem = findById(sourceCollection, req.params.id);
1490
+ if (!sourceItem) return reply.status(404).send({ error: "Not found" });
1491
+ const pivot = this.storage.getCollection(def.through) ?? [];
1492
+ const matchingIds = new Set(
1493
+ pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
1494
+ );
1495
+ return (this.storage.getCollection(def.target) ?? []).filter(
1496
+ (t) => matchingIds.has(String(t["id"]))
1497
+ );
1498
+ });
1499
+ server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
1500
+ const targetCollection = this.storage.getCollection(def.target) ?? [];
1501
+ const targetItem = findById(targetCollection, req.params.id);
1502
+ if (!targetItem) return reply.status(404).send({ error: "Not found" });
1503
+ const pivot = this.storage.getCollection(def.through) ?? [];
1504
+ const matchingIds = new Set(
1505
+ pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
1506
+ );
1507
+ return (this.storage.getCollection(source) ?? []).filter(
1508
+ (t) => matchingIds.has(String(t["id"]))
1509
+ );
1510
+ });
1511
+ }
1294
1512
  };
1295
1513
 
1296
1514
  // src/router/routes/snapshot.routes.ts
@@ -1453,7 +1671,7 @@ function buildOptions(opts) {
1453
1671
  }
1454
1672
  function createInMemoryStorage(data) {
1455
1673
  const raw = data;
1456
- const relations = raw["_rel"] ?? {};
1674
+ const relations = parseRelations(raw["_rel"]);
1457
1675
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
1458
1676
  const collections = Object.fromEntries(
1459
1677
  Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")