@aggiovato/yrest 0.2.2 → 0.4.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
@@ -23,8 +23,13 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  mod
24
24
  ));
25
25
 
26
+ // node_modules/tsup/assets/cjs_shims.js
27
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
28
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
29
+
26
30
  // src/cli/index.ts
27
31
  var import_commander = require("commander");
32
+ var import_module = require("module");
28
33
 
29
34
  // src/cli/commands/init.ts
30
35
  var import_node_fs = require("fs");
@@ -101,6 +106,7 @@ host: localhost # Host to bind
101
106
  # readonly: false # Block write operations (POST, PUT, PATCH, DELETE)
102
107
  # delay: 0 # Simulated network latency in milliseconds
103
108
  # pageable: false # Wrap GET collections in { data, pagination }. Use true (limit 10) or a number
109
+ # snapshot: false # Save initial db state and expose /_snapshot endpoints (GET / POST save / POST reset)
104
110
  `;
105
111
  function registerInit(program2) {
106
112
  program2.command("init").description("Create a sample db.yml and yrest.config.yml in the current directory").option("-f, --file <name>", "Output filename", "db.yml").option("-s, --sample <name>", `Sample data to use (${SAMPLES.join(", ")})`, "basic").action((flags) => {
@@ -133,6 +139,11 @@ var import_node_fs2 = require("fs");
133
139
  var import_node_path2 = require("path");
134
140
  var import_node_crypto = require("crypto");
135
141
  var import_yaml = require("yaml");
142
+ function deepCopyData(source) {
143
+ return Object.fromEntries(
144
+ Object.entries(source).map(([k, v]) => [k, v.map((item) => ({ ...item }))])
145
+ );
146
+ }
136
147
  function createYamlStorage(filePath) {
137
148
  const absPath = (0, import_node_path2.resolve)(filePath);
138
149
  const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
@@ -140,6 +151,11 @@ function createYamlStorage(filePath) {
140
151
  const data = Object.fromEntries(
141
152
  Object.entries(raw).filter(([key]) => key !== "_rel")
142
153
  );
154
+ let snapshot = {
155
+ data: deepCopyData(data),
156
+ relations: { ...relations },
157
+ savedAt: /* @__PURE__ */ new Date()
158
+ };
143
159
  return {
144
160
  getData() {
145
161
  return data;
@@ -169,6 +185,24 @@ function createYamlStorage(filePath) {
169
185
  Object.assign(data, freshData);
170
186
  for (const key of Object.keys(relations)) delete relations[key];
171
187
  Object.assign(relations, freshRelations);
188
+ },
189
+ getSnapshot() {
190
+ return snapshot;
191
+ },
192
+ saveSnapshot() {
193
+ snapshot = {
194
+ data: deepCopyData(data),
195
+ relations: { ...relations },
196
+ savedAt: /* @__PURE__ */ new Date()
197
+ };
198
+ },
199
+ resetToSnapshot() {
200
+ const snap = deepCopyData(snapshot.data);
201
+ for (const key of Object.keys(data)) delete data[key];
202
+ Object.assign(data, snap);
203
+ for (const key of Object.keys(relations)) delete relations[key];
204
+ Object.assign(relations, { ...snapshot.relations });
205
+ this.persist();
172
206
  }
173
207
  };
174
208
  }
@@ -177,11 +211,92 @@ function createYamlStorage(filePath) {
177
211
  var import_fastify = __toESM(require("fastify"));
178
212
  var import_cors = __toESM(require("@fastify/cors"));
179
213
 
180
- // src/services/resourceService.ts
214
+ // src/utils/params.ts
181
215
  function nextId(items) {
182
216
  const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
183
217
  return ids.length > 0 ? Math.max(...ids) + 1 : 1;
184
218
  }
219
+ function firstParam(value) {
220
+ if (value === void 0) return void 0;
221
+ return Array.isArray(value) ? value[0] : value;
222
+ }
223
+
224
+ // src/services/query.service.ts
225
+ var OPERATORS = ["_gte", "_lte", "_ne", "_like", "_start", "_regex"];
226
+ function applyOperator(itemValue, op, filterValue) {
227
+ const strItem = String(itemValue);
228
+ const numItem = Number(itemValue);
229
+ const numFilter = Number(filterValue);
230
+ const numeric = !isNaN(numItem) && !isNaN(numFilter) && filterValue.trim() !== "";
231
+ switch (op) {
232
+ case "_gte":
233
+ return numeric ? numItem >= numFilter : strItem >= filterValue;
234
+ case "_lte":
235
+ return numeric ? numItem <= numFilter : strItem <= filterValue;
236
+ case "_ne":
237
+ return strItem !== filterValue;
238
+ case "_like":
239
+ return strItem.toLowerCase().includes(filterValue.toLowerCase());
240
+ case "_start":
241
+ return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
242
+ case "_regex": {
243
+ try {
244
+ return new RegExp(filterValue, "i").test(strItem);
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+ }
250
+ }
251
+ function filterByQuery(items, query) {
252
+ const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
253
+ if (filters.length === 0) return items;
254
+ return items.filter(
255
+ (item) => filters.every(([key, value]) => {
256
+ const op = OPERATORS.find((o) => key.endsWith(o));
257
+ if (op) {
258
+ const field = key.slice(0, -op.length);
259
+ if (item[field] === void 0) return false;
260
+ const filterVal = Array.isArray(value) ? value[0] : value;
261
+ return applyOperator(item[field], op, filterVal);
262
+ }
263
+ if (item[key] === void 0) return false;
264
+ const itemStr = String(item[key]);
265
+ return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
266
+ })
267
+ );
268
+ }
269
+ function fullTextSearch(items, term) {
270
+ const lower = term.toLowerCase();
271
+ return items.filter(
272
+ (item) => Object.values(item).some((val) => {
273
+ if (val === null || val === void 0 || typeof val === "object") return false;
274
+ return String(val).toLowerCase().includes(lower);
275
+ })
276
+ );
277
+ }
278
+ function sortBy(items, field, order) {
279
+ const direction = order === "desc" ? -1 : 1;
280
+ return [...items].sort((a, b) => {
281
+ const av = a[field];
282
+ const bv = b[field];
283
+ if (av === void 0) return 1;
284
+ if (bv === void 0) return -1;
285
+ if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
286
+ return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
287
+ });
288
+ }
289
+ function projectFields(input, fields) {
290
+ if (fields.length === 0) return input;
291
+ const project = (item) => Object.fromEntries(fields.filter((f) => f in item).map((f) => [f, item[f]]));
292
+ return Array.isArray(input) ? input.map(project) : project(input);
293
+ }
294
+ function paginate(items, page, limit) {
295
+ const start = (page - 1) * limit;
296
+ return items.slice(start, start + limit);
297
+ }
298
+
299
+ // src/services/resource.service.ts
185
300
  function findById(items, id) {
186
301
  return items.find((i) => String(i["id"]) === id);
187
302
  }
@@ -218,36 +333,17 @@ function patchItem(storage, resource, id, body) {
218
333
  storage.persist();
219
334
  return updated;
220
335
  }
221
- function firstParam(value) {
222
- if (value === void 0) return void 0;
223
- return Array.isArray(value) ? value[0] : value;
224
- }
225
- function filterByQuery(items, query) {
226
- const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
227
- if (filters.length === 0) return items;
228
- return items.filter(
229
- (item) => filters.every(([key, value]) => {
230
- if (item[key] === void 0) return false;
231
- const itemStr = String(item[key]);
232
- return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
233
- })
234
- );
235
- }
236
- function sortBy(items, field, order) {
237
- const direction = order === "desc" ? -1 : 1;
238
- return [...items].sort((a, b) => {
239
- const av = a[field];
240
- const bv = b[field];
241
- if (av === void 0) return 1;
242
- if (bv === void 0) return -1;
243
- if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
244
- return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
245
- });
246
- }
247
- function paginate(items, page, limit) {
248
- const start = (page - 1) * limit;
249
- return items.slice(start, start + limit);
336
+ function deleteItem(storage, resource, id) {
337
+ const collection = storage.getCollection(resource) ?? [];
338
+ const idx = findIndexById(collection, id);
339
+ if (idx === -1) return void 0;
340
+ const [deleted] = collection.splice(idx, 1);
341
+ storage.setCollection(resource, collection);
342
+ storage.persist();
343
+ return deleted;
250
344
  }
345
+
346
+ // src/services/expand.service.ts
251
347
  function expandItems(input, query, resource, storage) {
252
348
  const isArray = Array.isArray(input);
253
349
  const items = isArray ? input : [input];
@@ -280,14 +376,35 @@ function expandItems(input, query, resource, storage) {
280
376
  });
281
377
  return isArray ? expanded : expanded[0];
282
378
  }
283
- function deleteItem(storage, resource, id) {
284
- const collection = storage.getCollection(resource) ?? [];
285
- const idx = findIndexById(collection, id);
286
- if (idx === -1) return void 0;
287
- const [deleted] = collection.splice(idx, 1);
288
- storage.setCollection(resource, collection);
289
- storage.persist();
290
- return deleted;
379
+ function embedItems(input, query, resource, storage) {
380
+ const isArray = Array.isArray(input);
381
+ const items = isArray ? input : [input];
382
+ const embedParam = query["_embed"];
383
+ if (!embedParam) return isArray ? items : input;
384
+ const keys = (Array.isArray(embedParam) ? embedParam : [embedParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
385
+ const relations = storage.getRelations();
386
+ const embeds = /* @__PURE__ */ new Map();
387
+ for (const embedKey of keys) {
388
+ outer: for (const [childCollection, fields] of Object.entries(relations)) {
389
+ for (const [fkField, parentCollection] of Object.entries(fields)) {
390
+ if (parentCollection === resource && childCollection === embedKey) {
391
+ embeds.set(embedKey, { childCollection, fkField });
392
+ break outer;
393
+ }
394
+ }
395
+ }
396
+ }
397
+ if (embeds.size === 0) return isArray ? items : input;
398
+ const result = items.map((item) => {
399
+ const out = { ...item };
400
+ for (const [embedKey, { childCollection, fkField }] of embeds) {
401
+ out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
402
+ (child) => String(child[fkField]) === String(item["id"])
403
+ );
404
+ }
405
+ return out;
406
+ });
407
+ return isArray ? result : result[0];
291
408
  }
292
409
 
293
410
  // src/router/routes/collection.routes.ts
@@ -295,9 +412,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
295
412
  server.get(base, (req, reply) => {
296
413
  const collection = storage.getCollection(resource) ?? [];
297
414
  const filtered = filterByQuery(collection, req.query);
415
+ const searchTerm = firstParam(req.query["_q"]);
416
+ const searched = searchTerm ? fullTextSearch(filtered, searchTerm) : filtered;
417
+ const fields = (firstParam(req.query["_fields"]) ?? "").split(",").map((f) => f.trim()).filter(Boolean);
298
418
  const sortField = firstParam(req.query["_sort"]);
299
419
  const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
300
- const sorted = sortField ? sortBy(filtered, sortField, sortOrder) : filtered;
420
+ const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
301
421
  if (options.pageable.enabled) {
302
422
  const defaultLimit = options.pageable.limit;
303
423
  const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
@@ -307,7 +427,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
307
427
  );
308
428
  const totalItems = sorted.length;
309
429
  const totalPages = Math.ceil(totalItems / limit) || 1;
310
- const data = expandItems(paginate(sorted, page, limit), req.query, resource, storage);
430
+ const data = projectFields(
431
+ embedItems(
432
+ expandItems(paginate(sorted, page, limit), req.query, resource, storage),
433
+ req.query,
434
+ resource,
435
+ storage
436
+ ),
437
+ fields
438
+ );
311
439
  const pagination = {
312
440
  page,
313
441
  limit,
@@ -331,9 +459,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
331
459
  reply.header("X-Total-Count", String(sorted.length));
332
460
  result = paginate(sorted, page, limit);
333
461
  }
334
- return expandItems(result, req.query, resource, storage);
462
+ return projectFields(
463
+ embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
464
+ fields
465
+ );
335
466
  });
336
467
  server.post(base, (req, reply) => {
468
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
469
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
470
+ }
337
471
  const item = createItem(storage, resource, req.body);
338
472
  return reply.status(201).send(expandItems(item, req.query, resource, storage));
339
473
  });
@@ -344,14 +478,24 @@ var registerItemRoutes = (server, storage, resource, base) => {
344
478
  server.get(`${base}/:id`, (req, reply) => {
345
479
  const item = findById(storage.getCollection(resource) ?? [], req.params.id);
346
480
  if (!item) return reply.status(404).send({ error: "Not found" });
347
- return expandItems(item, req.query, resource, storage);
481
+ const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
482
+ return projectFields(
483
+ embedItems(expandItems(item, req.query, resource, storage), req.query, resource, storage),
484
+ fields
485
+ );
348
486
  });
349
487
  server.put(`${base}/:id`, (req, reply) => {
488
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
489
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
490
+ }
350
491
  const item = replaceItem(storage, resource, req.params.id, req.body);
351
492
  if (!item) return reply.status(404).send({ error: "Not found" });
352
493
  return expandItems(item, req.query, resource, storage);
353
494
  });
354
495
  server.patch(`${base}/:id`, (req, reply) => {
496
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
497
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
498
+ }
355
499
  const item = patchItem(storage, resource, req.params.id, req.body);
356
500
  if (!item) return reply.status(404).send({ error: "Not found" });
357
501
  return expandItems(item, req.query, resource, storage);
@@ -367,7 +511,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
367
511
  var registerNestedRoutes = (server, storage, relations, base) => {
368
512
  for (const [child, fields] of Object.entries(relations)) {
369
513
  for (const [field, parent] of Object.entries(fields)) {
370
- server.get(`${base}/${parent}/:id/${child}`, (req, reply) => {
514
+ const collectionPath = `${base}/${parent}/:id/${child}`;
515
+ const itemPath = `${base}/${parent}/:id/${child}/:childId`;
516
+ server.get(collectionPath, (req, reply) => {
371
517
  const parentCollection = storage.getCollection(parent) ?? [];
372
518
  const parentItem = findById(parentCollection, req.params.id);
373
519
  if (!parentItem) return reply.status(404).send({ error: "Not found" });
@@ -376,6 +522,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
376
522
  );
377
523
  return children;
378
524
  });
525
+ server.get(itemPath, (req, reply) => {
526
+ const parentCollection = storage.getCollection(parent) ?? [];
527
+ const parentItem = findById(parentCollection, req.params.id);
528
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
529
+ const childItem = (storage.getCollection(child) ?? []).find(
530
+ (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
531
+ );
532
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
533
+ return childItem;
534
+ });
379
535
  }
380
536
  }
381
537
  };
@@ -500,6 +656,30 @@ curl ${host}${base}/${parent}/1/${child}`);
500
656
  examples.push(`# Pageable envelope
501
657
  curl "${host}${base}/${firstCol}?_page=2"`);
