@yrest/cli 0.8.1 → 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,6 +249,7 @@ 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();
@@ -251,6 +303,9 @@ var import_node_fs2 = require("fs");
251
303
  var import_node_path = require("path");
252
304
  var import_node_url = require("url");
253
305
 
306
+ // src/router/templates/about.helpers.ts
307
+ init_cjs_shims();
308
+
254
309
  // src/utils/interpolate.ts
255
310
  init_cjs_shims();
256
311
  var import_node_crypto = require("crypto");
@@ -298,16 +353,7 @@ function hasTemplates(value) {
298
353
  return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
299
354
  }
300
355
 
301
- // src/router/templates/about.template.ts
302
- var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
303
- var LOGO_SRC = (() => {
304
- try {
305
- const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
306
- return `data:image/png;base64,${buf.toString("base64")}`;
307
- } catch {
308
- return "";
309
- }
310
- })();
356
+ // src/router/templates/about.helpers.ts
311
357
  var METHOD_COLOR = {
312
358
  GET: "#3fb950",
313
359
  POST: "#58a6ff",
@@ -372,6 +418,139 @@ function resourceAccordion(name, base, isOpen) {
372
418
  </table>
373
419
  </details>`;
374
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
+ }
375
554
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
376
555
  const examples = [];
377
556
  const firstCol = collections[0];
@@ -402,35 +581,36 @@ curl -X DELETE ${p}/1`
402
581
  const firstRel = Object.entries(relations)[0];
403
582
  if (firstRel) {
404
583
  const [child, fields] = firstRel;
405
- const fk = Object.keys(fields)[0];
406
- const expandKey = fk.replace(/Id$/i, "");
407
- examples.push(
408
- `# Embed parent with ?_expand
409
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
410
- );
411
- }
412
- const firstRelEntry = Object.entries(relations)[0];
413
- if (firstRelEntry) {
414
- const [child, fields] = firstRelEntry;
415
- const parent = Object.values(fields)[0];
416
- if (parent) {
417
- examples.push(`# Nested resource
418
- 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
+ }
419
599
  }
420
600
  }
421
601
  if (options.pageable.enabled && firstCol) {
422
602
  examples.push(`# Pageable envelope
423
603
  curl "${host}${base}/${firstCol}?_page=2"`);
424
604
  }
425
- const firstParentRel = Object.entries(relations).find(
426
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
427
- );
428
605
  if (firstCol) {
429
606
  examples.push(
430
607
  `# Project fields with ?_fields
431
608
  curl "${host}${base}/${firstCol}?_fields=id,name"`
432
609
  );
433
610
  }
611
+ const firstParentRel = Object.entries(relations).find(
612
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
613
+ );
434
614
  if (firstParentRel && firstCol) {
435
615
  const [childName] = firstParentRel;
436
616
  examples.push(
@@ -456,9 +636,21 @@ curl ${curlFlag}${fullPath}`);
456
636
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
457
637
  return `<pre>${highlighted}</pre>`;
458
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
+ })();
459
650
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
460
651
  const collections = Object.keys(storage.getData());
461
652
  const relations = storage.getRelations();
653
+ const customRoutes = storage.getRoutes();
462
654
  const base = options.base;
463
655
  const host = `http://${options.host}:${options.port}`;
464
656
  const modes = [];
@@ -472,105 +664,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
472
664
  if (options.idStrategy !== "increment")
473
665
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
474
666
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
475
- const nestedRows = [];
476
- for (const [child, fields] of Object.entries(relations)) {
477
- for (const [, parent] of Object.entries(fields)) {
478
- const nestedPath = `${base}/${parent}/:id/${child}`;
479
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
480
- nestedRows.push(
481
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
482
- );
483
- }
484
- }
485
- const nestedAccordion = nestedRows.length ? `
486
- <details class="resource-card nested-card">
487
- <summary>
488
- <span class="resource-name">Nested routes</span>
489
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
490
- </summary>
491
- <table><tbody>${nestedRows.join("")}</tbody></table>
492
- </details>` : "";
493
- const snapshotAccordion = options.snapshot ? `
494
- <details class="resource-card nested-card">
495
- <summary>
496
- <span class="resource-name">/_snapshot</span>
497
- <span class="route-count">3 routes</span>
498
- </summary>
499
- <table><tbody>
500
- ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
501
- ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
502
- ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
503
- </tbody></table>
504
- </details>` : "";
505
- const customRoutes = storage.getRoutes();
506
- const customRoutesAccordion = customRoutes.length ? `
507
- <details class="resource-card nested-card">
508
- <summary>
509
- <span class="resource-name">Custom routes</span>
510
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
511
- </summary>
512
- <table><tbody>
513
- ${customRoutes.map((r) => {
514
- const fullPath = `${base}${r.path}`;
515
- const tags = [];
516
- if (r.error) {
517
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
518
- }
519
- if (r.delay && r.delay > 0) {
520
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
521
- }
522
- if (r.scenarios?.length) {
523
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
524
- tags.push(
525
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
526
- );
527
- }
528
- if (r.otherwise) {
529
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
530
- }
531
- let desc;
532
- if (r.error) {
533
- desc = `Error injection \u2014 <code>${r.error}</code>`;
534
- } else if (r.handler) {
535
- const found = handlers.has(r.handler);
536
- const handlerName = escapeHtml(r.handler);
537
- desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
538
- } else if (r.scenarios?.length) {
539
- const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
540
- desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
541
- } else if (r.response?.body != null && hasTemplates(r.response.body)) {
542
- desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
543
- } else {
544
- const status = r.response?.status ?? 200;
545
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
546
- }
547
- if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
548
- return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
549
- }).join("")}
550
- </tbody></table>
551
- </details>` : "";
552
- const routesByHandler = /* @__PURE__ */ new Map();
553
- for (const r of customRoutes) {
554
- if (r.handler) {
555
- const list = routesByHandler.get(r.handler) ?? [];
556
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
557
- routesByHandler.set(r.handler, list);
558
- }
559
- }
560
- const handlersAccordion = handlers.size > 0 ? `
561
- <details class="resource-card nested-card">
562
- <summary>
563
- <span class="resource-name">Handlers</span>
564
- <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
565
- </summary>
566
- <table><tbody>
567
- ${[...handlers.keys()].map((name) => {
568
- const routes = routesByHandler.get(name);
569
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
570
- return endpointRow("fn", name + "()", routeDesc);
571
- }).join("")}
572
- </tbody></table>
573
- </details>` : "";
574
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.`;
575
668
  return `<!DOCTYPE html>
576
669
  <html lang="en">
@@ -718,10 +811,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
718
811
  <h2>Endpoints</h2>
719
812
  <div class="endpoints-grid">
720
813
  ${accordions}
721
- ${nestedAccordion}
722
- ${snapshotAccordion}
723
- ${customRoutesAccordion}
724
- ${handlersAccordion}
814
+ ${nestedRoutesAccordion(relations, base)}
815
+ ${options.snapshot ? snapshotAccordion() : ""}
816
+ ${customRoutesAccordion(customRoutes, base, handlers)}
817
+ ${handlersAccordion(handlers, customRoutes, base)}
725
818
  </div>
726
819
 
727
820
  <h2>Query Parameters</h2>
@@ -928,10 +1021,11 @@ function expandItems(input, query, resource, storage) {
928
1021
  const resourceRelations = storage.getRelations()[resource] ?? {};
929
1022
  const expansions = /* @__PURE__ */ new Map();
930
1023
  for (const expandKey of keys) {
931
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
1024
+ for (const [field, def] of Object.entries(resourceRelations)) {
1025
+ if (def.type === "many2many") continue;
932
1026
  const derivedKey = field.replace(/Id$/i, "");
933
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
934
- expansions.set(expandKey, { field, parentCollection });
1027
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
1028
+ expansions.set(expandKey, { field, parentCollection: def.target });
935
1029
  break;
936
1030
  }
937
1031
  }
@@ -960,10 +1054,24 @@ function embedItems(input, query, resource, storage) {
960
1054
  const relations = storage.getRelations();
961
1055
  const embeds = /* @__PURE__ */ new Map();
962
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
+ }
963
1071
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
964
- for (const [fkField, parentCollection] of Object.entries(fields)) {
965
- if (parentCollection === resource && childCollection === embedKey) {
966
- 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 });
967
1075
  break outer;
968
1076
  }
969
1077
  }
@@ -972,10 +1080,57 @@ function embedItems(input, query, resource, storage) {
972
1080
  if (embeds.size === 0) return isArray ? items : input;
973
1081
  const result = items.map((item) => {
974
1082
  const out = { ...item };
975
- for (const [embedKey, { childCollection, fkField }] of embeds) {
976
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
977
- (child) => String(child[fkField]) === String(item["id"])
978
- );
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
+ }
979
1134
  }
980
1135
  return out;
981
1136
  });
@@ -1015,7 +1170,12 @@ var CollectionRouteCommand = class {
1015
1170
  const totalPages = Math.ceil(totalItems / limit) || 1;
1016
1171
  const data = projectFields(
1017
1172
  embedItems(
1018
- 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
+ ),
1019
1179
  req.query,
1020
1180
  this.resource,
1021
1181
  this.storage
@@ -1047,7 +1207,12 @@ var CollectionRouteCommand = class {
1047
1207
  }
1048
1208
  return projectFields(
1049
1209
  embedItems(
1050
- 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
+ ),
1051
1216
  req.query,
1052
1217
  this.resource,
1053
1218
  this.storage
@@ -1233,7 +1398,12 @@ var ItemRouteCommand = class {
1233
1398
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1234
1399
  return projectFields(
1235
1400
  embedItems(
1236
- 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
+ ),
1237
1407
  req.query,
1238
1408
  this.resource,
1239
1409
  this.storage
@@ -1277,32 +1447,68 @@ var NestedRouteCommand = class {
1277
1447
  relations;
1278
1448
  base;
1279
1449
  register(server) {
1280
- for (const [child, fields] of Object.entries(this.relations)) {
1281
- for (const [field, parent] of Object.entries(fields)) {
1282
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1283
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1284
- server.get(collectionPath, (req, reply) => {
1285
- const parentCollection = this.storage.getCollection(parent) ?? [];
1286
- const parentItem = findById(parentCollection, req.params.id);
1287
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1288
- const children = (this.storage.getCollection(child) ?? []).filter(
1289
- (item) => String(item[field]) === req.params.id
1290
- );
1291
- return children;
1292
- });
1293
- server.get(itemPath, (req, reply) => {
1294
- const parentCollection = this.storage.getCollection(parent) ?? [];
1295
- const parentItem = findById(parentCollection, req.params.id);
1296
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1297
- const childItem = (this.storage.getCollection(child) ?? []).find(
1298
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1299
- );
1300
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1301
- return childItem;
1302
- });
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
+ }
1303
1457
  }
1304
1458
  }
1305
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
+ }
1306
1512
  };
1307
1513
 
1308
1514
  // src/router/routes/snapshot.routes.ts
@@ -1465,7 +1671,7 @@ function buildOptions(opts) {
1465
1671
  }
1466
1672
  function createInMemoryStorage(data) {
1467
1673
  const raw = data;
1468
- const relations = raw["_rel"] ?? {};
1674
+ const relations = parseRelations(raw["_rel"]);
1469
1675
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
1470
1676
  const collections = Object.fromEntries(
1471
1677
  Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")