@yrest/cli 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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",
@@ -265,8 +708,11 @@ var METHOD_COLOR = {
265
708
  DELETE: "#f85149",
266
709
  fn: "#f0883e"
267
710
  };
711
+ function escapeHtml(str) {
712
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
713
+ }
268
714
  function badge(label, color, bg) {
269
- return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
715
+ return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${escapeHtml(label)}</span>`;
270
716
  }
271
717
  function methodBadge(method) {
272
718
  const color = METHOD_COLOR[method] ?? "#7d8590";
@@ -276,7 +722,7 @@ function endpointRow(method, path, desc) {
276
722
  return `
277
723
  <tr>
278
724
  <td class="method-cell">${methodBadge(method)}</td>
279
- <td class="path-cell"><code>${path}</code></td>
725
+ <td class="path-cell"><code>${escapeHtml(path)}</code></td>
280
726
  <td class="desc-cell">${desc}</td>
281
727
  </tr>`;
282
728
  }
@@ -310,7 +756,7 @@ function resourceAccordion(name, base, isOpen) {
310
756
  return `
311
757
  <details class="resource-card" ${isOpen ? "open" : ""}>
312
758
  <summary>
313
- <span class="resource-name">/${name}</span>
759
+ <span class="resource-name">/${escapeHtml(name)}</span>
314
760
  <span class="route-count">6 routes</span>
315
761
  </summary>
316
762
  <table>
@@ -318,6 +764,139 @@ function resourceAccordion(name, base, isOpen) {
318
764
  </table>
319
765
  </details>`;
320
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
+ }
321
900
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
322
901
  const examples = [];
323
902
  const firstCol = collections[0];
@@ -348,35 +927,36 @@ curl -X DELETE ${p}/1`
348
927
  const firstRel = Object.entries(relations)[0];
349
928
  if (firstRel) {
350
929
  const [child, fields] = firstRel;
351
- const fk = Object.keys(fields)[0];
352
- const expandKey = fk.replace(/Id$/i, "");
353
- examples.push(
354
- `# Embed parent with ?_expand
355
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
356
- );
357
- }
358
- const firstRelEntry = Object.entries(relations)[0];
359
- if (firstRelEntry) {
360
- const [child, fields] = firstRelEntry;
361
- const parent = Object.values(fields)[0];
362
- if (parent) {
363
- examples.push(`# Nested resource
364
- 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
+ }
365
945
  }
366
946
  }
367
947
  if (options.pageable.enabled && firstCol) {
368
948
  examples.push(`# Pageable envelope
369
949
  curl "${host}${base}/${firstCol}?_page=2"`);
370
950
  }
371
- const firstParentRel = Object.entries(relations).find(
372
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
373
- );
374
951
  if (firstCol) {
375
952
  examples.push(
376
953
  `# Project fields with ?_fields
377
954
  curl "${host}${base}/${firstCol}?_fields=id,name"`
378
955
  );
379
956
  }
957
+ const firstParentRel = Object.entries(relations).find(
958
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
959
+ );
380
960
  if (firstParentRel && firstCol) {
381
961
  const [childName] = firstParentRel;
382
962
  examples.push(
@@ -394,7 +974,7 @@ curl -X POST ${host}/_snapshot/reset`
394
974
  }
395
975
  if (firstCustomRoute) {
396
976
  const method = firstCustomRoute.method?.toUpperCase() ?? "GET";
397
- const fullPath = `${host}${base}${firstCustomRoute.path}`;
977
+ const fullPath = `${host}${base}${escapeHtml(firstCustomRoute.path ?? "")}`;
398
978
  const curlFlag = method === "GET" ? "" : `-X ${method} `;
399
979
  examples.push(`# Custom route
400
980
  curl ${curlFlag}${fullPath}`);
@@ -402,9 +982,21 @@ curl ${curlFlag}${fullPath}`);
402
982
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
403
983
  return `<pre>${highlighted}</pre>`;
404
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
+ })();
405
996
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
406
997
  const collections = Object.keys(storage.getData());
407
998
  const relations = storage.getRelations();
999
+ const customRoutes = storage.getRoutes();
408
1000
  const base = options.base;
409
1001
  const host = `http://${options.host}:${options.port}`;
410
1002
  const modes = [];
@@ -418,104 +1010,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
418
1010
  if (options.idStrategy !== "increment")
