@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/cli/index.js CHANGED
@@ -36,62 +36,470 @@ var import_node_fs = require("fs");
36
36
  var import_node_path = require("path");
37
37
 
38
38
  // src/cli/commands/templates/basic.ts
39
- var basicTemplate = `users:
39
+ var basicTemplate = `# yrest basic sample
40
+ # Run: npx @yrest/cli serve db.yml
41
+ # Docs: GET http://localhost:3070/_about
42
+
43
+ users:
40
44
  - id: 1
41
- name: Ana
42
- email: ana@test.com
45
+ name: Ana Garc\xEDa
46
+ email: ana@example.com
47
+ role: admin
48
+ active: true
43
49
  - id: 2
44
- name: Luis
45
- email: luis@test.com
50
+ name: Luis Mart\xEDnez
51
+ email: luis@example.com
52
+ role: editor
53
+ active: true
54
+ - id: 3
55
+ name: Sara L\xF3pez
56
+ email: sara@example.com
57
+ role: user
58
+ active: true
59
+ - id: 4
60
+ name: Diego Ruiz
61
+ email: diego@example.com
62
+ role: user
63
+ active: false
46
64
 
47
65
  products:
48
66
  - id: 1
49
- name: Laptop
50
- price: 999
67
+ name: Laptop Pro 15
68
+ price: 1299.99
69
+ stock: 15
70
+ category: electronics
71
+ featured: true
72
+ - id: 2
73
+ name: Wireless Mouse
74
+ price: 39.99
75
+ stock: 80
76
+ category: accessories
77
+ featured: false
78
+ - id: 3
79
+ name: Mechanical Keyboard
80
+ price: 129.99
81
+ stock: 45
82
+ category: accessories
83
+ featured: true
84
+ - id: 4
85
+ name: 4K Monitor 27"
86
+ price: 549.99
87
+ stock: 20
88
+ category: electronics
89
+ featured: true
90
+ - id: 5
91
+ name: USB-C Hub 7-in-1
92
+ price: 49.99
93
+ stock: 100
94
+ category: accessories
95
+ featured: false
96
+
97
+ categories:
98
+ - id: 1
99
+ name: Electronics
100
+ slug: electronics
101
+ description: Laptops, monitors and computing gear
51
102
  - id: 2
