@blamejs/blamejs-shop 0.0.59 → 0.0.60

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.
@@ -0,0 +1,691 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.inventorySnapshots
4
+ * @title Inventory snapshots — point-in-time inventory captures for
5
+ * audit + reconciliation
6
+ *
7
+ * @intro
8
+ * The catalog `inventory` table answers "right now"; the per-
9
+ * location `inventory_stock` table (from inventory-locations)
10
+ * answers "right now, broken down by warehouse." Neither answers
11
+ * "what did stock look like at close of business yesterday?" or
12
+ * "how did the count change between the EOM snapshot and today?"
13
+ *
14
+ * This primitive captures a point-in-time copy of every (sku,
15
+ * location_code) -> quantity pair the operator cares about and
16
+ * pins it under an operator-chosen label. Subsequent snapshots
17
+ * are diffable: `deltaBetween(from, to)` walks both row sets and
18
+ * produces a per-key delta record showing additions / removals /
19
+ * net changes, plus an explicit no-change list when the caller
20
+ * wants to see what stayed flat.
21
+ *
22
+ * Verbs:
23
+ * takeSnapshot({ label, locations?, skus?, reason })
24
+ * — Captures the current stock counts. With `locations` and a
25
+ * wired `inventoryLocations` handle, reads from
26
+ * `inventory_stock` and persists per-(sku, location_code)
27
+ * rows; otherwise reads from the catalog `inventory` table
28
+ * and persists with `location_code = NULL` (the "no per-
29
+ * location detail" sentinel). `skus` narrows the capture to
30
+ * the listed SKUs only (otherwise every SKU with a row in
31
+ * the source table is captured). Returns
32
+ * `{ id, label, taken_at, sku_count, location_count,
33
+ * total_units, hash_sha3_512 }`.
34
+ *
35
+ * getSnapshot(snapshot_id)
36
+ * — Reads the snapshot header + hydrated rows. Returns null on
37
+ * miss so the caller-handler maps cleanly to HTTP 404.
38
+ *
39
+ * listSnapshots({ from?, to?, limit?, cursor? })
40
+ * — Paginated list ordered (taken_at DESC, id DESC). `from` /
41
+ * `to` are inclusive epoch-ms bounds. Cursor is HMAC-tagged
42
+ * so an operator can't hand-craft one to skip ahead.
43
+ *
44
+ * deltaBetween({ from_snapshot_id, to_snapshot_id, sku? })
45
+ * — Per-(sku, location_code) deltas. Each row is
46
+ * `{ sku, location_code, from_quantity, to_quantity, delta,
47
+ * change }` where `change` is one of:
48
+ * 'added' — present in `to`, not in `from`
49
+ * 'removed' — present in `from`, not in `to`
50
+ * 'changed' — present in both with different quantity
51
+ * 'unchanged' — present in both with equal quantity
52
+ * When the optional `sku` filter is supplied only deltas for
53
+ * that SKU are returned (across every location_code).
54
+ *
55
+ * summary(snapshot_id)
56
+ * — Aggregate counts + the SKUs flagged as outliers. An
57
+ * outlier is a row whose quantity is either zero (potential
58
+ * stockout) or above the 95th percentile of the snapshot
59
+ * (potential overstock). Returns
60
+ * `{ id, label, taken_at, sku_count, location_count,
61
+ * total_units, hash_sha3_512, hash_matches, outliers: [
62
+ * { sku, location_code, quantity, reason } ] }`.
63
+ * `hash_matches` recomputes the canonical hash from the
64
+ * stored rows so the caller can verify the snapshot hasn't
65
+ * been tampered with after the fact.
66
+ *
67
+ * purgeOlderThan(days)
68
+ * — Deletes every snapshot whose `taken_at < now - days*86400000`.
69
+ * FK CASCADE drops the row table contents with the header.
70
+ * Returns the count of snapshots removed.
71
+ *
72
+ * Composition:
73
+ * - b.uuid.v7 — snapshot + row PKs (sortable; B-tree locality)
74
+ * - b.guardUuid — strict UUID validation on snapshot_id reads
75
+ * - b.pagination — HMAC-tagged cursor for listSnapshots
76
+ * - b.crypto.sha3Hash — SHA3-512 canonical-row hash for tamper-evidence
77
+ * - catalog — single-bucket inventory read source (required)
78
+ * - inventoryLocations (optional) — multi-location read source
79
+ *
80
+ * Three-tier input validation discipline (use it, don't write the
81
+ * labels): every public verb here is a defensive request-shape
82
+ * reader OR a config-time entry point. Both throw on bad input.
83
+ * No drop-silent hot-path sinks — every snapshot creation is a
84
+ * deliberate operator action and a silent drop on bad input would
85
+ * ship a useless empty snapshot.
86
+ */
87
+
88
+ var bShop;
89
+ function _b() {
90
+ if (!bShop) bShop = require("./index");
91
+ return bShop.framework;
92
+ }
93
+
94
+ // ---- constants ----------------------------------------------------------
95
+
96
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
97
+ var LOCATION_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
98
+ var LABEL_RE = /^[\S\s]{0,128}$/;
99
+ var MAX_LABEL_LEN = 128;
100
+ var MAX_REASON_LEN = 4000;
101
+ var MAX_LIST_LIMIT = 200;
102
+ var MAX_PURGE_DAYS = 365 * 100; // 100 years — practical sanity cap
103
+ var MAX_SKUS_PER_TAKE = 50000; // refuse pathological inputs
104
+ var MAX_LOCATIONS_PER_TAKE = 1000;
105
+ var OUTLIER_PERCENTILE = 0.95; // 95th percentile = "overstock"
106
+ var MAX_OUTLIERS = 50;
107
+
108
+ var SNAPSHOT_ORDER_KEY = ["taken_at:desc", "id:desc"];
109
+
110
+ // ---- validators ---------------------------------------------------------
111
+
112
+ function _id(s, label) {
113
+ try {
114
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
115
+ } catch (e) {
116
+ throw new TypeError("inventory-snapshots: " + label + " — " + (e && e.message || "invalid UUID"));
117
+ }
118
+ }
119
+ function _label(s) {
120
+ if (s == null) return "";
121
+ if (typeof s !== "string" || s.length > MAX_LABEL_LEN || !LABEL_RE.test(s)) {
122
+ throw new TypeError("inventory-snapshots: label must be a string ≤ " + MAX_LABEL_LEN + " chars");
123
+ }
124
+ return s;
125
+ }
126
+ function _reason(s) {
127
+ if (s == null) return "";
128
+ if (typeof s !== "string" || s.length > MAX_REASON_LEN) {
129
+ throw new TypeError("inventory-snapshots: reason must be a string ≤ " + MAX_REASON_LEN + " chars");
130
+ }
131
+ return s;
132
+ }
133
+ function _sku(s) {
134
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
135
+ throw new TypeError("inventory-snapshots: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
136
+ }
137
+ }
138
+ function _locationCode(s, label) {
139
+ if (typeof s !== "string" || !LOCATION_CODE_RE.test(s)) {
140
+ throw new TypeError("inventory-snapshots: " + (label || "location_code") +
141
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
142
+ }
143
+ }
144
+ function _limit(n, label) {
145
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
146
+ throw new TypeError("inventory-snapshots: " + label + " must be an integer in 1..." + MAX_LIST_LIMIT);
147
+ }
148
+ }
149
+ function _epochMs(n, label) {
150
+ if (!Number.isInteger(n) || n < 0) {
151
+ throw new TypeError("inventory-snapshots: " + label + " must be a non-negative integer (epoch ms)");
152
+ }
153
+ }
154
+ function _days(n) {
155
+ if (!Number.isInteger(n) || n < 0 || n > MAX_PURGE_DAYS) {
156
+ throw new TypeError("inventory-snapshots: days must be a non-negative integer ≤ " + MAX_PURGE_DAYS);
157
+ }
158
+ }
159
+
160
+ function _now() { return Date.now(); }
161
+
162
+ // Canonical row hash: SHA3-512 over the newline-joined
163
+ // "<sku>|<location_or_->|<qty>" lines sorted by (sku, location_code).
164
+ // The deterministic ordering ensures the hash is reproducible
165
+ // regardless of which order the source rows arrived in. A NULL
166
+ // location_code is rendered as the literal "-" so the encoding is
167
+ // unambiguous between "row with location_code = NULL" and a row
168
+ // whose location_code is the string "null".
169
+ function _hashRows(rows) {
170
+ var copy = rows.slice().sort(function (a, b) {
171
+ if (a.sku < b.sku) return -1;
172
+ if (a.sku > b.sku) return 1;
173
+ var al = a.location_code == null ? "" : a.location_code;
174
+ var bl = b.location_code == null ? "" : b.location_code;
175
+ if (al < bl) return -1;
176
+ if (al > bl) return 1;
177
+ return 0;
178
+ });
179
+ var parts = [];
180
+ for (var i = 0; i < copy.length; i += 1) {
181
+ var loc = copy[i].location_code == null ? "-" : copy[i].location_code;
182
+ parts.push(copy[i].sku + "|" + loc + "|" + copy[i].quantity);
183
+ }
184
+ return _b().crypto.sha3Hash(parts.join("\n"));
185
+ }
186
+
187
+ // ---- factory ------------------------------------------------------------
188
+
189
+ function create(opts) {
190
+ opts = opts || {};
191
+ if (!opts.catalog || !opts.catalog.inventory || typeof opts.catalog.inventory.get !== "function") {
192
+ throw new TypeError("inventory-snapshots.create: opts.catalog with inventory.get(sku) required");
193
+ }
194
+ // The catalog handle is shape-checked at factory time so the boot
195
+ // wiring fails loud when a caller forgets to thread it through. The
196
+ // snapshot reads inventory data through the injected `query` handle
197
+ // directly (catalog doesn't expose a "list every SKU" verb), so the
198
+ // reference doesn't survive past the shape gate.
199
+ // inventoryLocations is optional; when wired, takeSnapshot can
200
+ // capture per-location detail by reading inventory_stock rows.
201
+ // Factory holds the handle (rather than just reading the table)
202
+ // so the boot wiring stays explicit — a snapshot taken against a
203
+ // location list must have the locations primitive present to
204
+ // validate the codes.
205
+ var invLocations = opts.inventoryLocations || null;
206
+ if (invLocations !== null) {
207
+ if (typeof invLocations !== "object" || typeof invLocations.getLocation !== "function") {
208
+ throw new TypeError("inventory-snapshots.create: opts.inventoryLocations, when supplied, must expose getLocation(code)");
209
+ }
210
+ }
211
+ var query = opts.query;
212
+ if (!query) {
213
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
214
+ }
215
+ // Pagination cursors are HMAC-tagged so an operator can't hand-
216
+ // craft one to skip ahead or replay across deployments. The
217
+ // secret must be stable for the deployment; rotating it
218
+ // invalidates outstanding cursors (acceptable). Tests inject a
219
+ // fixed dev string; production must supply an explicit secret.
220
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
221
+ if (process.env.NODE_ENV === "production") {
222
+ throw new Error("inventory-snapshots.create: opts.cursorSecret is required in production");
223
+ }
224
+ opts.cursorSecret = "inventory-snapshots-cursor-secret-dev-only";
225
+ }
226
+ var cursorSecret = opts.cursorSecret;
227
+
228
+ // Read source rows for a snapshot. When `locations` is supplied,
229
+ // walks `inventory_stock` (multi-location). Otherwise walks the
230
+ // catalog `inventory` table (single-bucket; location_code = null).
231
+ // `skus` narrows both paths. Returns the normalized rows array
232
+ // ready for insertion.
233
+ async function _readSource(locations, skus) {
234
+ var rows = [];
235
+ if (locations) {
236
+ // Multi-location capture. Validate every location code resolves
237
+ // to a real location row up front (refuse the snapshot rather
238
+ // than silently capture zero rows for a typo'd code).
239
+ for (var li = 0; li < locations.length; li += 1) {
240
+ var loc = await invLocations.getLocation(locations[li]);
241
+ if (!loc) {
242
+ throw new TypeError("inventory-snapshots.takeSnapshot: location " +
243
+ JSON.stringify(locations[li]) + " not found");
244
+ }
245
+ }
246
+ // Build the IN clause for locations + optional SKUs. Parameter
247
+ // placeholders are ?1..?N so the operator-supplied codes can't
248
+ // be SQL-injected.
249
+ var params = [];
250
+ var locPlaceholders = locations.map(function (c, i) { params.push(c); return "?" + (i + 1); }).join(",");
251
+ var sql = "SELECT sku, location_code, quantity FROM inventory_stock WHERE location_code IN (" +
252
+ locPlaceholders + ")";
253
+ if (skus && skus.length) {
254
+ var skuPlaceholders = skus.map(function (s, i) {
255
+ params.push(s);
256
+ return "?" + (locations.length + i + 1);
257
+ }).join(",");
258
+ sql += " AND sku IN (" + skuPlaceholders + ")";
259
+ }
260
+ sql += " ORDER BY sku ASC, location_code ASC";
261
+ var r = await query(sql, params);
262
+ for (var i = 0; i < r.rows.length; i += 1) {
263
+ rows.push({
264
+ sku: r.rows[i].sku,
265
+ location_code: r.rows[i].location_code,
266
+ quantity: r.rows[i].quantity,
267
+ });
268
+ }
269
+ return rows;
270
+ }
271
+ // Single-bucket capture from the catalog inventory table.
272
+ var sql2;
273
+ var params2 = [];
274
+ if (skus && skus.length) {
275
+ var ph = skus.map(function (s, i) { params2.push(s); return "?" + (i + 1); }).join(",");
276
+ sql2 = "SELECT sku, stock_on_hand AS quantity FROM inventory WHERE sku IN (" + ph + ") ORDER BY sku ASC";
277
+ } else {
278
+ sql2 = "SELECT sku, stock_on_hand AS quantity FROM inventory ORDER BY sku ASC";
279
+ }
280
+ var r2 = await query(sql2, params2);
281
+ for (var j = 0; j < r2.rows.length; j += 1) {
282
+ rows.push({
283
+ sku: r2.rows[j].sku,
284
+ location_code: null,
285
+ quantity: r2.rows[j].quantity,
286
+ });
287
+ }
288
+ return rows;
289
+ }
290
+
291
+ // Hydrate a snapshot header + its rows. Returns null on miss so
292
+ // the caller-handler maps cleanly to HTTP 404.
293
+ async function _getHydrated(id) {
294
+ var hRow = await query("SELECT * FROM inventory_snapshots WHERE id = ?1", [id]);
295
+ if (!hRow.rows.length) return null;
296
+ var snapshot = hRow.rows[0];
297
+ var rRows = await query(
298
+ "SELECT * FROM inventory_snapshot_rows WHERE snapshot_id = ?1 " +
299
+ "ORDER BY sku ASC, location_code ASC",
300
+ [id],
301
+ );
302
+ snapshot.rows = rRows.rows;
303
+ return snapshot;
304
+ }
305
+
306
+ // Count distinct location_codes (NULL counts as zero locations
307
+ // since "single-bucket" snapshots carry no per-location detail).
308
+ function _countLocations(rows) {
309
+ var seen = {};
310
+ var n = 0;
311
+ for (var i = 0; i < rows.length; i += 1) {
312
+ if (rows[i].location_code == null) continue;
313
+ if (!Object.prototype.hasOwnProperty.call(seen, rows[i].location_code)) {
314
+ seen[rows[i].location_code] = true;
315
+ n += 1;
316
+ }
317
+ }
318
+ return n;
319
+ }
320
+
321
+ function _countSkus(rows) {
322
+ var seen = {};
323
+ var n = 0;
324
+ for (var i = 0; i < rows.length; i += 1) {
325
+ if (!Object.prototype.hasOwnProperty.call(seen, rows[i].sku)) {
326
+ seen[rows[i].sku] = true;
327
+ n += 1;
328
+ }
329
+ }
330
+ return n;
331
+ }
332
+
333
+ function _sumUnits(rows) {
334
+ var t = 0;
335
+ for (var i = 0; i < rows.length; i += 1) t += rows[i].quantity;
336
+ return t;
337
+ }
338
+
339
+ return {
340
+
341
+ // Capture a point-in-time copy of the current stock counts.
342
+ // Persists the header + every row in one logical operation; if
343
+ // any insert fails, the partial header is rolled back via a
344
+ // compensating DELETE so the DB doesn't carry an orphaned
345
+ // header forward.
346
+ takeSnapshot: async function (input) {
347
+ if (!input || typeof input !== "object") {
348
+ throw new TypeError("inventory-snapshots.takeSnapshot: input object required");
349
+ }
350
+ var label = _label(input.label);
351
+ var reason = _reason(input.reason);
352
+
353
+ // Validate the optional locations + skus arrays before
354
+ // touching the source tables. An empty array is a separate
355
+ // case from "undefined" — the operator may want to take a
356
+ // snapshot scoped to zero locations (which produces an empty
357
+ // multi-location snapshot, useful for testing); we still
358
+ // refuse if the values themselves are malformed.
359
+ var locations = input.locations;
360
+ if (locations !== undefined && locations !== null) {
361
+ if (!Array.isArray(locations)) {
362
+ throw new TypeError("inventory-snapshots.takeSnapshot: locations must be an array of location codes");
363
+ }
364
+ if (locations.length > MAX_LOCATIONS_PER_TAKE) {
365
+ throw new TypeError("inventory-snapshots.takeSnapshot: locations must contain ≤ " + MAX_LOCATIONS_PER_TAKE + " entries");
366
+ }
367
+ for (var li = 0; li < locations.length; li += 1) {
368
+ _locationCode(locations[li], "locations[" + li + "]");
369
+ }
370
+ if (locations.length > 0 && !invLocations) {
371
+ throw new TypeError("inventory-snapshots.takeSnapshot: locations supplied but inventoryLocations dep not wired into the factory");
372
+ }
373
+ } else {
374
+ locations = null;
375
+ }
376
+
377
+ var skus = input.skus;
378
+ if (skus !== undefined && skus !== null) {
379
+ if (!Array.isArray(skus)) {
380
+ throw new TypeError("inventory-snapshots.takeSnapshot: skus must be an array of SKU strings");
381
+ }
382
+ if (skus.length > MAX_SKUS_PER_TAKE) {
383
+ throw new TypeError("inventory-snapshots.takeSnapshot: skus must contain ≤ " + MAX_SKUS_PER_TAKE + " entries");
384
+ }
385
+ for (var si = 0; si < skus.length; si += 1) {
386
+ _sku(skus[si]);
387
+ }
388
+ } else {
389
+ skus = null;
390
+ }
391
+
392
+ // Read the source rows BEFORE inserting the header so a bad
393
+ // SKU / location reference fails up front instead of leaving
394
+ // an empty header row behind.
395
+ var sourceRows = await _readSource(locations, skus);
396
+
397
+ var id = _b().uuid.v7();
398
+ var ts = _now();
399
+ var takenAt = input.taken_at == null ? ts : input.taken_at;
400
+ _epochMs(takenAt, "taken_at");
401
+
402
+ var skuCount = _countSkus(sourceRows);
403
+ var locationCount = _countLocations(sourceRows);
404
+ var totalUnits = _sumUnits(sourceRows);
405
+ var hash = _hashRows(sourceRows);
406
+
407
+ try {
408
+ await query(
409
+ "INSERT INTO inventory_snapshots (id, label, taken_at, reason, sku_count, " +
410
+ "location_count, total_units, hash_sha3_512, created_at) " +
411
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
412
+ [id, label, takenAt, reason, skuCount, locationCount, totalUnits, hash, ts],
413
+ );
414
+ for (var i = 0; i < sourceRows.length; i += 1) {
415
+ var r = sourceRows[i];
416
+ await query(
417
+ "INSERT INTO inventory_snapshot_rows (id, snapshot_id, sku, location_code, quantity, captured_at) " +
418
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
419
+ [_b().uuid.v7(), id, r.sku, r.location_code, r.quantity, ts],
420
+ );
421
+ }
422
+ } catch (e) {
423
+ // Best-effort rollback of the header + any rows that landed
424
+ // before the failure (ON DELETE CASCADE on the FK clears the
425
+ // rows). D1 doesn't expose a transaction handle to this
426
+ // primitive, so the rollback is a compensating DELETE.
427
+ try { await query("DELETE FROM inventory_snapshots WHERE id = ?1", [id]); }
428
+ catch (_e2) { /* drop-silent — the original error is what the caller needs */ }
429
+ throw e;
430
+ }
431
+
432
+ return {
433
+ id: id,
434
+ label: label,
435
+ taken_at: takenAt,
436
+ sku_count: skuCount,
437
+ location_count: locationCount,
438
+ total_units: totalUnits,
439
+ hash_sha3_512: hash,
440
+ };
441
+ },
442
+
443
+ // Read a snapshot + its hydrated rows. Returns null on miss so
444
+ // the caller-handler maps cleanly to HTTP 404.
445
+ getSnapshot: async function (snapshotId) {
446
+ _id(snapshotId, "snapshot_id");
447
+ return await _getHydrated(snapshotId);
448
+ },
449
+
450
+ // Paginated list ordered (taken_at DESC, id DESC). Cursor is
451
+ // HMAC-tagged so an operator can't tamper or replay across
452
+ // orderKey changes. `from` / `to` are inclusive epoch-ms bounds.
453
+ listSnapshots: async function (listOpts) {
454
+ listOpts = listOpts || {};
455
+ if (listOpts.from != null) _epochMs(listOpts.from, "from");
456
+ if (listOpts.to != null) _epochMs(listOpts.to, "to");
457
+ var limit = listOpts.limit == null ? 50 : listOpts.limit;
458
+ _limit(limit, "limit");
459
+ var cursorVals = null;
460
+ if (listOpts.cursor != null) {
461
+ if (typeof listOpts.cursor !== "string") {
462
+ throw new TypeError("inventory-snapshots.listSnapshots: cursor must be an opaque string or null");
463
+ }
464
+ try {
465
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
466
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(SNAPSHOT_ORDER_KEY)) {
467
+ throw new TypeError("inventory-snapshots.listSnapshots: cursor orderKey mismatch");
468
+ }
469
+ cursorVals = state.vals;
470
+ } catch (e) {
471
+ if (e instanceof TypeError) throw e;
472
+ throw new TypeError("inventory-snapshots.listSnapshots: cursor — " + (e && e.message || "malformed"));
473
+ }
474
+ }
475
+
476
+ // Build the WHERE clause incrementally so each optional filter
477
+ // (from / to / cursor) appends an indexed predicate. The
478
+ // (taken_at DESC) index covers the ordering + the range
479
+ // predicates without a sort step.
480
+ var clauses = [];
481
+ var params = [];
482
+ var idx = 1;
483
+ if (listOpts.from != null) {
484
+ clauses.push("taken_at >= ?" + idx); params.push(listOpts.from); idx += 1;
485
+ }
486
+ if (listOpts.to != null) {
487
+ clauses.push("taken_at <= ?" + idx); params.push(listOpts.to); idx += 1;
488
+ }
489
+ if (cursorVals) {
490
+ clauses.push("(taken_at < ?" + idx + " OR (taken_at = ?" + idx + " AND id < ?" + (idx + 1) + "))");
491
+ params.push(cursorVals[0]); idx += 1;
492
+ params.push(cursorVals[1]); idx += 1;
493
+ }
494
+ var sql = "SELECT * FROM inventory_snapshots";
495
+ if (clauses.length) sql += " WHERE " + clauses.join(" AND ");
496
+ sql += " ORDER BY taken_at DESC, id DESC LIMIT ?" + idx;
497
+ params.push(limit);
498
+
499
+ var rows = (await query(sql, params)).rows;
500
+ var last = rows[rows.length - 1];
501
+ var next = null;
502
+ if (last && rows.length === limit) {
503
+ next = _b().pagination.encodeCursor({
504
+ orderKey: SNAPSHOT_ORDER_KEY,
505
+ vals: [last.taken_at, last.id],
506
+ forward: true,
507
+ }, cursorSecret);
508
+ }
509
+ return { rows: rows, next_cursor: next };
510
+ },
511
+
512
+ // Compare two snapshots row-by-row. Walks both row sets, keyed
513
+ // by (sku, location_code), and emits one record per key with
514
+ // the from-quantity, to-quantity, signed delta, and a `change`
515
+ // classification ('added', 'removed', 'changed', 'unchanged').
516
+ // The optional `sku` filter limits the result to a single SKU
517
+ // (across every location_code it appears in).
518
+ deltaBetween: async function (input) {
519
+ if (!input || typeof input !== "object") {
520
+ throw new TypeError("inventory-snapshots.deltaBetween: input object required");
521
+ }
522
+ _id(input.from_snapshot_id, "from_snapshot_id");
523
+ _id(input.to_snapshot_id, "to_snapshot_id");
524
+ var skuFilter = null;
525
+ if (input.sku != null) {
526
+ _sku(input.sku);
527
+ skuFilter = input.sku;
528
+ }
529
+
530
+ var fromSnap = await _getHydrated(input.from_snapshot_id);
531
+ if (!fromSnap) {
532
+ throw new TypeError("inventory-snapshots.deltaBetween: from_snapshot_id " +
533
+ input.from_snapshot_id + " not found");
534
+ }
535
+ var toSnap = await _getHydrated(input.to_snapshot_id);
536
+ if (!toSnap) {
537
+ throw new TypeError("inventory-snapshots.deltaBetween: to_snapshot_id " +
538
+ input.to_snapshot_id + " not found");
539
+ }
540
+
541
+ // Index each snapshot by its (sku, location_code) key. The
542
+ // sentinel "" separator can't appear in a valid SKU or
543
+ // location_code (both validate against alphanumeric +
544
+ // ._- only) so the key is unambiguous.
545
+ function _key(sku, loc) { return sku + "" + (loc == null ? "" : loc); }
546
+ var fromMap = {};
547
+ var toMap = {};
548
+ var keys = {};
549
+ var i;
550
+ for (i = 0; i < fromSnap.rows.length; i += 1) {
551
+ if (skuFilter && fromSnap.rows[i].sku !== skuFilter) continue;
552
+ var k = _key(fromSnap.rows[i].sku, fromSnap.rows[i].location_code);
553
+ fromMap[k] = fromSnap.rows[i];
554
+ keys[k] = true;
555
+ }
556
+ for (i = 0; i < toSnap.rows.length; i += 1) {
557
+ if (skuFilter && toSnap.rows[i].sku !== skuFilter) continue;
558
+ var k2 = _key(toSnap.rows[i].sku, toSnap.rows[i].location_code);
559
+ toMap[k2] = toSnap.rows[i];
560
+ keys[k2] = true;
561
+ }
562
+
563
+ var keyList = Object.keys(keys);
564
+ keyList.sort();
565
+ var out = [];
566
+ for (i = 0; i < keyList.length; i += 1) {
567
+ var fk = fromMap[keyList[i]];
568
+ var tk = toMap[keyList[i]];
569
+ var sku = fk ? fk.sku : tk.sku;
570
+ var loc = fk ? fk.location_code : tk.location_code;
571
+ var fromQty = fk ? fk.quantity : 0;
572
+ var toQty = tk ? tk.quantity : 0;
573
+ var change;
574
+ if (!fk) change = "added";
575
+ else if (!tk) change = "removed";
576
+ else if (fromQty === toQty) change = "unchanged";
577
+ else change = "changed";
578
+ out.push({
579
+ sku: sku,
580
+ location_code: loc,
581
+ from_quantity: fromQty,
582
+ to_quantity: toQty,
583
+ delta: toQty - fromQty,
584
+ change: change,
585
+ });
586
+ }
587
+ return {
588
+ from_snapshot_id: input.from_snapshot_id,
589
+ to_snapshot_id: input.to_snapshot_id,
590
+ rows: out,
591
+ };
592
+ },
593
+
594
+ // Snapshot summary: aggregate counts + outlier flags + tamper-
595
+ // evidence check via canonical-row hash recompute.
596
+ summary: async function (snapshotId) {
597
+ _id(snapshotId, "snapshot_id");
598
+ var snap = await _getHydrated(snapshotId);
599
+ if (!snap) return null;
600
+
601
+ // Tamper-evidence: re-hash the persisted rows + compare to
602
+ // the stored hash. A mismatch means an UPDATE landed against
603
+ // the row table outside this primitive (or the row table was
604
+ // partially restored from a backup) — either way the caller
605
+ // needs to know before trusting the summary.
606
+ var recomputed = _hashRows(snap.rows);
607
+ var hashMatches = recomputed === snap.hash_sha3_512;
608
+
609
+ // Outliers: zero-quantity rows (potential stockout) + rows in
610
+ // the top 5% of the snapshot by quantity (potential overstock).
611
+ // Capped at MAX_OUTLIERS to keep the response bounded even on
612
+ // a snapshot with thousands of zero-stock rows.
613
+ var quantities = snap.rows.map(function (r) { return r.quantity; }).sort(function (a, b) { return a - b; });
614
+ var threshold = null;
615
+ if (quantities.length > 0) {
616
+ var idx = Math.floor(quantities.length * OUTLIER_PERCENTILE);
617
+ if (idx >= quantities.length) idx = quantities.length - 1;
618
+ threshold = quantities[idx];
619
+ }
620
+ var outliers = [];
621
+ for (var i = 0; i < snap.rows.length && outliers.length < MAX_OUTLIERS; i += 1) {
622
+ var r = snap.rows[i];
623
+ if (r.quantity === 0) {
624
+ outliers.push({
625
+ sku: r.sku,
626
+ location_code: r.location_code,
627
+ quantity: r.quantity,
628
+ reason: "stockout",
629
+ });
630
+ } else if (threshold !== null && r.quantity >= threshold && quantities.length >= 20) {
631
+ // Only flag overstock when the snapshot is large enough
632
+ // for a percentile to be meaningful (< 20 rows and the
633
+ // 95th percentile collapses to the max — every row gets
634
+ // flagged, which is noise).
635
+ outliers.push({
636
+ sku: r.sku,
637
+ location_code: r.location_code,
638
+ quantity: r.quantity,
639
+ reason: "overstock",
640
+ });
641
+ }
642
+ }
643
+
644
+ return {
645
+ id: snap.id,
646
+ label: snap.label,
647
+ taken_at: snap.taken_at,
648
+ sku_count: snap.sku_count,
649
+ location_count: snap.location_count,
650
+ total_units: snap.total_units,
651
+ hash_sha3_512: snap.hash_sha3_512,
652
+ hash_matches: hashMatches,
653
+ outliers: outliers,
654
+ };
655
+ },
656
+
657
+ // Delete every snapshot older than `days`. FK CASCADE drops the
658
+ // associated row table contents. Returns the count of snapshots
659
+ // removed. `days = 0` deletes everything — operators that want
660
+ // to wipe the snapshot history wholesale call with 0.
661
+ purgeOlderThan: async function (days) {
662
+ _days(days);
663
+ var cutoff = _now() - (days * 86400000);
664
+ // Read the ids first so the return shape can carry both the
665
+ // count and the ids that were removed (operators occasionally
666
+ // want to log "purged snapshots: a, b, c" for a compliance
667
+ // trail). Cap the listed ids at MAX_LIST_LIMIT to keep the
668
+ // response bounded; the count reflects the full delete.
669
+ var listed = await query(
670
+ "SELECT id FROM inventory_snapshots WHERE taken_at < ?1 ORDER BY taken_at ASC LIMIT ?2",
671
+ [cutoff, MAX_LIST_LIMIT],
672
+ );
673
+ var ids = listed.rows.map(function (r) { return r.id; });
674
+ var del = await query(
675
+ "DELETE FROM inventory_snapshots WHERE taken_at < ?1",
676
+ [cutoff],
677
+ );
678
+ return {
679
+ removed: Number(del.rowCount || 0),
680
+ removed_ids: ids,
681
+ cutoff: cutoff,
682
+ };
683
+ },
684
+ };
685
+ }
686
+
687
+ module.exports = {
688
+ create: create,
689
+ SNAPSHOT_ORDER_KEY: SNAPSHOT_ORDER_KEY,
690
+ OUTLIER_PERCENTILE: OUTLIER_PERCENTILE,
691
+ };