419
1011
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
420
1012
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
421
- const nestedRows = [];
422
- for (const [child, fields] of Object.entries(relations)) {
423
- for (const [, parent] of Object.entries(fields)) {
424
- const nestedPath = `${base}/${parent}/:id/${child}`;
425
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
426
- nestedRows.push(
427
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
428
- );
429
- }
430
- }
431
- const nestedAccordion = nestedRows.length ? `
432
- <details class="resource-card nested-card">
433
- <summary>
434
- <span class="resource-name">Nested routes</span>
435
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
436
- </summary>
437
- <table><tbody>${nestedRows.join("")}</tbody></table>
438
- </details>` : "";
439
- const snapshotAccordion = options.snapshot ? `
440
- <details class="resource-card nested-card">
441
- <summary>
442
- <span class="resource-name">/_snapshot</span>
443
- <span class="route-count">3 routes</span>
444
- </summary>
445
- <table><tbody>
446
- ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
447
- ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
448
- ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
449
- </tbody></table>
450
- </details>` : "";
451
- const customRoutes = storage.getRoutes();
452
- const customRoutesAccordion = customRoutes.length ? `
453
- <details class="resource-card nested-card">
454
- <summary>
455
- <span class="resource-name">Custom routes</span>
456
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
457
- </summary>
458
- <table><tbody>
459
- ${customRoutes.map((r) => {
460
- const fullPath = `${base}${r.path}`;
461
- const tags = [];
462
- if (r.error) {
463
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
464
- }
465
- if (r.delay && r.delay > 0) {
466
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
467
- }
468
- if (r.scenarios?.length) {
469
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
470
- tags.push(
471
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
472
- );
473
- }
474
- if (r.otherwise) {
475
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
476
- }
477
- let desc;
478
- if (r.error) {
479
- desc = `Error injection \u2014 <code>${r.error}</code>`;
480
- } else if (r.handler) {
481
- const found = handlers.has(r.handler);
482
- desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
483
- } else if (r.scenarios?.length) {
484
- const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
485
- desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
486
- } else if (r.response?.body != null && hasTemplates(r.response.body)) {
487
- desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
488
- } else {
489
- const status = r.response?.status ?? 200;
490
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
491
- }
492
- if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
493
- return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
494
- }).join("")}
495
- </tbody></table>
496
- </details>` : "";
497
- const routesByHandler = /* @__PURE__ */ new Map();
498
- for (const r of customRoutes) {
499
- if (r.handler) {
500
- const list = routesByHandler.get(r.handler) ?? [];
501
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
502
- routesByHandler.set(r.handler, list);
503
- }
504
- }
505
- const handlersAccordion = handlers.size > 0 ? `
506
- <details class="resource-card nested-card">
507
- <summary>
508
- <span class="resource-name">Handlers</span>
509
- <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
510
- </summary>
511
- <table><tbody>
512
- ${[...handlers.keys()].map((name) => {
513
- const routes = routesByHandler.get(name);
514
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
515
- return endpointRow("fn", name + "()", routeDesc);
516
- }).join("")}
517
- </tbody></table>
518
- </details>` : "";
519
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.`;
520
1014
  return `<!DOCTYPE html>
521
1015
  <html lang="en">
@@ -663,10 +1157,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
663
1157
  <h2>Endpoints</h2>
664
1158
  <div class="endpoints-grid">
665
1159
  ${accordions}
666
- ${nestedAccordion}
667
- ${snapshotAccordion}
668
- ${customRoutesAccordion}
669
- ${handlersAccordion}
1160
+ ${nestedRoutesAccordion(relations, base)}
1161
+ ${options.snapshot ? snapshotAccordion() : ""}
1162
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1163
+ ${handlersAccordion(handlers, customRoutes, base)}
670
1164
  </div>
671
1165
 
672
1166
  <h2>Query Parameters</h2>
