@blamejs/blamejs-shop 0.0.64 → 0.0.65

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,1614 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.catalogDrafts
4
+ * @title Catalog drafts — atomic-publication staging workflow for
5
+ * multi-change catalog batches with a 7-day rollback window.
6
+ *
7
+ * @intro
8
+ * The catalog primitive owns single-row mutation (one product, one
9
+ * variant, one price). The bulk-ops primitive owns filter-based
10
+ * mass operations. `catalogDrafts` owns the third lane: the
11
+ * operator is assembling a batch of N changes — new products,
12
+ * price updates, archives, tag flips — that should land together,
13
+ * atomically, with a preview review window before publication and
14
+ * an undo window after.
15
+ *
16
+ * The lifecycle:
17
+ *
18
+ * openDraft — operator names a batch with a stable slug.
19
+ * stageChange — append a typed change verb (create_product,
20
+ * set_price, archive_variant, ...). Each verb
21
+ * carries a payload shaped to the verb. Changes
22
+ * are sequence-numbered so the operator authors
23
+ * in natural order (create_product first, then
24
+ * set_price for the variant of that product).
25
+ * listChanges — read the staged changes, cursor-paginated.
26
+ * removeChange — drop a single staged change by id.
27
+ * previewMerged — return the resulting catalog state *as if*
28
+ * publishDraft ran, without writing.
29
+ * publishDraft — pre-flight every change against the live
30
+ * catalog, then apply them all. Pre-flight
31
+ * failures refuse the entire publish (no partial
32
+ * writes). `dry_run: true` runs the pre-flight
33
+ * without writing.
34
+ * cancelDraft — abandon a draft (any non-terminal status).
35
+ * rollbackDraft — within 7 days of publishDraft, restore the
36
+ * catalog to the state it was in before publish.
37
+ * Records both the original and the abandoned
38
+ * state in `catalog_draft_rollback_log` so an
39
+ * auditor reconstructs the round-trip.
40
+ * listDrafts — operator dashboard feed; filter by status /
41
+ * owner.
42
+ * historyForSku — "every draft that touched this SKU" — for the
43
+ * SKU-level history view operators reach for
44
+ * when investigating a price / inventory change.
45
+ *
46
+ * Composition:
47
+ * var cd = bShop.catalogDrafts.create({
48
+ * query: q,
49
+ * catalog: cat, // catalog handle
50
+ * });
51
+ * var draft = await cd.openDraft({ slug: "spring-2026", title: "Spring refresh" });
52
+ * await cd.stageChange({ draft_slug: "spring-2026", kind: "create_product",
53
+ * payload: { slug: "tee-blue", title: "Blue tee", status: "active" } });
54
+ * await cd.stageChange({ draft_slug: "spring-2026", kind: "set_price",
55
+ * payload: { sku: "TEE-BLU-M", currency: "USD", amount_minor: 2500 } });
56
+ * var preview = await cd.previewMerged({ draft_slug: "spring-2026" });
57
+ * await cd.publishDraft({ draft_slug: "spring-2026" });
58
+ * await cd.rollbackDraft({ draft_slug: "spring-2026", reason: "downstream alert" });
59
+ *
60
+ * `opts.catalog` is the catalog handle the primitive consults +
61
+ * writes through. It must expose the standard catalog subsurfaces
62
+ * (`products`, `variants`, `prices`, `inventory`) plus the
63
+ * tag-level methods (`tags.add(product_id, tag)`,
64
+ * `tags.remove(product_id, tag)`). The handle is injected so the
65
+ * tests can pass an in-memory composition and the production code
66
+ * can pass `bShop.catalog.create(...)` straight through.
67
+ *
68
+ * @related b.guardUuid, b.uuid.v7, catalog, productBulkOps
69
+ */
70
+
71
+ // ---- constants ----------------------------------------------------------
72
+
73
+ var STATUSES = Object.freeze([
74
+ "open", "preview", "published", "cancelled", "rolled_back",
75
+ ]);
76
+
77
+ // The change-kind closed enum. Adding a new kind requires:
78
+ // 1. extending the CHECK constraint in the migration
79
+ // 2. extending KIND_PAYLOAD_VALIDATORS below
80
+ // 3. extending the publish + preview switch tables
81
+ // 4. extending the test coverage
82
+ // All four landings ship in the same release — no half-shipped verb.
83
+ var KINDS = Object.freeze([
84
+ "create_product",
85
+ "update_product",
86
+ "archive_product",
87
+ "create_variant",
88
+ "update_variant",
89
+ "archive_variant",
90
+ "set_price",
91
+ "set_inventory",
92
+ "add_tag",
93
+ "remove_tag",
94
+ ]);
95
+
96
+ var TERMINAL_STATUSES = Object.freeze(["published", "cancelled", "rolled_back"]);
97
+
98
+ var MAX_SLUG_LEN = 80;
99
+ var MAX_TITLE_LEN = 200;
100
+ var MAX_DESC_LEN = 2000;
101
+ var MAX_REASON_LEN = 280;
102
+ var MAX_OWNER_LEN = 200;
103
+ var MAX_LIST_LIMIT = 200;
104
+ var MAX_TAG_LEN = 64;
105
+ var MAX_CHANGES_PER_DRAFT = 5000;
106
+
107
+ var ROLLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
108
+
109
+ // Slug shape matches the project's recurring slug convention used
110
+ // across coupon-stacking / refund-policy / customer-segments — alnum
111
+ // leading char, alnum + . _ - thereafter, capped length.
112
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
113
+ var TAG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
114
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
115
+ var CURRENCY_RE = /^[A-Z]{3}$/;
116
+ // Product slug shape matches catalog.js (lowercase alnum + hyphen,
117
+ // must end in alnum). Mirrored here so stageChange refuses bad
118
+ // payloads at stage time rather than at publish time.
119
+ var PRODUCT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,198}[a-z0-9])?$/;
120
+
121
+ // Reuse the same prose-input refusal posture other primitives apply
122
+ // to operator-authored fields — refuse control bytes + zero-width /
123
+ // direction-override codepoints so a rollback_reason / cancel_reason
124
+ // can't smuggle UI-confusing glyphs into the audit dashboard.
125
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
126
+ var ZERO_WIDTH_RE = new RegExp(
127
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
128
+ );
129
+
130
+ var bShop;
131
+ function _b() {
132
+ if (!bShop) bShop = require("./index");
133
+ return bShop.framework;
134
+ }
135
+
136
+ // ---- validators ---------------------------------------------------------
137
+
138
+ function _slug(s, label) {
139
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
140
+ throw new TypeError("catalogDrafts: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
141
+ }
142
+ return s;
143
+ }
144
+
145
+ function _title(s, label) {
146
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
147
+ throw new TypeError("catalogDrafts: " + label + " must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
148
+ }
149
+ if (CONTROL_BYTE_RE.test(s)) {
150
+ throw new TypeError("catalogDrafts: " + label + " must not contain control bytes");
151
+ }
152
+ return s;
153
+ }
154
+
155
+ function _description(s) {
156
+ if (s == null) return "";
157
+ if (typeof s !== "string" || s.length > MAX_DESC_LEN) {
158
+ throw new TypeError("catalogDrafts: description must be a string <= " + MAX_DESC_LEN + " chars when provided");
159
+ }
160
+ if (CONTROL_BYTE_RE.test(s)) {
161
+ throw new TypeError("catalogDrafts: description must not contain control bytes");
162
+ }
163
+ return s;
164
+ }
165
+
166
+ function _optReason(s, label) {
167
+ if (s == null) return null;
168
+ if (typeof s !== "string" || !s.length) {
169
+ throw new TypeError("catalogDrafts: " + label + " must be a non-empty string when provided");
170
+ }
171
+ if (s.length > MAX_REASON_LEN) {
172
+ throw new TypeError("catalogDrafts: " + label + " must be <= " + MAX_REASON_LEN + " chars");
173
+ }
174
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
175
+ throw new TypeError("catalogDrafts: " + label + " contains control / zero-width bytes");
176
+ }
177
+ return s;
178
+ }
179
+
180
+ function _reqReason(s, label) {
181
+ if (typeof s !== "string" || !s.length) {
182
+ throw new TypeError("catalogDrafts: " + label + " must be a non-empty string");
183
+ }
184
+ return _optReason(s, label);
185
+ }
186
+
187
+ function _optOwner(s) {
188
+ if (s == null) return null;
189
+ if (typeof s !== "string" || !s.length || s.length > MAX_OWNER_LEN) {
190
+ throw new TypeError("catalogDrafts: owner_id must be a non-empty string <= " + MAX_OWNER_LEN + " chars when provided");
191
+ }
192
+ if (CONTROL_BYTE_RE.test(s)) {
193
+ throw new TypeError("catalogDrafts: owner_id must not contain control bytes");
194
+ }
195
+ return s;
196
+ }
197
+
198
+ function _optEpochMs(n, label) {
199
+ if (n == null) return null;
200
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
201
+ throw new TypeError("catalogDrafts: " + label + " must be a positive integer (epoch ms) when provided");
202
+ }
203
+ return n;
204
+ }
205
+
206
+ function _kind(s) {
207
+ if (typeof s !== "string" || KINDS.indexOf(s) === -1) {
208
+ throw new TypeError("catalogDrafts: kind must be one of " + KINDS.join(", "));
209
+ }
210
+ return s;
211
+ }
212
+
213
+ function _status(s) {
214
+ if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
215
+ throw new TypeError("catalogDrafts: status must be one of " + STATUSES.join(", "));
216
+ }
217
+ return s;
218
+ }
219
+
220
+ function _uuid(s, label) {
221
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
222
+ catch (e) { throw new TypeError("catalogDrafts: " + label + " — " + (e && e.message || "invalid UUID")); }
223
+ }
224
+
225
+ function _productSlug(s, label) {
226
+ if (typeof s !== "string" || !PRODUCT_SLUG_RE.test(s)) {
227
+ throw new TypeError("catalogDrafts: " + label + " must be a lowercase alnum-hyphen slug (<= 200 chars)");
228
+ }
229
+ return s;
230
+ }
231
+
232
+ function _sku(s, label) {
233
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
234
+ throw new TypeError("catalogDrafts: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= 128 chars)");
235
+ }
236
+ return s;
237
+ }
238
+
239
+ function _currency(s) {
240
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
241
+ throw new TypeError("catalogDrafts: currency must be 3-letter uppercase ISO 4217");
242
+ }
243
+ return s;
244
+ }
245
+
246
+ function _amountMinor(n) {
247
+ if (!Number.isInteger(n) || n < 0) {
248
+ throw new TypeError("catalogDrafts: amount_minor must be a non-negative integer");
249
+ }
250
+ return n;
251
+ }
252
+
253
+ function _stockOnHand(n) {
254
+ if (!Number.isInteger(n) || n < 0) {
255
+ throw new TypeError("catalogDrafts: stock_on_hand must be a non-negative integer");
256
+ }
257
+ return n;
258
+ }
259
+
260
+ function _tag(s) {
261
+ if (typeof s !== "string" || !TAG_RE.test(s)) {
262
+ throw new TypeError("catalogDrafts: tag must match /^[a-z0-9][a-z0-9-]*$/ (<= " + MAX_TAG_LEN + " chars)");
263
+ }
264
+ return s;
265
+ }
266
+
267
+ function _now() { return Date.now(); }
268
+
269
+ // ---- payload validators -------------------------------------------------
270
+
271
+ // Each verb owns a payload shape. The validator runs at stageChange
272
+ // time so a malformed payload refuses early rather than blowing up
273
+ // pre-flight at publishDraft time, when the operator already
274
+ // believes the draft is reviewable.
275
+ //
276
+ // Returns the normalized payload (with any defaults filled). The
277
+ // stageChange writer JSON-encodes the result and the publish path
278
+ // re-reads it.
279
+ var KIND_PAYLOAD_VALIDATORS = {
280
+ create_product: function (p) {
281
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: create_product payload must be an object");
282
+ _productSlug(p.slug, "create_product.slug");
283
+ _title(p.title, "create_product.title");
284
+ var description = _description(p.description);
285
+ var status = p.status == null ? "draft" : p.status;
286
+ if (typeof status !== "string" || ["draft", "active", "archived"].indexOf(status) === -1) {
287
+ throw new TypeError("catalogDrafts: create_product.status must be one of draft, active, archived");
288
+ }
289
+ return { slug: p.slug, title: p.title, description: description, status: status };
290
+ },
291
+ update_product: function (p) {
292
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: update_product payload must be an object");
293
+ // The product is addressed by slug (operator-stable handle); the
294
+ // catalog primitive's products.bySlug -> products.update bridge
295
+ // resolves the id at publish time. Patch keys must each be a
296
+ // known column.
297
+ _productSlug(p.slug, "update_product.slug");
298
+ if (!p.patch || typeof p.patch !== "object") {
299
+ throw new TypeError("catalogDrafts: update_product.patch must be an object");
300
+ }
301
+ var out = { slug: p.slug, patch: {} };
302
+ var keys = Object.keys(p.patch);
303
+ if (keys.length === 0) {
304
+ throw new TypeError("catalogDrafts: update_product.patch must include at least one column");
305
+ }
306
+ for (var i = 0; i < keys.length; i += 1) {
307
+ var k = keys[i];
308
+ if (k === "slug") { _productSlug(p.patch[k], "update_product.patch.slug"); out.patch.slug = p.patch[k]; }
309
+ else if (k === "title") { _title(p.patch[k], "update_product.patch.title"); out.patch.title = p.patch[k]; }
310
+ else if (k === "description") { out.patch.description = _description(p.patch[k]); }
311
+ else if (k === "status") {
312
+ if (["draft", "active", "archived"].indexOf(p.patch[k]) === -1) {
313
+ throw new TypeError("catalogDrafts: update_product.patch.status invalid");
314
+ }
315
+ out.patch.status = p.patch[k];
316
+ } else {
317
+ throw new TypeError("catalogDrafts: update_product.patch — unsupported column " + JSON.stringify(k));
318
+ }
319
+ }
320
+ return out;
321
+ },
322
+ archive_product: function (p) {
323
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: archive_product payload must be an object");
324
+ _productSlug(p.slug, "archive_product.slug");
325
+ return { slug: p.slug };
326
+ },
327
+ create_variant: function (p) {
328
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: create_variant payload must be an object");
329
+ _productSlug(p.product_slug, "create_variant.product_slug");
330
+ _sku(p.sku, "create_variant.sku");
331
+ var out = { product_slug: p.product_slug, sku: p.sku };
332
+ if (p.title != null) {
333
+ if (typeof p.title !== "string" || p.title.length > MAX_TITLE_LEN) {
334
+ throw new TypeError("catalogDrafts: create_variant.title must be a string <= " + MAX_TITLE_LEN + " chars");
335
+ }
336
+ out.title = p.title;
337
+ }
338
+ if (p.options != null) {
339
+ if (typeof p.options !== "object" || Array.isArray(p.options)) {
340
+ throw new TypeError("catalogDrafts: create_variant.options must be a plain object");
341
+ }
342
+ out.options = p.options;
343
+ }
344
+ if (p.weight_grams != null) {
345
+ if (!Number.isInteger(p.weight_grams) || p.weight_grams < 0) {
346
+ throw new TypeError("catalogDrafts: create_variant.weight_grams must be a non-negative integer");
347
+ }
348
+ out.weight_grams = p.weight_grams;
349
+ }
350
+ if (p.requires_shipping != null) {
351
+ if (typeof p.requires_shipping !== "boolean") {
352
+ throw new TypeError("catalogDrafts: create_variant.requires_shipping must be a boolean");
353
+ }
354
+ out.requires_shipping = p.requires_shipping;
355
+ }
356
+ return out;
357
+ },
358
+ update_variant: function (p) {
359
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: update_variant payload must be an object");
360
+ _sku(p.sku, "update_variant.sku");
361
+ if (!p.patch || typeof p.patch !== "object") {
362
+ throw new TypeError("catalogDrafts: update_variant.patch must be an object");
363
+ }
364
+ var keys = Object.keys(p.patch);
365
+ if (keys.length === 0) {
366
+ throw new TypeError("catalogDrafts: update_variant.patch must include at least one column");
367
+ }
368
+ var ALLOWED = ["sku", "title", "options", "weight_grams", "requires_shipping", "position"];
369
+ var out = { sku: p.sku, patch: {} };
370
+ for (var i = 0; i < keys.length; i += 1) {
371
+ var k = keys[i];
372
+ if (ALLOWED.indexOf(k) === -1) {
373
+ throw new TypeError("catalogDrafts: update_variant.patch — unsupported column " + JSON.stringify(k));
374
+ }
375
+ if (k === "sku") { _sku(p.patch[k], "update_variant.patch.sku"); out.patch.sku = p.patch[k]; }
376
+ else if (k === "title") {
377
+ if (typeof p.patch[k] !== "string" || p.patch[k].length > MAX_TITLE_LEN) {
378
+ throw new TypeError("catalogDrafts: update_variant.patch.title invalid");
379
+ }
380
+ out.patch.title = p.patch[k];
381
+ } else if (k === "options") {
382
+ if (typeof p.patch[k] !== "object" || Array.isArray(p.patch[k])) {
383
+ throw new TypeError("catalogDrafts: update_variant.patch.options must be a plain object");
384
+ }
385
+ out.patch.options = p.patch[k];
386
+ } else if (k === "weight_grams") {
387
+ if (!Number.isInteger(p.patch[k]) || p.patch[k] < 0) {
388
+ throw new TypeError("catalogDrafts: update_variant.patch.weight_grams must be a non-negative integer");
389
+ }
390
+ out.patch.weight_grams = p.patch[k];
391
+ } else if (k === "requires_shipping") {
392
+ if (typeof p.patch[k] !== "boolean") {
393
+ throw new TypeError("catalogDrafts: update_variant.patch.requires_shipping must be a boolean");
394
+ }
395
+ out.patch.requires_shipping = p.patch[k];
396
+ } else /* position */ {
397
+ if (!Number.isInteger(p.patch[k]) || p.patch[k] < 0) {
398
+ throw new TypeError("catalogDrafts: update_variant.patch.position must be a non-negative integer");
399
+ }
400
+ out.patch.position = p.patch[k];
401
+ }
402
+ }
403
+ return out;
404
+ },
405
+ archive_variant: function (p) {
406
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: archive_variant payload must be an object");
407
+ _sku(p.sku, "archive_variant.sku");
408
+ return { sku: p.sku };
409
+ },
410
+ set_price: function (p) {
411
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: set_price payload must be an object");
412
+ _sku(p.sku, "set_price.sku");
413
+ _currency(p.currency);
414
+ _amountMinor(p.amount_minor);
415
+ return { sku: p.sku, currency: p.currency, amount_minor: p.amount_minor };
416
+ },
417
+ set_inventory: function (p) {
418
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: set_inventory payload must be an object");
419
+ _sku(p.sku, "set_inventory.sku");
420
+ _stockOnHand(p.stock_on_hand);
421
+ return { sku: p.sku, stock_on_hand: p.stock_on_hand };
422
+ },
423
+ add_tag: function (p) {
424
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: add_tag payload must be an object");
425
+ _productSlug(p.product_slug, "add_tag.product_slug");
426
+ _tag(p.tag);
427
+ return { product_slug: p.product_slug, tag: p.tag };
428
+ },
429
+ remove_tag: function (p) {
430
+ if (!p || typeof p !== "object") throw new TypeError("catalogDrafts: remove_tag payload must be an object");
431
+ _productSlug(p.product_slug, "remove_tag.product_slug");
432
+ _tag(p.tag);
433
+ return { product_slug: p.product_slug, tag: p.tag };
434
+ },
435
+ };
436
+
437
+ // ---- SKU extraction (for historyForSku) --------------------------------
438
+
439
+ // Each verb either carries a SKU directly or doesn't. The list helps
440
+ // historyForSku build a "every draft that touched this sku" feed by
441
+ // scanning catalog_draft_changes for matching payloads — composed
442
+ // via the payload_json text-contains search the SQLite/D1 dialect
443
+ // can express portably.
444
+ function _payloadSkuOf(kind, payload) {
445
+ switch (kind) {
446
+ case "create_variant":
447
+ case "update_variant":
448
+ case "archive_variant":
449
+ case "set_price":
450
+ case "set_inventory":
451
+ return payload && payload.sku ? String(payload.sku) : null;
452
+ default:
453
+ return null;
454
+ }
455
+ }
456
+
457
+ // ---- row hydration -----------------------------------------------------
458
+
459
+ function _hydrateDraftRow(r) {
460
+ if (!r) return null;
461
+ return {
462
+ slug: r.slug,
463
+ title: r.title,
464
+ description: r.description == null ? "" : r.description,
465
+ status: r.status,
466
+ scheduled_publish_at: r.scheduled_publish_at == null ? null : Number(r.scheduled_publish_at),
467
+ published_at: r.published_at == null ? null : Number(r.published_at),
468
+ cancelled_at: r.cancelled_at == null ? null : Number(r.cancelled_at),
469
+ rolled_back_at: r.rolled_back_at == null ? null : Number(r.rolled_back_at),
470
+ rollback_reason: r.rollback_reason == null ? null : r.rollback_reason,
471
+ cancel_reason: r.cancel_reason == null ? null : r.cancel_reason,
472
+ owner_id: r.owner_id == null ? null : r.owner_id,
473
+ created_at: Number(r.created_at),
474
+ updated_at: Number(r.updated_at),
475
+ };
476
+ }
477
+
478
+ function _hydrateChangeRow(r) {
479
+ if (!r) return null;
480
+ var payload;
481
+ try { payload = JSON.parse(r.payload_json); }
482
+ catch (_e) { payload = null; }
483
+ return {
484
+ id: r.id,
485
+ draft_slug: r.draft_slug,
486
+ kind: r.kind,
487
+ payload: payload,
488
+ sequence_order: Number(r.sequence_order),
489
+ created_at: Number(r.created_at),
490
+ };
491
+ }
492
+
493
+ // ---- factory -----------------------------------------------------------
494
+
495
+ function create(opts) {
496
+ opts = opts || {};
497
+ var query = opts.query;
498
+ if (!query) {
499
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
500
+ }
501
+ var catalog = opts.catalog;
502
+ if (!catalog || typeof catalog !== "object") {
503
+ throw new TypeError("catalogDrafts.create: opts.catalog handle required");
504
+ }
505
+ if (!catalog.products || !catalog.variants || !catalog.prices || !catalog.inventory) {
506
+ throw new TypeError("catalogDrafts.create: opts.catalog must expose products, variants, prices, inventory subsurfaces");
507
+ }
508
+ // Tags surface is optional only when no draft uses add_tag/remove_tag.
509
+ // Refuse here so the misconfiguration surfaces at boot rather than
510
+ // at publishDraft for the first draft that includes a tag verb.
511
+ if (!catalog.tags || typeof catalog.tags.add !== "function" || typeof catalog.tags.remove !== "function") {
512
+ throw new TypeError("catalogDrafts.create: opts.catalog.tags{add,remove} required");
513
+ }
514
+
515
+ // ---- internal helpers --------------------------------------------
516
+
517
+ async function _getDraft(slug) {
518
+ var r = await query("SELECT * FROM catalog_drafts WHERE slug = ?1 LIMIT 1", [slug]);
519
+ return _hydrateDraftRow(r.rows[0] || null);
520
+ }
521
+
522
+ async function _assertDraftExists(slug) {
523
+ var d = await _getDraft(slug);
524
+ if (!d) {
525
+ var nf = new Error("catalogDrafts: draft " + JSON.stringify(slug) + " not found");
526
+ nf.code = "DRAFT_NOT_FOUND";
527
+ throw nf;
528
+ }
529
+ return d;
530
+ }
531
+
532
+ async function _assertDraftMutable(slug) {
533
+ var d = await _assertDraftExists(slug);
534
+ if (TERMINAL_STATUSES.indexOf(d.status) !== -1) {
535
+ var err = new Error(
536
+ "catalogDrafts: draft " + JSON.stringify(slug) +
537
+ " is " + d.status + " (terminal — refuse mutation)"
538
+ );
539
+ err.code = "DRAFT_TERMINAL";
540
+ throw err;
541
+ }
542
+ return d;
543
+ }
544
+
545
+ async function _nextSequenceFor(slug) {
546
+ var r = await query(
547
+ "SELECT COALESCE(MAX(sequence_order), 0) AS max_seq FROM catalog_draft_changes WHERE draft_slug = ?1",
548
+ [slug],
549
+ );
550
+ var row = r.rows[0] || { max_seq: 0 };
551
+ return Number(row.max_seq) + 1;
552
+ }
553
+
554
+ async function _countChangesFor(slug) {
555
+ var r = await query(
556
+ "SELECT COUNT(1) AS n FROM catalog_draft_changes WHERE draft_slug = ?1",
557
+ [slug],
558
+ );
559
+ var row = r.rows[0] || { n: 0 };
560
+ return Number(row.n);
561
+ }
562
+
563
+ async function _listChangesAll(slug) {
564
+ var r = await query(
565
+ "SELECT * FROM catalog_draft_changes WHERE draft_slug = ?1 " +
566
+ "ORDER BY sequence_order ASC, id ASC",
567
+ [slug],
568
+ );
569
+ var out = [];
570
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateChangeRow(r.rows[i]));
571
+ return out;
572
+ }
573
+
574
+ // ---- openDraft ---------------------------------------------------
575
+
576
+ async function openDraft(input) {
577
+ if (!input || typeof input !== "object") {
578
+ throw new TypeError("catalogDrafts.openDraft: input object required");
579
+ }
580
+ var slug = _slug(input.slug, "slug");
581
+ var title = _title(input.title, "title");
582
+ var description = _description(input.description);
583
+ var scheduledAt = _optEpochMs(input.scheduled_publish_at, "scheduled_publish_at");
584
+ var ownerId = _optOwner(input.owner_id);
585
+
586
+ var existing = await _getDraft(slug);
587
+ if (existing) {
588
+ throw new TypeError("catalogDrafts.openDraft: slug " + JSON.stringify(slug) + " already exists");
589
+ }
590
+ var ts = _now();
591
+ await query(
592
+ "INSERT INTO catalog_drafts " +
593
+ "(slug, title, description, status, scheduled_publish_at, published_at, cancelled_at, " +
594
+ " rolled_back_at, rollback_reason, cancel_reason, owner_id, created_at, updated_at) " +
595
+ "VALUES (?1, ?2, ?3, 'open', ?4, NULL, NULL, NULL, NULL, NULL, ?5, ?6, ?6)",
596
+ [slug, title, description, scheduledAt, ownerId, ts],
597
+ );
598
+ return await _getDraft(slug);
599
+ }
600
+
601
+ // ---- stageChange -------------------------------------------------
602
+
603
+ async function stageChange(input) {
604
+ if (!input || typeof input !== "object") {
605
+ throw new TypeError("catalogDrafts.stageChange: input object required");
606
+ }
607
+ var draftSlug = _slug(input.draft_slug, "draft_slug");
608
+ var kind = _kind(input.kind);
609
+ var validator = KIND_PAYLOAD_VALIDATORS[kind];
610
+ // Defensive: every entry in KINDS has a matching validator. The
611
+ // refuse here protects against a future KINDS addition that lands
612
+ // without its validator.
613
+ if (typeof validator !== "function") {
614
+ throw new TypeError("catalogDrafts.stageChange: no payload validator registered for kind " + kind);
615
+ }
616
+ var payload = validator(input.payload);
617
+
618
+ await _assertDraftMutable(draftSlug);
619
+ var existing = await _countChangesFor(draftSlug);
620
+ if (existing >= MAX_CHANGES_PER_DRAFT) {
621
+ throw new TypeError(
622
+ "catalogDrafts.stageChange: draft " + JSON.stringify(draftSlug) +
623
+ " has reached the change cap of " + MAX_CHANGES_PER_DRAFT
624
+ );
625
+ }
626
+ var seq = await _nextSequenceFor(draftSlug);
627
+ var id = _b().uuid.v7();
628
+ var ts = _now();
629
+ await query(
630
+ "INSERT INTO catalog_draft_changes " +
631
+ "(id, draft_slug, kind, payload_json, sequence_order, created_at) " +
632
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
633
+ [id, draftSlug, kind, JSON.stringify(payload), seq, ts],
634
+ );
635
+ // updated_at on the parent advances so listDrafts(updated_at DESC)
636
+ // surfaces actively-edited drafts first.
637
+ await query(
638
+ "UPDATE catalog_drafts SET updated_at = ?1 WHERE slug = ?2",
639
+ [ts, draftSlug],
640
+ );
641
+ var rowR = await query("SELECT * FROM catalog_draft_changes WHERE id = ?1", [id]);
642
+ return _hydrateChangeRow(rowR.rows[0] || null);
643
+ }
644
+
645
+ // ---- listChanges -------------------------------------------------
646
+
647
+ async function listChanges(draftSlug, listOpts) {
648
+ _slug(draftSlug, "draft_slug");
649
+ listOpts = listOpts || {};
650
+ await _assertDraftExists(draftSlug);
651
+ var limit = listOpts.limit == null ? 50 : listOpts.limit;
652
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
653
+ throw new TypeError("catalogDrafts.listChanges: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
654
+ }
655
+ // Cursor is the last seen sequence_order — opaque to the caller
656
+ // but a simple non-negative integer string. The keyset shape
657
+ // matches the natural sequence-order index so pagination scales
658
+ // without an OFFSET scan.
659
+ var afterSeq = null;
660
+ if (listOpts.cursor != null) {
661
+ if (typeof listOpts.cursor !== "string" || !/^\d+$/.test(listOpts.cursor)) {
662
+ throw new TypeError("catalogDrafts.listChanges: cursor must be an opaque integer-string when provided");
663
+ }
664
+ afterSeq = parseInt(listOpts.cursor, 10);
665
+ }
666
+ var sql, params;
667
+ if (afterSeq == null) {
668
+ sql = "SELECT * FROM catalog_draft_changes WHERE draft_slug = ?1 " +
669
+ "ORDER BY sequence_order ASC, id ASC LIMIT ?2";
670
+ params = [draftSlug, limit];
671
+ } else {
672
+ sql = "SELECT * FROM catalog_draft_changes WHERE draft_slug = ?1 AND sequence_order > ?2 " +
673
+ "ORDER BY sequence_order ASC, id ASC LIMIT ?3";
674
+ params = [draftSlug, afterSeq, limit];
675
+ }
676
+ var r = await query(sql, params);
677
+ var out = [];
678
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateChangeRow(r.rows[i]));
679
+ var nextCursor = null;
680
+ if (out.length === limit) {
681
+ nextCursor = String(out[out.length - 1].sequence_order);
682
+ }
683
+ return { rows: out, next_cursor: nextCursor };
684
+ }
685
+
686
+ // ---- removeChange ------------------------------------------------
687
+
688
+ async function removeChange(input) {
689
+ if (!input || typeof input !== "object") {
690
+ throw new TypeError("catalogDrafts.removeChange: input object required");
691
+ }
692
+ var draftSlug = _slug(input.draft_slug, "draft_slug");
693
+ if (typeof input.change_id !== "string" || !input.change_id.length) {
694
+ throw new TypeError("catalogDrafts.removeChange: change_id must be a non-empty string");
695
+ }
696
+ await _assertDraftMutable(draftSlug);
697
+ var r = await query(
698
+ "DELETE FROM catalog_draft_changes WHERE draft_slug = ?1 AND id = ?2",
699
+ [draftSlug, input.change_id],
700
+ );
701
+ if (Number(r.rowCount || 0) === 0) {
702
+ var nf = new Error(
703
+ "catalogDrafts.removeChange: change " + JSON.stringify(input.change_id) +
704
+ " not found in draft " + JSON.stringify(draftSlug)
705
+ );
706
+ nf.code = "CHANGE_NOT_FOUND";
707
+ throw nf;
708
+ }
709
+ var ts = _now();
710
+ await query("UPDATE catalog_drafts SET updated_at = ?1 WHERE slug = ?2", [ts, draftSlug]);
711
+ return { removed: true, change_id: input.change_id, draft_slug: draftSlug };
712
+ }
713
+
714
+ // ---- catalog snapshot helpers ------------------------------------
715
+
716
+ // Capture the live state of every product / variant / price /
717
+ // inventory / tag row a draft's changes will touch. The snapshot
718
+ // is used by publishDraft to record the pre-publish state for
719
+ // potential rollback, and by previewMerged to compose the simulated
720
+ // post-publish state.
721
+ async function _captureSnapshot(changes) {
722
+ var productSlugs = Object.create(null);
723
+ var skus = Object.create(null);
724
+ for (var i = 0; i < changes.length; i += 1) {
725
+ var c = changes[i];
726
+ var p = c.payload || {};
727
+ if (p.slug) productSlugs[p.slug] = true;
728
+ if (p.product_slug) productSlugs[p.product_slug] = true;
729
+ if (p.sku) skus[p.sku] = true;
730
+ }
731
+ var snap = { products: {}, variants: {}, prices: {}, inventory: {}, tags: {} };
732
+ var slugList = Object.keys(productSlugs);
733
+ for (var j = 0; j < slugList.length; j += 1) {
734
+ var slug = slugList[j];
735
+ var prod = null;
736
+ try { prod = await catalog.products.bySlug(slug); }
737
+ catch (_e) { prod = null; }
738
+ snap.products[slug] = prod || null;
739
+ if (prod && catalog.tags && typeof catalog.tags.listForProduct === "function") {
740
+ var tlist = [];
741
+ try { tlist = await catalog.tags.listForProduct(prod.id); }
742
+ catch (_e) { tlist = []; }
743
+ snap.tags[slug] = tlist.slice();
744
+ } else {
745
+ snap.tags[slug] = [];
746
+ }
747
+ }
748
+ var skuList = Object.keys(skus);
749
+ for (var k = 0; k < skuList.length; k += 1) {
750
+ var sku = skuList[k];
751
+ var variant = null;
752
+ try { variant = await catalog.variants.bySku(sku); }
753
+ catch (_e) { variant = null; }
754
+ snap.variants[sku] = variant || null;
755
+ // Capture price + inventory keyed by sku. Prices snapshot per
756
+ // currency captured at publish time as a flat map; only the
757
+ // currencies the draft touches matter for rollback, so the map
758
+ // is sparse on purpose.
759
+ var pricesByCurrency = {};
760
+ if (variant) {
761
+ // Compose the set of currencies this draft touches for this sku.
762
+ var ccyForSku = Object.create(null);
763
+ for (var m = 0; m < changes.length; m += 1) {
764
+ var cm = changes[m];
765
+ if (cm.kind === "set_price" && cm.payload && cm.payload.sku === sku && cm.payload.currency) {
766
+ ccyForSku[cm.payload.currency] = true;
767
+ }
768
+ }
769
+ var ccyList = Object.keys(ccyForSku);
770
+ for (var n = 0; n < ccyList.length; n += 1) {
771
+ var ccy = ccyList[n];
772
+ var cur = null;
773
+ try { cur = await catalog.prices.current(variant.id, ccy); }
774
+ catch (_e) { cur = null; }
775
+ pricesByCurrency[ccy] = cur;
776
+ }
777
+ }
778
+ snap.prices[sku] = pricesByCurrency;
779
+ var inv = null;
780
+ try { inv = await catalog.inventory.get(sku); }
781
+ catch (_e) { inv = null; }
782
+ snap.inventory[sku] = inv || null;
783
+ }
784
+ return snap;
785
+ }
786
+
787
+ // ---- preview / publish: pre-flight reasoning ---------------------
788
+
789
+ // Walk a change list against the live catalog (or the simulated
790
+ // state for previewMerged) and return either a list of problems or
791
+ // a normalized plan that the writer phase replays without
792
+ // re-validating against the world.
793
+ //
794
+ // `mode = 'preflight'` returns the planned writes as objects but
795
+ // doesn't apply them. The publishDraft writer phase iterates the
796
+ // plan and dispatches each entry to the catalog handle.
797
+ //
798
+ // `mode = 'simulate'` builds an in-memory merged catalog snapshot
799
+ // and returns it as the previewMerged payload.
800
+ async function _planChanges(draftSlug, changes, mode) {
801
+ var problems = [];
802
+ // Simulated state for previewMerged. Keyed by slug / sku so
803
+ // intra-draft references resolve (create_product followed by
804
+ // add_tag against the same slug).
805
+ var sim = { products: {}, variants: {}, prices: {}, inventory: {}, tags: {} };
806
+
807
+ async function _resolveProduct(slug) {
808
+ if (Object.prototype.hasOwnProperty.call(sim.products, slug)) {
809
+ return sim.products[slug];
810
+ }
811
+ var p = null;
812
+ try { p = await catalog.products.bySlug(slug); }
813
+ catch (_e) { p = null; }
814
+ sim.products[slug] = p;
815
+ if (!sim.tags[slug]) {
816
+ var tags = [];
817
+ if (p && catalog.tags && typeof catalog.tags.listForProduct === "function") {
818
+ try { tags = await catalog.tags.listForProduct(p.id); }
819
+ catch (_e) { tags = []; }
820
+ }
821
+ sim.tags[slug] = tags;
822
+ }
823
+ return sim.products[slug];
824
+ }
825
+
826
+ async function _resolveVariant(sku) {
827
+ if (Object.prototype.hasOwnProperty.call(sim.variants, sku)) {
828
+ return sim.variants[sku];
829
+ }
830
+ var v = null;
831
+ try { v = await catalog.variants.bySku(sku); }
832
+ catch (_e) { v = null; }
833
+ sim.variants[sku] = v;
834
+ return v;
835
+ }
836
+
837
+ var plan = [];
838
+
839
+ for (var i = 0; i < changes.length; i += 1) {
840
+ var c = changes[i];
841
+ var p = c.payload || {};
842
+ if (c.kind === "create_product") {
843
+ var existing = await _resolveProduct(p.slug);
844
+ if (existing) {
845
+ problems.push({ change_id: c.id, kind: c.kind, reason: "product_already_exists", slug: p.slug });
846
+ continue;
847
+ }
848
+ // Simulated id for previewMerged; the writer phase issues a
849
+ // real id from b.uuid.v7 at publish time.
850
+ var simId = "sim-" + p.slug;
851
+ sim.products[p.slug] = {
852
+ id: simId, slug: p.slug, title: p.title,
853
+ description: p.description, status: p.status,
854
+ };
855
+ sim.tags[p.slug] = [];
856
+ plan.push({ kind: c.kind, payload: p });
857
+ } else if (c.kind === "update_product") {
858
+ var prod = await _resolveProduct(p.slug);
859
+ if (!prod) {
860
+ problems.push({ change_id: c.id, kind: c.kind, reason: "product_not_found", slug: p.slug });
861
+ continue;
862
+ }
863
+ var merged = {};
864
+ var pkeys = Object.keys(prod);
865
+ for (var pi = 0; pi < pkeys.length; pi += 1) merged[pkeys[pi]] = prod[pkeys[pi]];
866
+ var patchKeys = Object.keys(p.patch);
867
+ for (var pk = 0; pk < patchKeys.length; pk += 1) merged[patchKeys[pk]] = p.patch[patchKeys[pk]];
868
+ // Slug rename — preserve the tag list across the rename so
869
+ // the simulated snapshot stays consistent.
870
+ if (p.patch.slug && p.patch.slug !== p.slug) {
871
+ sim.products[p.patch.slug] = merged;
872
+ sim.tags[p.patch.slug] = (sim.tags[p.slug] || []).slice();
873
+ delete sim.products[p.slug];
874
+ delete sim.tags[p.slug];
875
+ } else {
876
+ sim.products[p.slug] = merged;
877
+ }
878
+ plan.push({ kind: c.kind, payload: p, resolved_product_id: prod.id });
879
+ } else if (c.kind === "archive_product") {
880
+ var pa = await _resolveProduct(p.slug);
881
+ if (!pa) {
882
+ problems.push({ change_id: c.id, kind: c.kind, reason: "product_not_found", slug: p.slug });
883
+ continue;
884
+ }
885
+ var archived = {};
886
+ var akeys = Object.keys(pa);
887
+ for (var ai = 0; ai < akeys.length; ai += 1) archived[akeys[ai]] = pa[akeys[ai]];
888
+ archived.status = "archived";
889
+ sim.products[p.slug] = archived;
890
+ plan.push({ kind: c.kind, payload: p, resolved_product_id: pa.id });
891
+ } else if (c.kind === "create_variant") {
892
+ var parent = await _resolveProduct(p.product_slug);
893
+ if (!parent) {
894
+ problems.push({ change_id: c.id, kind: c.kind, reason: "product_not_found", slug: p.product_slug });
895
+ continue;
896
+ }
897
+ var skuExisting = await _resolveVariant(p.sku);
898
+ if (skuExisting) {
899
+ problems.push({ change_id: c.id, kind: c.kind, reason: "sku_already_exists", sku: p.sku });
900
+ continue;
901
+ }
902
+ sim.variants[p.sku] = {
903
+ id: "sim-var-" + p.sku, product_id: parent.id, sku: p.sku,
904
+ title: p.title || "",
905
+ options: p.options || {},
906
+ weight_grams: p.weight_grams == null ? 0 : p.weight_grams,
907
+ requires_shipping: p.requires_shipping == null ? true : p.requires_shipping,
908
+ };
909
+ plan.push({ kind: c.kind, payload: p, resolved_product_id: parent.id });
910
+ } else if (c.kind === "update_variant") {
911
+ var vu = await _resolveVariant(p.sku);
912
+ if (!vu) {
913
+ problems.push({ change_id: c.id, kind: c.kind, reason: "sku_not_found", sku: p.sku });
914
+ continue;
915
+ }
916
+ var vmerged = {};
917
+ var vkeys = Object.keys(vu);
918
+ for (var vi = 0; vi < vkeys.length; vi += 1) vmerged[vkeys[vi]] = vu[vkeys[vi]];
919
+ var vpKeys = Object.keys(p.patch);
920
+ for (var vpi = 0; vpi < vpKeys.length; vpi += 1) vmerged[vpKeys[vpi]] = p.patch[vpKeys[vpi]];
921
+ if (p.patch.sku && p.patch.sku !== p.sku) {
922
+ sim.variants[p.patch.sku] = vmerged;
923
+ delete sim.variants[p.sku];
924
+ } else {
925
+ sim.variants[p.sku] = vmerged;
926
+ }
927
+ plan.push({ kind: c.kind, payload: p, resolved_variant_id: vu.id });
928
+ } else if (c.kind === "archive_variant") {
929
+ var va = await _resolveVariant(p.sku);
930
+ if (!va) {
931
+ problems.push({ change_id: c.id, kind: c.kind, reason: "sku_not_found", sku: p.sku });
932
+ continue;
933
+ }
934
+ // Archiving a variant means deleting it via the catalog
935
+ // primitive's variants.delete surface (no archived-variant
936
+ // status in the catalog schema; the variant is removed, the
937
+ // SKU frees up). Mirror in the simulation.
938
+ delete sim.variants[p.sku];
939
+ plan.push({ kind: c.kind, payload: p, resolved_variant_id: va.id });
940
+ } else if (c.kind === "set_price") {
941
+ var vp = await _resolveVariant(p.sku);
942
+ if (!vp) {
943
+ problems.push({ change_id: c.id, kind: c.kind, reason: "sku_not_found", sku: p.sku });
944
+ continue;
945
+ }
946
+ if (!sim.prices[p.sku]) sim.prices[p.sku] = {};
947
+ sim.prices[p.sku][p.currency] = {
948
+ variant_id: vp.id, currency: p.currency, amount_minor: p.amount_minor,
949
+ };
950
+ plan.push({ kind: c.kind, payload: p, resolved_variant_id: vp.id });
951
+ } else if (c.kind === "set_inventory") {
952
+ var vi2 = await _resolveVariant(p.sku);
953
+ if (!vi2) {
954
+ problems.push({ change_id: c.id, kind: c.kind, reason: "sku_not_found", sku: p.sku });
955
+ continue;
956
+ }
957
+ sim.inventory[p.sku] = { sku: p.sku, stock_on_hand: p.stock_on_hand };
958
+ plan.push({ kind: c.kind, payload: p });
959
+ } else if (c.kind === "add_tag") {
960
+ var ta = await _resolveProduct(p.product_slug);
961
+ if (!ta) {
962
+ problems.push({ change_id: c.id, kind: c.kind, reason: "product_not_found", slug: p.product_slug });
963
+ continue;
964
+ }
965
+ var existingTags = sim.tags[p.product_slug] || [];
966
+ if (existingTags.indexOf(p.tag) === -1) {
967
+ sim.tags[p.product_slug] = existingTags.concat([p.tag]);
968
+ }
969
+ plan.push({ kind: c.kind, payload: p, resolved_product_id: ta.id });
970
+ } else if (c.kind === "remove_tag") {
971
+ var tr = await _resolveProduct(p.product_slug);
972
+ if (!tr) {
973
+ problems.push({ change_id: c.id, kind: c.kind, reason: "product_not_found", slug: p.product_slug });
974
+ continue;
975
+ }
976
+ var prior = sim.tags[p.product_slug] || [];
977
+ sim.tags[p.product_slug] = prior.filter(function (t) { return t !== p.tag; });
978
+ plan.push({ kind: c.kind, payload: p, resolved_product_id: tr.id });
979
+ } else {
980
+ // Unreachable through the public surface — stageChange refuses
981
+ // any kind not in KINDS. Defensive belt-and-braces.
982
+ problems.push({ change_id: c.id, kind: c.kind, reason: "unknown_kind" });
983
+ }
984
+ }
985
+ if (mode === "simulate") {
986
+ return { problems: problems, simulated: sim };
987
+ }
988
+ return { problems: problems, plan: plan };
989
+ }
990
+
991
+ // ---- previewMerged ------------------------------------------------
992
+
993
+ async function previewMerged(input) {
994
+ if (!input || typeof input !== "object") {
995
+ throw new TypeError("catalogDrafts.previewMerged: input object required");
996
+ }
997
+ var draftSlug = _slug(input.draft_slug, "draft_slug");
998
+ await _assertDraftExists(draftSlug);
999
+ var changes = await _listChangesAll(draftSlug);
1000
+ var result = await _planChanges(draftSlug, changes, "simulate");
1001
+ // Flip the draft into `preview` status when it was `open` — this
1002
+ // is the operator's "I've seen the preview" gesture. Skip the
1003
+ // transition for terminal-status drafts (published / cancelled /
1004
+ // rolled_back) so previewing a historical draft doesn't mutate
1005
+ // its row.
1006
+ var d = await _getDraft(draftSlug);
1007
+ if (d && d.status === "open") {
1008
+ var ts = _now();
1009
+ await query(
1010
+ "UPDATE catalog_drafts SET status = 'preview', updated_at = ?1 WHERE slug = ?2",
1011
+ [ts, draftSlug],
1012
+ );
1013
+ }
1014
+ return {
1015
+ draft_slug: draftSlug,
1016
+ problems: result.problems,
1017
+ merged: result.simulated,
1018
+ change_count: changes.length,
1019
+ };
1020
+ }
1021
+
1022
+ // ---- publishDraft -------------------------------------------------
1023
+
1024
+ // Writer phase. Iterates the validated plan and dispatches each
1025
+ // entry to the catalog handle. Wrapped in a try/catch that, on
1026
+ // first failure, attempts to surface the failure to the caller
1027
+ // (the row stays at `preview` status; the writes that landed
1028
+ // before the failure are NOT auto-reverted — partial-write
1029
+ // recovery is the operator's gesture via rollbackDraft, which
1030
+ // requires the published_at stamp anyway).
1031
+ //
1032
+ // The publish path's atomicity guarantee is "pre-flight everything
1033
+ // before writing anything" — once pre-flight passes, the writer
1034
+ // dispatches with confidence the live catalog matches the
1035
+ // simulation. A writer-phase failure during dispatch is a
1036
+ // best-effort scenario; the operator's escape hatch is to inspect
1037
+ // what landed (the catalog surface is the source of truth) and
1038
+ // either roll forward with a follow-up draft or open a support
1039
+ // ticket.
1040
+ async function _applyPlan(plan) {
1041
+ var applied = [];
1042
+ // Resolve product_id by slug and variant_id by sku at apply time.
1043
+ // The pre-flight stage stored simulated ids for products /
1044
+ // variants created earlier in the same batch; the writer phase
1045
+ // ignores those and re-resolves against the live catalog so
1046
+ // every dispatched call addresses real row ids.
1047
+ var productIdBySlug = Object.create(null);
1048
+ var variantIdBySku = Object.create(null);
1049
+
1050
+ async function _liveProductId(slug) {
1051
+ if (productIdBySlug[slug]) return productIdBySlug[slug];
1052
+ var p = null;
1053
+ try { p = await catalog.products.bySlug(slug); }
1054
+ catch (_e) { p = null; }
1055
+ if (p) productIdBySlug[slug] = p.id;
1056
+ return p ? p.id : null;
1057
+ }
1058
+
1059
+ async function _liveVariantId(sku) {
1060
+ if (variantIdBySku[sku]) return variantIdBySku[sku];
1061
+ var v = null;
1062
+ try { v = await catalog.variants.bySku(sku); }
1063
+ catch (_e) { v = null; }
1064
+ if (v) variantIdBySku[sku] = v.id;
1065
+ return v ? v.id : null;
1066
+ }
1067
+
1068
+ for (var i = 0; i < plan.length; i += 1) {
1069
+ var step = plan[i];
1070
+ var p = step.payload;
1071
+ if (step.kind === "create_product") {
1072
+ var newP = await catalog.products.create({
1073
+ slug: p.slug, title: p.title, description: p.description, status: p.status,
1074
+ });
1075
+ productIdBySlug[p.slug] = newP.id;
1076
+ applied.push({ kind: step.kind, result: newP });
1077
+ } else if (step.kind === "update_product") {
1078
+ var upid = await _liveProductId(p.slug);
1079
+ var upd = await catalog.products.update(upid, p.patch);
1080
+ // Slug rename: update the lookup table so a later step in
1081
+ // the same batch addressing the product by the new slug
1082
+ // resolves correctly.
1083
+ if (p.patch.slug && p.patch.slug !== p.slug) {
1084
+ productIdBySlug[p.patch.slug] = upid;
1085
+ delete productIdBySlug[p.slug];
1086
+ }
1087
+ applied.push({ kind: step.kind, result: upd });
1088
+ } else if (step.kind === "archive_product") {
1089
+ var apid = await _liveProductId(p.slug);
1090
+ var arch = await catalog.products.archive(apid);
1091
+ applied.push({ kind: step.kind, result: arch });
1092
+ } else if (step.kind === "create_variant") {
1093
+ var parentId = await _liveProductId(p.product_slug);
1094
+ var variantInput = { sku: p.sku };
1095
+ if (p.title != null) variantInput.title = p.title;
1096
+ if (p.options != null) variantInput.options = p.options;
1097
+ if (p.weight_grams != null) variantInput.weight_grams = p.weight_grams;
1098
+ if (p.requires_shipping != null) variantInput.requires_shipping = p.requires_shipping;
1099
+ var newV = await catalog.variants.create(parentId, variantInput);
1100
+ variantIdBySku[p.sku] = newV.id;
1101
+ applied.push({ kind: step.kind, result: newV });
1102
+ } else if (step.kind === "update_variant") {
1103
+ var uvid = await _liveVariantId(p.sku);
1104
+ var updV = await catalog.variants.update(uvid, p.patch);
1105
+ if (p.patch.sku && p.patch.sku !== p.sku) {
1106
+ variantIdBySku[p.patch.sku] = uvid;
1107
+ delete variantIdBySku[p.sku];
1108
+ }
1109
+ applied.push({ kind: step.kind, result: updV });
1110
+ } else if (step.kind === "archive_variant") {
1111
+ var avid = await _liveVariantId(p.sku);
1112
+ var del = await catalog.variants.delete(avid);
1113
+ delete variantIdBySku[p.sku];
1114
+ applied.push({ kind: step.kind, result: { deleted: del } });
1115
+ } else if (step.kind === "set_price") {
1116
+ var spvid = await _liveVariantId(p.sku);
1117
+ var pr = await catalog.prices.set(spvid, {
1118
+ currency: p.currency, amount_minor: p.amount_minor,
1119
+ });
1120
+ applied.push({ kind: step.kind, result: pr });
1121
+ } else if (step.kind === "set_inventory") {
1122
+ // The catalog inventory surface owns create + restock +
1123
+ // release; "set_inventory" semantically maps to "make stock
1124
+ // equal N". When the row exists, compute the delta; when it
1125
+ // doesn't, create. The catalog primitive doesn't ship a
1126
+ // direct set-stock op so the draft writer composes the
1127
+ // available verbs.
1128
+ var inv = null;
1129
+ try { inv = await catalog.inventory.get(p.sku); }
1130
+ catch (_e) { inv = null; }
1131
+ if (!inv) {
1132
+ var made = await catalog.inventory.create(p.sku, { stock_on_hand: p.stock_on_hand });
1133
+ applied.push({ kind: step.kind, result: made });
1134
+ } else {
1135
+ var delta = p.stock_on_hand - Number(inv.stock_on_hand);
1136
+ if (delta > 0 && typeof catalog.inventory.restock === "function") {
1137
+ var r1 = await catalog.inventory.restock(p.sku, delta);
1138
+ applied.push({ kind: step.kind, result: r1 });
1139
+ } else if (delta < 0 && typeof catalog.inventory.release === "function") {
1140
+ var r2 = await catalog.inventory.release(p.sku, -delta);
1141
+ applied.push({ kind: step.kind, result: r2 });
1142
+ } else {
1143
+ // delta === 0 — no write needed.
1144
+ applied.push({ kind: step.kind, result: inv });
1145
+ }
1146
+ }
1147
+ } else if (step.kind === "add_tag") {
1148
+ var atid = await _liveProductId(p.product_slug);
1149
+ var ta = await catalog.tags.add(atid, p.tag);
1150
+ applied.push({ kind: step.kind, result: ta });
1151
+ } else if (step.kind === "remove_tag") {
1152
+ var rtid = await _liveProductId(p.product_slug);
1153
+ var tr = await catalog.tags.remove(rtid, p.tag);
1154
+ applied.push({ kind: step.kind, result: tr });
1155
+ }
1156
+ }
1157
+ return applied;
1158
+ }
1159
+
1160
+ async function publishDraft(input) {
1161
+ if (!input || typeof input !== "object") {
1162
+ throw new TypeError("catalogDrafts.publishDraft: input object required");
1163
+ }
1164
+ var draftSlug = _slug(input.draft_slug, "draft_slug");
1165
+ var dryRun = false;
1166
+ if (input.dry_run != null) {
1167
+ if (typeof input.dry_run !== "boolean") {
1168
+ throw new TypeError("catalogDrafts.publishDraft: dry_run must be a boolean when provided");
1169
+ }
1170
+ dryRun = input.dry_run;
1171
+ }
1172
+ var d = await _assertDraftExists(draftSlug);
1173
+ if (TERMINAL_STATUSES.indexOf(d.status) !== -1) {
1174
+ var sErr = new Error(
1175
+ "catalogDrafts.publishDraft: draft " + JSON.stringify(draftSlug) +
1176
+ " is " + d.status + " (terminal — refuse publish)"
1177
+ );
1178
+ sErr.code = "DRAFT_TERMINAL";
1179
+ throw sErr;
1180
+ }
1181
+ var changes = await _listChangesAll(draftSlug);
1182
+ if (changes.length === 0) {
1183
+ throw new TypeError("catalogDrafts.publishDraft: draft " + JSON.stringify(draftSlug) + " has no staged changes");
1184
+ }
1185
+ var preflight = await _planChanges(draftSlug, changes, "preflight");
1186
+ if (preflight.problems.length > 0) {
1187
+ var pErr = new Error(
1188
+ "catalogDrafts.publishDraft: pre-flight refused " + preflight.problems.length +
1189
+ " change(s) — see .problems"
1190
+ );
1191
+ pErr.code = "PREFLIGHT_FAILED";
1192
+ pErr.problems = preflight.problems;
1193
+ throw pErr;
1194
+ }
1195
+ if (dryRun) {
1196
+ return {
1197
+ dry_run: true,
1198
+ draft_slug: draftSlug,
1199
+ plan_count: preflight.plan.length,
1200
+ problems: [],
1201
+ };
1202
+ }
1203
+ // Capture the pre-publish snapshot BEFORE applying the plan so
1204
+ // rollbackDraft has the original state to restore.
1205
+ var preSnapshot = await _captureSnapshot(changes);
1206
+ var applied = await _applyPlan(preflight.plan);
1207
+ var ts = _now();
1208
+ await query(
1209
+ "UPDATE catalog_drafts SET status = 'published', published_at = ?1, updated_at = ?1 " +
1210
+ "WHERE slug = ?2",
1211
+ [ts, draftSlug],
1212
+ );
1213
+ // Store the pre-publish snapshot in the rollback-log table under
1214
+ // the SAME row id used at rollback time? No — the rollback log
1215
+ // is a separate write per rollback gesture. Persist the
1216
+ // pre-publish snapshot under a sentinel row keyed by draft_slug
1217
+ // with a NULL rollback_state_json so rollbackDraft can read it
1218
+ // out. The sentinel row stays for the 7-day window; rollback
1219
+ // overwrites it with the round-trip.
1220
+ var sentinelId = _b().uuid.v7();
1221
+ await query(
1222
+ "INSERT INTO catalog_draft_rollback_log " +
1223
+ "(id, draft_slug, original_state_json, rollback_state_json, occurred_at) " +
1224
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
1225
+ [sentinelId, draftSlug, JSON.stringify(preSnapshot), JSON.stringify({ pending: true }), ts],
1226
+ );
1227
+ return {
1228
+ draft_slug: draftSlug,
1229
+ status: "published",
1230
+ applied: applied.length,
1231
+ published_at: ts,
1232
+ };
1233
+ }
1234
+
1235
+ // ---- cancelDraft --------------------------------------------------
1236
+
1237
+ async function cancelDraft(input) {
1238
+ if (!input || typeof input !== "object") {
1239
+ throw new TypeError("catalogDrafts.cancelDraft: input object required");
1240
+ }
1241
+ var draftSlug = _slug(input.draft_slug, "draft_slug");
1242
+ var reason = _reqReason(input.reason, "reason");
1243
+ var d = await _assertDraftExists(draftSlug);
1244
+ if (TERMINAL_STATUSES.indexOf(d.status) !== -1) {
1245
+ var err = new Error(
1246
+ "catalogDrafts.cancelDraft: draft " + JSON.stringify(draftSlug) +
1247
+ " is " + d.status + " (terminal — refuse cancel)"
1248
+ );
1249
+ err.code = "DRAFT_TERMINAL";
1250
+ throw err;
1251
+ }
1252
+ var ts = _now();
1253
+ await query(
1254
+ "UPDATE catalog_drafts SET status = 'cancelled', cancelled_at = ?1, cancel_reason = ?2, updated_at = ?1 " +
1255
+ "WHERE slug = ?3",
1256
+ [ts, reason, draftSlug],
1257
+ );
1258
+ return await _getDraft(draftSlug);
1259
+ }
1260
+
1261
+ // ---- rollbackDraft ------------------------------------------------
1262
+
1263
+ async function rollbackDraft(input) {
1264
+ if (!input || typeof input !== "object") {
1265
+ throw new TypeError("catalogDrafts.rollbackDraft: input object required");
1266
+ }
1267
+ var draftSlug = _slug(input.draft_slug, "draft_slug");
1268
+ var reason = _reqReason(input.reason, "reason");
1269
+ var d = await _assertDraftExists(draftSlug);
1270
+ if (d.status !== "published") {
1271
+ var sErr = new Error(
1272
+ "catalogDrafts.rollbackDraft: only published drafts can be rolled back (" +
1273
+ JSON.stringify(draftSlug) + " is " + d.status + ")"
1274
+ );
1275
+ sErr.code = "DRAFT_NOT_PUBLISHED";
1276
+ throw sErr;
1277
+ }
1278
+ if (d.published_at == null) {
1279
+ throw new TypeError("catalogDrafts.rollbackDraft: published_at unexpectedly null for draft " + JSON.stringify(draftSlug));
1280
+ }
1281
+ var now = _now();
1282
+ if (now - d.published_at > ROLLBACK_WINDOW_MS) {
1283
+ var wErr = new Error(
1284
+ "catalogDrafts.rollbackDraft: 7-day rollback window has elapsed for draft " +
1285
+ JSON.stringify(draftSlug)
1286
+ );
1287
+ wErr.code = "ROLLBACK_WINDOW_ELAPSED";
1288
+ throw wErr;
1289
+ }
1290
+
1291
+ // Read the pre-publish snapshot from the sentinel rollback-log row.
1292
+ var sentinelR = await query(
1293
+ "SELECT * FROM catalog_draft_rollback_log WHERE draft_slug = ?1 " +
1294
+ "ORDER BY occurred_at ASC LIMIT 1",
1295
+ [draftSlug],
1296
+ );
1297
+ var sentinel = sentinelR.rows[0];
1298
+ if (!sentinel) {
1299
+ throw new Error("catalogDrafts.rollbackDraft: no snapshot recorded for draft " + JSON.stringify(draftSlug));
1300
+ }
1301
+ var original;
1302
+ try { original = JSON.parse(sentinel.original_state_json); }
1303
+ catch (_e) {
1304
+ throw new Error("catalogDrafts.rollbackDraft: snapshot is unreadable for draft " + JSON.stringify(draftSlug));
1305
+ }
1306
+
1307
+ // Capture the current (post-publish) catalog state for the same
1308
+ // set of slugs / skus the original snapshot covered. This is the
1309
+ // "state being abandoned" — the auditor sees both sides of the
1310
+ // round-trip.
1311
+ var changes = await _listChangesAll(draftSlug);
1312
+ var postSnapshot = await _captureSnapshot(changes);
1313
+
1314
+ // Restore the original state. For each captured product / variant
1315
+ // / price / inventory row, write back the values from the
1316
+ // original snapshot. Created-during-publish rows are torn down;
1317
+ // archived-during-publish rows are restored.
1318
+ await _restoreSnapshot(original);
1319
+
1320
+ var ts = _now();
1321
+ // Persist the round-trip on the existing sentinel row (preserve
1322
+ // the original snapshot, fill in the rollback-state).
1323
+ await query(
1324
+ "UPDATE catalog_draft_rollback_log SET rollback_state_json = ?1, occurred_at = ?2 WHERE id = ?3",
1325
+ [JSON.stringify(postSnapshot), ts, sentinel.id],
1326
+ );
1327
+ await query(
1328
+ "UPDATE catalog_drafts SET status = 'rolled_back', rolled_back_at = ?1, rollback_reason = ?2, updated_at = ?1 " +
1329
+ "WHERE slug = ?3",
1330
+ [ts, reason, draftSlug],
1331
+ );
1332
+ return await _getDraft(draftSlug);
1333
+ }
1334
+
1335
+ // Restore the catalog state from a snapshot. Best-effort: each
1336
+ // row's restore is wrapped so a single failure (e.g. a downstream
1337
+ // FK preventing a tag removal) doesn't strand the rest of the
1338
+ // restore. The post-restore catalog still has the snapshot recorded
1339
+ // in the rollback log so the operator can reconcile by hand if
1340
+ // the automated pass missed a row.
1341
+ async function _restoreSnapshot(snap) {
1342
+ // Restore products + tags first (tags depend on the product row).
1343
+ var productSlugs = Object.keys(snap.products || {});
1344
+ for (var i = 0; i < productSlugs.length; i += 1) {
1345
+ var slug = productSlugs[i];
1346
+ var originalProd = snap.products[slug];
1347
+ var current = null;
1348
+ try { current = await catalog.products.bySlug(slug); }
1349
+ catch (_e) { current = null; }
1350
+ if (!originalProd && current) {
1351
+ // Created-by-this-draft. Archive in lieu of hard-delete so
1352
+ // any audit trail referencing the product id stays
1353
+ // referentially intact.
1354
+ try { await catalog.products.archive(current.id); }
1355
+ catch (_e) { /* drop-silent — see _restoreSnapshot intro */ }
1356
+ } else if (originalProd && current) {
1357
+ // Pre-existing — restore mutable fields.
1358
+ var patch = {};
1359
+ if (originalProd.slug != null && originalProd.slug !== current.slug) patch.slug = originalProd.slug;
1360
+ if (originalProd.title != null && originalProd.title !== current.title) patch.title = originalProd.title;
1361
+ if (originalProd.description != null && originalProd.description !== current.description) patch.description = originalProd.description;
1362
+ if (originalProd.status != null && originalProd.status !== current.status) patch.status = originalProd.status;
1363
+ if (Object.keys(patch).length > 0) {
1364
+ try { await catalog.products.update(current.id, patch); }
1365
+ catch (_e) { /* drop-silent */ }
1366
+ }
1367
+ } else if (originalProd && !current) {
1368
+ // Existed in snapshot, gone now (archive-then-delete by a
1369
+ // separate path) — re-create with the original payload.
1370
+ try {
1371
+ await catalog.products.create({
1372
+ slug: originalProd.slug, title: originalProd.title,
1373
+ description: originalProd.description, status: originalProd.status,
1374
+ });
1375
+ } catch (_e) { /* drop-silent */ }
1376
+ }
1377
+ }
1378
+
1379
+ // Restore variants.
1380
+ var skus = Object.keys(snap.variants || {});
1381
+ for (var j = 0; j < skus.length; j += 1) {
1382
+ var sku = skus[j];
1383
+ var origV = snap.variants[sku];
1384
+ var curV = null;
1385
+ try { curV = await catalog.variants.bySku(sku); }
1386
+ catch (_e) { curV = null; }
1387
+ if (!origV && curV) {
1388
+ try { await catalog.variants.delete(curV.id); }
1389
+ catch (_e) { /* drop-silent */ }
1390
+ } else if (origV && !curV) {
1391
+ // Need a product to attach to — resolve via the snapshot's
1392
+ // product id mapping.
1393
+ var parentId = origV.product_id;
1394
+ if (parentId) {
1395
+ try {
1396
+ await catalog.variants.create(parentId, {
1397
+ sku: origV.sku,
1398
+ title: origV.title || "",
1399
+ options: origV.options || {},
1400
+ weight_grams: origV.weight_grams == null ? 0 : origV.weight_grams,
1401
+ requires_shipping: origV.requires_shipping == null ? true : origV.requires_shipping,
1402
+ });
1403
+ } catch (_e) { /* drop-silent */ }
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ // Restore prices.
1409
+ var priceSkus = Object.keys(snap.prices || {});
1410
+ for (var pk = 0; pk < priceSkus.length; pk += 1) {
1411
+ var psku = priceSkus[pk];
1412
+ var byCurrency = snap.prices[psku] || {};
1413
+ var curV2 = null;
1414
+ try { curV2 = await catalog.variants.bySku(psku); }
1415
+ catch (_e) { curV2 = null; }
1416
+ if (!curV2) continue; // variant gone; skip
1417
+ var ccyList = Object.keys(byCurrency);
1418
+ for (var c = 0; c < ccyList.length; c += 1) {
1419
+ var ccy = ccyList[c];
1420
+ var origPrice = byCurrency[ccy];
1421
+ if (origPrice && origPrice.amount_minor != null) {
1422
+ try {
1423
+ await catalog.prices.set(curV2.id, {
1424
+ currency: ccy, amount_minor: origPrice.amount_minor,
1425
+ });
1426
+ } catch (_e) { /* drop-silent */ }
1427
+ }
1428
+ }
1429
+ }
1430
+
1431
+ // Restore inventory.
1432
+ var invSkus = Object.keys(snap.inventory || {});
1433
+ for (var ik = 0; ik < invSkus.length; ik += 1) {
1434
+ var isku = invSkus[ik];
1435
+ var origI = snap.inventory[isku];
1436
+ if (!origI) continue;
1437
+ var curI = null;
1438
+ try { curI = await catalog.inventory.get(isku); }
1439
+ catch (_e) { curI = null; }
1440
+ if (!curI) continue;
1441
+ var origStock = Number(origI.stock_on_hand || 0);
1442
+ var curStock = Number(curI.stock_on_hand || 0);
1443
+ if (origStock === curStock) continue;
1444
+ var diff = origStock - curStock;
1445
+ if (diff > 0 && typeof catalog.inventory.restock === "function") {
1446
+ try { await catalog.inventory.restock(isku, diff); }
1447
+ catch (_e) { /* drop-silent */ }
1448
+ } else if (diff < 0 && typeof catalog.inventory.release === "function") {
1449
+ try { await catalog.inventory.release(isku, -diff); }
1450
+ catch (_e) { /* drop-silent */ }
1451
+ }
1452
+ }
1453
+
1454
+ // Restore tags. For each product, compute the symmetric diff
1455
+ // (set-of-original \ set-of-current and vice versa) and apply
1456
+ // add / remove.
1457
+ if (catalog.tags) {
1458
+ var tagSlugs = Object.keys(snap.tags || {});
1459
+ for (var ts = 0; ts < tagSlugs.length; ts += 1) {
1460
+ var tslug = tagSlugs[ts];
1461
+ var origTags = (snap.tags[tslug] || []).slice();
1462
+ var prod = null;
1463
+ try { prod = await catalog.products.bySlug(tslug); }
1464
+ catch (_e) { prod = null; }
1465
+ if (!prod) continue;
1466
+ var liveTags = [];
1467
+ if (typeof catalog.tags.listForProduct === "function") {
1468
+ try { liveTags = await catalog.tags.listForProduct(prod.id); }
1469
+ catch (_e) { liveTags = []; }
1470
+ }
1471
+ var origSet = Object.create(null);
1472
+ var liveSet = Object.create(null);
1473
+ for (var oi = 0; oi < origTags.length; oi += 1) origSet[origTags[oi]] = true;
1474
+ for (var li = 0; li < liveTags.length; li += 1) liveSet[liveTags[li]] = true;
1475
+ // To restore: add anything in origSet missing from liveSet;
1476
+ // remove anything in liveSet missing from origSet.
1477
+ for (var oj = 0; oj < origTags.length; oj += 1) {
1478
+ if (!liveSet[origTags[oj]]) {
1479
+ try { await catalog.tags.add(prod.id, origTags[oj]); }
1480
+ catch (_e) { /* drop-silent */ }
1481
+ }
1482
+ }
1483
+ for (var lj = 0; lj < liveTags.length; lj += 1) {
1484
+ if (!origSet[liveTags[lj]]) {
1485
+ try { await catalog.tags.remove(prod.id, liveTags[lj]); }
1486
+ catch (_e) { /* drop-silent */ }
1487
+ }
1488
+ }
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ // ---- listDrafts ---------------------------------------------------
1494
+
1495
+ async function listDrafts(listOpts) {
1496
+ listOpts = listOpts || {};
1497
+ var statusFilter = null;
1498
+ if (listOpts.status != null) {
1499
+ statusFilter = _status(listOpts.status);
1500
+ }
1501
+ var ownerFilter = null;
1502
+ if (listOpts.owner != null) {
1503
+ ownerFilter = _optOwner(listOpts.owner);
1504
+ }
1505
+ var limit = listOpts.limit == null ? 50 : listOpts.limit;
1506
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
1507
+ throw new TypeError("catalogDrafts.listDrafts: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
1508
+ }
1509
+ var sql, params;
1510
+ if (statusFilter && ownerFilter) {
1511
+ sql = "SELECT * FROM catalog_drafts WHERE status = ?1 AND owner_id = ?2 " +
1512
+ "ORDER BY updated_at DESC, slug ASC LIMIT ?3";
1513
+ params = [statusFilter, ownerFilter, limit];
1514
+ } else if (statusFilter) {
1515
+ sql = "SELECT * FROM catalog_drafts WHERE status = ?1 ORDER BY updated_at DESC, slug ASC LIMIT ?2";
1516
+ params = [statusFilter, limit];
1517
+ } else if (ownerFilter) {
1518
+ sql = "SELECT * FROM catalog_drafts WHERE owner_id = ?1 ORDER BY updated_at DESC, slug ASC LIMIT ?2";
1519
+ params = [ownerFilter, limit];
1520
+ } else {
1521
+ sql = "SELECT * FROM catalog_drafts ORDER BY updated_at DESC, slug ASC LIMIT ?1";
1522
+ params = [limit];
1523
+ }
1524
+ var r = await query(sql, params);
1525
+ var out = [];
1526
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateDraftRow(r.rows[i]));
1527
+ return out;
1528
+ }
1529
+
1530
+ // ---- historyForSku ------------------------------------------------
1531
+
1532
+ async function historyForSku(sku) {
1533
+ _sku(sku, "sku");
1534
+ // SQLite / D1 dialect supports JSON1 functions but the project's
1535
+ // portable surface uses LIKE-on-payload_json to find every change
1536
+ // whose payload references the SKU. The payload writer guarantees
1537
+ // every sku field is stored as `"sku":"<value>"` so the LIKE
1538
+ // pattern is unambiguous (no false matches against a product_slug
1539
+ // that happens to share the substring).
1540
+ var needle = '"sku":"' + sku.replace(/"/g, '\\"') + '"';
1541
+ var r = await query(
1542
+ "SELECT c.*, d.title AS draft_title, d.status AS draft_status, " +
1543
+ " d.published_at AS draft_published_at, d.rolled_back_at AS draft_rolled_back_at " +
1544
+ "FROM catalog_draft_changes c " +
1545
+ "JOIN catalog_drafts d ON d.slug = c.draft_slug " +
1546
+ "WHERE c.payload_json LIKE ?1 " +
1547
+ "ORDER BY c.created_at DESC, c.id DESC",
1548
+ ["%" + needle + "%"],
1549
+ );
1550
+ var out = [];
1551
+ for (var i = 0; i < r.rows.length; i += 1) {
1552
+ var row = r.rows[i];
1553
+ var ch = _hydrateChangeRow({
1554
+ id: row.id,
1555
+ draft_slug: row.draft_slug,
1556
+ kind: row.kind,
1557
+ payload_json: row.payload_json,
1558
+ sequence_order: row.sequence_order,
1559
+ created_at: row.created_at,
1560
+ });
1561
+ // Filter LIKE false-positives by exact-matching the payload's
1562
+ // sku field through the parsed object. The LIKE pre-filter
1563
+ // narrows the scan; this loop guarantees correctness.
1564
+ if (_payloadSkuOf(ch.kind, ch.payload) !== sku) continue;
1565
+ ch.draft = {
1566
+ title: row.draft_title,
1567
+ status: row.draft_status,
1568
+ published_at: row.draft_published_at == null ? null : Number(row.draft_published_at),
1569
+ rolled_back_at: row.draft_rolled_back_at == null ? null : Number(row.draft_rolled_back_at),
1570
+ };
1571
+ out.push(ch);
1572
+ }
1573
+ return out;
1574
+ }
1575
+
1576
+ // ---- getDraft -----------------------------------------------------
1577
+
1578
+ // Exposed for ergonomic reads — many callers want the draft row
1579
+ // and the staged-change list together. listChanges separately is
1580
+ // for cursor pagination over large drafts.
1581
+ async function getDraft(slug) {
1582
+ _slug(slug, "slug");
1583
+ return await _getDraft(slug);
1584
+ }
1585
+
1586
+ return {
1587
+ STATUSES: STATUSES.slice(),
1588
+ KINDS: KINDS.slice(),
1589
+ TERMINAL_STATUSES: TERMINAL_STATUSES.slice(),
1590
+ ROLLBACK_WINDOW_MS: ROLLBACK_WINDOW_MS,
1591
+ MAX_CHANGES_PER_DRAFT: MAX_CHANGES_PER_DRAFT,
1592
+
1593
+ openDraft: openDraft,
1594
+ stageChange: stageChange,
1595
+ listChanges: listChanges,
1596
+ removeChange: removeChange,
1597
+ previewMerged: previewMerged,
1598
+ publishDraft: publishDraft,
1599
+ cancelDraft: cancelDraft,
1600
+ rollbackDraft: rollbackDraft,
1601
+ listDrafts: listDrafts,
1602
+ historyForSku: historyForSku,
1603
+ getDraft: getDraft,
1604
+ };
1605
+ }
1606
+
1607
+ module.exports = {
1608
+ create: create,
1609
+ STATUSES: STATUSES,
1610
+ KINDS: KINDS,
1611
+ TERMINAL_STATUSES: TERMINAL_STATUSES,
1612
+ ROLLBACK_WINDOW_MS: ROLLBACK_WINDOW_MS,
1613
+ MAX_CHANGES_PER_DRAFT: MAX_CHANGES_PER_DRAFT,
1614
+ };