@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.
@@ -9,62 +9,470 @@ import { existsSync, writeFileSync } from "fs";
9
9
  import { resolve } from "path";
10
10
 
11
11
  // src/cli/commands/templates/basic.ts
12
- var basicTemplate = `users:
12
+ var basicTemplate = `# yrest basic sample
13
+ # Run: npx @yrest/cli serve db.yml
14
+ # Docs: GET http://localhost:3070/_about
15
+
16
+ users:
13
17
  - id: 1
14
- name: Ana
15
- email: ana@test.com
18
+ name: Ana Garc\xEDa
19
+ email: ana@example.com
20
+ role: admin
21
+ active: true
16
22
  - id: 2
17
- name: Luis
18
- email: luis@test.com
23
+ name: Luis Mart\xEDnez
24
+ email: luis@example.com
25
+ role: editor
26
+ active: true
27
+ - id: 3
28
+ name: Sara L\xF3pez
29
+ email: sara@example.com
30
+ role: user
31
+ active: true
32
+ - id: 4
33
+ name: Diego Ruiz
34
+ email: diego@example.com
35
+ role: user
36
+ active: false
19
37
 
20
38
  products:
21
39
  - id: 1
22
- name: Laptop
23
- price: 999
40
+ name: Laptop Pro 15
41
+ price: 1299.99
42
+ stock: 15
43
+ category: electronics
44
+ featured: true
45
+ - id: 2
46
+ name: Wireless Mouse
47
+ price: 39.99
48
+ stock: 80
49
+ category: accessories
50
+ featured: false
51
+ - id: 3
52
+ name: Mechanical Keyboard
53
+ price: 129.99
54
+ stock: 45
55
+ category: accessories
56
+ featured: true
57
+ - id: 4
58
+ name: 4K Monitor 27"
59
+ price: 549.99
60
+ stock: 20
61
+ category: electronics
62
+ featured: true
63
+ - id: 5
64
+ name: USB-C Hub 7-in-1
65
+ price: 49.99
66
+ stock: 100
67
+ category: accessories
68
+ featured: false
69
+
70
+ categories:
71
+ - id: 1
72
+ name: Electronics
73
+ slug: electronics
74
+ description: Laptops, monitors and computing gear
24
75
  - id: 2