52
- name: Phone
53
- price: 499
103
+ name: Accessories
104
+ slug: accessories
105
+ description: Peripherals and add-ons
106
+
107
+ # Try these queries:
108
+ # GET /users?role=admin
109
+ # GET /products?featured=true&_sort=price&_order=asc
110
+ # GET /products?price_lte=100
111
+ # GET /users?_q=garcia
112
+ # GET /products?_fields=id,name,price&_page=1&_limit=3
54
113
  `;
55
114
 
56
115
  // src/cli/commands/templates/relational.ts
57
- var relationalTemplate = `_rel:
116
+ var relationalTemplate = `# yrest relational sample \u2014 blog
117
+ # Demonstrates: many2one, one2one and many2many relationships
118
+ # Run: npx @yrest/cli serve db.yml
119
+ # Docs: GET http://localhost:3070/_about
120
+
121
+ _rel:
122
+ # many2one \u2014 posts and comments belong to a user
58
123
  posts:
59
124
  userId: users
125
+ # many2many \u2014 posts can have multiple tags via the post_tags pivot
126
+ tags:
127
+ type: many2many
128
+ target: tags
129
+ through: post_tags
130
+ foreignKey: postId
131
+ otherKey: tagId
60
132
  comments:
61
133
  postId: posts
134
+ userId: users
135
+
136
+ # one2one \u2014 each user has exactly one profile
137
+ profiles:
138
+ userId:
139
+ type: one2one
140
+ target: users
62
141
 
63
142
  users:
64
143
  - id: 1
65
- name: Ana
66
- email: ana@test.com
144
+ name: Ana Garc\xEDa
145
+ email: ana@example.com
146
+ role: author
67
147
  - id: 2
68
- name: Luis
69
- email: luis@test.com
148
+ name: Luis Mart\xEDnez
149
+ email: luis@example.com
150
+ role: author
151
+ - id: 3
152
+ name: Sara L\xF3pez
153
+ email: sara@example.com
154
+ role: reader
155
+
156
+ profiles:
157
+ - id: 1
158
+ userId: 1
159
+ bio: Full-stack developer and open-source enthusiast
160
+ avatar: https://i.pravatar.cc/150?img=1
161
+ website: https://ana.dev
162
+ - id: 2
163
+ userId: 2
164
+ bio: Backend engineer, coffee addict
165
+ avatar: https://i.pravatar.cc/150?img=2
166
+ website: https://luisdev.io
167
+ - id: 3
168
+ userId: 3
169
+ bio: Designer turned frontend developer
170
+ avatar: https://i.pravatar.cc/150?img=3
171
+ website: null
172
+
173
+ tags:
174
+ - id: 1
175
+ name: typescript
176
+ color: "#3178c6"
177
+ - id: 2
178
+ name: api
179
+ color: "#10b981"
180
+ - id: 3
181
+ name: testing
182
+ color: "#f59e0b"
183
+ - id: 4
184
+ name: devtools
185
+ color: "#8b5cf6"
186
+ - id: 5
187
+ name: yaml
188
+ color: "#ef4444"
70
189
 
71
190
  posts:
72
191
  - id: 1
73
- title: First post
74
- body: Content of the first post
192
+ title: Getting started with TypeScript
193
+ slug: getting-started-typescript
194
+ body: TypeScript adds static typing to JavaScript, catching errors at compile time...
75
195
  userId: 1
196
+ published: true
197
+ views: 1420
198
+ createdAt: "2024-11-01"
76
199
  - id: 2
77
- title: Second post
78
- body: Content of the second post
200
+ title: Building REST APIs with Fastify
201
+ slug: rest-apis-fastify
202
+ body: Fastify is the fastest Node.js web framework, perfect for building APIs...
79
203
  userId: 1
204
+ published: true
205
+ views: 980
206
+ createdAt: "2024-11-15"
207
+ - id: 3
208
+ title: Testing strategies for modern apps
209
+ slug: testing-strategies-modern
210
+ body: A solid test strategy covers unit, integration and end-to-end scenarios...
211
+ userId: 2
212
+ published: true
213
+ views: 640
214
+ createdAt: "2024-12-03"
215
+ - id: 4
216
+ title: YAML as a database format
217
+ slug: yaml-database-format
218
+ body: YAML is human-readable and expressive enough for mock data during development...
219
+ userId: 2
220
+ published: false
221
+ views: 0
222
+ createdAt: "2025-01-10"
223
+
224
+ # pivot table for posts \u2194 tags (many2many)
225
+ post_tags:
226
+ - { id: 1, postId: 1, tagId: 1 }
227
+ - { id: 2, postId: 1, tagId: 2 }
228
+ - { id: 3, postId: 2, tagId: 2 }
229
+ - { id: 4, postId: 2, tagId: 4 }
230
+ - { id: 5, postId: 3, tagId: 3 }
231
+ - { id: 6, postId: 3, tagId: 1 }
232
+ - { id: 7, postId: 4, tagId: 5 }
233
+ - { id: 8, postId: 4, tagId: 2 }
80
234
 
81
235
  comments:
82
236
  - id: 1
83
- body: Great post!
237
+ body: Great introduction! This helped me a lot.
84
238
  postId: 1
239
+ userId: 3
240
+ likes: 5
85
241
  - id: 2
86
- body: Thanks for sharing
242
+ body: Could you cover generics in a follow-up post?
87
243
  postId: 1
244
+ userId: 2
245
+ likes: 3
246
+ - id: 3
247
+ body: Fastify is indeed much faster than Express in my benchmarks.
248
+ postId: 2
249
+ userId: 3
250
+ likes: 8
251
+ - id: 4
252
+ body: Do you have a GitHub repo with these examples?
253
+ postId: 2
254
+ userId: 1
255
+ likes: 1
256
+ - id: 5
257
+ body: E2E tests are underrated. Solid post!
258
+ postId: 3
259
+ userId: 1
260
+ likes: 4
261
+
262
+ # Try these queries:
263
+ # GET /posts?published=true&_sort=views&_order=desc
264
+ # GET /posts/1?_expand=user \u2192 embeds author object
265
+ # GET /users/1?_embed=posts \u2192 embeds posts array
266
+ # GET /users/1/profiles \u2192 one2one nested route
267
+ # GET /posts/1/tags \u2192 many2many nested route
268
+ # GET /posts/1?_embed=tags \u2192 many2many via ?_embed
269
+ # GET /users/1?_embed=profiles \u2192 one2one via ?_embed (returns object, not array)
270
+ `;
271
+
272
+ // src/cli/commands/templates/ecommerce.ts
273
+ var ecommerceTemplate = `# yrest ecommerce sample
274
+ # Demonstrates: many2one, many2many, _routes with scenarios, template vars and delay
275
+ # Run: npx @yrest/cli serve db.yml
276
+ # Docs: GET http://localhost:3070/_about
277
+
278
+ _rel:
279
+ # many2one
280
+ orders:
281
+ userId: users
282
+ order_items:
283
+ orderId: orders
284
+ productId: products
285
+
286
+ # many2many \u2014 products belong to multiple categories via pivot
287
+ products:
288
+ categories:
289
+ type: many2many
290
+ target: categories
291
+ through: product_categories
292
+ foreignKey: productId
293
+ otherKey: categoryId
294
+
295
+ _routes:
296
+ # Login with conditional scenarios
297
+ - method: POST
298
+ path: /auth/login
299
+ scenarios:
300
+ - when:
301
+ body.email: admin@example.com
302
+ body.password: secret
303
+ response:
304
+ status: 200
305
+ body:
306
+ token: tok-admin-abc123
307
+ role: admin
308
+ userId: 1
309
+ - when:
310
+ body.email: user@example.com
311
+ body.password: secret
312
+ response:
313
+ status: 200
314
+ body:
315
+ token: tok-user-xyz789
316
+ role: user
317
+ userId: 2
318
+ otherwise:
319
+ status: 401
320
+ body:
321
+ error: Invalid credentials
322
+
323
+ # Logout \u2014 always 204
324
+ - method: POST
325
+ path: /auth/logout
326
+ response:
327
+ status: 204
328
+
329
+ # Static featured products list
330
+ - method: GET
331
+ path: /store/featured
332
+ response:
333
+ status: 200
334
+ body:
335
+ - id: 1
336
+ name: Laptop Pro 15
337
+ price: 1299.99
338
+ badge: Best Seller
339
+ - id: 4
340
+ name: 4K Monitor 27"
341
+ price: 549.99
342
+ badge: New Arrival
343
+ - id: 3
344
+ name: Mechanical Keyboard
345
+ price: 129.99
346
+ badge: On Sale
347
+
348
+ # Template variables \u2014 echoes the requested product id
349
+ - method: GET
350
+ path: /products/:id/summary
351
+ response:
352
+ status: 200
353
+ body:
354
+ productId: "{{params.id}}"
355
+ requestedAt: "{{now}}"
356
+ source: mock
357
+
358
+ # Cancel order with simulated latency and template vars
359
+ - method: POST
360
+ path: /orders/:id/cancel
361
+ delay: 500
362
+ response:
363
+ status: 200
364
+ body:
365
+ orderId: "{{params.id}}"
366
+ status: cancelled
367
+ cancelledAt: "{{now}}"
368
+
369
+ # Simulate a service outage for testing error handling
370
+ - method: GET
371
+ path: /store/inventory/sync
372
+ error: 503
373
+ errorBody:
374
+ message: Inventory service temporarily unavailable
375
+ retryAfter: 30
376
+
377
+ users:
378
+ - id: 1
379
+ name: Ana Garc\xEDa
380
+ email: admin@example.com
381
+ role: admin
382
+ active: true
383
+ - id: 2
384
+ name: Luis Mart\xEDnez
385
+ email: user@example.com
386
+ role: user
387
+ active: true
388
+ - id: 3
389
+ name: Sara L\xF3pez
390
+ email: sara@example.com
391
+ role: user
392
+ active: true
393
+ - id: 4
394
+ name: Diego Ruiz
395
+ email: diego@example.com
396
+ role: user
397
+ active: false
398
+
399
+ categories:
400
+ - id: 1
401
+ name: Laptops
402
+ slug: laptops
403
+ - id: 2
404
+ name: Peripherals
405
+ slug: peripherals
406
+ - id: 3
407
+ name: Monitors
408
+ slug: monitors
409
+ - id: 4
410
+ name: Accessories
411
+ slug: accessories
412
+
413
+ products:
414
+ - id: 1
415
+ name: Laptop Pro 15
416
+ description: High-performance laptop for developers
417
+ price: 1299.99
418
+ stock: 15
419
+ sku: LAP-001
420
+ active: true
421
+ - id: 2
422
+ name: Wireless Mouse
423
+ description: Ergonomic wireless mouse with USB-C receiver
424
+ price: 39.99
425
+ stock: 80
426
+ sku: MOU-001
427
+ active: true
428
+ - id: 3
429
+ name: Mechanical Keyboard
430
+ description: Tactile switches, full RGB, TKL layout
431
+ price: 129.99
432
+ stock: 45
433
+ sku: KEY-001
434
+ active: true
435
+ - id: 4
436
+ name: 4K Monitor 27"
437
+ description: IPS panel, 144Hz, HDR400
438
+ price: 549.99
439
+ stock: 20
440
+ sku: MON-001
441
+ active: true
442
+ - id: 5
443
+ name: USB-C Hub 7-in-1
444
+ description: HDMI, SD card and USB-A ports
445
+ price: 49.99
446
+ stock: 100
447
+ sku: HUB-001
448
+ active: true
449
+
450
+ # pivot table for products \u2194 categories (many2many)
451
+ product_categories:
452
+ - { id: 1, productId: 1, categoryId: 1 }
453
+ - { id: 2, productId: 2, categoryId: 2 }
454
+ - { id: 3, productId: 2, categoryId: 4 }
455
+ - { id: 4, productId: 3, categoryId: 2 }
456
+ - { id: 5, productId: 3, categoryId: 4 }
457
+ - { id: 6, productId: 4, categoryId: 3 }
458
+ - { id: 7, productId: 5, categoryId: 4 }
459
+
460
+ orders:
461
+ - id: 1
462
+ userId: 2
463
+ status: delivered
464
+ total: 1339.98
465
+ createdAt: "2024-12-10"
466
+ - id: 2
467
+ userId: 3
468
+ status: processing
469
+ total: 179.98
470
+ createdAt: "2025-01-15"
471
+ - id: 3
472
+ userId: 2
473
+ status: pending
474
+ total: 549.99
475
+ createdAt: "2025-02-01"
476
+
477
+ order_items:
478
+ - { id: 1, orderId: 1, productId: 1, quantity: 1, unitPrice: 1299.99 }
479
+ - { id: 2, orderId: 1, productId: 2, quantity: 1, unitPrice: 39.99 }
480
+ - { id: 3, orderId: 2, productId: 3, quantity: 1, unitPrice: 129.99 }
481
+ - { id: 4, orderId: 2, productId: 2, quantity: 1, unitPrice: 39.99 }
482
+ - { id: 5, orderId: 3, productId: 4, quantity: 1, unitPrice: 549.99 }
483
+
484
+ # Try these queries:
485
+ # POST /auth/login { "email": "admin@example.com", "password": "secret" }
486
+ # GET /store/featured
487
+ # GET /products/1/summary
488
+ # GET /products/1/categories \u2192 many2many nested route
489
+ # GET /products/1?_embed=categories \u2192 many2many via ?_embed
490
+ # GET /users/2?_embed=orders \u2192 many2one ?_embed
491
+ # GET /users/2/orders \u2192 nested route
492
+ # GET /orders/1?_embed=order_items \u2192 nested items
493
+ # POST /orders/1/cancel \u2192 delayed response with template vars
494
+ # GET /store/inventory/sync \u2192 forced 503 error
88
495
  `;
