@yrest/cli 0.6.0 → 0.8.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/README.md CHANGED
@@ -1,4 +1,8 @@
1
- # yRest
1
+ <div align="center">
2
+ <img src="assets/yRest-banner.png" alt="yRest" width="100%" />
3
+ </div>
4
+
5
+ <br/>
2
6
 
3
7
  [![npm version](https://img.shields.io/npm/v/@yrest/cli)](https://www.npmjs.com/package/@yrest/cli)
4
8
  [![npm downloads](https://img.shields.io/npm/dw/@yrest/cli)](https://www.npmjs.com/package/@yrest/cli)
@@ -6,6 +10,7 @@
6
10
  [![CI](https://github.com/aggiovato/yRest/actions/workflows/ci.yml/badge.svg)](https://github.com/aggiovato/yRest/actions)
7
11
  [![Node](https://img.shields.io/node/v/@yrest/cli)](https://www.npmjs.com/package/@yrest/cli)
8
12
  [![TypeScript](https://img.shields.io/badge/TypeScript-ready-blue)](https://www.typescriptlang.org/)
13
+ [![Socket](https://badge.socket.dev/npm/package/@yrest/cli/0.8.0)](https://socket.dev/npm/package/@yrest/cli)
9
14
 
10
15
  YAML-powered json-server alternative. Zero-config REST API mock server with full CRUD, relations, filters and snapshots from a `db.yml` file.
11
16
 
@@ -56,6 +61,7 @@ A YAML-first alternative to json-server for frontend development.
56
61
  | Custom static routes (`_routes`) | ✅ | ❌ |
57
62
  | Template variables in responses | ✅ | ❌ |
58
63
  | Handler functions (JS logic) | ✅ | ❌ |
64
+ | Conditional scenarios (`scenarios:`) | ✅ | ❌ |
59
65
  | Snapshot endpoints | ✅ | ❌ |
60
66
  | Config file | ✅ | ⚠️ |
61
67
  | API overview page (`/_about`) | ✅ | ❌ |
@@ -156,6 +162,7 @@ npx @yrest/cli serve db.yml --handlers yrest.handlers.js
156
162
  | `--pageable [n]` | `false` | Wrap GET collection responses in `{ data, pagination }`. Optional limit |
157
163
  | `--snapshot` | `false` | Save initial state snapshot and expose `/_snapshot` endpoints |
158
164
  | `--handlers <file>` | _(none)_ | Path to a JS file exporting handler functions for custom routes |
165
+ | `--id-strategy` | `increment` | Id generation for new items: `increment` (auto-int) or `uuid` |
159
166
 
160
167
  All flags can also be set in `yrest.config.yml` (see below). CLI flags always take priority over the config file.
161
168
 
@@ -177,6 +184,7 @@ host: localhost
177
184
  # pageable: false # true (limit 10), or a number (custom limit)
178
185
  # snapshot: false
179
186
  # handlers: yrest.handlers.js
187
+ # idStrategy: increment # increment or uuid
180
188
  ```
181
189
 
182
190
  **Priority order** (highest wins): CLI flags → `yrest.config.yml` → schema defaults.
@@ -497,6 +505,133 @@ Available variables:
497
505
 
498
506
  When a field contains only a single `{{variable}}` placeholder, the resolved value preserves its original type (number, boolean, object). When embedded in a larger string it is stringified.
499
507
 
508
+ ### Conditional scenarios
509
+
510
+ Define multiple conditional response variants for a custom route. Scenarios are evaluated in declaration order — the first matching `when:` wins. If none match, the `otherwise:` block is used (if defined), otherwise the static `response:` block.
511
+
512
+ ```yaml
513
+ _routes:
514
+ - method: POST
515
+ path: /login
516
+ scenarios:
517
+ - when:
518
+ body.email: ana@test.com
519
+ body.password: secret
520
+ response:
521
+ status: 200
522
+ body:
523
+ token: tok-ana
524
+ - when:
525
+ body.email: admin@test.com
526
+ body.password: admin
527
+ response:
528
+ status: 200
529
+ body:
530
+ token: tok-admin
531
+ role: admin
532
+ otherwise:
533
+ status: 401
534
+ body:
535
+ error: Invalid credentials
536
+ ```
537
+
538
+ **`when:` as an object** — all entries must match (AND semantics):
539
+
540
+ ```yaml
541
+ when:
542
+ body.email: ana@test.com
543
+ body.password: secret
544
+ ```
545
+
546
+ **`when:` as an array of objects** — any group satisfying all its conditions matches (OR of ANDs):
547
+
548
+ ```yaml
549
+ when:
550
+ - body.role: admin
551
+ - body.role: superadmin
552
+ ```
553
+
554
+ Condition keys use dot-notation to address request data:
555
+
556
+ | Prefix | Example | Resolves to |
557
+ | ----------- | ------------------- | -------------------------- |
558
+ | `body.X` | `body.email` | `req.body.email` |
559
+ | `params.X` | `params.id` | `req.params.id` |
560
+ | `query.X` | `query.page` | `req.query.page` |
561
+ | `headers.X` | `headers.x-api-key` | `req.headers["x-api-key"]` |
562
+
563
+ Field operator suffixes (`_ne`, `_like`, `_start`, `_regex`, `_gte`, `_lte`) work on condition keys exactly as they do on query params:
564
+
565
+ ```yaml
566
+ scenarios:
567
+ - when:
568
+ body.name_like: ana # name contains "ana" (case-insensitive)
569
+ body.age_gte: "18" # age >= 18
570
+ response:
571
+ status: 200
572
+ body: { ok: true }
573
+ ```
574
+
575
+ Template variables (`{{}}`) are supported in both scenario and `otherwise` response bodies:
576
+
577
+ ```yaml
578
+ scenarios:
579
+ - when:
580
+ body.email: ana@test.com
581
+ response:
582
+ status: 200
583
+ body:
584
+ message: "Welcome {{body.email}}"
585
+ otherwise:
586
+ status: 401
587
+ body:
588
+ error: "Unknown user: {{body.email}}"
589
+ ```
590
+
591
+ ### Per-route delay
592
+
593
+ Add a fixed delay (ms) to a specific route without affecting the rest of the server. Takes priority over the global `--delay` option for that route:
594
+
595
+ ```yaml
596
+ _routes:
597
+ - method: GET
598
+ path: /slow-endpoint
599
+ delay: 800
600
+ response:
601
+ status: 200
602
+ body: { data: loaded }
603
+ ```
604
+
605
+ ### Error injection
606
+
607
+ Force a custom route to always return a specific HTTP error, regardless of handlers, scenarios or the static response. Useful for simulating outages, payment failures or auth errors:
608
+
609
+ ```yaml
610
+ _routes:
611
+ - method: GET
612
+ path: /payments
613
+ error: 503
614
+ errorBody:
615
+ message: Service temporarily unavailable
616
+ retryAfter: 30
617
+
618
+ - method: POST
619
+ path: /checkout
620
+ error: 402
621
+ errorBody:
622
+ error: Payment required
623
+
624
+ - method: GET
625
+ path: /slow-failure
626
+ delay: 400
627
+ error: 500
628
+ ```
629
+
630
+ - `error` takes priority over `handler`, `scenarios` and `response` — the route always returns this status.
631
+ - `errorBody` is optional. If omitted, the default body is `{ "error": "Forced error NNN" }`.
632
+ - `delay` still applies before the error is returned.
633
+ - Shown in `/_about` with a red `error·NNN` badge.
634
+
500
635
  ### Handler functions
501
636
 
502
637
  For routes that need real logic (conditional responses, stateful mocks, request inspection), reference a JavaScript function via the `handler:` field:
@@ -844,23 +979,26 @@ const server = createYrestServer({
844
979
 
845
980
  ## Roadmap
846
981
 
847
- | Feature | Status |
848
- | ------------------------------------------------- | ------ |
849
- | Full CRUD from `db.yml` | ✅ |
850
- | Field filters, operators, full-text search | ✅ |
851
- | Relations, `_expand`, `_embed`, nested routes | ✅ |
852
- | Pagination, sorting, field projection | ✅ |
853
- | Watch, readonly, delay, snapshot modes | ✅ |
854
- | Custom routes (`_routes`) with static responses | ✅ |
855
- | Template variables in responses (`{{params.id}}`) | ✅ |
856
- | Handler functions (`yrest.handlers.js`) | ✅ |
857
- | Visual panel (`/_panel`) | 🔜 |
858
- | Programmatic API for Vitest / Playwright | ✅ |
859
- | Docker image | 🔜 |
860
- | OpenAPI export (`yrest openapi db.yml`) | 🔜 |
861
- | VS Code extension with YAML snippets | 🔜 |
862
- | Request validation with JSON Schema | 🔜 |
863
- | Conditional scenarios | 🔜 |
982
+ | Feature | Status |
983
+ | -------------------------------------------------- | ------ |
984
+ | Full CRUD from `db.yml` | ✅ |
985
+ | Field filters, operators, full-text search | ✅ |
986
+ | Relations, `_expand`, `_embed`, nested routes | ✅ |
987
+ | Pagination, sorting, field projection | ✅ |
988
+ | Watch, readonly, delay, snapshot modes | ✅ |
989
+ | Custom routes (`_routes`) with static responses | ✅ |
990
+ | Template variables in responses (`{{params.id}}`) | ✅ |
991
+ | Handler functions (`yrest.handlers.js`) | ✅ |
992
+ | Visual panel (`/_panel`) | 🔜 |
993
+ | Programmatic API for Vitest / Playwright | ✅ |
994
+ | Docker image | 🔜 |
995
+ | OpenAPI export (`yrest openapi db.yml`) | 🔜 |
996
+ | VS Code extension with YAML snippets | 🔜 |
997
+ | Request validation with JSON Schema | 🔜 |
998
+ | Conditional scenarios (`scenarios:`, `otherwise:`) | |
999
+ | Per-route delay (`delay:`) | ✅ |
1000
+ | Error injection (`error:` in `_routes`) | ✅ |
1001
+ | Configurable ID strategy (`idStrategy`) | ✅ |
864
1002
 
865
1003
  ---
866
1004
 
Binary file
Binary file
Binary file
Binary file
Binary file
package/dist/cli/index.js CHANGED
@@ -132,8 +132,8 @@ function registerInit(program2) {
132
132
  }
133
133
 
134
134
  // src/cli/commands/serve.ts
135
- var import_node_fs5 = require("fs");
136
- var import_node_path3 = require("path");
135
+ var import_node_fs6 = require("fs");
136
+ var import_node_path4 = require("path");
137
137
 
138
138
  // src/storage/yrestStorage.ts
139
139
  var import_node_fs2 = require("fs");
@@ -223,6 +223,11 @@ function createYrestStorage(filePath) {
223
223
  var import_fastify = __toESM(require("fastify"));
224
224
  var import_cors = __toESM(require("@fastify/cors"));
225
225
 
226
+ // src/router/templates/about.template.ts
227
+ var import_node_fs3 = require("fs");
228
+ var import_node_path3 = require("path");
229
+ var import_node_url = require("url");
230
+
226
231
  // src/utils/interpolate.ts
227
232
  var import_node_crypto2 = require("crypto");
228
233
  function getPath(obj, path) {
@@ -270,6 +275,15 @@ function hasTemplates(value) {
270
275
  }
271
276
 
272
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
+ })();
273
287
  var METHOD_COLOR = {
274
288
  GET: "#3fb950",
275
289
  POST: "#58a6ff",
@@ -428,6 +442,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
428
442
  modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
429
443
  if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
430
444
  if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
445
+ if (options.idStrategy !== "increment")
446
+ modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
431
447
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
432
448
  const nestedRows = [];
433
449
  for (const [child, fields] of Object.entries(relations)) {
@@ -469,16 +485,38 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
469
485
  <table><tbody>
470
486
  ${customRoutes.map((r) => {
471
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
+ }
472
504
  let desc;
473
- if (r.handler) {
505
+ if (r.error) {
506
+ desc = `Error injection \u2014 <code>${r.error}</code>`;
507
+ } else if (r.handler) {
474
508
  const found = handlers.has(r.handler);
475
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`;
476
513
  } else if (r.response?.body != null && hasTemplates(r.response.body)) {
477
- desc = `Dynamic body \u2014 <code>{{\u2026}}</code>`;
514
+ desc = `Dynamic \u2014 <code>{{\u2026}}</code>`;
478
515
  } else {
479
516
  const status = r.response?.status ?? 200;
480
- desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + custom headers` : ""}`;
517
+ desc = `Static \u2014 <code>${status}</code>${r.response?.headers ? ` + headers` : ""}`;
481
518
  }
519
+ if (tags.length) desc += `&ensp;${tags.join("&ensp;")}`;
482
520
  return endpointRow(r.method?.toUpperCase() ?? "GET", fullPath, desc);
483
521
  }).join("")}
484
522
  </tbody></table>
@@ -630,7 +668,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
630
668
 
631
669
  <div class="banner">
632
670
  <div class="banner-inner">
633
- <h1><span class="y">y</span><span class="rest">rest</span></h1>
671
+ ${LOGO_SRC ? `<img src="${LOGO_SRC}" alt="yRest" height="68" style="display:block;margin-bottom:0px" />` : `<h1><span class="y">y</span><span class="rest">Rest</span></h1>`}
634
672
  <p>Zero-config REST API mock server</p>
635
673
  <div class="banner-meta">
636
674
  <span>URL <strong>${host}</strong></span>
@@ -682,7 +720,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
682
720
  ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
683
721
 
684
722
  <footer>
685
- Powered by <a href="https://github.com/aggiovato/yaml-rest" target="_blank">@aggiovato/yrest</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
723
+ Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
686
724
  </footer>
687
725
 
688
726
  </div>
@@ -713,6 +751,10 @@ function nextId(items) {
713
751
  const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
714
752
  return ids.length > 0 ? Math.max(...ids) + 1 : 1;
715
753
  }
754
+ function generateId(items, strategy) {
755
+ if (strategy === "uuid") return crypto.randomUUID();
756
+ return nextId(items);
757
+ }
716
758
  function firstParam(value) {
717
759
  if (value === void 0) return void 0;
718
760
  return Array.isArray(value) ? value[0] : value;
@@ -800,10 +842,10 @@ function findById(items, id) {
800
842
  function findIndexById(items, id) {
801
843
  return items.findIndex((i) => String(i["id"]) === id);
802
844
  }
803
- function createItem(storage, resource, body) {
845
+ function createItem(storage, resource, body, idStrategy = "increment") {
804
846
  const collection = storage.getCollection(resource) ?? [];
805
847
  const item = {
806
- id: body["id"] !== void 0 ? body["id"] : nextId(collection),
848
+ id: body["id"] !== void 0 ? body["id"] : generateId(collection, idStrategy),
807
849
  ...body
808
850
  };
809
851
  storage.setCollection(resource, [...collection, item]);
@@ -981,13 +1023,74 @@ var CollectionRouteCommand = class {
981
1023
  if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
982
1024
  return reply.status(400).send({ error: "Request body must be a JSON object" });
983
1025
  }
984
- const item = createItem(this.storage, this.resource, req.body);
1026
+ const item = createItem(
1027
+ this.storage,
1028
+ this.resource,
1029
+ req.body,
1030
+ this.options.idStrategy
1031
+ );
985
1032
  return reply.status(201).send(expandItems(item, req.query, this.resource, this.storage));
986
1033
  });
987
1034
  }
988
1035
  };
989
1036
 
1037
+ // src/utils/conditions.ts
1038
+ function resolveRequestPath(dotPath, req) {
1039
+ const [root, ...rest] = dotPath.split(".");
1040
+ let value;
1041
+ switch (root) {
1042
+ case "body":
1043
+ value = req.body;
1044
+ break;
1045
+ case "params":
1046
+ value = req.params;
1047
+ break;
1048
+ case "query":
1049
+ value = req.query;
1050
+ break;
1051
+ case "headers":
1052
+ value = req.headers;
1053
+ break;
1054
+ default:
1055
+ return void 0;
1056
+ }
1057
+ for (const key of rest) {
1058
+ if (value != null && typeof value === "object") {
1059
+ value = value[key];
1060
+ } else {
1061
+ return void 0;
1062
+ }
1063
+ }
1064
+ return value;
1065
+ }
1066
+ function matchConditionGroup(group, req) {
1067
+ return Object.entries(group).every(([key, expected]) => {
1068
+ const op = OPERATORS.find((o) => key.endsWith(o));
1069
+ if (op) {
1070
+ const path = key.slice(0, -op.length);
1071
+ const value2 = resolveRequestPath(path, req);
1072
+ if (value2 === void 0) return false;
1073
+ return applyOperator(value2, op, String(expected));
1074
+ }
1075
+ const value = resolveRequestPath(key, req);
1076
+ return String(value) === String(expected);
1077
+ });
1078
+ }
1079
+ function matchWhen(when, req) {
1080
+ if (Array.isArray(when)) {
1081
+ return when.some((group) => matchConditionGroup(group, req));
1082
+ }
1083
+ return matchConditionGroup(when, req);
1084
+ }
1085
+ function findMatchingScenario(scenarios, req) {
1086
+ return scenarios.find((s) => matchWhen(s.when, req));
1087
+ }
1088
+
990
1089
  // src/router/routes/custom.routes.ts
1090
+ function resolveBody(body, ctx) {
1091
+ if (body != null && hasTemplates(body)) return interpolate(body, ctx);
1092
+ return body ?? null;
1093
+ }
991
1094
  var CustomRouteCommand = class {
992
1095
  constructor(storage, base, handlers = /* @__PURE__ */ new Map()) {
993
1096
  this.storage = storage;
@@ -1012,6 +1115,13 @@ var CustomRouteCommand = class {
1012
1115
  method,
1013
1116
  url,
1014
1117
  handler: async (req, reply) => {
1118
+ if (route.delay && route.delay > 0) {
1119
+ await new Promise((resolve5) => setTimeout(resolve5, route.delay));
1120
+ }
1121
+ if (route.error) {
1122
+ const body2 = route.errorBody ?? { error: `Forced error ${route.error}` };
1123
+ return reply.status(route.error).send(body2);
1124
+ }
1015
1125
  for (const [key, value] of Object.entries(headers)) {
1016
1126
  reply.header(key, value);
1017
1127
  }
@@ -1021,13 +1131,13 @@ var CustomRouteCommand = class {
1021
1131
  return reply.status(501).send({ error: `Handler "${handlerName}" is not defined in the handlers file` });
1022
1132
  }
1023
1133
  try {
1024
- const ctx = {
1134
+ const ctx2 = {
1025
1135
  params: req.params,
1026
1136
  query: req.query,
1027
1137
  body: req.body,
1028
1138
  headers: req.headers
1029
1139
  };
1030
- const result = await fn(ctx);
1140
+ const result = await fn(ctx2);
1031
1141
  const resStatus = result.status ?? 200;
1032
1142
  for (const [k, v] of Object.entries(result.headers ?? {})) {
1033
1143
  reply.header(k, v);
@@ -1039,12 +1149,24 @@ var CustomRouteCommand = class {
1039
1149
  return reply.status(500).send({ error: `Handler "${handlerName}" threw an error: ${msg}` });
1040
1150
  }
1041
1151
  }
1042
- const body = dynamic ? interpolate(rawBody, {
1152
+ const ctx = {
1043
1153
  params: req.params,
1044
1154
  query: req.query,
1045
1155
  body: req.body,
1046
1156
  headers: req.headers
1047
- }) : rawBody;
1157
+ };
1158
+ if (route.scenarios?.length) {
1159
+ const matched = findMatchingScenario(route.scenarios, ctx);
1160
+ const active = matched?.response ?? route.otherwise;
1161
+ if (active) {
1162
+ const aStatus = active.status ?? 200;
1163
+ const aBody = resolveBody(active.body, ctx);
1164
+ for (const [k, v] of Object.entries(active.headers ?? {})) reply.header(k, v);
1165
+ if (!active.body && aStatus === 204) return reply.status(aStatus).send();
1166
+ return reply.status(aStatus).send(aBody);
1167
+ }
1168
+ }
1169
+ const body = dynamic ? interpolate(rawBody, ctx) : rawBody;
1048
1170
  if (body === null && status === 204) return reply.status(status).send();
1049
1171
  return reply.status(status).send(body);
1050
1172
  }
@@ -1296,22 +1418,29 @@ var yrestOptionsSchema = import_zod.z.object({
1296
1418
  pageable: import_zod.z.union([import_zod.z.boolean(), import_zod.z.coerce.number().int().positive()]).default(false).transform((v) => ({
1297
1419
  enabled: v !== false,
1298
1420
  limit: v === false || v === true ? 10 : v
1299
- }))
1421
+ })),
1422
+ /**
1423
+ * Strategy used to generate `id` values for new items when no `id` is provided in the body.
1424
+ *
1425
+ * - `"increment"` (default) — next integer above the current max id in the collection.
1426
+ * - `"uuid"` — a random UUID v4 string (`crypto.randomUUID()`).
1427
+ */
1428
+ idStrategy: import_zod.z.enum(["increment", "uuid"]).default("increment")
1300
1429
  });
1301
1430
 
1302
1431
  // src/config/loadConfigFile.ts
1303
- var import_node_fs3 = require("fs");
1432
+ var import_node_fs4 = require("fs");
1304
1433
  var import_yaml2 = require("yaml");
1305
1434
  function loadConfigFile(configPath) {
1306
- if (!(0, import_node_fs3.existsSync)(configPath)) return {};
1307
- const raw = (0, import_node_fs3.readFileSync)(configPath, "utf8");
1435
+ if (!(0, import_node_fs4.existsSync)(configPath)) return {};
1436
+ const raw = (0, import_node_fs4.readFileSync)(configPath, "utf8");
1308
1437
  return (0, import_yaml2.parse)(raw) ?? {};
1309
1438
  }
1310
1439
 
1311
1440
  // src/utils/handlers.ts
1312
- var import_node_fs4 = require("fs");
1441
+ var import_node_fs5 = require("fs");
1313
1442
  async function loadHandlers(filePath) {
1314
- if (!(0, import_node_fs4.existsSync)(filePath)) return /* @__PURE__ */ new Map();
1443
+ if (!(0, import_node_fs5.existsSync)(filePath)) return /* @__PURE__ */ new Map();
1315
1444
  try {
1316
1445
  const mod = await import(filePath);
1317
1446
  const map = /* @__PURE__ */ new Map();
@@ -1341,8 +1470,12 @@ function registerServe(program2) {
1341
1470
  ).option(
1342
1471
  "--handlers <file>",
1343
1472
  "Path to a JavaScript file exporting custom route handler functions"
1473
+ ).option(
1474
+ "--id-strategy <strategy>",
1475
+ "Id generation strategy for new items: increment (default) or uuid",
1476
+ "increment"
1344
1477
  ).action(async (file, flags, cmd) => {
1345
- const fileConfig = loadConfigFile((0, import_node_path3.join)(process.cwd(), "yrest.config.yml"));
1478
+ const fileConfig = loadConfigFile((0, import_node_path4.join)(process.cwd(), "yrest.config.yml"));
1346
1479
  const cliOverrides = Object.fromEntries(
1347
1480
  Object.entries(flags).filter(([key]) => cmd.getOptionValueSource(key) === "cli")
1348
1481
  );
@@ -1361,7 +1494,7 @@ function registerServe(program2) {
1361
1494
  console.error(`Error: cannot load "${options.file}" \u2014 ${msg}`);
1362
1495
  process.exit(1);
1363
1496
  }
1364
- const handlers = options.handlers ? await loadHandlers((0, import_node_path3.resolve)(options.handlers)) : /* @__PURE__ */ new Map();
1497
+ const handlers = options.handlers ? await loadHandlers((0, import_node_path4.resolve)(options.handlers)) : /* @__PURE__ */ new Map();
1365
1498
  const yrestServer = createYrestServerFromStorage(storage, options, handlers);
1366
1499
  await yrestServer.start();
1367
1500
  const collections = Object.keys(storage.getData());
@@ -1423,9 +1556,9 @@ function registerServe(program2) {
1423
1556
  ${dim(modes.map((m) => `[${m}]`).join(" "))}`);
1424
1557
  console.log("");
1425
1558
  if (options.watch) {
1426
- const absFile = (0, import_node_path3.resolve)(options.file);
1559
+ const absFile = (0, import_node_path4.resolve)(options.file);
1427
1560
  let debounce;
1428
- (0, import_node_fs5.watchFile)(absFile, { interval: 300 }, (curr, prev) => {
1561
+ (0, import_node_fs6.watchFile)(absFile, { interval: 300 }, (curr, prev) => {
1429
1562
  if (curr.mtimeMs === prev.mtimeMs) return;
1430
1563
  clearTimeout(debounce);
1431
1564
  debounce = setTimeout(() => {
@@ -1445,8 +1578,8 @@ function registerServe(program2) {
1445
1578
  }
1446
1579
 
1447
1580
  // src/cli/commands/handler.ts
1448
- var import_node_fs6 = require("fs");
1449
- var import_node_path4 = require("path");
1581
+ var import_node_fs7 = require("fs");
1582
+ var import_node_path5 = require("path");
1450
1583
  var import_yaml3 = require("yaml");
1451
1584
  var HANDLERS_FILE_HEADER = `// yrest handlers \u2014 loaded via "handlers:" in yrest.config.yml
1452
1585
  // Handler signature: (req: HandlerRequest) => HandlerResponse | Promise<HandlerResponse>
@@ -1473,44 +1606,44 @@ function registerHandler(program2) {
1473
1606
  "--register",
1474
1607
  "Also add a _routes entry to db.yml linking this handler to method + path"
1475
1608
  ).action((name, flags) => {
1476
- const fileConfig = loadConfigFile((0, import_node_path4.join)(process.cwd(), "yrest.config.yml"));
1477
- const handlersPath = (0, import_node_path4.resolve)(
1609
+ const fileConfig = loadConfigFile((0, import_node_path5.join)(process.cwd(), "yrest.config.yml"));
1610
+ const handlersPath = (0, import_node_path5.resolve)(
1478
1611
  fileConfig.handlers ?? "yrest.handlers.js"
1479
1612
  );
1480
- const dbPath = (0, import_node_path4.resolve)(fileConfig.file ?? "db.yml");
1481
- if (!(0, import_node_fs6.existsSync)(handlersPath)) {
1482
- (0, import_node_fs6.writeFileSync)(
1613
+ const dbPath = (0, import_node_path5.resolve)(fileConfig.file ?? "db.yml");
1614
+ if (!(0, import_node_fs7.existsSync)(handlersPath)) {
1615
+ (0, import_node_fs7.writeFileSync)(
1483
1616
  handlersPath,
1484
1617
  HANDLERS_FILE_HEADER + buildStub(name, flags.method, flags.path),
1485
1618
  "utf8"
1486
1619
  );
1487
- console.log(` Created ${(0, import_node_path4.basename)(handlersPath)}`);
1620
+ console.log(` Created ${(0, import_node_path5.basename)(handlersPath)}`);
1488
1621
  } else {
1489
- const existing = (0, import_node_fs6.readFileSync)(handlersPath, "utf8");
1622
+ const existing = (0, import_node_fs7.readFileSync)(handlersPath, "utf8");
1490
1623
  if (existing.includes(`function ${name}(`)) {
1491
- console.error(` Error: handler "${name}" already exists in ${(0, import_node_path4.basename)(handlersPath)}`);
1624
+ console.error(` Error: handler "${name}" already exists in ${(0, import_node_path5.basename)(handlersPath)}`);
1492
1625
  process.exit(1);
1493
1626
  }
1494
- (0, import_node_fs6.appendFileSync)(handlersPath, buildStub(name, flags.method, flags.path), "utf8");
1495
- console.log(` Added handler "${name}" to ${(0, import_node_path4.basename)(handlersPath)}`);
1627
+ (0, import_node_fs7.appendFileSync)(handlersPath, buildStub(name, flags.method, flags.path), "utf8");
1628
+ console.log(` Added handler "${name}" to ${(0, import_node_path5.basename)(handlersPath)}`);
1496
1629
  }
1497
1630
  if (flags.register) {
1498
1631
  if (!flags.method || !flags.path) {
1499
1632
  console.error(" Error: --register requires --method and --path");
1500
1633
  process.exit(1);
1501
1634
  }
1502
- if (!(0, import_node_fs6.existsSync)(dbPath)) {
1635
+ if (!(0, import_node_fs7.existsSync)(dbPath)) {
1503
1636
  console.error(` Error: database file not found at ${dbPath}`);
1504
1637
  process.exit(1);
1505
1638
  }
1506
- const raw = (0, import_yaml3.parse)((0, import_node_fs6.readFileSync)(dbPath, "utf8")) ?? {};
1639
+ const raw = (0, import_yaml3.parse)((0, import_node_fs7.readFileSync)(dbPath, "utf8")) ?? {};
1507
1640
  if (!Array.isArray(raw["_routes"])) raw["_routes"] = [];
1508
1641
  const routes = raw["_routes"];
1509
1642
  const alreadyRegistered = routes.some((r) => r["handler"] === name);
1510
1643
  if (!alreadyRegistered) {
1511
1644
  routes.push({ method: flags.method.toUpperCase(), path: flags.path, handler: name });
1512
- (0, import_node_fs6.writeFileSync)(dbPath, (0, import_yaml3.stringify)(raw), "utf8");
1513
- console.log(` Added _routes entry to ${(0, import_node_path4.basename)(dbPath)}`);
1645
+ (0, import_node_fs7.writeFileSync)(dbPath, (0, import_yaml3.stringify)(raw), "utf8");
1646
+ console.log(` Added _routes entry to ${(0, import_node_path5.basename)(dbPath)}`);
1514
1647
  } else {
1515
1648
  console.log(` Handler "${name}" already in _routes \u2014 skipped`);
1516
1649
  }
@@ -1518,10 +1651,10 @@ function registerHandler(program2) {
1518
1651
  console.log(`
1519
1652
  Next steps:`);
1520
1653
  if (!fileConfig.handlers) {
1521
- console.log(` 1. Add handlers: ${(0, import_node_path4.basename)(handlersPath)} to yrest.config.yml`);
1522
- console.log(` 2. Implement the "${name}" function in ${(0, import_node_path4.basename)(handlersPath)}`);
1654
+ console.log(` 1. Add handlers: ${(0, import_node_path5.basename)(handlersPath)} to yrest.config.yml`);
1655
+ console.log(` 2. Implement the "${name}" function in ${(0, import_node_path5.basename)(handlersPath)}`);
1523
1656
  } else {
1524
- console.log(` 1. Implement the "${name}" function in ${(0, import_node_path4.basename)(handlersPath)}`);
1657
+ console.log(` 1. Implement the "${name}" function in ${(0, import_node_path5.basename)(handlersPath)}`);
1525
1658
  }
1526
1659
  if (!flags.register) {
1527
1660
  console.log(