25
- name: Phone
26
- price: 499
76
+ name: Accessories
77
+ slug: accessories
78
+ description: Peripherals and add-ons
79
+
80
+ # Try these queries:
81
+ # GET /users?role=admin
82
+ # GET /products?featured=true&_sort=price&_order=asc
83
+ # GET /products?price_lte=100
84
+ # GET /users?_q=garcia
85
+ # GET /products?_fields=id,name,price&_page=1&_limit=3
27
86
  `;
28
87
 
29
88
  // src/cli/commands/templates/relational.ts
30
- var relationalTemplate = `_rel:
89
+ var relationalTemplate = `# yrest relational sample \u2014 blog
90
+ # Demonstrates: many2one, one2one and many2many relationships
91
+ # Run: npx @yrest/cli serve db.yml
92
+ # Docs: GET http://localhost:3070/_about
93
+
94
+ _rel:
95
+ # many2one \u2014 posts and comments belong to a user
31
96
  posts:
32
97
  userId: users
98
+ # many2many \u2014 posts can have multiple tags via the post_tags pivot
99
+ tags:
100
+ type: many2many
101
+ target: tags
102
+ through: post_tags
103
+ foreignKey: postId
104
+ otherKey: tagId
33
105
  comments:
34
106
  postId: posts
107
+ userId: users
108
+
109
+ # one2one \u2014 each user has exactly one profile
110
+ profiles:
111
+ userId:
112
+ type: one2one
113
+ target: users
35
114
 
36
115
  users:
37
116
  - id: 1
38
- name: Ana
39
- email: ana@test.com
117
+ name: Ana Garc\xEDa
118
+ email: ana@example.com
119
+ role: author
40
120
  - id: 2
41
- name: Luis
42
- email: luis@test.com
121
+ name: Luis Mart\xEDnez
122
+ email: luis@example.com
123
+ role: author
124
+ - id: 3
125
+ name: Sara L\xF3pez
126
+ email: sara@example.com
127
+ role: reader
128
+
129
+ profiles:
130
+ - id: 1
131
+ userId: 1
132
+ bio: Full-stack developer and open-source enthusiast
133
+ avatar: https://i.pravatar.cc/150?img=1
134
+ website: https://ana.dev
135
+ - id: 2
136
+ userId: 2
137
+ bio: Backend engineer, coffee addict
138
+ avatar: https://i.pravatar.cc/150?img=2
139
+ website: https://luisdev.io
140
+ - id: 3
141
+ userId: 3
142
+ bio: Designer turned frontend developer
143
+ avatar: https://i.pravatar.cc/150?img=3
144
+ website: null
145
+
146
+ tags:
147
+ - id: 1
148
+ name: typescript
149
+ color: "#3178c6"
150
+ - id: 2
151
+ name: api
152
+ color: "#10b981"
153
+ - id: 3
154
+ name: testing
155
+ color: "#f59e0b"
156
+ - id: 4
157
+ name: devtools
158
+ color: "#8b5cf6"
159
+ - id: 5
160
+ name: yaml
161
+ color: "#ef4444"
43
162
 
44
163
  posts:
45
164
  - id: 1
46
- title: First post
47
- body: Content of the first post
165
+ title: Getting started with TypeScript
166
+ slug: getting-started-typescript
167
+ body: TypeScript adds static typing to JavaScript, catching errors at compile time...
48
168
  userId: 1
169
+ published: true
170
+ views: 1420
171
+ createdAt: "2024-11-01"
49
172
  - id: 2
50
- title: Second post
51
- body: Content of the second post
173
+ title: Building REST APIs with Fastify
174
+ slug: rest-apis-fastify
175
+ body: Fastify is the fastest Node.js web framework, perfect for building APIs...
52
176
  userId: 1
177
+ published: true
178
+ views: 980
179
+ createdAt: "2024-11-15"
180
+ - id: 3
181
+ title: Testing strategies for modern apps
182
+ slug: testing-strategies-modern
183
+ body: A solid test strategy covers unit, integration and end-to-end scenarios...
184
+ userId: 2
185
+ published: true
186
+ views: 640
187
+ createdAt: "2024-12-03"
188
+ - id: 4
189
+ title: YAML as a database format
190
+ slug: yaml-database-format
191
+ body: YAML is human-readable and expressive enough for mock data during development...
192
+ userId: 2
193
+ published: false
194
+ views: 0
195
+ createdAt: "2025-01-10"
196
+
197
+ # pivot table for posts \u2194 tags (many2many)
198
+ post_tags:
199
+ - { id: 1, postId: 1, tagId: 1 }
200
+ - { id: 2, postId: 1, tagId: 2 }
201
+ - { id: 3, postId: 2, tagId: 2 }
202
+ - { id: 4, postId: 2, tagId: 4 }
203
+ - { id: 5, postId: 3, tagId: 3 }
204
+ - { id: 6, postId: 3, tagId: 1 }
205
+ - { id: 7, postId: 4, tagId: 5 }
206
+ - { id: 8, postId: 4, tagId: 2 }
53
207
 
54
208
  comments:
55
209
  - id: 1
56
- body: Great post!
210
+ body: Great introduction! This helped me a lot.
57
211
  postId: 1
212
+ userId: 3
213
+ likes: 5
58
214
  - id: 2
59
- body: Thanks for sharing
215
+ body: Could you cover generics in a follow-up post?
60
216
  postId: 1
217
+ userId: 2
218
+ likes: 3
219
+ - id: 3
220
+ body: Fastify is indeed much faster than Express in my benchmarks.
221
+ postId: 2
222
+ userId: 3
223
+ likes: 8
224
+ - id: 4
225
+ body: Do you have a GitHub repo with these examples?
226
+ postId: 2
227
+ userId: 1
228
+ likes: 1
229
+ - id: 5
230
+ body: E2E tests are underrated. Solid post!
231
+ postId: 3
232
+ userId: 1
233
+ likes: 4
234
+
235
+ # Try these queries:
236
+ # GET /posts?published=true&_sort=views&_order=desc
237
+ # GET /posts/1?_expand=user \u2192 embeds author object
238
+ # GET /users/1?_embed=posts \u2192 embeds posts array
239
+ # GET /users/1/profiles \u2192 one2one nested route
240
+ # GET /posts/1/tags \u2192 many2many nested route
241
+ # GET /posts/1?_embed=tags \u2192 many2many via ?_embed
242
+ # GET /users/1?_embed=profiles \u2192 one2one via ?_embed (returns object, not array)
243
+ `;
244
+
245
+ // src/cli/commands/templates/ecommerce.ts
246
+ var ecommerceTemplate = `# yrest ecommerce sample
247
+ # Demonstrates: many2one, many2many, _routes with scenarios, template vars and delay
248
+ # Run: npx @yrest/cli serve db.yml
249
+ # Docs: GET http://localhost:3070/_about
250
+
251
+ _rel:
252
+ # many2one
253
+ orders:
254
+ userId: users
255
+ order_items:
256
+ orderId: orders
257
+ productId: products
258
+
259
+ # many2many \u2014 products belong to multiple categories via pivot
260
+ products:
261
+ categories:
262
+ type: many2many
263
+ target: categories
264
+ through: product_categories
265
+ foreignKey: productId
266
+ otherKey: categoryId
267
+
268
+ _routes:
269
+ # Login with conditional scenarios
270
+ - method: POST
271
+ path: /auth/login
272
+ scenarios:
273
+ - when:
274
+ body.email: admin@example.com
275
+ body.password: secret
276
+ response:
277
+ status: 200
278
+ body:
279
+ token: tok-admin-abc123
280
+ role: admin
281
+ userId: 1
282
+ - when:
283
+ body.email: user@example.com
284
+ body.password: secret
285
+ response:
286
+ status: 200
287
+ body:
288
+ token: tok-user-xyz789
289
+ role: user
290
+ userId: 2
291
+ otherwise:
292
+ status: 401
293
+ body:
294
+ error: Invalid credentials
295
+
296
+ # Logout \u2014 always 204
297
+ - method: POST
298
+ path: /auth/logout
299
+ response:
300
+ status: 204
301
+
302
+ # Static featured products list
303
+ - method: GET
304
+ path: /store/featured
305
+ response:
306
+ status: 200
307
+ body:
308
+ - id: 1
309
+ name: Laptop Pro 15
310
+ price: 1299.99
311
+ badge: Best Seller
312
+ - id: 4
313
+ name: 4K Monitor 27"
314
+ price: 549.99
315
+ badge: New Arrival
316
+ - id: 3
317
+ name: Mechanical Keyboard
318
+ price: 129.99
319
+ badge: On Sale
320
+
321
+ # Template variables \u2014 echoes the requested product id
322
+ - method: GET
323
+ path: /products/:id/summary
324
+ response:
325
+ status: 200
326
+ body:
327
+ productId: "{{params.id}}"
328
+ requestedAt: "{{now}}"
329
+ source: mock
330
+
331
+ # Cancel order with simulated latency and template vars
332
+ - method: POST
333
+ path: /orders/:id/cancel
334
+ delay: 500
335
+ response:
336
+ status: 200
337
+ body:
338
+ orderId: "{{params.id}}"
339
+ status: cancelled
340
+ cancelledAt: "{{now}}"
341
+
342
+ # Simulate a service outage for testing error handling
343
+ - method: GET
344
+ path: /store/inventory/sync
345
+ error: 503
346
+ errorBody:
347
+ message: Inventory service temporarily unavailable
348
+ retryAfter: 30
349
+
350
+ users:
351
+ - id: 1
352
+ name: Ana Garc\xEDa
353
+ email: admin@example.com
354
+ role: admin
355
+ active: true
356
+ - id: 2
357
+ name: Luis Mart\xEDnez
358
+ email: user@example.com
359
+ role: user
360
+ active: true
361
+ - id: 3
362
+ name: Sara L\xF3pez
363
+ email: sara@example.com
364
+ role: user
365
+ active: true
366
+ - id: 4
367
+ name: Diego Ruiz
368
+ email: diego@example.com
369
+ role: user
370
+ active: false
371
+
372
+ categories:
373
+ - id: 1
374
+ name: Laptops
375
+ slug: laptops
376
+ - id: 2
377
+ name: Peripherals
378
+ slug: peripherals
379
+ - id: 3
380
+ name: Monitors
381
+ slug: monitors
382
+ - id: 4
383
+ name: Accessories
384
+ slug: accessories
385
+
386
+ products:
387
+ - id: 1
388
+ name: Laptop Pro 15
389
+ description: High-performance laptop for developers
390
+ price: 1299.99
391
+ stock: 15
392
+ sku: LAP-001
393
+ active: true
394
+ - id: 2
395
+ name: Wireless Mouse
396
+ description: Ergonomic wireless mouse with USB-C receiver
397
+ price: 39.99
398
+ stock: 80
399
+ sku: MOU-001
400
+ active: true
401
+ - id: 3
402
+ name: Mechanical Keyboard
403
+ description: Tactile switches, full RGB, TKL layout
404
+ price: 129.99
405
+ stock: 45
406
+ sku: KEY-001
407
+ active: true
408
+ - id: 4
409
+ name: 4K Monitor 27"
410
+ description: IPS panel, 144Hz, HDR400
411
+ price: 549.99
412
+ stock: 20
413
+ sku: MON-001
414
+ active: true
415
+ - id: 5
416
+ name: USB-C Hub 7-in-1
417
+ description: HDMI, SD card and USB-A ports
418
+ price: 49.99
419
+ stock: 100
420
+ sku: HUB-001
421
+ active: true
422
+
423
+ # pivot table for products \u2194 categories (many2many)
424
+ product_categories:
425
+ - { id: 1, productId: 1, categoryId: 1 }
426
+ - { id: 2, productId: 2, categoryId: 2 }
427
+ - { id: 3, productId: 2, categoryId: 4 }
428
+ - { id: 4, productId: 3, categoryId: 2 }
429
+ - { id: 5, productId: 3, categoryId: 4 }
430
+ - { id: 6, productId: 4, categoryId: 3 }
431
+ - { id: 7, productId: 5, categoryId: 4 }
432
+
433
+ orders:
434
+ - id: 1
435
+ userId: 2
436
+ status: delivered
437
+ total: 1339.98
438
+ createdAt: "2024-12-10"
439
+ - id: 2
440
+ userId: 3
441
+ status: processing
442
+ total: 179.98
443
+ createdAt: "2025-01-15"
444
+ - id: 3
445
+ userId: 2
446
+ status: pending
447
+ total: 549.99
448
+ createdAt: "2025-02-01"
449
+
450
+ order_items:
451
+ - { id: 1, orderId: 1, productId: 1, quantity: 1, unitPrice: 1299.99 }
452
+ - { id: 2, orderId: 1, productId: 2, quantity: 1, unitPrice: 39.99 }
453
+ - { id: 3, orderId: 2, productId: 3, quantity: 1, unitPrice: 129.99 }
454
+ - { id: 4, orderId: 2, productId: 2, quantity: 1, unitPrice: 39.99 }
455
+ - { id: 5, orderId: 3, productId: 4, quantity: 1, unitPrice: 549.99 }
456
+
457
+ # Try these queries:
458
+ # POST /auth/login { "email": "admin@example.com", "password": "secret" }
459
+ # GET /store/featured
460
+ # GET /products/1/summary
461
+ # GET /products/1/categories \u2192 many2many nested route
462
+ # GET /products/1?_embed=categories \u2192 many2many via ?_embed
463
+ # GET /users/2?_embed=orders \u2192 many2one ?_embed
464
+ # GET /users/2/orders \u2192 nested route
465
+ # GET /orders/1?_embed=order_items \u2192 nested items
466
+ # POST /orders/1/cancel \u2192 delayed response with template vars
467
+ # GET /store/inventory/sync \u2192 forced 503 error
61
468
  `;