89
496
 
90
497
  // src/cli/commands/templates/index.ts
91
- var SAMPLES = ["basic", "relational"];
498
+ var SAMPLES = ["basic", "relational", "ecommerce"];
92
499
  var templates = {
93
500
  basic: basicTemplate,
94
- relational: relationalTemplate
501
+ relational: relationalTemplate,
502
+ ecommerce: ecommerceTemplate
95
503
  };
96
504
 
97
505
  // src/cli/commands/init.ts
@@ -141,6 +549,50 @@ var import_node_path2 = require("path");
141
549
  var import_node_crypto = require("crypto");
142
550
  var import_yaml = require("yaml");
143
551
 
552
+ // src/storage/parseRelations.ts
553
+ function parseRelations(raw) {
554
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
555
+ const result = {};
556
+ for (const [collection, fields] of Object.entries(raw)) {
557
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
558
+ result[collection] = {};
559
+ for (const [key, value] of Object.entries(fields)) {
560
+ const def = normaliseRelationDef(key, value);
561
+ if (def) result[collection][key] = def;
562
+ }
563
+ }
564
+ return result;
565
+ }
566
+ function normaliseRelationDef(key, value) {
567
+ if (typeof value === "string") {
568
+ return { type: "many2one", target: value };
569
+ }
570
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
571
+ const v = value;
572
+ const type = v["type"];
573
+ const nested = v["nested"] === true ? true : void 0;
574
+ if (type === "many2one" || type === void 0) {
575
+ const target = v["target"];
576
+ if (typeof target !== "string") return null;
577
+ return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
578
+ }
579
+ if (type === "one2one") {
580
+ const target = v["target"];
581
+ if (typeof target !== "string") return null;
582
+ return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
583
+ }
584
+ if (type === "many2many") {
585
+ const target = typeof v["target"] === "string" ? v["target"] : key;
586
+ const through = v["through"];
587
+ const foreignKey = v["foreignKey"];
588
+ const otherKey = v["otherKey"];
589
+ if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
590
+ return null;
591
+ return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
592
+ }
593
+ return null;
594
+ }
595
+
144
596
  // src/utils/deepCopy.ts
