@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/index.d.ts 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;
package/dist/index.js CHANGED
@@ -41,6 +41,11 @@ var import_node_fs = require("fs");
41
41
  var import_node_path = require("path");
42
42
  var import_node_crypto = require("crypto");
43
43
  var import_yaml = require("yaml");
44
+ function deepCopyData(source) {
45
+ return Object.fromEntries(
46
+ Object.entries(source).map(([k, v]) => [k, v.map((item) => ({ ...item }))])
47
+ );
48
+ }
44
49
  function createYamlStorage(filePath) {
45
50
  const absPath = (0, import_node_path.resolve)(filePath);
46
51
  const raw = (0, import_yaml.parse)((0, import_node_fs.readFileSync)(absPath, "utf8")) ?? {};
@@ -48,6 +53,11 @@ function createYamlStorage(filePath) {
48
53
  const data = Object.fromEntries(
49
54
  Object.entries(raw).filter(([key]) => key !== "_rel")
50
55
  );
56
+ let snapshot = {
57
+ data: deepCopyData(data),
58
+ relations: { ...relations },
59
+ savedAt: /* @__PURE__ */ new Date()
60
+ };
51
61
  return {
52
62
  getData() {
53
63
  return data;
@@ -77,6 +87,24 @@ function createYamlStorage(filePath) {
77
87
  Object.assign(data, freshData);
78
88
  for (const key of Object.keys(relations)) delete relations[key];
79
89
  Object.assign(relations, freshRelations);
90
+ },
91
+ getSnapshot() {
92
+ return snapshot;
93
+ },
94
+ saveSnapshot() {
95
+ snapshot = {
96
+ data: deepCopyData(data),
97
+ relations: { ...relations },
98
+ savedAt: /* @__PURE__ */ new Date()
99
+ };
100
+ },
101
+ resetToSnapshot() {
102
+ const snap = deepCopyData(snapshot.data);
103
+ for (const key of Object.keys(data)) delete data[key];
104
+ Object.assign(data, snap);
105
+ for (const key of Object.keys(relations)) delete relations[key];
106
+ Object.assign(relations, { ...snapshot.relations });
107
+ this.persist();
80
108
  }
81
109
  };
82
110
  }
@@ -85,11 +113,92 @@ function createYamlStorage(filePath) {
85
113
  var import_fastify = __toESM(require("fastify"));
86
114
  var import_cors = __toESM(require("@fastify/cors"));
87
115
 
88
- // src/services/resourceService.ts
116
+ // src/utils/params.ts
89
117
  function nextId(items) {
90
118
  const ids = items.map((i) => i["id"]).filter((id) => typeof id === "number");
91
119
  return ids.length > 0 ? Math.max(...ids) + 1 : 1;
92
120
  }
121
+ function firstParam(value) {
122
+ if (value === void 0) return void 0;
123
+ return Array.isArray(value) ? value[0] : value;
124
+ }
125
+
126
+ // src/services/query.service.ts
127
+ var OPERATORS = ["_gte", "_lte", "_ne", "_like", "_start", "_regex"];
128
+ function applyOperator(itemValue, op, filterValue) {
129
+ const strItem = String(itemValue);
130
+ const numItem = Number(itemValue);
131
+ const numFilter = Number(filterValue);
132
+ const numeric = !isNaN(numItem) && !isNaN(numFilter) && filterValue.trim() !== "";
133
+ switch (op) {
134
+ case "_gte":
135
+ return numeric ? numItem >= numFilter : strItem >= filterValue;
136
+ case "_lte":
137
+ return numeric ? numItem <= numFilter : strItem <= filterValue;
138
+ case "_ne":
139
+ return strItem !== filterValue;
140
+ case "_like":
141
+ return strItem.toLowerCase().includes(filterValue.toLowerCase());
142
+ case "_start":
143
+ return strItem.toLowerCase().startsWith(filterValue.toLowerCase());
144
+ case "_regex": {
145
+ try {
146
+ return new RegExp(filterValue, "i").test(strItem);
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+ }
152
+ }
153
+ function filterByQuery(items, query) {
154
+ const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
155
+ if (filters.length === 0) return items;
156
+ return items.filter(
157
+ (item) => filters.every(([key, value]) => {
158
+ const op = OPERATORS.find((o) => key.endsWith(o));
159
+ if (op) {
160
+ const field = key.slice(0, -op.length);
161
+ if (item[field] === void 0) return false;
162
+ const filterVal = Array.isArray(value) ? value[0] : value;
163
+ return applyOperator(item[field], op, filterVal);
164
+ }
165
+ if (item[key] === void 0) return false;
166
+ const itemStr = String(item[key]);
167
+ return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
168
+ })
169
+ );
170
+ }
171
+ function fullTextSearch(items, term) {
172
+ const lower = term.toLowerCase();
173
+ return items.filter(
174
+ (item) => Object.values(item).some((val) => {
175
+ if (val === null || val === void 0 || typeof val === "object") return false;
176
+ return String(val).toLowerCase().includes(lower);
177
+ })
178
+ );
179
+ }
180
+ function sortBy(items, field, order) {
181
+ const direction = order === "desc" ? -1 : 1;
182
+ return [...items].sort((a, b) => {
183
+ const av = a[field];
184
+ const bv = b[field];
185
+ if (av === void 0) return 1;
186
+ if (bv === void 0) return -1;
187
+ if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
188
+ return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
189
+ });
190
+ }
191
+ function projectFields(input, fields) {
192
+ if (fields.length === 0) return input;
193
+ const project = (item) => Object.fromEntries(fields.filter((f) => f in item).map((f) => [f, item[f]]));
194
+ return Array.isArray(input) ? input.map(project) : project(input);
195
+ }
196
+ function paginate(items, page, limit) {
197
+ const start = (page - 1) * limit;
198
+ return items.slice(start, start + limit);
199
+ }
200
+
201
+ // src/services/resource.service.ts
93
202
  function findById(items, id) {
94
203
  return items.find((i) => String(i["id"]) === id);
95
204
  }
@@ -126,36 +235,17 @@ function patchItem(storage, resource, id, body) {
126
235
  storage.persist();
127
236
  return updated;
128
237
  }
129
- function firstParam(value) {
130
- if (value === void 0) return void 0;
131
- return Array.isArray(value) ? value[0] : value;
132
- }
133
- function filterByQuery(items, query) {
134
- const filters = Object.entries(query).filter(([key]) => !key.startsWith("_"));
135
- if (filters.length === 0) return items;
136
- return items.filter(
137
- (item) => filters.every(([key, value]) => {
138
- if (item[key] === void 0) return false;
139
- const itemStr = String(item[key]);
140
- return Array.isArray(value) ? value.includes(itemStr) : itemStr === value;
141
- })
142
- );
143
- }
144
- function sortBy(items, field, order) {
145
- const direction = order === "desc" ? -1 : 1;
146
- return [...items].sort((a, b) => {
147
- const av = a[field];
148
- const bv = b[field];
149
- if (av === void 0) return 1;
150
- if (bv === void 0) return -1;
151
- if (typeof av === "number" && typeof bv === "number") return (av - bv) * direction;
152
- return String(av).localeCompare(String(bv), void 0, { sensitivity: "base" }) * direction;
153
- });
154
- }
155
- function paginate(items, page, limit) {
156
- const start = (page - 1) * limit;
157
- return items.slice(start, start + limit);
238
+ function deleteItem(storage, resource, id) {
239
+ const collection = storage.getCollection(resource) ?? [];
240
+ const idx = findIndexById(collection, id);
241
+ if (idx === -1) return void 0;
242
+ const [deleted] = collection.splice(idx, 1);
243
+ storage.setCollection(resource, collection);
244
+ storage.persist();
245
+ return deleted;
158
246
  }
247
+
248
+ // src/services/expand.service.ts
159
249
  function expandItems(input, query, resource, storage) {
160
250
  const isArray = Array.isArray(input);
161
251
  const items = isArray ? input : [input];
@@ -188,14 +278,35 @@ function expandItems(input, query, resource, storage) {
188
278
  });
189
279
  return isArray ? expanded : expanded[0];
190
280
  }
191
- function deleteItem(storage, resource, id) {
192
- const collection = storage.getCollection(resource) ?? [];
193
- const idx = findIndexById(collection, id);
194
- if (idx === -1) return void 0;
195
- const [deleted] = collection.splice(idx, 1);
196
- storage.setCollection(resource, collection);
197
- storage.persist();
198
- return deleted;
281
+ function embedItems(input, query, resource, storage) {
282
+ const isArray = Array.isArray(input);
283
+ const items = isArray ? input : [input];
284
+ const embedParam = query["_embed"];
285
+ if (!embedParam) return isArray ? items : input;
286
+ const keys = (Array.isArray(embedParam) ? embedParam : [embedParam]).flatMap((v) => v.split(",")).map((k) => k.trim()).filter(Boolean);
287
+ const relations = storage.getRelations();
288
+ const embeds = /* @__PURE__ */ new Map();
289
+ for (const embedKey of keys) {
290
+ outer: for (const [childCollection, fields] of Object.entries(relations)) {
291
+ for (const [fkField, parentCollection] of Object.entries(fields)) {
292
+ if (parentCollection === resource && childCollection === embedKey) {
293
+ embeds.set(embedKey, { childCollection, fkField });
294
+ break outer;
295
+ }
296
+ }
297
+ }
298
+ }
299
+ if (embeds.size === 0) return isArray ? items : input;
300
+ const result = items.map((item) => {
301
+ const out = { ...item };
302
+ for (const [embedKey, { childCollection, fkField }] of embeds) {
303
+ out[embedKey] = (storage.getCollection(childCollection) ?? []).filter(
304
+ (child) => String(child[fkField]) === String(item["id"])
305
+ );
306
+ }
307
+ return out;
308
+ });
309
+ return isArray ? result : result[0];
199
310
  }
200
311
 
201
312
  // src/router/routes/collection.routes.ts
@@ -203,9 +314,12 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
203
314
  server.get(base, (req, reply) => {
204
315
  const collection = storage.getCollection(resource) ?? [];
205
316
  const filtered = filterByQuery(collection, req.query);
317
+ const searchTerm = firstParam(req.query["_q"]);
318
+ const searched = searchTerm ? fullTextSearch(filtered, searchTerm) : filtered;
319
+ const fields = (firstParam(req.query["_fields"]) ?? "").split(",").map((f) => f.trim()).filter(Boolean);
206
320
  const sortField = firstParam(req.query["_sort"]);
207
321
  const sortOrder = firstParam(req.query["_order"]) === "desc" ? "desc" : "asc";
208
- const sorted = sortField ? sortBy(filtered, sortField, sortOrder) : filtered;
322
+ const sorted = sortField ? sortBy(searched, sortField, sortOrder) : searched;
209
323
  if (options.pageable.enabled) {
210
324
  const defaultLimit = options.pageable.limit;
211
325
  const page = Math.max(1, parseInt(firstParam(req.query["_page"]) ?? "1", 10) || 1);
@@ -215,7 +329,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
215
329
  );
216
330
  const totalItems = sorted.length;
217
331
  const totalPages = Math.ceil(totalItems / limit) || 1;
218
- const data = expandItems(paginate(sorted, page, limit), req.query, resource, storage);
332
+ const data = projectFields(
333
+ embedItems(
334
+ expandItems(paginate(sorted, page, limit), req.query, resource, storage),
335
+ req.query,
336
+ resource,
337
+ storage
338
+ ),
339
+ fields
340
+ );
219
341
  const pagination = {
220
342
  page,
221
343
  limit,
@@ -239,9 +361,15 @@ var registerCollectionRoutes = (server, storage, resource, base, options) => {
239
361
  reply.header("X-Total-Count", String(sorted.length));
240
362
  result = paginate(sorted, page, limit);
241
363
  }
242
- return expandItems(result, req.query, resource, storage);
364
+ return projectFields(
365
+ embedItems(expandItems(result, req.query, resource, storage), req.query, resource, storage),
366
+ fields
367
+ );
243
368
  });
244
369
  server.post(base, (req, reply) => {
370
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
371
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
372
+ }
245
373
  const item = createItem(storage, resource, req.body);
246
374
  return reply.status(201).send(expandItems(item, req.query, resource, storage));
247
375
  });
@@ -252,14 +380,24 @@ var registerItemRoutes = (server, storage, resource, base) => {
252
380
  server.get(`${base}/:id`, (req, reply) => {
253
381
  const item = findById(storage.getCollection(resource) ?? [], req.params.id);
254
382
  if (!item) return reply.status(404).send({ error: "Not found" });
255
- return expandItems(item, req.query, resource, storage);
383
+ const fields = (req.query["_fields"] ?? "").split(",").map((f) => f.trim()).filter(Boolean);
384
+ return projectFields(
385
+ embedItems(expandItems(item, req.query, resource, storage), req.query, resource, storage),
386
+ fields
387
+ );
256
388
  });
257
389
  server.put(`${base}/:id`, (req, reply) => {
390
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
391
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
392
+ }
258
393
  const item = replaceItem(storage, resource, req.params.id, req.body);
259
394
  if (!item) return reply.status(404).send({ error: "Not found" });
260
395
  return expandItems(item, req.query, resource, storage);
261
396
  });
262
397
  server.patch(`${base}/:id`, (req, reply) => {
398
+ if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
399
+ return reply.status(400).send({ error: "Request body must be a JSON object" });
400
+ }
263
401
  const item = patchItem(storage, resource, req.params.id, req.body);
264
402
  if (!item) return reply.status(404).send({ error: "Not found" });
265
403
  return expandItems(item, req.query, resource, storage);
@@ -275,7 +413,9 @@ var registerItemRoutes = (server, storage, resource, base) => {
275
413
  var registerNestedRoutes = (server, storage, relations, base) => {
276
414
  for (const [child, fields] of Object.entries(relations)) {
277
415
  for (const [field, parent] of Object.entries(fields)) {
278
- server.get(`${base}/${parent}/:id/${child}`, (req, reply) => {
416
+ const collectionPath = `${base}/${parent}/:id/${child}`;
417
+ const itemPath = `${base}/${parent}/:id/${child}/:childId`;
418
+ server.get(collectionPath, (req, reply) => {
279
419
  const parentCollection = storage.getCollection(parent) ?? [];
280
420
  const parentItem = findById(parentCollection, req.params.id);
281
421
  if (!parentItem) return reply.status(404).send({ error: "Not found" });
@@ -284,6 +424,16 @@ var registerNestedRoutes = (server, storage, relations, base) => {
284
424
  );
285
425
  return children;
286
426
  });
427
+ server.get(itemPath, (req, reply) => {
428
+ const parentCollection = storage.getCollection(parent) ?? [];
429
+ const parentItem = findById(parentCollection, req.params.id);
430
+ if (!parentItem) return reply.status(404).send({ error: "Not found" });
431
+ const childItem = (storage.getCollection(child) ?? []).find(
432
+ (item) => String(item[field]) === req.params.id && String(item["id"]) === req.params.childId
433
+ );
434
+ if (!childItem) return reply.status(404).send({ error: "Not found" });
435
+ return childItem;
436
+ });
287
437
  }
288
438
  }
289
439
  };
@@ -408,6 +558,30 @@ curl ${host}${base}/${parent}/1/${child}`);
408
558
  examples.push(`# Pageable envelope
409
559
  curl "${host}${base}/${firstCol}?_page=2"`);
410
560
  }
561
+ const firstParentRel = Object.entries(relations).find(
562
+ ([, fields]) => Object.values(fields).includes(firstCol ?? "")
563
+ );
564
+ if (firstCol) {
565
+ examples.push(
566
+ `# Project fields with ?_fields
567
+ curl "${host}${base}/${firstCol}?_fields=id,name"`
568
+ );
569
+ }
570
+ if (firstParentRel && firstCol) {
571
+ const [childName] = firstParentRel;
572
+ examples.push(
573
+ `# Embed child collection with ?_embed
574
+ curl "${host}${base}/${firstCol}/1?_embed=${childName}"`
575
+ );
576
+ }
577
+ if (options.snapshot) {
578
+ examples.push(
579
+ `# Snapshot endpoints
580
+ curl ${host}/_snapshot
581
+ curl -X POST ${host}/_snapshot/save
582
+ curl -X POST ${host}/_snapshot/reset`
583
+ );
584
+ }
411
585
  const highlighted = examples.map((e) => e.replace(/^(#.+)$/gm, '<span class="cm">$1</span>')).join("\n\n");
412
586
  return `<pre>${highlighted}</pre>`;
413
587
  }
@@ -422,6 +596,7 @@ function generateAboutHtml(storage, options) {
422
596
  if (options.delay > 0) modes.push(badge(`delay \xB7 ${options.delay}ms`, "#fb923c", "#fb923c18"));
423
597
  if (options.pageable.enabled)
424
598
  modes.push(badge(`pageable \xB7 limit ${options.pageable.limit}`, "#34d399", "#34d39918"));
599
+ if (options.snapshot) modes.push(badge("snapshot", "#c084fc", "#c084fc18"));
425
600
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
426
601
  const nestedRows = [];
427
602
  for (const [child, fields] of Object.entries(relations)) {
@@ -441,6 +616,18 @@ function generateAboutHtml(storage, options) {
441
616
  </summary>
442
617
  <table><tbody>${nestedRows.join("")}</tbody></table>
443
618
  </details>` : "";
619
+ const snapshotAccordion = options.snapshot ? `
620
+ <details class="resource-card nested-card">
621
+ <summary>
622
+ <span class="resource-name">/_snapshot</span>
623
+ <span class="route-count">3 routes</span>
624
+ </summary>
625
+ <table><tbody>
626
+ ${endpointRow("GET", "/_snapshot", "Returns metadata of the current snapshot: <code>savedAt</code> and item counts per collection.")}
627
+ ${endpointRow("POST", "/_snapshot/save", "Replaces the stored snapshot with the current database state.")}
628
+ ${endpointRow("POST", "/_snapshot/reset", "Restores the database to the last saved snapshot and persists to disk.")}
629
+ </tbody></table>
630
+ </details>` : "";
444
631
  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.`;
445
632
  return `<!DOCTYPE html>
446
633
  <html lang="en">
@@ -589,6 +776,7 @@ function generateAboutHtml(storage, options) {
589
776
  <div class="endpoints-grid">
590
777
  ${accordions}
591
778
  ${nestedAccordion}
779
+ ${snapshotAccordion}
592
780
  </div>
593
781
 
594
782
  <h2>Query Parameters</h2>
@@ -597,9 +785,17 @@ function generateAboutHtml(storage, options) {
597
785
  <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
598
786
  <tbody>
599
787
  <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>
788
+ <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>
789
+ <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>
790
+ <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
791
+ <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
792
+ <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
793
+ <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>
600
794
  <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>
601
795
  <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
602
796
  <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>
797
+ <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>
798
+ <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>
603
799
  </tbody>
604
800
  </table>
605
801
  </div>
@@ -623,11 +819,51 @@ function registerAboutRoute(server, storage, options) {
623
819
  });
624
820
  }
625
821
 
822
+ // src/router/routes/snapshot.routes.ts
823
+ function registerSnapshotRoutes(server, storage) {
824
+ server.get("/_snapshot", (_req, reply) => {
825
+ const { data, savedAt } = storage.getSnapshot();
826
+ return reply.send({
827
+ savedAt: savedAt.toISOString(),
828
+ collections: Object.fromEntries(
829
+ Object.entries(data).map(([name, items]) => [name, items.length])
830
+ )
831
+ });
832
+ });
833
+ server.post("/_snapshot/save", (_req, reply) => {
834
+ storage.saveSnapshot();
835
+ const { data, savedAt } = storage.getSnapshot();
836
+ return reply.send({
837
+ message: "Snapshot saved",
838
+ savedAt: savedAt.toISOString(),
839
+ collections: Object.fromEntries(
840
+ Object.entries(data).map(([name, items]) => [name, items.length])
841
+ )
842
+ });
843
+ });
844
+ server.post("/_snapshot/reset", (_req, reply) => {
845
+ storage.resetToSnapshot();
846
+ const { data, savedAt } = storage.getSnapshot();
847
+ return reply.send({
848
+ message: "Database restored to snapshot",
849
+ savedAt: savedAt.toISOString(),
850
+ collections: Object.fromEntries(
851
+ Object.entries(data).map(([name, items]) => [name, items.length])
852
+ )
853
+ });
854
+ });
855
+ }
856
+
626
857
  // src/server/createServer.ts
627
858
  var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
628
859
  async function createServer(storage, options) {
629
860
  const server = (0, import_fastify.default)();
630
861
  await server.register(import_cors.default);
862
+ server.setErrorHandler((err, _req, reply) => {
863
+ const status = err.statusCode ?? 500;
864
+ const message = status < 500 ? err.message || "Request error" : "Internal server error";
865
+ reply.status(status).send({ error: message });
866
+ });
631
867
  if (options.readonly) {
632
868
  server.addHook("onRequest", (_req, reply, done) => {
633
869
  if (MUTATING_METHODS.has(_req.method)) {
@@ -642,6 +878,7 @@ async function createServer(storage, options) {
642
878
  });
643
879
  }
644
880
  registerAboutRoute(server, storage, options);
881
+ if (options.snapshot) registerSnapshotRoutes(server, storage);
645
882
  registerResourceRoutes(server, storage, options);
646
883
  return server;
647
884
  }
@@ -662,6 +899,11 @@ var serverOptionsSchema = import_zod.z.object({
662
899
  base: import_zod.z.string().default("").transform((v) => v && !v.startsWith("/") ? `/${v}` : v),
663
900
  /** When `true`, the server reloads the YAML file automatically on disk changes. */
664
901
  watch: import_zod.z.boolean().default(false),
902
+ /**
903
+ * When `true`, saves a snapshot of the initial database state on startup.
904
+ * Exposes `/_snapshot` endpoints to inspect, restore or update the snapshot.
905
+ */
906
+ snapshot: import_zod.z.boolean().default(false),
665
907
  /** When `true`, all mutating requests (POST, PUT, PATCH, DELETE) are rejected with 405. */
666
908
  readonly: import_zod.z.boolean().default(false),
667
909
  /** Milliseconds to delay every response, simulating network latency. `0` = disabled. */