@@ -752,6 +1246,7 @@ function applyOperator(itemValue, op, filterValue) {
752
1246
  case "_start":
753
1247
  return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
754
1248
  case "_regex": {
1249
+ if (filterValue.length > 200) return false;
755
1250
  try {
756
1251
  return new RegExp(filterValue, "i").test(strItem);
757
1252
  } catch {
@@ -865,10 +1360,11 @@ function expandItems(input, query, resource, storage) {
865
1360
  const resourceRelations = storage.getRelations()[resource] ?? {};
866
1361
  const expansions = /* @__PURE__ */ new Map();
867
1362
  for (const expandKey of keys) {
868
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
1363
+ for (const [field, def] of Object.entries(resourceRelations)) {
1364
+ if (def.type === "many2many") continue;
869
1365
  const derivedKey = field.replace(/Id$/i, "");
870
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
871
- expansions.set(expandKey, { field, parentCollection });
1366
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
1367
+ expansions.set(expandKey, { field, parentCollection: def.target });
872
1368
  break;
873
1369
  }
874
1370
  }
@@ -897,10 +1393,24 @@ function embedItems(input, query, resource, storage) {
897
1393
  const relations = storage.getRelations();
898
1394
  const embeds = /* @__PURE__ */ new Map();
899
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
+ }
900
1410
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
901
- for (const [fkField, parentCollection] of Object.entries(fields)) {
902
- if (parentCollection === resource && childCollection === embedKey) {
903
- 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 });
904
1414
  break outer;
905
1415
  }
906
1416
  }
@@ -909,10 +1419,57 @@ function embedItems(input, query, resource, storage) {
909
1419
  if (embeds.size === 0) return isArray ? items : input;
910
1420
  const result = items.map((item) => {
911
1421
  const out = { ...item };
912
- for (const [embedKey, { childCollection, fkField }] of embeds) {
913
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
914
- (child) => String(child[fkField]) === String(item["id"])
915
- );
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
+ }
916
1473
  }
917
1474
  return out;
918
1475
  });
@@ -952,7 +1509,12 @@ var CollectionRouteCommand = class {
952
1509
  const totalPages = Math.ceil(totalItems / limit) || 1;
953
1510
  const data = projectFields(
954
1511
  embedItems(
955
- 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
+ ),
956
1518
  req.query,
957
1519
  this.resource,
958
1520
  this.storage
@@ -984,7 +1546,12 @@ var CollectionRouteCommand = class {
984
1546
  }
985
1547
  return projectFields(
986
1548
  embedItems(
987
- 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
+ ),
988
1555
  req.query,
989
1556
  this.resource,
990
1557
  this.storage
@@ -1165,7 +1732,12 @@ var ItemRouteCommand = class {
1165
1732
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1166
1733
  return projectFields(
1167
1734
  embedItems(
1168
- 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
+ ),
1169
1741
  req.query,
1170
1742
  this.resource,
1171
1743
  this.storage
@@ -1208,32 +1780,68 @@ var NestedRouteCommand = class {
1208
1780
  relations;
1209
1781
  base;
1210
1782
  register(server) {
1211
- for (const [child, fields] of Object.entries(this.relations)) {
1212
- for (const [field, parent] of Object.entries(fields)) {
1213
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1214
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1215
- server.get(collectionPath, (req, reply) => {
1216
- const parentCollection = this.storage.getCollection(parent) ?? [];
1217
- const parentItem = findById(parentCollection, req.params.id);
1218
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1219
- const children = (this.storage.getCollection(child) ?? []).filter(
1220
- (item) => String(item[field]) === req.params.id
1221
- );
1222
- return children;
1223
- });
1224
- server.get(itemPath, (req, reply) => {
1225
- const parentCollection = this.storage.getCollection(parent) ?? [];
1226
- const parentItem = findById(parentCollection, req.params.id);
1227
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1228
- const childItem = (this.storage.getCollection(child) ?? []).find(
1229
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1230
- );
1231
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1232
- return childItem;
1233
- });
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
+ }
1234
1790
  }
1235
1791
  }
1236
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
+ }
1237
1845
  };
1238
1846
 
1239
1847
  // src/router/routes/snapshot.routes.ts
@@ -1412,8 +2020,15 @@ function loadConfigFile(configPath) {
1412
2020
 
1413
2021
  // src/utils/handlers.ts
1414
2022
  import { existsSync as existsSync3 } from "fs";
2023
+ var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs"];
1415
2024
  async function loadHandlers(filePath) {
1416
2025
  if (!existsSync3(filePath)) return /* @__PURE__ */ new Map();
2026
+ if (!ALLOWED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
2027
+ console.error(
2028
+ ` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs and .cjs files are allowed\x1B[0m`
2029
+ );
2030
+ return /* @__PURE__ */ new Map();
2031
+ }
1417
2032
  try {
1418
2033
  const mod = await import(filePath);
1419
2034
  const map = /* @__PURE__ */ new Map();