145
597
  function deepCopyData(source) {
146
598
  return Object.fromEntries(
@@ -152,7 +604,7 @@ function deepCopyData(source) {
152
604
  function createYrestStorage(filePath) {
153
605
  const absPath = (0, import_node_path2.resolve)(filePath);
154
606
  const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
155
- const relations = raw["_rel"] ?? {};
607
+ const relations = parseRelations(raw["_rel"]);
156
608
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
157
609
  const data = Object.fromEntries(
158
610
  Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
@@ -189,7 +641,7 @@ function createYrestStorage(filePath) {
189
641
  },
190
642
  reload() {
191
643
  const fresh = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
192
- const freshRelations = fresh["_rel"] ?? {};
644
+ const freshRelations = parseRelations(fresh["_rel"]);
193
645
  const freshData = Object.fromEntries(
194
646
  Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
195
647
  );
@@ -274,16 +726,7 @@ function hasTemplates(value) {
274
726
  return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
275
727
  }
276
728
 
277
- // src/router/templates/about.template.ts
278
- var _dir = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
279
- var LOGO_SRC = (() => {
280
- try {
281
- const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/logo-color.png"));
282
- return `data:image/png;base64,${buf.toString("base64")}`;
283
- } catch {
284
- return "";
285
- }
286
- })();
729
+ // src/router/templates/about.helpers.ts
287
730
  var METHOD_COLOR = {
288
731
  GET: "#3fb950",
289
732
  POST: "#58a6ff",
@@ -348,6 +791,139 @@ function resourceAccordion(name, base, isOpen) {
348
791
  </table>
349
792
  </details>`;
350
793
  }
794
+ function nestedRoutesAccordion(relations, base) {
795
+ const rows = [];
796
+ for (const [source, fields] of Object.entries(relations)) {
797
+ for (const [key, def] of Object.entries(fields)) {
798
+ const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
799
+ if (def.type === "many2many") {
800
+ const singular = source.endsWith("s") ? source.slice(0, -1) : source;
801
+ const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
802
+ rows.push(
803
+ endpointRow(
804
+ "GET",
805
+ `${base}/${source}/:id/${key}`,
806
+ `List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
807
+ )
808
+ );
809
+ const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
810
+ rows.push(
811
+ endpointRow(
812
+ "GET",
813
+ `${base}/${def.target}/:id/${source}`,
814
+ `List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
815
+ )
816
+ );
817
+ } else {
818
+ const path = `${base}/${def.target}/:id/${source}`;
819
+ const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
820
+ const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
821
+ rows.push(
822
+ endpointRow(
823
+ "GET",
824
+ path,
825
+ `${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
826
+ )
827
+ );
828
+ }
829
+ }
830
+ }
831
+ if (!rows.length) return "";
832
+ return `
833
+ <details class="resource-card nested-card">
834
+ <summary>
835
+ <span class="resource-name">Nested routes</span>
836
+ <span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
837
+ </summary>
838
+ <table><tbody>${rows.join("")}</tbody></table>
839
+ </details>`;
840
+ }
841
+ function snapshotAccordion() {
842
+ return `
843
+ <details class="resource-card nested-card">
844
+ <summary>
845
+ <span class="resource-name">/_snapshot</span>
846
+ <span class="route-count">3 routes</span>
847
+ </summary>
848
+ <table><tbody>
849
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
850
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
851
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
852
+ </tbody></table>
853
+ </details>`;
854
+ }
855
+ function customRoutesAccordion(routes, base, handlers) {
856
+ if (!routes.length) return "";
857
+ const rows = routes.map((r) => {
858
+ const fullPath = `${base}${r.path}`;
859
+ const tags = [];
860
+ if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
861
+ if (r.delay && r.delay > 0)
862
+ tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
863
+ if (r.scenarios?.length) {
864
+ const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
865
+ tags.push(
866
+ `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
867
+ );
868
+ }
869
+ if (r.otherwise)
870
+ tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
871
+ let desc;
872
+ if (r.error) {
873
+ desc = `Error injection \u2014 <code>${r.error}</code>`;
874
+ } else if (r.handler) {
875
+ const found = handlers.has(r.handler);
876
+ const handlerName = escapeHtml(r.handler);
877
+ desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
878
+ } else if (r.scenarios?.length) {
879
+ const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
880
+ desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
881
+ } else if (r.response?.body != null && hasTemplates(r.response.body)) {
882
+ desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
883
+ } else {
884
+ const status = r.response?.status ?? 200;
885
+ desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
886
+ }
887
+ if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
888
+ return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
889
+ });
890
+ return `
891
+ <details class="resource-card nested-card">
892
+ <summary>
893
+ <span class="resource-name">Custom routes</span>
894
+ <span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
895
+ </summary>
896
+ <table><tbody>
897
+ ${rows.join("")}
898
+ </tbody></table>
899
+ </details>`;
900
+ }
901
+ function handlersAccordion(handlers, routes, base) {
902
+ if (!handlers.size) return "";
903
+ const routesByHandler = /* @__PURE__ */ new Map();
904
+ for (const r of routes) {
905
+ if (r.handler) {
906
+ const list = routesByHandler.get(r.handler) ?? [];
907
+ list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
908
+ routesByHandler.set(r.handler, list);
909
+ }
910
+ }
911
+ const rows = [...handlers.keys()].map((name) => {
912
+ const linked = routesByHandler.get(name);
913
+ const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
914
+ return endpointRow("fn", name + "()", routeDesc);
915
+ });
916
+ return `
917
+ <details class="resource-card nested-card">
918
+ <summary>
919
+ <span class="resource-name">Handlers</span>
920
+ <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
921
+ </summary>
922
+ <table><tbody>
923
+ ${rows.join("")}
924
+ </tbody></table>
925
+ </details>`;
926
+ }
351
927
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
352
928
  const examples = [];
353
929
  const firstCol = collections[0];
@@ -378,35 +954,36 @@ curl -X DELETE ${p}/1`
378
954
  const firstRel = Object.entries(relations)[0];
379
955
  if (firstRel) {
380
956
  const [child, fields] = firstRel;
381
- const fk = Object.keys(fields)[0];
382
- const expandKey = fk.replace(/Id$/i, "");
383
- examples.push(
384
- `# Embed parent with ?_expand
385
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
386
- );
387
- }
388
- const firstRelEntry = Object.entries(relations)[0];
389
- if (firstRelEntry) {
390
- const [child, fields] = firstRelEntry;
391
- const parent = Object.values(fields)[0];
392
- if (parent) {
393
- examples.push(`# Nested resource
394
- curl ${host}${base}/${parent}/1/${child}`);
957
+ const firstField = Object.entries(fields)[0];
958
+ if (firstField) {
959
+ const [fk, def] = firstField;
960
+ if (def.type !== "many2many") {
961
+ const expandKey = fk.replace(/Id$/i, "");
962
+ examples.push(
963
+ `# Embed parent with ?_expand
964
+ curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
965
+ `# Nested resource
966
+ curl ${host}${base}/${def.target}/1/${child}`
967
+ );
968
+ } else {
969
+ examples.push(`# Many-to-many embed
970
+ curl "${host}${base}/${child}/1/${fk}"`);
971
+ }
395
972
  }
396
973
  }
397
974
  if (options.pageable.enabled && firstCol) {
398
975
  examples.push(`# Pageable envelope
399
976
  curl "${host}${base}/${firstCol}?_page=2"`);
400
977
  }
401
- const firstParentRel = Object.entries(relations).find(
402
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
403
- );
404
978
  if (firstCol) {
405
979
  examples.push(
406
980
  `# Project fields with ?_fields
407
981
  curl "${host}${base}/${firstCol}?_fields=id,name"`
408
982
  );
409
983
  }
984
+ const firstParentRel = Object.entries(relations).find(
985
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
986
+ );
410
987
  if (firstParentRel && firstCol) {
411
988
  const [childName] = firstParentRel;
412
989
  examples.push(
@@ -432,9 +1009,21 @@ curl ${curlFlag}${fullPath}`);
432
1009
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
433
1010
  return `<pre>${highlighted}</pre>`;
434
1011
  }
1012
+
1013
+ // src/router/templates/about.template.ts
1014
+ var _dir = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
1015
+ var LOGO_SRC = (() => {
1016
+ try {
1017
+ const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/logo-color.png"));
1018
+ return `data:image/png;base64,${buf.toString("base64")}`;
1019
+ } catch {
1020
+ return "";
1021
+ }
1022
+ })();
435
1023
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
436
1024
  const collections = Object.keys(storage.getData());
437
1025
  const relations = storage.getRelations();
1026
+ const customRoutes = storage.getRoutes();
438
1027
  const base = options.base;
439
1028
  const host = `http://${options.host}:${options.port}`;
440
1029
  const modes = [];
@@ -448,105 +1037,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
448
1037
  if (options.idStrategy !== "increment")
449
1038
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
450
1039
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
451
- const nestedRows = [];
452
- for (const [child, fields] of Object.entries(relations)) {
453
- for (const [, parent] of Object.entries(fields)) {
454
- const nestedPath = `${base}/${parent}/:id/${child}`;
455
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
456
- nestedRows.push(
457
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
458
- );
459
- }
460
- }
461
- const nestedAccordion = nestedRows.length ? `
462
- <details class="resource-card nested-card">
463
- <summary>
464
- <span class="resource-name">Nested routes</span>
465
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
466
- </summary>
467
- <table><tbody>${nestedRows.join("")}</tbody></table>
468
- </details>` : "";
469
- const snapshotAccordion = options.snapshot ? `
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
- const customRoutes = storage.getRoutes();
482
- const customRoutesAccordion = customRoutes.length ? `
483
- <details class="resource-card nested-card">
484
- <summary>
485
- <span class="resource-name">Custom routes</span>
486
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
487
- </summary>
488
- <table><tbody>
489
- ${customRoutes.map((r) => {
490
- const fullPath = `${base}${r.path}`;
491
- const tags = [];
492
- if (r.error) {
493
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
494
- }
495
- if (r.delay && r.delay > 0) {
496
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
497
- }
498
- if (r.scenarios?.length) {
499
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
500
- tags.push(
501
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
502
- );
503
- }
504
- if (r.otherwise) {
505
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
506
- }
507
- let desc;
508
- if (r.error) {
509
- desc = `Error injection \u2014 <code>${r.error}</code>`;
510
- } else if (r.handler) {
511
- const found = handlers.has(r.handler);
512
- const handlerName = escapeHtml(r.handler);
513
- desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
514
- } else if (r.scenarios?.length) {
515
- const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
516
- desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
517
- } else if (r.response?.body != null && hasTemplates(r.response.body)) {
518
- desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
519
- } else {
520
- const status = r.response?.status ?? 200;
521
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
522
- }
523
- if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
524
- return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
525
- }).join("")}
526
- </tbody></table>
527
- </details>` : "";
528
- const routesByHandler = /* @__PURE__ */ new Map();
529
- for (const r of customRoutes) {
530
- if (r.handler) {
531
- const list = routesByHandler.get(r.handler) ?? [];
532
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
533
- routesByHandler.set(r.handler, list);
534
- }
535
- }
536
- const handlersAccordion = handlers.size > 0 ? `
537
- <details class="resource-card nested-card">
538
- <summary>
539
- <span class="resource-name">Handlers</span>
540
- <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
541
- </summary>
542
- <table><tbody>
543
- ${[...handlers.keys()].map((name) => {
544
- const routes = routesByHandler.get(name);
545
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
546
- return endpointRow("fn", name + "()", routeDesc);
547
- }).join("")}
548
- </tbody></table>
549
- </details>` : "";
550
1040
  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.`;
551
1041
  return `<!DOCTYPE html>
552
1042
  <html lang="en">
@@ -694,10 +1184,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
694
1184
  <h2>Endpoints</h2>
695
1185
  <div class="endpoints-grid">
696
1186
  ${accordions}
697
- ${nestedAccordion}
698
- ${snapshotAccordion}
699
- ${customRoutesAccordion}
700
- ${handlersAccordion}
1187
+ ${nestedRoutesAccordion(relations, base)}
1188
+ ${options.snapshot ? snapshotAccordion() : ""}
1189
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1190
+ ${handlersAccordion(handlers, customRoutes, base)}
701
1191
  </div>
702
1192
 
703
1193
  <h2>Query Parameters</h2>
@@ -897,10 +1387,11 @@ function expandItems(input, query, resource, storage) {
897
1387
  const resourceRelations = storage.getRelations()[resource] ?? {};
898
1388
  const expansions = /* @__PURE__ */ new Map();
899
1389
  for (const expandKey of keys) {
900
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
1390
+ for (const [field, def] of Object.entries(resourceRelations)) {
1391
+ if (def.type === "many2many") continue;
901
1392
  const derivedKey = field.replace(/Id$/i, "");
902
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
903
- expansions.set(expandKey, { field, parentCollection });
1393
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
1394
+ expansions.set(expandKey, { field, parentCollection: def.target });
904
1395
  break;
905
1396
  }
906
1397
  }
@@ -929,10 +1420,24 @@ function embedItems(input, query, resource, storage) {
929
1420
  const relations = storage.getRelations();
930
1421
  const embeds = /* @__PURE__ */ new Map();
931
1422
  for (const embedKey of keys) {
1423
+ const ownRelations = relations[resource] ?? {};
1424
+ if (embedKey in ownRelations) {
1425
+ const def = ownRelations[embedKey];
1426
+ if (def.type === "many2many") {
1427
+ embeds.set(embedKey, {
1428
+ kind: "many2many",
1429
+ target: def.target,
1430
+ through: def.through,
1431
+ foreignKey: def.foreignKey,
1432
+ otherKey: def.otherKey
1433
+ });
1434
+ continue;
1435
+ }
1436
+ }
932
1437
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
933
- for (const [fkField, parentCollection] of Object.entries(fields)) {
934
- if (parentCollection === resource && childCollection === embedKey) {
935
- embeds.set(embedKey, { childCollection, fkField });
1438
+ for (const [fkField, def] of Object.entries(fields)) {
1439
+ if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
1440
+ embeds.set(embedKey, { kind: def.type, childCollection, fkField });
936
1441
  break outer;
937
1442
  }
938
1443
  }
@@ -941,10 +1446,57 @@ function embedItems(input, query, resource, storage) {
941
1446
  if (embeds.size === 0) return isArray ? items : input;
942
1447
  const result = items.map((item) => {
943
1448
  const out = { ...item };
944
- for (const [embedKey, { childCollection, fkField }] of embeds) {
945
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
946
- (child) => String(child[fkField]) === String(item["id"])
947
- );
1449
+ for (const [embedKey, spec] of embeds) {
1450
+ if (spec.kind === "many2many") {
1451
+ const pivot = storage.getCollection(spec.through) ?? [];
1452
+ const matchingIds = new Set(
1453
+ pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
1454
+ );
1455
+ out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
1456
+ (t) => matchingIds.has(String(t["id"]))
1457
+ );
1458
+ } else if (spec.kind === "one2one") {
1459
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
1460
+ (child) => String(child[spec.fkField]) === String(item["id"])
1461
+ ) ?? null;
1462
+ } else {
1463
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
1464
+ (child) => String(child[spec.fkField]) === String(item["id"])
1465
+ );
1466
+ }
1467
+ }
1468
+ return out;
1469
+ });
1470
+ return isArray ? result : result[0];
1471
+ }
1472
+ function applyNested(input, resource, storage) {
1473
+ const isArray = Array.isArray(input);
1474
+ const items = isArray ? input : [input];
1475
+ const resourceRelations = storage.getRelations()[resource] ?? {};
1476
+ const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
1477
+ if (nestedDefs.length === 0) return input;
1478
+ const result = items.map((item) => {
1479
+ const out = { ...item };
1480
+ for (const [key, def] of nestedDefs) {
1481
+ if (def.type === "many2many") {
1482
+ const pivot = storage.getCollection(def.through) ?? [];
1483
+ const matchingIds = new Set(
1484
+ pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
1485
+ );
1486
+ out[key] = (storage.getCollection(def.target) ?? []).filter(
1487
+ (t) => matchingIds.has(String(t["id"]))
1488
+ );
1489
+ } else {
1490
+ const foreignKeyValue = item[key];
1491
+ if (foreignKeyValue === void 0) continue;
1492
+ const parent = (storage.getCollection(def.target) ?? []).find(
1493
+ (p) => String(p["id"]) === String(foreignKeyValue)
1494
+ );
1495
+ if (parent !== void 0) {
1496
+ const embedKey = key.replace(/Id$/i, "");
1497
+ out[embedKey] = parent;
1498
+ }
1499
+ }
948
1500
  }
949
1501
  return out;
950
1502
  });
@@ -984,7 +1536,12 @@ var CollectionRouteCommand = class {
984
1536
  const totalPages = Math.ceil(totalItems / limit) || 1;
985
1537
  const data = projectFields(
986
1538
  embedItems(
987
- expandItems(paginate(sorted, page, limit), req.query, this.resource, this.storage),
1539
+ expandItems(
1540
+ applyNested(paginate(sorted, page, limit), this.resource, this.storage),
1541
+ req.query,
1542
+ this.resource,
1543
+ this.storage
1544
+ ),
988
1545
  req.query,
989
1546
  this.resource,
990
1547
  this.storage
@@ -1016,7 +1573,12 @@ var CollectionRouteCommand = class {
1016
1573
  }
1017
1574
  return projectFields(
1018
1575
  embedItems(
1019
- expandItems(result, req.query, this.resource, this.storage),
1576
+ expandItems(
1577
+ applyNested(result, this.resource, this.storage),
1578
+ req.query,
1579
+ this.resource,
1580
+ this.storage
1581
+ ),
1020
1582
  req.query,
1021
1583
  this.resource,
1022
1584
  this.storage
@@ -1197,7 +1759,12 @@ var ItemRouteCommand = class {
1197
1759
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1198
1760
  return projectFields(
1199
1761
  embedItems(
1200
- expandItems(item, req.query, this.resource, this.storage),
1762
+ expandItems(
1763
+ applyNested(item, this.resource, this.storage),
1764
+ req.query,
1765
+ this.resource,
1766
+ this.storage
1767
+ ),
1201
1768
  req.query,
1202
1769
  this.resource,
1203
1770
  this.storage
@@ -1240,32 +1807,68 @@ var NestedRouteCommand = class {
1240
1807
  relations;
1241
1808
  base;
1242
1809
  register(server) {
1243
- for (const [child, fields] of Object.entries(this.relations)) {
1244
- for (const [field, parent] of Object.entries(fields)) {
1245
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1246
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1247
- server.get(collectionPath, (req, reply) => {
1248
- const parentCollection = this.storage.getCollection(parent) ?? [];
1249
- const parentItem = findById(parentCollection, req.params.id);
1250
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1251
- const children = (this.storage.getCollection(child) ?? []).filter(
1252
- (item) => String(item[field]) === req.params.id
1253
- );
1254
- return children;
1255
- });
1256
- server.get(itemPath, (req, reply) => {
1257
- const parentCollection = this.storage.getCollection(parent) ?? [];
1258
- const parentItem = findById(parentCollection, req.params.id);
1259
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1260
- const childItem = (this.storage.getCollection(child) ?? []).find(
1261
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1262
- );
1263
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1264
- return childItem;
1265
- });
1810
+ for (const [source, fields] of Object.entries(this.relations)) {
1811
+ for (const [key, def] of Object.entries(fields)) {
1812
+ if (def.type === "many2many") {
1813
+ this.registerMany2Many(server, source, key, def);
1814
+ } else {
1815
+ this.registerFkRelation(server, source, key, def.target, def.type);
1816
+ }
1266
1817
  }
1267
1818
  }
1268
1819
  }
1820
+ registerFkRelation(server, child, fkField, parent, type) {
1821
+ const collectionPath = `${this.base}/${parent}/:id/${child}`;
1822
+ const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1823
+ server.get(collectionPath, (req, reply) => {
1824
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1825
+ const parentItem = findById(parentCollection, req.params.id);
1826
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1827
+ const all = (this.storage.getCollection(child) ?? []).filter(
1828
+ (item) => String(item[fkField]) === req.params.id
1829
+ );
1830
+ if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
1831
+ return all;
1832
+ });
1833
+ if (type === "many2one") {
1834
+ server.get(itemPath, (req, reply) => {
1835
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1836
+ const parentItem = findById(parentCollection, req.params.id);
1837
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1838
+ const childItem = (this.storage.getCollection(child) ?? []).find(
1839
+ (item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
1840
+ );
1841
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
1842
+ return childItem;
1843
+ });
1844
+ }
1845
+ }
1846
+ registerMany2Many(server, source, alias, def) {
1847
+ server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
1848
+ const sourceCollection = this.storage.getCollection(source) ?? [];
1849
+ const sourceItem = findById(sourceCollection, req.params.id);
1850
+ if (!sourceItem) return reply.status(404).send({ error: "Not found" });
1851
+ const pivot = this.storage.getCollection(def.through) ?? [];
1852
+ const matchingIds = new Set(
1853
+ pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
1854
+ );
1855
+ return (this.storage.getCollection(def.target) ?? []).filter(
1856
+ (t) => matchingIds.has(String(t["id"]))
1857
+ );
1858
+ });
1859
+ server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
1860
+ const targetCollection = this.storage.getCollection(def.target) ?? [];
1861
+ const targetItem = findById(targetCollection, req.params.id);
1862
+ if (!targetItem) return reply.status(404).send({ error: "Not found" });
1863
+ const pivot = this.storage.getCollection(def.through) ?? [];
1864
+ const matchingIds = new Set(
1865
+ pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
1866
+ );
1867
+ return (this.storage.getCollection(source) ?? []).filter(
1868
+ (t) => matchingIds.has(String(t["id"]))
1869
+ );
1870
+ });
1871
+ }
1269
1872
  };
1270
1873
 
1271
1874
  // src/router/routes/snapshot.routes.ts