@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.
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",
@@ -292,8 +735,11 @@ var METHOD_COLOR = {
292
735
  DELETE: "#f85149",
293
736
  fn: "#f0883e"
294
737
  };
738
+ function escapeHtml(str) {
739
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
740
+ }
295
741
  function badge(label, color, bg) {
296
- return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${label}</span>`;
742
+ return `<span class="badge" style="background:${bg};color:${color};border:1px solid ${color}40">${escapeHtml(label)}</span>`;
297
743
  }
298
744
  function methodBadge(method) {
299
745
  const color = METHOD_COLOR[method] ?? "#7d8590";
@@ -303,7 +749,7 @@ function endpointRow(method, path, desc) {
303
749
  return `
304
750
  <tr>
305
751
  <td class="method-cell">${methodBadge(method)}</td>
306
- <td class="path-cell"><code>${path}</code></td>
752
+ <td class="path-cell"><code>${escapeHtml(path)}</code></td>
307
753
  <td class="desc-cell">${desc}</td>
308
754
  </tr>`;
309
755
  }
@@ -337,7 +783,7 @@ function resourceAccordion(name, base, isOpen) {
337
783
  return `
338
784
  <details class="resource-card" ${isOpen ? "open" : ""}>
339
785
  <summary>
340
- <span class="resource-name">/${name}</span>
786
+ <span class="resource-name">/${escapeHtml(name)}</span>
341
787
  <span class="route-count">6 routes</span>
342
788
  </summary>
343
789
  <table>
@@ -345,6 +791,139 @@ function resourceAccordion(name, base, isOpen) {
345
791
  </table>
346
792
  </details>`;
347
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
+ }
348
927
  function examplesBlock(collections, relations, base, host, options, firstCustomRoute) {
349
928
  const examples = [];
350
929
  const firstCol = collections[0];
@@ -375,35 +954,36 @@ curl -X DELETE ${p}/1`
375
954
  const firstRel = Object.entries(relations)[0];
376
955
  if (firstRel) {
377
956
  const [child, fields] = firstRel;
378
- const fk = Object.keys(fields)[0];
379
- const expandKey = fk.replace(/Id$/i, "");
380
- examples.push(
381
- `# Embed parent with ?_expand
382
- curl "${host}${base}/${child}/1?_expand=${expandKey}"`
383
- );
384
- }
385
- const firstRelEntry = Object.entries(relations)[0];
386
- if (firstRelEntry) {
387
- const [child, fields] = firstRelEntry;
388
- const parent = Object.values(fields)[0];
389
- if (parent) {
390
- examples.push(`# Nested resource
391
- 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
+ }
392
972
  }
393
973
  }
394
974
  if (options.pageable.enabled && firstCol) {
395
975
  examples.push(`# Pageable envelope
396
976
  curl "${host}${base}/${firstCol}?_page=2"`);
397
977
  }
398
- const firstParentRel = Object.entries(relations).find(
399
- ([, fields]) => Object.values(fields).includes(firstCol ?? "")
400
- );
401
978
  if (firstCol) {
402
979
  examples.push(
403
980
  `# Project fields with ?_fields
404
981
  curl "${host}${base}/${firstCol}?_fields=id,name"`
405
982
  );
406
983
  }
984
+ const firstParentRel = Object.entries(relations).find(
985
+ ([, fields]) => Object.values(fields).some((def) => def.type !== "many2many" && def.target === firstCol)
986
+ );
407
987
  if (firstParentRel && firstCol) {
408
988
  const [childName] = firstParentRel;
409
989
  examples.push(
@@ -421,7 +1001,7 @@ curl -X POST ${host}/_snapshot/reset`
421
1001
  }
422
1002
  if (firstCustomRoute) {
423
1003
  const method = firstCustomRoute.method?.toUpperCase() ?? "GET";
424
- const fullPath = `${host}${base}${firstCustomRoute.path}`;
1004
+ const fullPath = `${host}${base}${escapeHtml(firstCustomRoute.path ?? "")}`;
425
1005
  const curlFlag = method === "GET" ? "" : `-X ${method} `;
426
1006
  examples.push(`# Custom route
427
1007
  curl ${curlFlag}${fullPath}`);
@@ -429,9 +1009,21 @@ curl ${curlFlag}${fullPath}`);
429
1009
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
430
1010
  return `<pre>${highlighted}</pre>`;
431
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
+ })();
432
1023
  function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map()) {
433
1024
  const collections = Object.keys(storage.getData());
434
1025
  const relations = storage.getRelations();
1026
+ const customRoutes = storage.getRoutes();
435
1027
  const base = options.base;
436
1028
  const host = `http://${options.host}:${options.port}`;
437
1029
  const modes = [];
@@ -445,104 +1037,6 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
445
1037
  if (options.idStrategy !== "increment")
446
1038
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
447
1039
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
448
- const nestedRows = [];
449
- for (const [child, fields] of Object.entries(relations)) {
450
- for (const [, parent] of Object.entries(fields)) {
451
- const nestedPath = `${base}/${parent}/:id/${child}`;
452
- const parentSingular = parent.endsWith("s") ? parent.slice(0, -1) : parent;
453
- nestedRows.push(
454
- endpointRow("GET", nestedPath, `List ${child} belonging to a ${parentSingular}.`)
455
- );
456
- }
457
- }
458
- const nestedAccordion = nestedRows.length ? `
459
- <details class="resource-card nested-card">
460
- <summary>
461
- <span class="resource-name">Nested routes</span>
462
- <span class="route-count">${nestedRows.length} route${nestedRows.length !== 1 ? "s" : ""}</span>
463
- </summary>
464
- <table><tbody>${nestedRows.join("")}</tbody></table>
465
- </details>` : "";
466
- const snapshotAccordion = options.snapshot ? `
467
- <details class="resource-card nested-card">
468
- <summary>
469
- <span class="resource-name">/_snapshot</span>
470
- <span class="route-count">3 routes</span>
471
- </summary>
472
- <table><tbody>
473
- ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
474
- ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
475
- ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
476
- </tbody></table>
477
- </details>` : "";
478
- const customRoutes = storage.getRoutes();
479
- const customRoutesAccordion = customRoutes.length ? `
480
- <details class="resource-card nested-card">
481
- <summary>
482
- <span class="resource-name">Custom routes</span>
483
- <span class="route-count">${customRoutes.length} route${customRoutes.length !== 1 ? "s" : ""}</span>
484
- </summary>
485
- <table><tbody>
486
- ${customRoutes.map((r) => {
487
- const fullPath = `${base}${r.path}`;
488
- const tags = [];
489
- if (r.error) {
490
- tags.push(`<span style="color:#f85149;font-size:11px">error\xB7${r.error}</span>`);
491
- }
492
- if (r.delay && r.delay > 0) {
493
- tags.push(`<span style="color:#fb923c;font-size:11px">delay\xB7${r.delay}ms</span>`);
494
- }
495
- if (r.scenarios?.length) {
496
- const hasOr = r.scenarios.some((s) => Array.isArray(s.when));
497
- tags.push(
498
- `<span style="color:#a371f7;font-size:11px">scenarios\xB7${r.scenarios.length}${hasOr ? " (OR)" : ""}</span>`
499
- );
500
- }
501
- if (r.otherwise) {
502
- tags.push(`<span style="color:var(--text-muted);font-size:11px">otherwise</span>`);
503
- }
504
- let desc;
505
- if (r.error) {
506
- desc = `Error injection \u2014 <code>${r.error}</code>`;
507
- } else if (r.handler) {
508
- const found = handlers.has(r.handler);
509
- desc = found ? `Handler \u2014 <code>${r.handler}()</code>` : `Handler \u2014 <code>${r.handler}()</code> <span style="color:#f85149">(not loaded)</span>`;
510
- } else if (r.scenarios?.length) {
511
- const hasTemplateInScenarios = r.scenarios.some((s) => s.response.body != null && hasTemplates(s.response.body)) || r.otherwise?.body != null && hasTemplates(r.otherwise.body);
512
- desc = hasTemplateInScenarios ? `Scenarios \u2014 <code>{{\u2026}}</code>` : `Scenarios`;
513
- } else if (r.response?.body != null && hasTemplates(r.response.body)) {
514
- desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
515
- } else {
516
- const status = r.response?.status ?? 200;
517
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
518
- }
519
- if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
520
- return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
521
- }).join("")}
522
- </tbody></table>
523
- </details>` : "";
524
- const routesByHandler = /* @__PURE__ */ new Map();
525
- for (const r of customRoutes) {
526
- if (r.handler) {
527
- const list = routesByHandler.get(r.handler) ?? [];
528
- list.push({ method: (r.method ?? "GET").toUpperCase(), path: `${base}${r.path}` });
529
- routesByHandler.set(r.handler, list);
530
- }
531
- }
532
- const handlersAccordion = handlers.size > 0 ? `
533
- <details class="resource-card nested-card">
534
- <summary>
535
- <span class="resource-name">Handlers</span>
536
- <span class="route-count">${handlers.size} function${handlers.size !== 1 ? "s" : ""}</span>
537
- </summary>
538
- <table><tbody>
539
- ${[...handlers.keys()].map((name) => {
540
- const routes = routesByHandler.get(name);
541
- const routeDesc = routes ? routes.map((r) => `<code>${r.method} ${r.path}</code>`).join(", ") : `<span style="color:var(--text-muted)">not referenced in _routes</span>`;
542
- return endpointRow("fn", name + "()", routeDesc);
543
- }).join("")}
544
- </tbody></table>
545
- </details>` : "";
546
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.`;
547
1041
  return `<!DOCTYPE html>
548
1042
  <html lang="en">
@@ -690,10 +1184,10 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
690
1184
  <h2>Endpoints</h2>
691
1185
  <div class="endpoints-grid">
692
1186
  ${accordions}
693
- ${nestedAccordion}
694
- ${snapshotAccordion}
695
- ${customRoutesAccordion}
696
- ${handlersAccordion}
1187
+ ${nestedRoutesAccordion(relations, base)}
1188
+ ${options.snapshot ? snapshotAccordion() : ""}
1189
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1190
+ ${handlersAccordion(handlers, customRoutes, base)}
697
1191
  </div>
698
1192
 
699
1193
  <h2>Query Parameters</h2>
@@ -779,6 +1273,7 @@ function applyOperator(itemValue, op, filterValue) {
779
1273
  case "_start":
780
1274
  return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
781
1275
  case "_regex": {
1276
+ if (filterValue.length > 200) return false;
782
1277
  try {
783
1278
  return new RegExp(filterValue, "i").test(strItem);
784
1279
  } catch {
@@ -892,10 +1387,11 @@ function expandItems(input, query, resource, storage) {
892
1387
  const resourceRelations = storage.getRelations()[resource] ?? {};
893
1388
  const expansions = /* @__PURE__ */ new Map();
894
1389
  for (const expandKey of keys) {
895
- for (const [field, parentCollection] of Object.entries(resourceRelations)) {
1390
+ for (const [field, def] of Object.entries(resourceRelations)) {
1391
+ if (def.type === "many2many") continue;
896
1392
  const derivedKey = field.replace(/Id$/i, "");
897
- if (derivedKey === expandKey || parentCollection === expandKey || parentCollection === `${expandKey}s`) {
898
- expansions.set(expandKey, { field, parentCollection });
1393
+ if (derivedKey === expandKey || def.target === expandKey || def.target === `${expandKey}s`) {
1394
+ expansions.set(expandKey, { field, parentCollection: def.target });
899
1395
  break;
900
1396
  }
901
1397
  }
@@ -924,10 +1420,24 @@ function embedItems(input, query, resource, storage) {
924
1420
  const relations = storage.getRelations();
925
1421
  const embeds = /* @__PURE__ */ new Map();
926
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
+ }
927
1437
  outer: for (const [childCollection, fields] of Object.entries(relations)) {
928
- for (const [fkField, parentCollection] of Object.entries(fields)) {
929
- if (parentCollection === resource && childCollection === embedKey) {
930
- 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 });
931
1441
  break outer;
932
1442
  }
933
1443
  }
@@ -936,10 +1446,57 @@ function embedItems(input, query, resource, storage) {
936
1446
  if (embeds.size === 0) return isArray ? items : input;
937
1447
  const result = items.map((item) => {
938
1448
  const out = { ...item };
939
- for (const [embedKey, { childCollection, fkField }] of embeds) {
940
- out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
941
- (child) => String(child[fkField]) === String(item["id"])
942
- );
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
+ }
943
1500
  }
944
1501
  return out;
945
1502
  });
@@ -979,7 +1536,12 @@ var CollectionRouteCommand = class {
979
1536
  const totalPages = Math.ceil(totalItems / limit) || 1;
980
1537
  const data = projectFields(
981
1538
  embedItems(
982
- 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
+ ),
983
1545
  req.query,
984
1546
  this.resource,
985
1547
  this.storage
@@ -1011,7 +1573,12 @@ var CollectionRouteCommand = class {
1011
1573
  }
1012
1574
  return projectFields(
1013
1575
  embedItems(
1014
- 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
+ ),
1015
1582
  req.query,
1016
1583
  this.resource,
1017
1584
  this.storage
@@ -1192,7 +1759,12 @@ var ItemRouteCommand = class {
1192
1759
  const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
1193
1760
  return projectFields(
1194
1761
  embedItems(
1195
- 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
+ ),
1196
1768
  req.query,
1197
1769
  this.resource,
1198
1770
  this.storage
@@ -1235,32 +1807,68 @@ var NestedRouteCommand = class {
1235
1807
  relations;
1236
1808
  base;
1237
1809
  register(server) {
1238
- for (const [child, fields] of Object.entries(this.relations)) {
1239
- for (const [field, parent] of Object.entries(fields)) {
1240
- const collectionPath = `${this.base}/${parent}/:id/${child}`;
1241
- const itemPath = `${this.base}/${parent}/:id/${child}/:childId`;
1242
- server.get(collectionPath, (req, reply) => {
1243
- const parentCollection = this.storage.getCollection(parent) ?? [];
1244
- const parentItem = findById(parentCollection, req.params.id);
1245
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1246
- const children = (this.storage.getCollection(child) ?? []).filter(
1247
- (item) => String(item[field]) === req.params.id
1248
- );
1249
- return children;
1250
- });
1251
- server.get(itemPath, (req, reply) => {
1252
- const parentCollection = this.storage.getCollection(parent) ?? [];
1253
- const parentItem = findById(parentCollection, req.params.id);
1254
- if (!parentItem) return reply.status(404).send({ error: "Not found" });
1255
- const childItem = (this.storage.getCollection(child) ?? []).find(
1256
- (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
1257
- );
1258
- if (!childItem) return reply.status(404).send({ error: "Not found" });
1259
- return childItem;
1260
- });
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
+ }
1261
1817
  }
1262
1818
  }
1263
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
+ }
1264
1872
  };
1265
1873
 
1266
1874
  // src/router/routes/snapshot.routes.ts
@@ -1439,8 +2047,15 @@ function loadConfigFile(configPath) {
1439
2047
 
1440
2048
  // src/utils/handlers.ts
1441
2049
  var import_node_fs5 = require("fs");
2050
+ var ALLOWED_EXTENSIONS = [".js", ".mjs", ".cjs"];
1442
2051
  async function loadHandlers(filePath) {
1443
2052
  if (!(0, import_node_fs5.existsSync)(filePath)) return /* @__PURE__ */ new Map();
2053
+ if (!ALLOWED_EXTENSIONS.some((ext) => filePath.endsWith(ext))) {
2054
+ console.error(
2055
+ ` \x1B[31m[handlers] ${filePath} \u2014 only .js, .mjs and .cjs files are allowed\x1B[0m`
2056
+ );
2057
+ return /* @__PURE__ */ new Map();
2058
+ }
1444
2059
  try {
1445
2060
  const mod = await import(filePath);
1446
2061
  const map = /* @__PURE__ */ new Map();