@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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { program } from "commander";
5
+ import { createRequire } from "module";
5
6
 
6
7
  // src/cli/commands/init.ts
7
8
  import { existsSync, writeFileSync } from "fs";
@@ -78,6 +79,7 @@ host: localhost # Host to bind
78
79
  # readonly: false # Block write operations (POST, PUT, PATCH, DELETE)
79
80
  # delay: 0 # Simulated network latency in milliseconds
80
81
  # pageable: false # Wrap GET collections in { data, pagination }. Use true (limit 10) or a number
82
+ # snapshot: false # Save initial db state and expose /_snapshot endpoints (GET / POST save / POST reset)
81
83
  `;
82
84
  function registerInit(program2) {
83
85
  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) => {
@@ -110,6 +112,11 @@ import { readFileSync, writeFileSync as writeFileSync2, renameSync } from "fs";
110
112
  import { resolve as resolve2, dirname } from "path";
111
113
  import { randomUUID } from "crypto";
112
114
  import { parse, stringify } from "yaml";
115
+ function deepCopyData(source) {
116
+ return Object.fromEntries(
117
+ Object.entries(source).map(([k, v]) => [k, v.map((item) => ({ ...item }))])
118
+ );
119
+ }
113
120
  function createYamlStorage(filePath) {
114
121
  const absPath = resolve2(filePath);
115
122
  const raw = parse(readFileSync(absPath, "utf8")) ?? {};
@@ -117,6 +124,11 @@ function createYamlStorage(filePath) {
117
124
  const data = Object.fromEntries(
118
125
  Object.entries(raw).filter(([key]) => key !== "_rel")
119
126
  );
127
+ let snapshot = {
128
+ data: deepCopyData(data),
129
+ relations: { ...relations },
130
+ savedAt: /* @__PURE__ */ new Date()
131
+ };
120
132
  return {
121
133
  getData() {
122
134
  return data;
@@ -146,6 +158,24 @@ function createYamlStorage(filePath) {
146
158
  Object.assign(data, freshData);
147
159
  for (const key of Object.keys(relations)) delete relations[key];
148
160
  Object.assign(relations, freshRelations);
161
+ },
162
+ getSnapshot() {
163
+ return snapshot;
164
+ },
165
+ saveSnapshot() {
166
+ snapshot = {
167
+ data: deepCopyData(data),
168
+ relations: { ...relations },
169
+ savedAt: /* @__PURE__ */ new Date()
170
+ };
171
+ },
172
+ resetToSnapshot() {
173
+ const snap = deepCopyData(snapshot.data);
174
+ for (const key of Object.keys(data)) delete data[key];
175
+ Object.assign(data, snap);
176
+ for (const key of Object.keys(relations)) delete relations[key];
177
+ Object.assign(relations, { ...snapshot.relations });
178
+ this.persist();
149
179
  }
150
180
  };
151
181
  }
@@ -154,11 +184,92 @@ function createYamlStorage(filePath) {
154
184
  import Fastify from "fastify";
155
185
  import cors from "@fastify/cors";
156
186
 
157
- // src/services/resourceService.ts
187
+ // src/utils/params.ts
158
188
  function nextId(items) {
159
189
  const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
160
190
  return ids.length > 0 ? Math.max(...ids) + 1 : 1;
161
191
  }
192
+ function firstParam(value) {
193
+ if (value === void 0) return void 0;
194
+ return Array.isArray(value) ? value[0] : value;
195
+ }
196
+
197
+ // src/services/query.service.ts
198
+ var OPERATORS = ["_gte", "_lte", "_ne", "_like", "_start", "_regex"];
199
+ function applyOperator(itemValue, op, filterValue) {
200
+ const strItem = String(itemValue);
201
+ const numItem = Number(itemValue);
202
+ const numFilter = Number(filterValue);
203
+ const numeric = !isNaN(numItem) && !isNaN(numFilter) && filterValue.trim() !== "";
204
+ switch (op) {
205
+ case "_gte":
206
+ return numeric ? numItem >= numFilter : strItem >= filterValue;
207
+ case "_lte":
208
+ return numeric ? numItem <= numFilter : strItem <= filterValue;
209
+ case "_ne":
210
+ return strItem !== filterValue;
211
+ case "_like":
212
+ return strItem.toLowerCase().includes(filterValue.toLowerCase());
213
+ case "_start":
214
+ return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
215
+ case "_regex": {
216
+ try {
217
+ return new RegExp(filterValue, "i").test(strItem);
218
+ } catch {
219
+ return false;
220
+ }
221
+ }
222
+ }
223
+ }
224
+ function filterByQuery(items, query) {
225
+ const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
226
+ if (filters.length === 0) return items;
227
+ return items.filter(
228
+ (item) => filters.every(([key, value]) => {
229
+ const op = OPERATORS.find((o) => key.endsWith(o));
230
+ if (op) {
231
+ const field = key.slice(0, -op.length);
232
+ if (item[field] === void 0) return false;
233
+ const filterVal = Array.isArray(value) ? value[0] : value;
234
+ return applyOperator(item[field], op, filterVal);
235
+ }
236
+ if (item[key] === void 0) return false;
237
+ const itemStr = String(item[key]);
238
+ return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
239
+ })
240
+ );
241
+ }
242
+ function fullTextSearch(items, term) {
243
+ const lower = term.toLowerCase();
244
+ return items.filter(
245
+ (item) => Object.values(item).some((val) => {
246
+ if (val === null || val === void 0 || typeof val === "object") return false;
247
+ return String(val).toLowerCase().includes(lower);
248
+ })
249
+ );
250
+ }
251
+ function sortBy(items, field, order) {
252
+ const direction = order === "desc" ? -1 : 1;
253
+ return [...items].sort((a, b) => {
254
+ const av = a[field];
255
+ const bv = b[field];
256
+ if (av === void 0) return 1;
257
+ if (bv === void 0) return -1;
258
+ if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
259
+ return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
260
+ });
261
+ }
262
+ function projectFields(input, fields) {
263
+ if (fields.length === 0) return input;
264
+ const project = (item) => Object.fromEntries(fields.filter((f) => f in item).map((f) => [f, item[f]]));
265
+ return Array.isArray(input) ? input.map(project) : project(input);
266
+ }
267
+ function paginate(items, page, limit) {
268
+ const start = (page - 1) * limit;
269
+ return items.slice(start, start + limit);
270
+ }
271
+
272
+ // src/services/resource.service.ts
162
273
  function findById(items, id) {
163
274
  return items.find((i) => String(i["id"]) === id);
164
275
  }
@@ -195,36 +306,17 @@ function patchItem(storage, resource, id, body) {
195
306
  storage.persist();
196
307
  return updated;
197
308
  }
198
- function firstParam(value) {
199
- if (value === void 0) return void 0;
200
- return Array.isArray(value) ? value[0] : value;
201
- }
202
- function filterByQuery(items, query) {
203
- const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
204
- if (filters.length === 0) return items;
205
- return items.filter(
206
- (item) => filters.every(([key, value]) => {
207
- if (item[key] === void 0) return false;
208
- const itemStr = String(item[key]);
209
- return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
210
- })
211
- );
212
- }
213
- function sortBy(items, field, order) {
214
- const direction = order === "desc" ? -1 : 1;
215
- return [...items].sort((a, b) => {
216
- const av = a[field];
217
- const bv = b[field];
218
- if (av === void 0) return 1;
219
- if (bv === void 0) return -1;
220
- if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
221
- return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
222
- });
223
- }
224
- function paginate(items, page, limit) {
225
- const start = (page - 1) * limit;
226
- return items.slice(start, start + limit);
309
+ function deleteItem(storage, resource, id) {
310
+ const collection = storage.getCollection(resource) ?? [];
311
+ const idx = findIndexById(collection, id);
312
+ if (idx === -1) return void 0;
313
+ const [deleted] = collection.splice(idx, 1);
314
+ storage.setCollection(resource, collection);
315
+ storage.persist();
316
+ return deleted;
227
317
  }
318
+
319
+ // src/services/expand.service.ts
228
320
  function expandItems(input, query, resource, storage) {
229
321
  const isArray = Array.isArray(input);
230
322
  const items = isArray ? input : [input];
@@ -257,14 +349,35 @@ function expandItems(input, query, resource, storage) {
257
349
  });
258
350
  return isArray ? expanded : expanded[0];
259
351
  }
260
- function deleteItem(storage, resource, id) {
261
- const collection = storage.getCollection(resource) ?? [];
262
- const idx = findIndexById(collection, id);
263
- if (idx === -1) return void 0;
264
- const [deleted] = collection.splice(idx, 1);
265
- storage.setCollection(resource, collection);
266
- storage.persist();
267
- return deleted;
352
+ function embedItems(input, query, resource, storage) {
353
+ const isArray = Array.isArray(input);
354
+ const items = isArray ? input : [input];
355
+ const embedParam = query["_embed"];
356
+ if (!embedParam) return isArray ? items : input;
357
+ const keys = (Array.isArray(embedParam) ? embedParam : [embedParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
358
+ const relations = storage.getRelations();
359
+ const embeds = /* @__PURE__ */ new Map();
360
+ for (const embedKey of keys) {
361
+ outer: for (const [childCollection, fields] of Object.entries(relations)) {
362
+ for (const [fkField, parentCollection] of Object.entries(fields)) {
363
+ if (parentCollection === resource && childCollection === embedKey) {
364
+ embeds.set(embedKey, { childCollection, fkField });
365
+ break outer;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ if (embeds.size === 0) return isArray ? items : input;
371
+ const result = items.map((item) => {
372
+ const out = { ...item };
373
+ for (const [embedKey, { childCollection, fkField }] of embeds) {
374
+ out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
375
+ (child) => String(child[fkField]) === String(item["id"])
376
+ );
377
+ }
378
+ return out;
379
+ });
380
+ return isArray ? result : result[0];
268
381
  }
269
382
 
270
383
  // src/router/routes/collection.routes.ts
@@ -272,9 +385,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
272
385
  server.get(base, (req, reply) => {
273
386
  const collection = storage.getCollection(resource) ?? [];
274
387
  const filtered = filterByQuery(collection, req.query);
388
+ const searchTerm = firstParam(req.query["_q"]);
389
+ const searched = searchTerm ? fullTextSearch(filtered, searchTerm) : filtered;
390
+ const fields = (firstParam(req.query["_fields"]) ?? "").split(",").map((f) => f.trim()).filter(Boolean);
275
391
  const sortField = firstParam(req.query["_sort"]);
276
392
  const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
277
- const sorted = sortField ? sortBy(filtered, sortField, sortOrder) : filtered;
393
+ const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
278
394
  if (options.pageable.enabled) {
279
395
  const defaultLimit = options.pageable.limit;
280
396
  const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
@@ -284,7 +400,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
284
400
  );
285
401
  const totalItems = sorted.length;
286
402
  const totalPages = Math.ceil(totalItems / limit) || 1;
287
- const data = expandItems(paginate(sorted, page, limit), req.query, resource, storage);
403
+ const data = projectFields(
404
+ embedItems(
405
+ expandItems(paginate(sorted, page, limit), req.query, resource, storage),
406
+ req.query,
407
+ resource,
408
+ storage
409
+ ),
410
+ fields
411
+ );
288
412
  const pagination = {
289
413
  page,
290
414
  limit,
@@ -308,9 +432,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
308
432
  reply.header("X-Total-Count", String(sorted.length));
309
433
  result = paginate(sorted, page, limit);
310
434
  }
311
- return expandItems(result, req.query, resource, storage);
435
+ return projectFields(
436
+ embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
437
+ fields
438
+ );
312
439
  });
313
440
  server.post(base, (req, reply) => {
441
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
442
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
443
+ }
314
444
  const item = createItem(storage, resource, req.body);
315
445
  return reply.status(201).send(expandItems(item, req.query, resource, storage));
316
446
  });
@@ -321,14 +451,24 @@ var registerItemRoutes = (server, storage, resource, base) => {
321
451
  server.get(`${base}/:id`, (req, reply) => {
322
452
  const item = findById(storage.getCollection(resource) ?? [], req.params.id);
323
453
  if (!item) return reply.status(404).send({ error: "Not found" });
324
- return expandItems(item, req.query, resource, storage);
454
+ const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
455
+ return projectFields(
456
+ embedItems(expandItems(item, req.query, resource, storage), req.query, resource, storage),
457
+ fields
458
+ );
325
459
  });
326
460
  server.put(`${base}/:id`, (req, reply) => {
461
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
462
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
463
+ }
327
464
  const item = replaceItem(storage, resource, req.params.id, req.body);
328
465
  if (!item) return reply.status(404).send({ error: "Not found" });
329
466
  return expandItems(item, req.query, resource, storage);
330
467
  });
331
468
  server.patch(`${base}/:id`, (req, reply) => {
469
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
470
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
471
+ }
332
472
  const item = patchItem(storage, resource, req.params.id, req.body);
333
473
  if (!item) return reply.status(404).send({ error: "Not found" });
334
474
  return expandItems(item, req.query, resource, storage);
@@ -344,7 +484,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
344
484
  var registerNestedRoutes = (server, storage, relations, base) => {
345
485
  for (const [child, fields] of Object.entries(relations)) {
346
486
  for (const [field, parent] of Object.entries(fields)) {
347
- server.get(`${base}/${parent}/:id/${child}`, (req, reply) => {
487
+ const collectionPath = `${base}/${parent}/:id/${child}`;
488
+ const itemPath = `${base}/${parent}/:id/${child}/:childId`;
489
+ server.get(collectionPath, (req, reply) => {
348
490
  const parentCollection = storage.getCollection(parent) ?? [];
349
491
  const parentItem = findById(parentCollection, req.params.id);
350
492
  if (!parentItem) return reply.status(404).send({ error: "Not found" });
@@ -353,6 +495,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
353
495
  );
354
496
  return children;
355
497
  });
498
+ server.get(itemPath, (req, reply) => {
499
+ const parentCollection = storage.getCollection(parent) ?? [];
500
+ const parentItem = findById(parentCollection, req.params.id);
501
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
502
+ const childItem = (storage.getCollection(child) ?? []).find(
503
+ (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
504
+ );
505
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
506
+ return childItem;
507
+ });
356
508
  }
357
509
  }
358
510
  };
@@ -477,6 +629,30 @@ curl ${host}${base}/${parent}/1/${child}`);
477
629
  examples.push(`# Pageable envelope
478
630
  curl "${host}${base}/${firstCol}?_page=2"`);