62
469
 
63
470
  // src/cli/commands/templates/index.ts
64
- var SAMPLES = ["basic", "relational"];
471
+ var SAMPLES = ["basic", "relational", "ecommerce"];
65
472
  var templates = {
66
473
  basic: basicTemplate,
67
- relational: relationalTemplate
474
+ relational: relationalTemplate,
475
+ ecommerce: ecommerceTemplate
68
476
  };
69
477
 
70
478
  // src/cli/commands/init.ts
@@ -114,6 +522,50 @@ import { resolve as resolve2, dirname } from "path";
114
522
  import { randomUUID } from "crypto";
115
523
  import { parse, stringify } from "yaml";
116
524
 
525
+ // src/storage/parseRelations.ts
526
+ function parseRelations(raw) {
527
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
528
+ const result = {};
529
+ for (const [collection, fields] of Object.entries(raw)) {
530
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
531
+ result[collection] = {};
532
+ for (const [key, value] of Object.entries(fields)) {
533
+ const def = normaliseRelationDef(key, value);
534
+ if (def) result[collection][key] = def;
535
+ }
536
+ }
537
+ return result;
538
+ }
539
+ function normaliseRelationDef(key, value) {
540
+ if (typeof value === "string") {
541
+ return { type: "many2one", target: value };
542
+ }
543
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
544
+ const v = value;
545
+ const type = v["type"];
546
+ const nested = v["nested"] === true ? true : void 0;
547
+ if (type === "many2one" || type === void 0) {
548
+ const target = v["target"];
549
+ if (typeof target !== "string") return null;
550
+ return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
551
+ }
552
+ if (type === "one2one") {
553
+ const target = v["target"];
554
+ if (typeof target !== "string") return null;
555
+ return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
556
+ }
557
+ if (type === "many2many") {
558
+ const target = typeof v["target"] === "string" ? v["target"] : key;
559
+ const through = v["through"];
560
+ const foreignKey = v["foreignKey"];
561
+ const otherKey = v["otherKey"];
562
+ if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
563
+ return null;
564
+ return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
565
+ }
566
+ return null;
567
+ }
568
+
117
569
  // src/utils/deepCopy.ts