502
658
  }
659
+ const firstParentRel = Object.entries(relations).find(
660
+ ([, fields]) => Object.values(fields).includes(firstCol ?? "")
661
+ );
662
+ if (firstCol) {
663
+ examples.push(
664
+ `# Project fields with ?_fields
665
+ curl "${host}${base}/${firstCol}?_fields=id,name"`
666
+ );
667
+ }
668
+ if (firstParentRel && firstCol) {
669
+ const [childName] = firstParentRel;
670
+ examples.push(
671
+ `# Embed child collection with ?_embed
672
+ curl "${host}${base}/${firstCol}/1?_embed=${childName}"`
673
+ );
674
+ }
675
+ if (options.snapshot) {
676
+ examples.push(
677
+ `# Snapshot endpoints
678
+ curl ${host}/_snapshot
679
+ curl -X POST ${host}/_snapshot/save
680
+ curl -X POST ${host}/_snapshot/reset`
681
+ );
682
+ }
503
683
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
504
684
  return `<pre>${highlighted}</pre>`;
505
685
  }
@@ -514,6 +694,7 @@ function generateAboutHtml(storage, options) {
514
694
  if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
515
695
  if (options.pageable.enabled)
516
696
  modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
697
+ if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
517
698
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
518
699
  const nestedRows = [];
519
700
  for (const [child, fields] of Object.entries(relations)) {
@@ -533,6 +714,18 @@ function generateAboutHtml(storage, options) {
533
714
  </summary>
534
715
  <table><tbody>${nestedRows.join("")}</tbody></table>
535
716
  </details>` : "";
717
+ const snapshotAccordion = options.snapshot ? `
718
+ <details class="resource-card nested-card">
719
+ <summary>
720
+ <span class="resource-name">/_snapshot</span>
721
+ <span class="route-count">3 routes</span>
722
+ </summary>
723
+ <table><tbody>
724
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
725
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
726
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
727
+ </tbody></table>
728
+ </details>` : "";
536
729
  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.`;
537
730
  return `<!DOCTYPE html>
538
731
  <html lang="en">
@@ -681,6 +874,7 @@ function generateAboutHtml(storage, options) {
681
874
  <div class="endpoints-grid">
682
875
  ${accordions}
683
876
  ${nestedAccordion}
877
+ ${snapshotAccordion}
684
878
  </div>
685
879
 
686
880
  <h2>Query Parameters</h2>
@@ -689,9 +883,17 @@ function generateAboutHtml(storage, options) {
689
883
  <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
690
884
  <tbody>
691
885
  <tr><td><code>?field=value</code></td><td><code>?name=Ana&amp;role=admin</code></td><td>Filter by any field. Multiple params are ANDed.</td></tr>
886
+ <tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&amp;price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
887
+ <tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
888
+ <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
889
+ <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
890
+ <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
891
+ <tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
692
892
  <tr><td><code>?_sort &amp; ?_order</code></td><td><code>?_sort=name&amp;_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
693
893
  <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
694
894
  <tr><td><code>?_expand</code></td><td><code>?_expand=user</code></td><td>Embed a related parent object inline. Requires <code>_rel</code> in the YAML file.</td></tr>
895
+ <tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
896
+ <tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
695
897
  </tbody>
696
898
  </table>
697
899
  </div>
@@ -715,11 +917,51 @@ function registerAboutRoute(server, storage, options) {
715
917
  });
716
918
  }
717
919
 
920
+ // src/router/routes/snapshot.routes.ts
921
+ function registerSnapshotRoutes(server, storage) {
922
+ server.get("/_snapshot", (_req, reply) => {
923
+ const { data, savedAt } = storage.getSnapshot();
924
+ return reply.send({
925
+ savedAt: savedAt.toISOString(),
926
+ collections: Object.fromEntries(
927
+ Object.entries(data).map(([name, items]) => [name, items.length])
928
+ )
929
+ });
930
+ });
931
+ server.post("/_snapshot/save", (_req, reply) => {
932
+ storage.saveSnapshot();
933
+ const { data, savedAt } = storage.getSnapshot();
934
+ return reply.send({
935
+ message: "Snapshot saved",
936
+ savedAt: savedAt.toISOString(),
937
+ collections: Object.fromEntries(
938
+ Object.entries(data).map(([name, items]) => [name, items.length])
939
+ )
940
+ });
941
+ });
942
+ server.post("/_snapshot/reset", (_req, reply) => {
943
+ storage.resetToSnapshot();
944
+ const { data, savedAt } = storage.getSnapshot();
945
+ return reply.send({
946
+ message: "Database restored to snapshot",
947
+ savedAt: savedAt.toISOString(),
948
+ collections: Object.fromEntries(
949
+ Object.entries(data).map(([name, items]) => [name, items.length])
950
+ )
951
+ });
952
+ });
953
+ }
954
+
718
955
  // src/server/createServer.ts
719
956
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
720
957
  async function createServer(storage, options) {
721
958
  const server = (0, import_fastify.default)();
722
959
  await server.register(import_cors.default);
960
+ server.setErrorHandler((err, _req, reply) => {
961
+ const status = err.statusCode ?? 500;
962
+ const message = status < 500 ? err.message || "Request error" : "Internal server error";
963
+ reply.status(status).send({ error: message });
964
+ });
723
965
  if (options.readonly) {
724
966
  server.addHook("onRequest", (_req, reply, done) => {
725
967
  if (MUTATING_METHODS.has(_req.method)) {
@@ -734,6 +976,7 @@ async function createServer(storage, options) {
734
976
  });
735
977
  }
736
978
  registerAboutRoute(server, storage, options);
979
+ if (options.snapshot) registerSnapshotRoutes(server, storage);
737
980
  registerResourceRoutes(server, storage, options);
738
981
  return server;
739
982
  }
@@ -754,6 +997,11 @@ var serverOptionsSchema = import_zod.z.object({
754
997
  base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
755
998
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
756
999
  watch: import_zod.z.boolean().default(false),
1000
+ /**
1001
+ * When `true`, saves a snapshot of the initial database state on startup.
1002
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
1003
+ */
1004
+ snapshot: import_zod.z.boolean().default(false),
757
1005
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
758
1006
  readonly: import_zod.z.boolean().default(false),
759
1007
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
@@ -786,6 +1034,9 @@ function registerServe(program2) {
786
1034
  ).option(
787
1035
  "--pageable [limit]",
788
1036
  "Wrap GET collection responses in { data, pagination } envelope. Optionally set default page size (default: 10)"
1037
+ ).option(
1038
+ "--snapshot",
1039
+ "Save a snapshot of the initial database state and expose /_snapshot endpoints"
789
1040
  ).action(async (file, flags, cmd) => {
790
1041
  const fileConfig = loadConfigFile((0, import_node_path3.join)(process.cwd(), "yrest.config.yml"));
791
1042
  const cliOverrides = Object.fromEntries(
@@ -798,7 +1049,14 @@ function registerServe(program2) {
798
1049
  ...cliOverrides
799
1050
  };
800
1051
  const options = serverOptionsSchema.parse(merged);
801
- const storage = createYamlStorage(options.file);
1052
+ let storage;
1053
+ try {
1054
+ storage = createYamlStorage(options.file);
1055
+ } catch (err) {
1056
+ const msg = err instanceof Error ? err.message : String(err);
1057
+ console.error(`Error: cannot load "${options.file}" \u2014 ${msg}`);
1058
+ process.exit(1);
1059
+ }
802
1060
  const server = await createServer(storage, options);
803
1061
  await server.listen({ port: options.port, host: options.host });
804
1062
  const collections = Object.keys(storage.getData());
@@ -811,6 +1069,8 @@ yrest running at http://${options.host}:${options.port}`);
811
1069
  console.log(
812
1070
  `[pageable] responses wrapped in { data, pagination } (limit: ${options.pageable.limit})`
813
1071
  );
1072
+ if (options.snapshot)
1073
+ console.log("[snapshot] /_snapshot endpoints enabled \u2014 GET / POST save / POST reset");
814
1074
  console.log(`
815
1075
  Resources (base: ${baseLabel}):`);
816
1076
  for (const name of collections) {
@@ -827,8 +1087,9 @@ Resources (base: ${baseLabel}):`);
827
1087
  try {
828
1088
  storage.reload();
829
1089
  console.log(`[watch] reloaded ${options.file}`);
830
- } catch {
831
- console.error(`[watch] failed to reload ${options.file} \u2014 check YAML syntax`);
1090
+ } catch (err) {
1091
+ const msg = err instanceof Error ? err.message : String(err);
1092
+ console.error(`[watch] failed to reload ${options.file} \u2014 ${msg}`);
832
1093
  }
833
1094
  }, 100);
834
1095
  });
@@ -839,7 +1100,9 @@ Resources (base: ${baseLabel}):`);
839
1100
  }
840
1101
 
841
1102
  // src/cli/index.ts
842
- import_commander.program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version("0.1.0");
1103
+ var require2 = (0, import_module.createRequire)(importMetaUrl);
1104
+ var { version } = require2("../../package.json");
1105
+ import_commander.program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version(version);
843
1106
  registerInit(import_commander.program);
844
1107
  registerServe(import_commander.program);
845
1108
  import_commander.program.parse();