@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.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,6 +214,7 @@ 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
@@ -217,6 +269,9 @@ import { readFileSync } from "fs";
217
269
  import { dirname, join } from "path";
218
270
  import { fileURLToPath as fileURLToPath2 } from "url";
219
271
 
272
+ // src/router/templates/about.helpers.ts
273
+ init_esm_shims();
274
+
220
275
  // src/utils/interpolate.ts
221
276
  init_esm_shims();
222
277
  import { randomUUID } from "crypto";
@@ -264,16 +319,7 @@ function hasTemplates(value) {
264
319
  return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
265
320
  }
266
321
 
267
- // src/router/templates/about.template.ts
268
- var _dir = dirname(fileURLToPath2(import.meta.url));
269
- var LOGO_SRC = (() => {
270
- try {
271
- const buf = readFileSync(join(_dir, "../../assets/logo-color.png"));
272
- return `data:image/png;base64,${buf.toString("base64")}`;
273
- } catch {
274
- return "";
275
- }
276
- })();
322
+ // src/router/templates/about.helpers.ts
277
323
  var METHOD_COLOR = {
278
324
  GET: "#3fb950",
279
325
  POST: "#58a6ff",
@@ -338,6 +384,139 @@ function resourceAccordion(name, base, isOpen) {
338
384
  </table>
339
385
  </details>`;
340
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
+ }
341
520
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
342
521
  const examples = [];
343
522
  const firstCol = collections[0];
@@ -368,35 +547,36 @@ curl -X DELETE ${p}/1`
368
547
  const firstRel = Object.entries(relations)[0];
369
548
  if (firstRel) {
370
549
  const [child, fields] = firstRel;
371
- const fk = Object.keys(fields)[0];
372
- const expandKey = fk.replace(/Id$/i, "");
373
- examples.push(
374
- `# Embed parent with ?_expand
375
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
376
- );
377
- }
378
- const firstRelEntry = Object.entries(relations)[0];
379
- if (firstRelEntry) {
380
- const [child, fields] = firstRelEntry;
381
- const parent = Object.values(fields)[0];
382
- if (parent) {
383
- examples.push(`# Nested resource
384
- 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
+ }
385
565
  }
386
566
  }
387
567
  if (options.pageable.enabled && firstCol) {
388
568
  examples.push(`# Pageable envelope
389
569
  curl "${host}${base}/${firstCol}?_page=2"`);
390
570
  }
391
- const firstParentRel = Object.entries(relations).find(
392
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
393
- );
394
571
  if (firstCol) {
395
572
  examples.push(
396
573
  `# Project fields with ?_fields
397
574
  curl "${host}${base}/${firstCol}?_fields=id,name"`
398
575
  );
399
576
  }
577
+ const firstParentRel = Object.entries(relations).find(
578
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
579
+ );
400
580
  if (firstParentRel && firstCol) {
401
581
  const [childName] = firstParentRel;
402
582
  examples.push(
@@ -422,9 +602,21 @@ curl ${curlFlag}${fullPath}`);
422
602
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
423
603
  return `<pre>${highlighted}</pre>`;
424
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
+ })();
425
616
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
426
617
  const collections = Object.keys(storage.getData());
427
618
  const relations = storage.getRelations();
619
+ const customRoutes = storage.getRoutes();
428
620
  const base = options.base;
429
621
  const host = `http://${options.host}:${options.port}`;
430
622
  const modes = [];
@@ -438,105 +630,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
438
630
  if (options.idStrategy !== "increment")
439
631
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
440
632
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
441
- const nestedRows = [];
442
- for (const [child, fields] of Object.entries(relations)) {
443
- for (const [, parent] of Object.entries(fields)) {
444
- const nestedPath = `${base}/${parent}/:id/${child}`;
445
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
446
- nestedRows.push(
447
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
448
- );
449
- }
450
- }
451
- const nestedAccordion = nestedRows.length ? `
452
- <details class="resource-card nested-card">
453
- <summary>
454
- <span class="resource-name">Nested routes</span>
455
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
456
- </summary>
457
- <table><tbody>${nestedRows.join("")}</tbody></table>
458
- </details>` : "";
459
- const snapshotAccordion = options.snapshot ? `
460
- <details class="resource-card nested-card">
461
- <summary>
462
- <span class="resource-name">/_snapshot</span>
463
- <span class="route-count">3 routes</span>
464
- </summary>
465
- <table><tbody>
466
- ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
467
- ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
468
- ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
469
- </tbody></table>
470
- </details>` : "";
471
- const customRoutes = storage.getRoutes();
472
- const customRoutesAccordion = customRoutes.length ? `
473
- <details class="resource-card nested-card">
474
- <summary>
475
- <span class="resource-name">Custom routes</span>
476
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
477
- </summary>
478
- <table><tbody>
479
- ${customRoutes.map((r) => {
480
- const fullPath = `${base}${r.path}`;
481
- const tags = [];
482
- if (r.error) {
483
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
484
- }
485
- if (r.delay && r.delay > 0) {
486
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
487
- }
488
- if (r.scenarios?.length) {
489
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
490
- tags.push(
491
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
492
- );
493
- }
494
- if (r.otherwise) {
495
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
496
- }
497
- let desc;
498
- if (r.error) {
499
- desc = `Error injection \u2014 <code>${r.error}</code>`;
500
- } else if (r.handler) {
501
- const found = handlers.has(r.handler);
502
- const handlerName = escapeHtml(r.handler);
503
- desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
504
- } else if (r.scenarios?.length) {
505
- const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
506
- desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
507
- } else if (r.response?.body != null && hasTemplates(r.response.body)) {
508
- desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
509
- } else {
510
- const status = r.response?.status ?? 200;
511
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
512
- }
513
- if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
514
- return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
515
- }).join("")}
516
- </tbody></table>
517
- </details>` : "";
518
- const routesByHandler = /* @__PURE__ */ new Map();
519
- for (const r of customRoutes) {
520
- if (r.handler) {
521
- const list = routesByHandler.get(r.handler) ?? [];
522
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
523
- routesByHandler.set(r.handler, list);
524
- }
525
- }
526
- const handlersAccordion = handlers.size > 0 ? `
527
- <details class="resource-card nested-card">
528
- <summary>
529
- <span class="resource-name">Handlers</span>
530
- <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
531
- </summary>
532
- <table><tbody>
533
- ${[...handlers.keys()].map((name) => {
534
- const routes = routesByHandler.get(name);
535
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
536
- return endpointRow("fn", name + "()", routeDesc);
537
- }).join("")}
538
- </tbody></table>
539
- </details>` : "";
540
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.`;
541
634
  return `<!DOCTYPE html>
542
635
  <html lang="en">
@@ -684,10 +777,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
684
777
  <h2>Endpoints</h2>
685
778
  <div class="endpoints-grid">
686
779
  ${accordions}
687
- ${nestedAccordion}
688
- ${snapshotAccordion}
689
- ${customRoutesAccordion}
690
- ${handlersAccordion}
780
+ ${nestedRoutesAccordion(relations, base)}
781
+ ${options.snapshot ? snapshotAccordion() : ""}
782
+ ${customRoutesAccordion(customRoutes, base, handlers)}
783
+ ${handlersAccordion(handlers, customRoutes, base)}
691
784
  </div>
692
785
 
693
786
  <h2>Query Parameters</h2>
@@ -894,10 +987,11 @@ function expandItems(input, query, resource, storage) {
894
987
  const resourceRelations = storage.getRelations()[resource] ?? {};
895
988
  const expansions = /* @__PURE__ */ new Map();
896
989
  for (const expandKey of keys) {
897
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
990
+ for (const [field, def] of Object.entries(resourceRelations)) {
991
+ if (def.type === "many2many") continue;
898
992
  const derivedKey = field.replace(/Id$/i, "");
899
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
900
- expansions.set(expandKey, { field, parentCollection });
993
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
994
+ expansions.set(expandKey, { field, parentCollection: def.target });
901
995
  break;
902
996
  }
903
997
  }
@@ -926,10 +1020,24 @@ function embedItems(input, query, resource, storage) {
926
1020
  const relations = storage.getRelations();
927
1021
  const embeds = /* @__PURE__ */ new Map();
928
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
+ }
929
1037
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
930
- for (const [fkField, parentCollection] of Object.entries(fields)) {
931
- if (parentCollection === resource && childCollection === embedKey) {
932
- 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 });
933
1041
  break outer;
934
1042
  }
935
1043
  }
@@ -938,10 +1046,57 @@ function embedItems(input, query, resource, storage) {
938
1046
  if (embeds.size === 0) return isArray ? items : input;
939
1047
  const result = items.map((item) => {
940
1048
  const out = { ...item };
941
- for (const [embedKey, { childCollection, fkField }] of embeds) {
942
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
943
- (child) => String(child[fkField]) === String(item["id"])
944
- );
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
+ }
945
1100
  }
946
1101
  return out;
947
1102
  });
@@ -981,7 +1136,12 @@ var CollectionRouteCommand = class {
981
1136
  const totalPages = Math.ceil(totalItems / limit) || 1;
982
1137
  const data = projectFields(
983
1138
  embedItems(
984
- 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
+ ),
985
1145
  req.query,
986
1146
  this.resource,
987
1147
  this.storage
@@ -1013,7 +1173,12 @@ var CollectionRouteCommand = class {
1013
1173
  }
1014
1174
  return projectFields(
1015
1175
  embedItems(
1016
- 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
+ ),
1017
1182
  req.query,
1018
1183
  this.resource,
1019
1184
  this.storage
@@ -1199,7 +1364,12 @@ var ItemRouteCommand = class {
1199
1364
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1200
1365
  return projectFields(
1201
1366
  embedItems(
1202
- 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
+ ),
1203
1373
  req.query,
1204
1374
  this.resource,
1205
1375
  this.storage
@@ -1243,32 +1413,68 @@ var NestedRouteCommand = class {
1243
1413
  relations;
1244
1414
  base;
1245
1415
  register(server) {
1246
- for (const [child, fields] of Object.entries(this.relations)) {
1247
- for (const [field, parent] of Object.entries(fields)) {
1248
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1249
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1250
- server.get(collectionPath, (req, reply) => {
1251
- const parentCollection = this.storage.getCollection(parent) ?? [];
1252
- const parentItem = findById(parentCollection, req.params.id);
1253
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1254
- const children = (this.storage.getCollection(child) ?? []).filter(
1255
- (item) => String(item[field]) === req.params.id
1256
- );
1257
- return children;
1258
- });
1259
- server.get(itemPath, (req, reply) => {
1260
- const parentCollection = this.storage.getCollection(parent) ?? [];
1261
- const parentItem = findById(parentCollection, req.params.id);
1262
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1263
- const childItem = (this.storage.getCollection(child) ?? []).find(
1264
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1265
- );
1266
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1267
- return childItem;
1268
- });
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
+ }
1269
1423
  }
1270
1424
  }
1271
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
+ }
1272
1478
  };
1273
1479
 
1274
1480
  // src/router/routes/snapshot.routes.ts
@@ -1431,7 +1637,7 @@ function buildOptions(opts) {
1431
1637
  }
1432
1638
  function createInMemoryStorage(data) {
1433
1639
  const raw = data;
1434
- const relations = raw["_rel"] ?? {};
1640
+ const relations = parseRelations(raw["_rel"]);
1435
1641
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
1436
1642
  const collections = Object.fromEntries(
1437
1643
  Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")