118
570
  function deepCopyData(source) {
119
571
  return Object.fromEntries(
@@ -125,7 +577,7 @@ function deepCopyData(source) {
125
577
  function createYrestStorage(filePath) {
126
578
  const absPath = resolve2(filePath);
127
579
  const raw = parse(readFileSync(absPath, "utf8")) ?? {};
128
- const relations = raw["_rel"] ?? {};
580
+ const relations = parseRelations(raw["_rel"]);
129
581
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
130
582
  const data = Object.fromEntries(
131
583
  Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
@@ -162,7 +614,7 @@ function createYrestStorage(filePath) {
162
614
  },
163
615
  reload() {
164
616
  const fresh = parse(readFileSync(absPath, "utf8")) ?? {};
165
- const freshRelations = fresh["_rel"] ?? {};
617
+ const freshRelations = parseRelations(fresh["_rel"]);
166
618
  const freshData = Object.fromEntries(
167
619
  Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
168
620
  );
@@ -247,16 +699,7 @@ function hasTemplates(value) {
247
699
  return typeof value === "string" ? value.includes("{{") : JSON.stringify(value).includes("{{");
248
700
  }
249
701
 
250
- // src/router/templates/about.template.ts
251
- var _dir = dirname2(fileURLToPath(import.meta.url));
252
- var LOGO_SRC = (() => {
253
- try {
254
- const buf = readFileSync2(join(_dir, "../../assets/logo-color.png"));
255
- return `data:image/png;base64,${buf.toString("base64")}`;
256
- } catch {
257
- return "";
258
- }
259
- })();
702
+ // src/router/templates/about.helpers.ts
260
703
  var METHOD_COLOR = {
261
704
  GET: "#3fb950",
262
705
  POST: "#58a6ff",
@@ -321,6 +764,139 @@ function resourceAccordion(name, base, isOpen) {
321
764
  </table>
322
765
  </details>`;
323
766
  }
767
+ function nestedRoutesAccordion(relations, base) {
768
+ const rows = [];
769
+ for (const [source, fields] of Object.entries(relations)) {
770
+ for (const [key, def] of Object.entries(fields)) {
771
+ const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
772
+ if (def.type === "many2many") {
773
+ const singular = source.endsWith("s") ? source.slice(0, -1) : source;
774
+ const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
775
+ rows.push(
776
+ endpointRow(
777
+ "GET",
778
+ `${base}/${source}/:id/${key}`,
779
+ `List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
780
+ )
781
+ );
782
+ const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
783
+ rows.push(
784
+ endpointRow(
785
+ "GET",
786
+ `${base}/${def.target}/:id/${source}`,
787
+ `List ${source} linked to a ${targetSingular} via ${def.through} (inverse). ${m2mBadge}`
788
+ )
789
+ );
790
+ } else {
791
+ const path = `${base}/${def.target}/:id/${source}`;
792
+ const parentSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
793
+ const typeBadge = def.type === "one2one" ? ` ${badge("one2one", "#34d399", "#34d39918")}` : "";
794
+ rows.push(
795
+ endpointRow(
796
+ "GET",
797
+ path,
798
+ `${def.type === "one2one" ? "Get" : "List"} ${source} belonging to a ${parentSingular}.${typeBadge}${nestedBadge}`
799
+ )
800
+ );
801
+ }
802
+ }
803
+ }
804
+ if (!rows.length) return "";
805
+ return `
806
+ <details class="resource-card nested-card">
807
+ <summary>
808
+ <span class="resource-name">Nested routes</span>
809
+ <span class="route-count">${rows.length} route${rows.length !== 1 ? "s" : ""}</span>
810
+ </summary>
811
+ <table><tbody>${rows.join("")}</tbody></table>
812
+ </details>`;
813
+ }
814
+ function snapshotAccordion() {
815
+ return `
816
+ <details class="resource-card nested-card">
817
+ <summary>
818
+ <span class="resource-name">/_snapshot</span>
819
+ <span class="route-count">3 routes</span>
820
+ </summary>
821
+ <table><tbody>
822
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
823
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
824
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
825
+ </tbody></table>
826
+ </details>`;
827
+ }
828
+ function customRoutesAccordion(routes, base, handlers) {
829
+ if (!routes.length) return "";
830
+ const rows = routes.map((r) => {
831
+ const fullPath = `${base}${r.path}`;
832
+ const tags = [];
833
+ if (r.error) tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
834
+ if (r.delay && r.delay > 0)
835
+ tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
836
+ if (r.scenarios?.length) {
837
+ const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
838
+ tags.push(
839
+ `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
840
+ );
841
+ }
842
+ if (r.otherwise)
843
+ tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
844
+ let desc;
845
+ if (r.error) {
846
+ desc = `Error injection \u2014 <code>${r.error}</code>`;
847
+ } else if (r.handler) {
848
+ const found = handlers.has(r.handler);
849
+ const handlerName = escapeHtml(r.handler);
850
+ desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
851
+ } else if (r.scenarios?.length) {
852
+ const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
853
+ desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
854
+ } else if (r.response?.body != null && hasTemplates(r.response.body)) {
855
+ desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
856
+ } else {
857
+ const status = r.response?.status ?? 200;
858
+ desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
859
+ }
860
+ if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
861
+ return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
862
+ });
863
+ return `
864
+ <details class="resource-card nested-card">
865
+ <summary>
866
+ <span class="resource-name">Custom routes</span>
867
+ <span class="route-count">${routes.length} route${routes.length !== 1 ? "s" : ""}</span>
868
+ </summary>
869
+ <table><tbody>
870
+ ${rows.join("")}
871
+ </tbody></table>
872
+ </details>`;
873
+ }
874
+ function handlersAccordion(handlers, routes, base) {
875
+ if (!handlers.size) return "";
876
+ const routesByHandler = /* @__PURE__ */ new Map();
877
+ for (const r of routes) {
878
+ if (r.handler) {
879
+ const list = routesByHandler.get(r.handler) ?? [];
880
+ list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
881
+ routesByHandler.set(r.handler, list);
882
+ }
883
+ }
884
+ const rows = [...handlers.keys()].map((name) => {
885
+ const linked = routesByHandler.get(name);
886
+ const routeDesc = linked ? linked.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
887
+ return endpointRow("fn", name + "()", routeDesc);
888
+ });
889
+ return `
890
+ <details class="resource-card nested-card">
891
+ <summary>
892
+ <span class="resource-name">Handlers</span>
893
+ <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
894
+ </summary>
895
+ <table><tbody>
896
+ ${rows.join("")}
897
+ </tbody></table>
898
+ </details>`;
899
+ }
324
900
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
325
901
  const examples = [];
326
902
  const firstCol = collections[0];
@@ -351,35 +927,36 @@ curl -X DELETE ${p}/1`
351
927
  const firstRel = Object.entries(relations)[0];
352
928
  if (firstRel) {
353
929
  const [child, fields] = firstRel;
354
- const fk = Object.keys(fields)[0];
355
- const expandKey = fk.replace(/Id$/i, "");
356
- examples.push(
357
- `# Embed parent with ?_expand
358
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
359
- );
360
- }
361
- const firstRelEntry = Object.entries(relations)[0];
362
- if (firstRelEntry) {
363
- const [child, fields] = firstRelEntry;
364
- const parent = Object.values(fields)[0];
365
- if (parent) {
366
- examples.push(`# Nested resource
367
- curl ${host}${base}/${parent}/1/${child}`);
930
+ const firstField = Object.entries(fields)[0];
931
+ if (firstField) {
932
+ const [fk, def] = firstField;
933
+ if (def.type !== "many2many") {
934
+ const expandKey = fk.replace(/Id$/i, "");
935
+ examples.push(
936
+ `# Embed parent with ?_expand
937
+ curl "${host}${base}/${child}/1?_expand=${expandKey}"`,
938
+ `# Nested resource
939
+ curl ${host}${base}/${def.target}/1/${child}`
940
+ );
941
+ } else {
942
+ examples.push(`# Many-to-many embed
943
+ curl "${host}${base}/${child}/1/${fk}"`);
944
+ }
368
945
  }
369
946
  }
370
947
  if (options.pageable.enabled && firstCol) {
371
948
  examples.push(`# Pageable envelope
372
949
  curl "${host}${base}/${firstCol}?_page=2"`);
373
950
  }
374
- const firstParentRel = Object.entries(relations).find(
375
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
376
- );
377
951
  if (firstCol) {
378
952
  examples.push(
379
953
  `# Project fields with ?_fields
380
954
  curl "${host}${base}/${firstCol}?_fields=id,name"`
381
955
  );
382
956
  }
957
+ const firstParentRel = Object.entries(relations).find(
958
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
959
+ );
383
960
  if (firstParentRel && firstCol) {
384
961
  const [childName] = firstParentRel;
385
962
  examples.push(
@@ -405,9 +982,21 @@ curl ${curlFlag}${fullPath}`);
405
982
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
406
983
  return `<pre>${highlighted}</pre>`;
407
984
  }
985
+
986
+ // src/router/templates/about.template.ts
987
+ var _dir = dirname2(fileURLToPath(import.meta.url));
988
+ var LOGO_SRC = (() => {
989
+ try {
990
+ const buf = readFileSync2(join(_dir, "../../assets/logo-color.png"));
991
+ return `data:image/png;base64,${buf.toString("base64")}`;
992
+ } catch {
993
+ return "";
994
+ }
995
+ })();
408
996
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
409
997
  const collections = Object.keys(storage.getData());
410
998
  const relations = storage.getRelations();
999
+ const customRoutes = storage.getRoutes();
411
1000
  const base = options.base;
412
1001
  const host = `http://${options.host}:${options.port}`;
413
1002
  const modes = [];
@@ -421,105 +1010,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
421
1010
  if (options.idStrategy !== "increment")
422
1011
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
423
1012
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
424
- const nestedRows = [];
425
- for (const [child, fields] of Object.entries(relations)) {
426
- for (const [, parent] of Object.entries(fields)) {
427
- const nestedPath = `${base}/${parent}/:id/${child}`;
428
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
429
- nestedRows.push(
430
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
431
- );
432
- }
433
- }
434
- const nestedAccordion = nestedRows.length ? `
435
- <details class="resource-card nested-card">
436
- <summary>
437
- <span class="resource-name">Nested routes</span>
438
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
439
- </summary>
440
- <table><tbody>${nestedRows.join("")}</tbody></table>
441
- </details>` : "";
442
- const snapshotAccordion = options.snapshot ? `
443
- <details class="resource-card nested-card">
444
- <summary>
445
- <span class="resource-name">/_snapshot</span>
446
- <span class="route-count">3 routes</span>
447
- </summary>
448
- <table><tbody>
449
- ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
450
- ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
451
- ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
452
- </tbody></table>
453
- </details>` : "";
454
- const customRoutes = storage.getRoutes();
455
- const customRoutesAccordion = customRoutes.length ? `
456
- <details class="resource-card nested-card">
457
- <summary>
458
- <span class="resource-name">Custom routes</span>
459
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
460
- </summary>
461
- <table><tbody>
462
- ${customRoutes.map((r) => {
463
- const fullPath = `${base}${r.path}`;
464
- const tags = [];
465
- if (r.error) {
466
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
467
- }
468
- if (r.delay && r.delay > 0) {
469
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
470
- }
471
- if (r.scenarios?.length) {
472
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
473
- tags.push(
474
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
475
- );
476
- }
477
- if (r.otherwise) {
478
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
479
- }
480
- let desc;
481
- if (r.error) {
482
- desc = `Error injection \u2014 <code>${r.error}</code>`;
483
- } else if (r.handler) {
484
- const found = handlers.has(r.handler);
485
- const handlerName = escapeHtml(r.handler);
486
- desc = found ? `Handler \u2014 <code>${handlerName}()</code>` : `Handler \u2014 <code>${handlerName}()</code> <span style="color:#f85149">(not loaded)</span>`;
487
- } else if (r.scenarios?.length) {
488
- const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
489
- desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
490
- } else if (r.response?.body != null && hasTemplates(r.response.body)) {
491
- desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
492
- } else {
493
- const status = r.response?.status ?? 200;
494
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
495
- }
496
- if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
497
- return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
498
- }).join("")}
499
- </tbody></table>
500
- </details>` : "";
501
- const routesByHandler = /* @__PURE__ */ new Map();
502
- for (const r of customRoutes) {
503
- if (r.handler) {
504
- const list = routesByHandler.get(r.handler) ?? [];
505
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
506
- routesByHandler.set(r.handler, list);
507
- }
508
- }
509
- const handlersAccordion = handlers.size > 0 ? `
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
- ${[...handlers.keys()].map((name) => {
517
- const routes = routesByHandler.get(name);
518
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
519
- return endpointRow("fn", name + "()", routeDesc);
520
- }).join("")}
521
- </tbody></table>
522
- </details>` : "";
523
1013
  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.`;
524
1014
  return `<!DOCTYPE html>
525
1015
  <html lang="en">
@@ -667,10 +1157,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
667
1157
  <h2>Endpoints</h2>
668
1158
  <div class="endpoints-grid">
669
1159
  ${accordions}
670
- ${nestedAccordion}
671
- ${snapshotAccordion}
672
- ${customRoutesAccordion}
673
- ${handlersAccordion}
1160
+ ${nestedRoutesAccordion(relations, base)}
1161
+ ${options.snapshot ? snapshotAccordion() : ""}
1162
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1163
+ ${handlersAccordion(handlers, customRoutes, base)}
674
1164
  </div>
675
1165
 
676
1166
  <h2>Query Parameters</h2>
@@ -870,10 +1360,11 @@ function expandItems(input, query, resource, storage) {
870
1360
  const resourceRelations = storage.getRelations()[resource] ?? {};
871
1361
  const expansions = /* @__PURE__ */ new Map();
872
1362
  for (const expandKey of keys) {
873
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
1363
+ for (const [field, def] of Object.entries(resourceRelations)) {
1364
+ if (def.type === "many2many") continue;
874
1365
  const derivedKey = field.replace(/Id$/i, "");
875
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
876
- expansions.set(expandKey, { field, parentCollection });
1366
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
1367
+ expansions.set(expandKey, { field, parentCollection: def.target });
877
1368
  break;
878
1369
  }
879
1370
  }
@@ -902,10 +1393,24 @@ function embedItems(input, query, resource, storage) {
902
1393
  const relations = storage.getRelations();
903
1394
  const embeds = /* @__PURE__ */ new Map();
904
1395
  for (const embedKey of keys) {
1396
+ const ownRelations = relations[resource] ?? {};
1397
+ if (embedKey in ownRelations) {
1398
+ const def = ownRelations[embedKey];
1399
+ if (def.type === "many2many") {
1400
+ embeds.set(embedKey, {
1401
+ kind: "many2many",
1402
+ target: def.target,
1403
+ through: def.through,
1404
+ foreignKey: def.foreignKey,
1405
+ otherKey: def.otherKey
1406
+ });
1407
+ continue;
1408
+ }
1409
+ }
905
1410
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
906
- for (const [fkField, parentCollection] of Object.entries(fields)) {
907
- if (parentCollection === resource && childCollection === embedKey) {
908
- embeds.set(embedKey, { childCollection, fkField });
1411
+ for (const [fkField, def] of Object.entries(fields)) {
1412
+ if ((def.type === "many2one" || def.type === "one2one") && def.target === resource && childCollection === embedKey) {
1413
+ embeds.set(embedKey, { kind: def.type, childCollection, fkField });
909
1414
  break outer;
910
1415
  }
911
1416
  }
@@ -914,10 +1419,57 @@ function embedItems(input, query, resource, storage) {
914
1419
  if (embeds.size === 0) return isArray ? items : input;
915
1420
  const result = items.map((item) => {
916
1421
  const out = { ...item };
917
- for (const [embedKey, { childCollection, fkField }] of embeds) {
918
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
919
- (child) => String(child[fkField]) === String(item["id"])
920
- );
1422
+ for (const [embedKey, spec] of embeds) {
1423
+ if (spec.kind === "many2many") {
1424
+ const pivot = storage.getCollection(spec.through) ?? [];
1425
+ const matchingIds = new Set(
1426
+ pivot.filter((row) => String(row[spec.foreignKey]) === String(item["id"])).map((row) => String(row[spec.otherKey]))
1427
+ );
1428
+ out[embedKey] = (storage.getCollection(spec.target) ?? []).filter(
1429
+ (t) => matchingIds.has(String(t["id"]))
1430
+ );
1431
+ } else if (spec.kind === "one2one") {
1432
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).find(
1433
+ (child) => String(child[spec.fkField]) === String(item["id"])
1434
+ ) ?? null;
1435
+ } else {
1436
+ out[embedKey] = (storage.getCollection(spec.childCollection) ?? []).filter(
1437
+ (child) => String(child[spec.fkField]) === String(item["id"])
1438
+ );
1439
+ }
1440
+ }
1441
+ return out;
1442
+ });
1443
+ return isArray ? result : result[0];
1444
+ }
1445
+ function applyNested(input, resource, storage) {
1446
+ const isArray = Array.isArray(input);
1447
+ const items = isArray ? input : [input];
1448
+ const resourceRelations = storage.getRelations()[resource] ?? {};
1449
+ const nestedDefs = Object.entries(resourceRelations).filter(([, def]) => def.nested === true);
1450
+ if (nestedDefs.length === 0) return input;
1451
+ const result = items.map((item) => {
1452
+ const out = { ...item };
1453
+ for (const [key, def] of nestedDefs) {
1454
+ if (def.type === "many2many") {
1455
+ const pivot = storage.getCollection(def.through) ?? [];
1456
+ const matchingIds = new Set(
1457
+ pivot.filter((row) => String(row[def.foreignKey]) === String(item["id"])).map((row) => String(row[def.otherKey]))
1458
+ );
1459
+ out[key] = (storage.getCollection(def.target) ?? []).filter(
1460
+ (t) => matchingIds.has(String(t["id"]))
1461
+ );
1462
+ } else {
1463
+ const foreignKeyValue = item[key];
1464
+ if (foreignKeyValue === void 0) continue;
1465
+ const parent = (storage.getCollection(def.target) ?? []).find(
1466
+ (p) => String(p["id"]) === String(foreignKeyValue)
1467
+ );
1468
+ if (parent !== void 0) {
1469
+ const embedKey = key.replace(/Id$/i, "");
1470
+ out[embedKey] = parent;
1471
+ }
1472
+ }
921
1473
  }
922
1474
  return out;
923
1475
  });
@@ -957,7 +1509,12 @@ var CollectionRouteCommand = class {
957
1509
  const totalPages = Math.ceil(totalItems / limit) || 1;
958
1510
  const data = projectFields(
959
1511
  embedItems(
960
- expandItems(paginate(sorted, page, limit), req.query, this.resource, this.storage),
1512
+ expandItems(
1513
+ applyNested(paginate(sorted, page, limit), this.resource, this.storage),
1514
+ req.query,
1515
+ this.resource,
1516
+ this.storage
1517
+ ),
961
1518
  req.query,
962
1519
  this.resource,
963
1520
  this.storage
@@ -989,7 +1546,12 @@ var CollectionRouteCommand = class {
989
1546
  }
990
1547
  return projectFields(
991
1548
  embedItems(
992
- expandItems(result, req.query, this.resource, this.storage),
1549
+ expandItems(
1550
+ applyNested(result, this.resource, this.storage),
1551
+ req.query,
1552
+ this.resource,
1553
+ this.storage
1554
+ ),
993
1555
  req.query,
994
1556
  this.resource,
995
1557
  this.storage
@@ -1170,7 +1732,12 @@ var ItemRouteCommand = class {
1170
1732
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1171
1733
  return projectFields(
1172
1734
  embedItems(
1173
- expandItems(item, req.query, this.resource, this.storage),
1735
+ expandItems(
1736
+ applyNested(item, this.resource, this.storage),
1737
+ req.query,
1738
+ this.resource,
1739
+ this.storage
1740
+ ),
1174
1741
  req.query,
1175
1742
  this.resource,
1176
1743
  this.storage
@@ -1213,32 +1780,68 @@ var NestedRouteCommand = class {
1213
1780
  relations;
1214
1781
  base;
1215
1782
  register(server) {
1216
- for (const [child, fields] of Object.entries(this.relations)) {
1217
- for (const [field, parent] of Object.entries(fields)) {
1218
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1219
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1220
- server.get(collectionPath, (req, reply) => {
1221
- const parentCollection = this.storage.getCollection(parent) ?? [];
1222
- const parentItem = findById(parentCollection, req.params.id);
1223
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1224
- const children = (this.storage.getCollection(child) ?? []).filter(
1225
- (item) => String(item[field]) === req.params.id
1226
- );
1227
- return children;
1228
- });
1229
- server.get(itemPath, (req, reply) => {
1230
- const parentCollection = this.storage.getCollection(parent) ?? [];
1231
- const parentItem = findById(parentCollection, req.params.id);
1232
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1233
- const childItem = (this.storage.getCollection(child) ?? []).find(
1234
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1235
- );
1236
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1237
- return childItem;
1238
- });
1783
+ for (const [source, fields] of Object.entries(this.relations)) {
1784
+ for (const [key, def] of Object.entries(fields)) {
1785
+ if (def.type === "many2many") {
1786
+ this.registerMany2Many(server, source, key, def);
1787
+ } else {
1788
+ this.registerFkRelation(server, source, key, def.target, def.type);
1789
+ }
1239
1790
  }
1240
1791
  }
1241
1792
  }
1793
+ registerFkRelation(server, child, fkField, parent, type) {
1794
+ const collectionPath = `${this.base}/${parent}/:id/${child}`;
1795
+ const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1796
+ server.get(collectionPath, (req, reply) => {
1797
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1798
+ const parentItem = findById(parentCollection, req.params.id);
1799
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1800
+ const all = (this.storage.getCollection(child) ?? []).filter(
1801
+ (item) => String(item[fkField]) === req.params.id
1802
+ );
1803
+ if (type === "one2one") return all[0] ?? reply.status(404).send({ error: "Not found" });
1804
+ return all;
1805
+ });
1806
+ if (type === "many2one") {
1807
+ server.get(itemPath, (req, reply) => {
1808
+ const parentCollection = this.storage.getCollection(parent) ?? [];
1809
+ const parentItem = findById(parentCollection, req.params.id);
1810
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
1811
+ const childItem = (this.storage.getCollection(child) ?? []).find(
1812
+ (item) => String(item[fkField]) === req.params.id && String(item["id"]) === req.params.childId
1813
+ );
1814
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
1815
+ return childItem;
1816
+ });
1817
+ }
1818
+ }
1819
+ registerMany2Many(server, source, alias, def) {
1820
+ server.get(`${this.base}/${source}/:id/${alias}`, (req, reply) => {
1821
+ const sourceCollection = this.storage.getCollection(source) ?? [];
1822
+ const sourceItem = findById(sourceCollection, req.params.id);
1823
+ if (!sourceItem) return reply.status(404).send({ error: "Not found" });
1824
+ const pivot = this.storage.getCollection(def.through) ?? [];
1825
+ const matchingIds = new Set(
1826
+ pivot.filter((row) => String(row[def.foreignKey]) === req.params.id).map((row) => String(row[def.otherKey]))
1827
+ );
1828
+ return (this.storage.getCollection(def.target) ?? []).filter(
1829
+ (t) => matchingIds.has(String(t["id"]))
1830
+ );
1831
+ });
1832
+ server.get(`${this.base}/${def.target}/:id/${source}`, (req, reply) => {
1833
+ const targetCollection = this.storage.getCollection(def.target) ?? [];
1834
+ const targetItem = findById(targetCollection, req.params.id);
1835
+ if (!targetItem) return reply.status(404).send({ error: "Not found" });
1836
+ const pivot = this.storage.getCollection(def.through) ?? [];
1837
+ const matchingIds = new Set(
1838
+ pivot.filter((row) => String(row[def.otherKey]) === req.params.id).map((row) => String(row[def.foreignKey]))
1839
+ );
1840
+ return (this.storage.getCollection(source) ?? []).filter(
1841
+ (t) => matchingIds.has(String(t["id"]))
1842
+ );
1843
+ });
1844
+ }
1242
1845
  };
1243
1846
 
1244
1847
  // src/router/routes/snapshot.routes.ts