@blamejs/blamejs-shop 0.0.64 → 0.0.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/business-hours.js +980 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +30 -0
- package/lib/metered-usage.js +782 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/preorder.js +595 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/site-redirects.js +690 -0
- package/lib/split-shipments.js +773 -0
- package/lib/theme-assets.js +711 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|