479
631
  }
632
+ const firstParentRel = Object.entries(relations).find(
633
+ ([, fields]) => Object.values(fields).includes(firstCol ?? "")
634
+ );
635
+ if (firstCol) {
636
+ examples.push(
637
+ `# Project fields with ?_fields
638
+ curl "${host}${base}/${firstCol}?_fields=id,name"`
639
+ );
640
+ }
641
+ if (firstParentRel && firstCol) {
642
+ const [childName] = firstParentRel;
643
+ examples.push(
644
+ `# Embed child collection with ?_embed
645
+ curl "${host}${base}/${firstCol}/1?_embed=${childName}"`
646
+ );
647
+ }
648
+ if (options.snapshot) {
649
+ examples.push(
650
+ `# Snapshot endpoints
651
+ curl ${host}/_snapshot
652
+ curl -X POST ${host}/_snapshot/save
653
+ curl -X POST ${host}/_snapshot/reset`
654
+ );
655
+ }
480
656
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
481
657
  return `<pre>${highlighted}</pre>`;
482
658
  }
@@ -491,6 +667,7 @@ function generateAboutHtml(storage, options) {
491
667
  if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
492
668
  if (options.pageable.enabled)
493
669
  modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
670
+ if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
494
671
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
495
672
  const nestedRows = [];
496
673
  for (const [child, fields] of Object.entries(relations)) {
@@ -510,6 +687,18 @@ function generateAboutHtml(storage, options) {
510
687
  </summary>
511
688
  <table><tbody>${nestedRows.join("")}</tbody></table>
512
689
  </details>` : "";
690
+ const snapshotAccordion = options.snapshot ? `
691
+ <details class="resource-card nested-card">
692
+ <summary>
693
+ <span class="resource-name">/_snapshot</span>
694
+ <span class="route-count">3 routes</span>
695
+ </summary>
696
+ <table><tbody>
697
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
698
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
699
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
700
+ </tbody></table>
701
+ </details>` : "";
513
702
  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.`;
514
703
  return `<!DOCTYPE html>
515
704
  <html lang="en">
@@ -658,6 +847,7 @@ function generateAboutHtml(storage, options) {
658
847
  <div class="endpoints-grid">
659
848
  ${accordions}
660
849
  ${nestedAccordion}
850
+ ${snapshotAccordion}
661
851
  </div>
662
852
 
663
853
  <h2>Query Parameters</h2>
@@ -666,9 +856,17 @@ function generateAboutHtml(storage, options) {
666
856
  <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
667
857
  <tbody>
668
858
  <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>
859
+ <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>
860
+ <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>
861
+ <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
862
+ <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
863
+ <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
864
+ <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>
669
865
  <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>
670
866
  <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
671
867
  <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>
868
+ <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>
869
+ <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>
672
870
  </tbody>
673
871
  </table>
674
872
  </div>
@@ -692,11 +890,51 @@ function registerAboutRoute(server, storage, options) {
692
890
  });
693
891
  }
694
892
 
893
+ // src/router/routes/snapshot.routes.ts
894
+ function registerSnapshotRoutes(server, storage) {
895
+ server.get("/_snapshot", (_req, reply) => {
896
+ const { data, savedAt } = storage.getSnapshot();
897
+ return reply.send({
898
+ savedAt: savedAt.toISOString(),
899
+ collections: Object.fromEntries(
900
+ Object.entries(data).map(([name, items]) => [name, items.length])
901
+ )
902
+ });
903
+ });
904
+ server.post("/_snapshot/save", (_req, reply) => {
905
+ storage.saveSnapshot();
906
+ const { data, savedAt } = storage.getSnapshot();
907
+ return reply.send({
908
+ message: "Snapshot saved",
909
+ savedAt: savedAt.toISOString(),
910
+ collections: Object.fromEntries(
911
+ Object.entries(data).map(([name, items]) => [name, items.length])
912
+ )
913
+ });
914
+ });
915
+ server.post("/_snapshot/reset", (_req, reply) => {
916
+ storage.resetToSnapshot();
917
+ const { data, savedAt } = storage.getSnapshot();
918
+ return reply.send({
919
+ message: "Database restored to snapshot",
920
+ savedAt: savedAt.toISOString(),
921
+ collections: Object.fromEntries(
922
+ Object.entries(data).map(([name, items]) => [name, items.length])
923
+ )
924
+ });
925
+ });
926
+ }
927
+
695
928
  // src/server/createServer.ts
696
929
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
697
930
  async function createServer(storage, options) {
698
931
  const server = Fastify();
699
932
  await server.register(cors);
933
+ server.setErrorHandler((err, _req, reply) => {
934
+ const status = err.statusCode ?? 500;
935
+ const message = status < 500 ? err.message || "Request error" : "Internal server error";
936
+ reply.status(status).send({ error: message });
937
+ });
700
938
  if (options.readonly) {
701
939
  server.addHook("onRequest", (_req, reply, done) => {
702
940
  if (MUTATING_METHODS.has(_req.method)) {
@@ -711,6 +949,7 @@ async function createServer(storage, options) {
711
949
  });
712
950
  }
713
951
  registerAboutRoute(server, storage, options);
952
+ if (options.snapshot) registerSnapshotRoutes(server, storage);
714
953
  registerResourceRoutes(server, storage, options);
715
954
  return server;
716
955
  }
@@ -731,6 +970,11 @@ var serverOptionsSchema = z.object({
731
970
  base: z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
732
971
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
733
972
  watch: z.boolean().default(false),
973
+ /**
974
+ * When `true`, saves a snapshot of the initial database state on startup.
975
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
976
+ */
977
+ snapshot: z.boolean().default(false),
734
978
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
735
979
  readonly: z.boolean().default(false),
736
980
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
@@ -763,6 +1007,9 @@ function registerServe(program2) {
763
1007
  ).option(
764
1008
  "--pageable [limit]",
765
1009
  "Wrap GET collection responses in { data, pagination } envelope. Optionally set default page size (default: 10)"
1010
+ ).option(
1011
+ "--snapshot",
1012
+ "Save a snapshot of the initial database state and expose /_snapshot endpoints"
766
1013
  ).action(async (file, flags, cmd) => {
767
1014
  const fileConfig = loadConfigFile(join(process.cwd(), "yrest.config.yml"));
768
1015
  const cliOverrides = Object.fromEntries(
@@ -775,7 +1022,14 @@ function registerServe(program2) {
775
1022
  ...cliOverrides
776
1023
  };
777
1024
  const options = serverOptionsSchema.parse(merged);
778
- const storage = createYamlStorage(options.file);
1025
+ let storage;
1026
+ try {
1027
+ storage = createYamlStorage(options.file);
1028
+ } catch (err) {
1029
+ const msg = err instanceof Error ? err.message : String(err);
1030
+ console.error(`Error: cannot load "${options.file}" \u2014 ${msg}`);
1031
+ process.exit(1);
1032
+ }
779
1033
  const server = await createServer(storage, options);
780
1034
  await server.listen({ port: options.port, host: options.host });
781
1035
  const collections = Object.keys(storage.getData());
@@ -788,6 +1042,8 @@ yrest running at http://${options.host}:${options.port}`);
788
1042
  console.log(
789
1043
  `[pageable] responses wrapped in { data, pagination } (limit: ${options.pageable.limit})`
790
1044
  );
1045
+ if (options.snapshot)
1046
+ console.log("[snapshot] /_snapshot endpoints enabled \u2014 GET / POST save / POST reset");
791
1047
  console.log(`
792
1048
  Resources (base: ${baseLabel}):`);
793
1049
  for (const name of collections) {
@@ -804,8 +1060,9 @@ Resources (base: ${baseLabel}):`);
804
1060
  try {
805
1061
  storage.reload();
806
1062
  console.log(`[watch] reloaded ${options.file}`);
807
- } catch {
808
- console.error(`[watch] failed to reload ${options.file} \u2014 check YAML syntax`);
1063
+ } catch (err) {
1064
+ const msg = err instanceof Error ? err.message : String(err);
1065
+ console.error(`[watch] failed to reload ${options.file} \u2014 ${msg}`);
809
1066
  }
810
1067
  }, 100);
811
1068
  });
@@ -816,7 +1073,9 @@ Resources (base: ${baseLabel}):`);
816
1073
  }
817
1074
 
818
1075
  // src/cli/index.ts
819
- program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version("0.1.0");
1076
+ var require2 = createRequire(import.meta.url);
1077
+ var { version } = require2("../../package.json");
1078
+ program.name("yrest").description("Zero-config REST API mock server powered by a YAML file").version(version);
820
1079
  registerInit(program);
821
1080
  registerServe(program);
822
1081
  program.parse();
package/dist/index.d.mts CHANGED
@@ -67,16 +67,28 @@ interface YamlStorage {
67
67
  * @throws {Error} If the file cannot be read or the YAML is malformed.
68
68
  */
69
69
  reload(): void;
70
+ /**
71
+ * Returns the saved snapshot: a frozen copy of `data` and `relations` taken
72
+ * at the last call to {@link saveSnapshot} (or at construction time).
73
+ */
74
+ getSnapshot(): {
75
+ data: Data;
76
+ relations: Relations;
77
+ savedAt: Date;
78
+ };
79
+ /**
80
+ * Replaces the stored snapshot with a deep copy of the current in-memory state.
81
+ * Future calls to {@link resetToSnapshot} will restore to this point.
82
+ */
83
+ saveSnapshot(): void;
84
+ /**
85
+ * Restores the in-memory state to the last saved snapshot and persists to disk.
86
+ * Mutates `data` and `relations` in place so existing references stay valid.
87
+ *
88
+ * @throws {Error} If the filesystem write fails.
89
+ */
90
+ resetToSnapshot(): void;
70
91
  }
71
- /**
72
- * Creates a {@link YamlStorage} instance backed by the given YAML file.
73
- *
74
- * The file is read and parsed eagerly on construction. The `_rel` key is
75
- * extracted as relational metadata; all other top-level keys become collections.
76
- *
77
- * @param filePath - Relative or absolute path to the YAML database file.
78
- * @throws {Error} If the file cannot be read or its YAML is invalid.
79
- */
80
92
  declare function createYamlStorage(filePath: string): YamlStorage;
81
93
 
82
94
  /**
@@ -99,6 +111,11 @@ declare const serverOptionsSchema: z.ZodObject<{
99
111
  base: z.ZodEffects<z.ZodDefault<z.ZodString>, string, string | undefined>;
100
112
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
101
113
  watch: z.ZodDefault<z.ZodBoolean>;
114
+ /**
115
+ * When `true`, saves a snapshot of the initial database state on startup.
116
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
117
+ */
118
+ snapshot: z.ZodDefault<z.ZodBoolean>;
102
119
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
103
120
  readonly: z.ZodDefault<z.ZodBoolean>;
104
121
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */
@@ -117,6 +134,7 @@ declare const serverOptionsSchema: z.ZodObject<{
117
134
  host: string;
118
135
  base: string;
119
136
  watch: boolean;
137
+ snapshot: boolean;
120
138
  readonly: boolean;
121
139
  delay: number;
122
140
  pageable: {
@@ -129,6 +147,7 @@ declare const serverOptionsSchema: z.ZodObject<{
129
147
  host?: string | undefined;
130
148
  base?: string | undefined;
131
149
  watch?: boolean | undefined;
150
+ snapshot?: boolean | undefined;
132
151
  readonly?: boolean | undefined;
133
152
  delay?: number | undefined;
134
153
  pageable?: number | boolean | undefined;