@blamejs/blamejs-shop 0.0.59 → 0.0.61

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,1034 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.productImport
4
+ * @title Product bulk-import — operator-managed loader for products
5
+ * + variants + prices + media across CSV / Shopify-JSON /
6
+ * native shapes.
7
+ *
8
+ * @intro
9
+ * Operators upload a CSV, a Shopify products-export JSON file, or
10
+ * the canonical native shape this primitive emits back from
11
+ * `lastReport()`. The driver parses → validates → dedupes by SKU →
12
+ * resolves the on-conflict policy → writes through the catalog
13
+ * primitive → records a run row in `product_imports` and one row
14
+ * per refused input in `product_import_errors`.
15
+ *
16
+ * Distinct from the existing `catalogImport` primitive — that one
17
+ * is a thin synchronous CSV → catalog.products adapter intended
18
+ * for the admin "paste a small CSV" path. `productImport` is the
19
+ * long-running, operator-managed bulk loader: persistent state in
20
+ * the DB across the lifetime of the run, structured per-row error
21
+ * reporting, multi-format input, and cancel-mid-run support.
22
+ *
23
+ * Surfaces:
24
+ *
25
+ * create({ query?, catalog }) — factory. `query` defaults to
26
+ * `b.externalDb.query` at runtime; tests inject a query bound
27
+ * to an in-memory SQLite. `catalog` is the catalog primitive
28
+ * instance the importer writes through (so the importer
29
+ * inherits every input validation the catalog enforces).
30
+ *
31
+ * dryRun({ rows, format })
32
+ * Validates the input without writing anything to the catalog.
33
+ * Returns the same outcome report shape `importRows` produces,
34
+ * with every counter incremented as if the write happened.
35
+ * The run row + per-row errors are still recorded so the
36
+ * operator console can inspect a dry-run before committing.
37
+ *
38
+ * importRows({ rows, format, on_conflict })
39
+ * The driver. `rows` is the in-memory row collection
40
+ * (parsed CSV array-of-arrays, Shopify-JSON product objects,
41
+ * or native product objects). `format` ∈ flat_csv /
42
+ * shopify_json / blamejs_native. `on_conflict` ∈ update /
43
+ * skip / error — controls the duplicate-SKU policy.
44
+ *
45
+ * importFromCsv(stream, opts)
46
+ * importFromJson(stream, opts)
47
+ * Stream wrappers — fully consume the stream (string,
48
+ * Buffer, Uint8Array, or Node Readable) and route through
49
+ * `importRows`. The stream is bounded by `maxBytes`
50
+ * (default 16 MiB); larger inputs throw before any write.
51
+ *
52
+ * lastReport()
53
+ * The outcome of the most recent run on this factory
54
+ * instance. Survives until the next driver call.
55
+ *
56
+ * cancelInflight()
57
+ * Signals the running driver to stop after the current row.
58
+ * Already-persisted rows stay; the run row transitions to
59
+ * `cancelled`; remaining rows are counted in `rows_skipped`.
60
+ *
61
+ * listImports({ status?, from?, to? })
62
+ * errorsForImport(import_id)
63
+ * Operator-console reads against the persisted run + error
64
+ * state.
65
+ *
66
+ * Per-row errors don't abort the import. They're collected in
67
+ * `product_import_errors` and counted against `rows_errored`. The
68
+ * driver only aborts (status = `failed`) when the input itself
69
+ * refuses parse / shape validation before any row processes.
70
+ *
71
+ * Atomicity: D1 doesn't expose multi-row transactions across the
72
+ * HTTP bridge, so the driver inserts sequentially. A partial
73
+ * failure leaves partial state; the operator re-uploads with
74
+ * `on_conflict: update` to reconcile.
75
+ *
76
+ * Composes:
77
+ * - b.csv — RFC-4180 parse of flat_csv inputs.
78
+ * - b.guardCsv — formula-injection / bidi / control-byte
79
+ * refusal applied to every CSV cell.
80
+ * - b.guardJson — depth + size + duplicate-key refusal applied
81
+ * to JSON inputs.
82
+ * - b.uuid.v7 — run id + error row id.
83
+ * - b.guardUuid — strict UUID validation for errorsForImport.
84
+ */
85
+
86
+ var bShop;
87
+ function _b() {
88
+ if (!bShop) bShop = require("./index");
89
+ return bShop.framework;
90
+ }
91
+
92
+ var FORMATS = Object.freeze(["flat_csv", "shopify_json", "blamejs_native"]);
93
+ var ON_CONFLICTS = Object.freeze(["update", "skip", "error"]);
94
+ var STATUSES = Object.freeze(["running", "complete", "failed", "cancelled"]);
95
+
96
+ var DEFAULT_MAX_BYTES = 16 * 1024 * 1024; // 16 MiB
97
+ var DEFAULT_MAX_ROWS = 100000;
98
+ var MAX_ERROR_DETAIL = 4000;
99
+
100
+ // flat_csv header. Operator's CSV must match this exact column order
101
+ // — anything else refuses with a header-shape error before any row
102
+ // processes.
103
+ var FLAT_CSV_HEADER = Object.freeze([
104
+ "product_slug",
105
+ "product_title",
106
+ "product_status",
107
+ "product_description",
108
+ "variant_sku",
109
+ "variant_title",
110
+ "variant_weight_grams",
111
+ "price_currency",
112
+ "price_amount_minor",
113
+ "inventory_qty",
114
+ "media_r2_key",
115
+ "media_content_type",
116
+ "media_alt_text",
117
+ ]);
118
+
119
+ // ---- factory ------------------------------------------------------------
120
+
121
+ function create(opts) {
122
+ opts = opts || {};
123
+ if (!opts.catalog) {
124
+ throw new TypeError("product-import.create: opts.catalog required");
125
+ }
126
+ var catalog = opts.catalog;
127
+ var query;
128
+ if (opts.query) {
129
+ query = opts.query;
130
+ } else {
131
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
132
+ }
133
+
134
+ // Per-factory mutable state. `lastReport()` returns the most-
135
+ // recently-completed run; `cancelInflight()` flips a flag the
136
+ // driver re-reads between rows.
137
+ var state = {
138
+ lastReport: null,
139
+ cancelFlag: false,
140
+ inflightId: null,
141
+ };
142
+
143
+ return {
144
+ dryRun: function (input) {
145
+ return _drive(query, catalog, state, input || {}, { dryRun: true });
146
+ },
147
+ importRows: function (input) {
148
+ return _drive(query, catalog, state, input || {}, { dryRun: false });
149
+ },
150
+ importFromCsv: function (stream, csvOpts) {
151
+ return _importFromCsv(query, catalog, state, stream, csvOpts || {});
152
+ },
153
+ importFromJson: function (stream, jsonOpts) {
154
+ return _importFromJson(query, catalog, state, stream, jsonOpts || {});
155
+ },
156
+ lastReport: function () { return state.lastReport; },
157
+ cancelInflight: function () {
158
+ // Drop-silent when nothing is inflight — the operator console
159
+ // races cancel against natural completion; refusing here would
160
+ // surface a transient race as a hard error.
161
+ if (state.inflightId) state.cancelFlag = true;
162
+ return state.inflightId;
163
+ },
164
+ listImports: function (listOpts) {
165
+ return _listImports(query, listOpts || {});
166
+ },
167
+ errorsForImport: function (importId) {
168
+ return _errorsForImport(query, importId);
169
+ },
170
+ FORMATS: FORMATS,
171
+ ON_CONFLICTS: ON_CONFLICTS,
172
+ FLAT_CSV_HEADER: FLAT_CSV_HEADER,
173
+ };
174
+ }
175
+
176
+ // ---- stream wrappers ----------------------------------------------------
177
+
178
+ async function _importFromCsv(query, catalog, state, stream, csvOpts) {
179
+ var bytes = await _consumeStream(stream, csvOpts.maxBytes);
180
+ // Content-safety gate runs against the raw bytes before parse —
181
+ // formula-injection / bidi / control-byte refusal applies to
182
+ // every cell, refusing the upload outright rather than mutating.
183
+ var guardRv = _b().guardCsv.validate(bytes, { profile: "strict" });
184
+ if (!guardRv.ok) {
185
+ var kinds = guardRv.issues.map(function (i) { return i.kind; });
186
+ throw new TypeError(
187
+ "product-import.importFromCsv: csv refused by content-safety guard — " +
188
+ kinds.join(", ")
189
+ );
190
+ }
191
+ var maxRows = csvOpts.maxRows == null ? DEFAULT_MAX_ROWS : csvOpts.maxRows;
192
+ if (!Number.isInteger(maxRows) || maxRows <= 0) {
193
+ throw new TypeError("product-import.importFromCsv: maxRows must be a positive integer");
194
+ }
195
+ var parsed;
196
+ try {
197
+ parsed = _b().csv.parse(bytes, {
198
+ header: false,
199
+ maxBytes: bytes.length + 1,
200
+ maxRows: maxRows + 1,
201
+ trim: false,
202
+ });
203
+ } catch (e) {
204
+ throw new TypeError(
205
+ "product-import.importFromCsv: csv parse failed — " +
206
+ ((e && e.message) || "malformed")
207
+ );
208
+ }
209
+ return _drive(query, catalog, state, {
210
+ rows: parsed,
211
+ format: "flat_csv",
212
+ on_conflict: csvOpts.on_conflict,
213
+ }, { dryRun: csvOpts.dry_run === true, inputByteCount: bytes.length });
214
+ }
215
+
216
+ async function _importFromJson(query, catalog, state, stream, jsonOpts) {
217
+ var bytes = await _consumeStream(stream, jsonOpts.maxBytes);
218
+ var text = Buffer.isBuffer(bytes) ? bytes.toString("utf8") : String(bytes);
219
+ // guardJson refuses depth / size / duplicate-key shapes; the
220
+ // operator CSV-equivalent for JSON inputs. Returns the parsed
221
+ // tree on success.
222
+ var parsed;
223
+ try {
224
+ parsed = _b().guardJson.parse(text, { profile: "strict" });
225
+ } catch (e) {
226
+ throw new TypeError(
227
+ "product-import.importFromJson: json refused — " +
228
+ ((e && e.message) || "malformed")
229
+ );
230
+ }
231
+ var format = jsonOpts.format == null ? "blamejs_native" : jsonOpts.format;
232
+ if (FORMATS.indexOf(format) === -1 || format === "flat_csv") {
233
+ throw new TypeError(
234
+ "product-import.importFromJson: format must be one of " +
235
+ "shopify_json, blamejs_native — got " + JSON.stringify(format)
236
+ );
237
+ }
238
+ var rows;
239
+ if (Array.isArray(parsed)) {
240
+ rows = parsed;
241
+ } else if (parsed && Array.isArray(parsed.products)) {
242
+ rows = parsed.products;
243
+ } else {
244
+ throw new TypeError(
245
+ "product-import.importFromJson: json must be an array of products " +
246
+ "or an object with a `products` array"
247
+ );
248
+ }
249
+ return _drive(query, catalog, state, {
250
+ rows: rows,
251
+ format: format,
252
+ on_conflict: jsonOpts.on_conflict,
253
+ }, { dryRun: jsonOpts.dry_run === true, inputByteCount: bytes.length });
254
+ }
255
+
256
+ // Stream consumer — accepts string / Buffer / Uint8Array / Node
257
+ // Readable. Bounds at `maxBytes` so a runaway stream can't OOM the
258
+ // process. The byte cap is the only defense; the input shape is
259
+ // validated downstream.
260
+ async function _consumeStream(stream, maxBytes) {
261
+ var cap = maxBytes == null ? DEFAULT_MAX_BYTES : maxBytes;
262
+ if (!Number.isInteger(cap) || cap <= 0) {
263
+ throw new TypeError("product-import: maxBytes must be a positive integer");
264
+ }
265
+ if (typeof stream === "string") {
266
+ var buf = Buffer.from(stream, "utf8");
267
+ if (buf.length > cap) {
268
+ throw new TypeError("product-import: input exceeds maxBytes (" + cap + " bytes)");
269
+ }
270
+ return buf;
271
+ }
272
+ if (Buffer.isBuffer(stream)) {
273
+ if (stream.length > cap) {
274
+ throw new TypeError("product-import: input exceeds maxBytes (" + cap + " bytes)");
275
+ }
276
+ return stream;
277
+ }
278
+ if (stream instanceof Uint8Array) {
279
+ if (stream.length > cap) {
280
+ throw new TypeError("product-import: input exceeds maxBytes (" + cap + " bytes)");
281
+ }
282
+ return Buffer.from(stream);
283
+ }
284
+ if (stream && typeof stream.on === "function") {
285
+ return await new Promise(function (resolve, reject) {
286
+ var chunks = [];
287
+ var size = 0;
288
+ stream.on("data", function (c) {
289
+ var b = Buffer.isBuffer(c) ? c : Buffer.from(c);
290
+ size += b.length;
291
+ if (size > cap) {
292
+ reject(new TypeError("product-import: input exceeds maxBytes (" + cap + " bytes)"));
293
+ stream.destroy();
294
+ return;
295
+ }
296
+ chunks.push(b);
297
+ });
298
+ stream.on("end", function () { resolve(Buffer.concat(chunks, size)); });
299
+ stream.on("error", reject);
300
+ });
301
+ }
302
+ throw new TypeError(
303
+ "product-import: stream must be a string, Buffer, Uint8Array, or Node Readable"
304
+ );
305
+ }
306
+
307
+ // ---- driver -------------------------------------------------------------
308
+
309
+ async function _drive(query, catalog, state, input, driveOpts) {
310
+ var dryRun = driveOpts.dryRun === true;
311
+ var format = input.format;
312
+ if (FORMATS.indexOf(format) === -1) {
313
+ throw new TypeError(
314
+ "product-import: format must be one of " + FORMATS.join(", ") +
315
+ " — got " + JSON.stringify(format)
316
+ );
317
+ }
318
+ var onConflict = input.on_conflict == null ? "error" : input.on_conflict;
319
+ if (ON_CONFLICTS.indexOf(onConflict) === -1) {
320
+ throw new TypeError(
321
+ "product-import: on_conflict must be one of " + ON_CONFLICTS.join(", ") +
322
+ " — got " + JSON.stringify(onConflict)
323
+ );
324
+ }
325
+ if (!Array.isArray(input.rows)) {
326
+ throw new TypeError("product-import: rows must be an array");
327
+ }
328
+
329
+ // Normalize the input rows into the canonical `{ slug, title,
330
+ // status, description, variants: [...], media: [...] }` shape.
331
+ // Per-row normalization errors land in the error list at the
332
+ // matching row_index; the row is skipped for catalog writes but
333
+ // still counted in `rows_processed`.
334
+ var importId = _b().uuid.v7();
335
+ var startedAt = Date.now();
336
+ var inputByteCount = driveOpts.inputByteCount == null ? 0 : driveOpts.inputByteCount;
337
+
338
+ // Run row goes in at `running`. The driver updates the counters +
339
+ // status at the end. The error rows go in as they're encountered
340
+ // — operators tailing `errorsForImport` see progress mid-run.
341
+ await query(
342
+ "INSERT INTO product_imports (id, format, on_conflict, started_at, status, input_byte_count) " +
343
+ "VALUES (?1, ?2, ?3, ?4, 'running', ?5)",
344
+ [importId, format, onConflict, startedAt, inputByteCount],
345
+ );
346
+ state.inflightId = importId;
347
+ state.cancelFlag = false;
348
+
349
+ var counters = {
350
+ rows_processed: 0,
351
+ products_created: 0,
352
+ products_updated: 0,
353
+ variants_created: 0,
354
+ variants_updated: 0,
355
+ rows_skipped: 0,
356
+ rows_errored: 0,
357
+ };
358
+ var errors = [];
359
+
360
+ try {
361
+ var normalized;
362
+ try {
363
+ normalized = _normalizeRows(input.rows, format);
364
+ } catch (e) {
365
+ // Header / shape refusal — record the failure and rethrow so
366
+ // the caller sees the throw shape, matching the customer-
367
+ // import "status = failed" path.
368
+ await _recordError(query, importId, 0, "", "import_aborted",
369
+ (e && e.message) || String(e));
370
+ counters.rows_errored = 1;
371
+ await _finalize(query, importId, "failed", counters);
372
+ state.lastReport = _report(importId, format, onConflict, "failed", counters, errors, dryRun);
373
+ state.inflightId = null;
374
+ throw e;
375
+ }
376
+
377
+ // Within-import SKU dedupe. The same SKU appearing twice in
378
+ // one upload is always a row-error regardless of `on_conflict`
379
+ // — the operator's CSV is malformed, not the catalog's state.
380
+ var seenSkus = Object.create(null);
381
+
382
+ for (var i = 0; i < normalized.length; i += 1) {
383
+ if (state.cancelFlag) {
384
+ counters.rows_skipped += (normalized.length - i);
385
+ break;
386
+ }
387
+ counters.rows_processed += 1;
388
+ var rec = normalized[i];
389
+ if (rec.error) {
390
+ counters.rows_errored += 1;
391
+ var errA = {
392
+ row_index: rec.row_index,
393
+ sku: rec.sku || "",
394
+ error_code: rec.error.code,
395
+ error_detail: rec.error.detail,
396
+ };
397
+ errors.push(errA);
398
+ await _recordError(query, importId, errA.row_index, errA.sku, errA.error_code, errA.error_detail);
399
+ continue;
400
+ }
401
+ // Walk variants — each row in the source produced one product
402
+ // entry containing one or more variants. The dedupe is across
403
+ // all variants in the whole import.
404
+ var rowHadError = false;
405
+ for (var v = 0; v < rec.variants.length; v += 1) {
406
+ var variant = rec.variants[v];
407
+ if (seenSkus[variant.sku]) {
408
+ counters.rows_errored += 1;
409
+ rowHadError = true;
410
+ var errB = {
411
+ row_index: rec.row_index,
412
+ sku: variant.sku,
413
+ error_code: "duplicate_sku_in_import",
414
+ error_detail: "sku " + JSON.stringify(variant.sku) +
415
+ " appears more than once in this import (first at row_index " +
416
+ seenSkus[variant.sku] + ")",
417
+ };
418
+ errors.push(errB);
419
+ await _recordError(query, importId, errB.row_index, errB.sku, errB.error_code, errB.error_detail);
420
+ continue;
421
+ }
422
+ seenSkus[variant.sku] = rec.row_index;
423
+ }
424
+ if (rowHadError) continue;
425
+
426
+ try {
427
+ await _processRow(catalog, rec, onConflict, counters, dryRun);
428
+ } catch (e) {
429
+ counters.rows_errored += 1;
430
+ var sku = rec.variants && rec.variants[0] ? rec.variants[0].sku : "";
431
+ var errC = {
432
+ row_index: rec.row_index,
433
+ sku: sku,
434
+ error_code: (e && e.code) || "row_failed",
435
+ error_detail: ((e && e.message) || String(e)).slice(0, MAX_ERROR_DETAIL),
436
+ };
437
+ errors.push(errC);
438
+ await _recordError(query, importId, errC.row_index, errC.sku, errC.error_code, errC.error_detail);
439
+ }
440
+ }
441
+
442
+ var finalStatus = state.cancelFlag ? "cancelled" : "complete";
443
+ await _finalize(query, importId, finalStatus, counters);
444
+ state.lastReport = _report(importId, format, onConflict, finalStatus, counters, errors, dryRun);
445
+ state.inflightId = null;
446
+ state.cancelFlag = false;
447
+ return state.lastReport;
448
+ } catch (e) {
449
+ state.inflightId = null;
450
+ state.cancelFlag = false;
451
+ throw e;
452
+ }
453
+ }
454
+
455
+ function _report(importId, format, onConflict, status, counters, errors, dryRun) {
456
+ return {
457
+ import_id: importId,
458
+ format: format,
459
+ on_conflict: onConflict,
460
+ status: status,
461
+ dry_run: dryRun,
462
+ rows_processed: counters.rows_processed,
463
+ products_created: counters.products_created,
464
+ products_updated: counters.products_updated,
465
+ variants_created: counters.variants_created,
466
+ variants_updated: counters.variants_updated,
467
+ rows_skipped: counters.rows_skipped,
468
+ rows_errored: counters.rows_errored,
469
+ errors: errors,
470
+ };
471
+ }
472
+
473
+ async function _finalize(query, importId, status, counters) {
474
+ var ts = Date.now();
475
+ await query(
476
+ "UPDATE product_imports SET status = ?1, completed_at = ?2, " +
477
+ "rows_processed = ?3, products_created = ?4, products_updated = ?5, " +
478
+ "variants_created = ?6, variants_updated = ?7, rows_skipped = ?8, " +
479
+ "rows_errored = ?9 WHERE id = ?10",
480
+ [
481
+ status, ts,
482
+ counters.rows_processed, counters.products_created, counters.products_updated,
483
+ counters.variants_created, counters.variants_updated,
484
+ counters.rows_skipped, counters.rows_errored,
485
+ importId,
486
+ ],
487
+ );
488
+ }
489
+
490
+ async function _recordError(query, importId, rowIndex, sku, code, detail) {
491
+ var id = _b().uuid.v7();
492
+ var truncated = (detail || "").length > MAX_ERROR_DETAIL
493
+ ? detail.slice(0, MAX_ERROR_DETAIL) : (detail || "");
494
+ await query(
495
+ "INSERT INTO product_import_errors (id, import_id, row_index, sku, error_code, error_detail) " +
496
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
497
+ [id, importId, rowIndex, sku || "", code, truncated],
498
+ );
499
+ }
500
+
501
+ // ---- per-row processing -------------------------------------------------
502
+
503
+ async function _processRow(catalog, rec, onConflict, counters, dryRun) {
504
+ // Resolve (or create) the parent product by slug.
505
+ var existing = null;
506
+ try {
507
+ existing = await catalog.products.bySlug(rec.slug);
508
+ } catch (e) {
509
+ // Bad slug shape — surfaces as a per-row error rather than
510
+ // crashing the driver.
511
+ throw _wrap(e, "invalid_slug");
512
+ }
513
+
514
+ var productId;
515
+ if (existing) {
516
+ if (onConflict === "skip") {
517
+ // Operator opted out of touching existing products. Count
518
+ // the variants as skipped — they're not getting written.
519
+ counters.rows_skipped += rec.variants.length;
520
+ return;
521
+ }
522
+ productId = existing.id;
523
+ if (onConflict === "update") {
524
+ var patch = {};
525
+ if (rec.title != null) patch.title = rec.title;
526
+ if (rec.description != null) patch.description = rec.description;
527
+ if (rec.status != null) patch.status = rec.status;
528
+ if (Object.keys(patch).length && !dryRun) {
529
+ await catalog.products.update(productId, patch);
530
+ }
531
+ counters.products_updated += 1;
532
+ }
533
+ // onConflict === "error" — fall through; per-variant SKU
534
+ // collision lands in the catalog UNIQUE refusal below.
535
+ } else {
536
+ if (dryRun) {
537
+ productId = "<dry-run-product>";
538
+ } else {
539
+ var p = await catalog.products.create({
540
+ slug: rec.slug,
541
+ title: rec.title,
542
+ status: rec.status || "draft",
543
+ description: rec.description == null ? "" : rec.description,
544
+ });
545
+ productId = p.id;
546
+ }
547
+ counters.products_created += 1;
548
+ }
549
+
550
+ for (var i = 0; i < rec.variants.length; i += 1) {
551
+ var v = rec.variants[i];
552
+ var variantExisting = null;
553
+ if (!dryRun) {
554
+ try { variantExisting = await catalog.variants.bySku(v.sku); }
555
+ catch (_e) { variantExisting = null; }
556
+ }
557
+ if (variantExisting) {
558
+ if (onConflict === "skip") {
559
+ counters.rows_skipped += 1;
560
+ continue;
561
+ }
562
+ if (onConflict === "error") {
563
+ throw _err("duplicate_sku", "sku " + JSON.stringify(v.sku) +
564
+ " already exists in the catalog");
565
+ }
566
+ // update
567
+ var vPatch = {};
568
+ if (v.title != null) vPatch.title = v.title;
569
+ if (v.weight_grams != null) vPatch.weight_grams = v.weight_grams;
570
+ if (Object.keys(vPatch).length) {
571
+ await catalog.variants.update(variantExisting.id, vPatch);
572
+ }
573
+ if (v.price) {
574
+ await catalog.prices.set(variantExisting.id, {
575
+ currency: v.price.currency,
576
+ amount_minor: v.price.amount_minor,
577
+ });
578
+ }
579
+ counters.variants_updated += 1;
580
+ if (v.inventory_qty != null && !dryRun) {
581
+ // Inventory.create refuses on duplicate SKU; in update
582
+ // mode we ignore the conflict (existing row's quantity
583
+ // is operator-managed, not import-managed).
584
+ try {
585
+ await catalog.inventory.create(v.sku, { stock_on_hand: v.inventory_qty });
586
+ } catch (_e) { /* drop-silent — operator opted into update, not stock reset */ }
587
+ }
588
+ // Media on update: append, not replace. Operator-curated
589
+ // images aren't clobbered by a re-import.
590
+ if (v.media && v.media.length && !dryRun) {
591
+ for (var m = 0; m < v.media.length; m += 1) {
592
+ await catalog.media.attach({
593
+ variant_id: variantExisting.id,
594
+ r2_key: v.media[m].r2_key,
595
+ content_type: v.media[m].content_type,
596
+ alt_text: v.media[m].alt_text || "",
597
+ });
598
+ }
599
+ }
600
+ } else {
601
+ if (dryRun) {
602
+ counters.variants_created += 1;
603
+ continue;
604
+ }
605
+ var created = await catalog.variants.create(productId, {
606
+ sku: v.sku,
607
+ title: v.title || "",
608
+ weight_grams: v.weight_grams == null ? 0 : v.weight_grams,
609
+ });
610
+ counters.variants_created += 1;
611
+ if (v.price) {
612
+ await catalog.prices.set(created.id, {
613
+ currency: v.price.currency,
614
+ amount_minor: v.price.amount_minor,
615
+ });
616
+ }
617
+ if (v.inventory_qty != null) {
618
+ await catalog.inventory.create(v.sku, { stock_on_hand: v.inventory_qty });
619
+ }
620
+ if (v.media && v.media.length) {
621
+ for (var m2 = 0; m2 < v.media.length; m2 += 1) {
622
+ await catalog.media.attach({
623
+ variant_id: created.id,
624
+ r2_key: v.media[m2].r2_key,
625
+ content_type: v.media[m2].content_type,
626
+ alt_text: v.media[m2].alt_text || "",
627
+ });
628
+ }
629
+ }
630
+ }
631
+ }
632
+
633
+ // Product-level media (attached to product_id, not variant_id).
634
+ if (rec.media && rec.media.length && !dryRun && productId !== "<dry-run-product>") {
635
+ for (var pm = 0; pm < rec.media.length; pm += 1) {
636
+ await catalog.media.attach({
637
+ product_id: productId,
638
+ r2_key: rec.media[pm].r2_key,
639
+ content_type: rec.media[pm].content_type,
640
+ alt_text: rec.media[pm].alt_text || "",
641
+ });
642
+ }
643
+ }
644
+ }
645
+
646
+ function _err(code, detail) {
647
+ var e = new Error(detail);
648
+ e.code = code;
649
+ return e;
650
+ }
651
+ function _wrap(e, code) {
652
+ var wrapped = new Error((e && e.message) || String(e));
653
+ wrapped.code = code;
654
+ wrapped.cause = e;
655
+ return wrapped;
656
+ }
657
+
658
+ // ---- normalization ------------------------------------------------------
659
+ //
660
+ // Each input format collapses to the same canonical shape:
661
+ //
662
+ // {
663
+ // row_index: <0-based source-row index>,
664
+ // slug, title, status, description,
665
+ // variants: [
666
+ // { sku, title, weight_grams, price: { currency, amount_minor },
667
+ // inventory_qty, media: [...] },
668
+ // ...
669
+ // ],
670
+ // media: [{ r2_key, content_type, alt_text }, ...],
671
+ // }
672
+ //
673
+ // Multiple flat_csv rows that share `product_slug` collapse to a
674
+ // single normalized record with the first row's product fields and
675
+ // every row's variant block appended to the variants array. For
676
+ // shopify_json + blamejs_native the input is already nested, so each
677
+ // element of the input array becomes one normalized record.
678
+
679
+ function _normalizeRows(rows, format) {
680
+ if (format === "flat_csv") return _normalizeFlatCsv(rows);
681
+ if (format === "shopify_json") return _normalizeShopify(rows);
682
+ return _normalizeNative(rows);
683
+ }
684
+
685
+ function _normalizeFlatCsv(rows) {
686
+ if (rows.length === 0) {
687
+ throw new TypeError("product-import: csv contained no rows (header required)");
688
+ }
689
+ var header = rows[0];
690
+ if (!Array.isArray(header) || header.length !== FLAT_CSV_HEADER.length) {
691
+ throw new TypeError(
692
+ "product-import: header row must have " + FLAT_CSV_HEADER.length +
693
+ " columns: " + FLAT_CSV_HEADER.join(", ")
694
+ );
695
+ }
696
+ for (var h = 0; h < FLAT_CSV_HEADER.length; h += 1) {
697
+ if (header[h] !== FLAT_CSV_HEADER[h]) {
698
+ throw new TypeError(
699
+ "product-import: header column " + (h + 1) +
700
+ " must be " + JSON.stringify(FLAT_CSV_HEADER[h]) +
701
+ ", got " + JSON.stringify(header[h])
702
+ );
703
+ }
704
+ }
705
+ var data = rows.slice(1);
706
+ var bySlug = Object.create(null);
707
+ var order = [];
708
+ for (var i = 0; i < data.length; i += 1) {
709
+ var rowIndex = i + 1; // 0-based on the input array; the
710
+ // header is row 0, first data row is 1.
711
+ var row = data[i];
712
+ if (!Array.isArray(row) || row.length !== FLAT_CSV_HEADER.length) {
713
+ // Per-row shape error — preserved as an error record rather
714
+ // than throwing. The driver will count + record it.
715
+ order.push({
716
+ row_index: rowIndex,
717
+ sku: "",
718
+ error: {
719
+ code: "row_shape",
720
+ detail: "row must have " + FLAT_CSV_HEADER.length +
721
+ " columns, got " + (Array.isArray(row) ? row.length : "non-array"),
722
+ },
723
+ });
724
+ continue;
725
+ }
726
+ var slug = row[0];
727
+ var weightStr = row[6];
728
+ var amountStr = row[8];
729
+ var qtyStr = row[9];
730
+ var weight = _strInt(weightStr, "variant_weight_grams");
731
+ var amount = _strInt(amountStr, "price_amount_minor");
732
+ var qty = _strInt(qtyStr, "inventory_qty");
733
+ if (weight.error || amount.error || qty.error) {
734
+ var detail = (weight.error || amount.error || qty.error);
735
+ order.push({
736
+ row_index: rowIndex,
737
+ sku: row[4] || "",
738
+ error: { code: "invalid_number", detail: detail },
739
+ });
740
+ continue;
741
+ }
742
+ var variant = {
743
+ sku: row[4],
744
+ title: row[5],
745
+ weight_grams: weight.value,
746
+ price: { currency: row[7], amount_minor: amount.value },
747
+ inventory_qty: qty.value,
748
+ media: [],
749
+ };
750
+ if (row[10]) {
751
+ variant.media.push({
752
+ r2_key: row[10],
753
+ content_type: row[11] || "image/jpeg",
754
+ alt_text: row[12] || "",
755
+ });
756
+ }
757
+ if (bySlug[slug]) {
758
+ bySlug[slug].variants.push(variant);
759
+ } else {
760
+ var rec = {
761
+ row_index: rowIndex,
762
+ slug: slug,
763
+ title: row[1],
764
+ status: row[2],
765
+ description: row[3],
766
+ variants: [variant],
767
+ media: [],
768
+ };
769
+ bySlug[slug] = rec;
770
+ order.push(rec);
771
+ }
772
+ }
773
+ return order;
774
+ }
775
+
776
+ function _strInt(s, label) {
777
+ if (typeof s !== "string") {
778
+ return { error: label + " must be a string-encoded non-negative integer" };
779
+ }
780
+ if (!/^\d+$/.test(s)) {
781
+ return { error: label + " must be a non-negative integer, got " + JSON.stringify(s) };
782
+ }
783
+ var n = parseInt(s, 10);
784
+ if (!Number.isInteger(n) || n < 0) {
785
+ return { error: label + " must be a non-negative integer, got " + JSON.stringify(s) };
786
+ }
787
+ return { value: n };
788
+ }
789
+
790
+ function _normalizeShopify(products) {
791
+ if (!Array.isArray(products)) {
792
+ throw new TypeError("product-import: shopify_json must be an array of product objects");
793
+ }
794
+ var out = [];
795
+ for (var i = 0; i < products.length; i += 1) {
796
+ var p = products[i];
797
+ if (!p || typeof p !== "object" || Array.isArray(p)) {
798
+ out.push({
799
+ row_index: i, sku: "",
800
+ error: { code: "row_shape", detail: "product entry must be an object" },
801
+ });
802
+ continue;
803
+ }
804
+ if (typeof p.handle !== "string" || !p.handle.length) {
805
+ out.push({
806
+ row_index: i, sku: "",
807
+ error: { code: "row_shape", detail: "shopify product missing `handle` (slug)" },
808
+ });
809
+ continue;
810
+ }
811
+ var status = p.status === "active" ? "active"
812
+ : p.status === "draft" ? "draft"
813
+ : p.status === "archived" ? "archived"
814
+ : "draft";
815
+ if (!Array.isArray(p.variants) || p.variants.length === 0) {
816
+ out.push({
817
+ row_index: i, sku: "",
818
+ error: { code: "row_shape", detail: "shopify product missing `variants` array" },
819
+ });
820
+ continue;
821
+ }
822
+ var variants = [];
823
+ var rowError = null;
824
+ for (var v = 0; v < p.variants.length; v += 1) {
825
+ var sv = p.variants[v];
826
+ if (!sv || typeof sv !== "object" || typeof sv.sku !== "string") {
827
+ rowError = {
828
+ code: "row_shape",
829
+ detail: "shopify variant " + v + " missing `sku`",
830
+ };
831
+ break;
832
+ }
833
+ // Shopify ships price as a decimal string ("19.99"); the
834
+ // catalog stores minor units. Convert by stripping the
835
+ // decimal and padding to two decimals. No currency conversion;
836
+ // operator-provided `currency` (default USD) is recorded as-is.
837
+ var amount = _shopifyMinor(sv.price);
838
+ if (amount.error) { rowError = { code: "invalid_number", detail: amount.error }; break; }
839
+ var weight = sv.grams == null ? 0 : sv.grams;
840
+ if (!Number.isInteger(weight) || weight < 0) {
841
+ rowError = { code: "invalid_number", detail: "variant.grams must be a non-negative integer" };
842
+ break;
843
+ }
844
+ variants.push({
845
+ sku: sv.sku,
846
+ title: sv.title || "",
847
+ weight_grams: weight,
848
+ price: { currency: sv.currency || "USD", amount_minor: amount.value },
849
+ inventory_qty: Number.isInteger(sv.inventory_quantity) && sv.inventory_quantity >= 0
850
+ ? sv.inventory_quantity : 0,
851
+ media: [],
852
+ });
853
+ }
854
+ if (rowError) {
855
+ out.push({ row_index: i, sku: "", error: rowError });
856
+ continue;
857
+ }
858
+ var media = [];
859
+ if (Array.isArray(p.images)) {
860
+ for (var m = 0; m < p.images.length; m += 1) {
861
+ var img = p.images[m];
862
+ if (img && typeof img === "object" && typeof img.src === "string") {
863
+ media.push({
864
+ r2_key: img.src,
865
+ content_type: img.content_type || "image/jpeg",
866
+ alt_text: img.alt || "",
867
+ });
868
+ }
869
+ }
870
+ }
871
+ out.push({
872
+ row_index: i,
873
+ slug: p.handle,
874
+ title: p.title || p.handle,
875
+ status: status,
876
+ description: p.body_html || "",
877
+ variants: variants,
878
+ media: media,
879
+ });
880
+ }
881
+ return out;
882
+ }
883
+
884
+ function _shopifyMinor(price) {
885
+ if (price == null) return { error: "variant.price required" };
886
+ var s = String(price);
887
+ if (!/^\d+(?:\.\d{1,2})?$/.test(s)) {
888
+ return { error: "variant.price must be a decimal string with ≤ 2 decimals, got " + JSON.stringify(price) };
889
+ }
890
+ var parts = s.split(".");
891
+ var whole = parts[0];
892
+ var dec = (parts[1] || "").padEnd(2, "0").slice(0, 2);
893
+ var minor = parseInt(whole, 10) * 100 + parseInt(dec, 10);
894
+ if (!Number.isInteger(minor) || minor < 0) {
895
+ return { error: "variant.price overflow" };
896
+ }
897
+ return { value: minor };
898
+ }
899
+
900
+ function _normalizeNative(products) {
901
+ if (!Array.isArray(products)) {
902
+ throw new TypeError("product-import: blamejs_native must be an array of product objects");
903
+ }
904
+ var out = [];
905
+ for (var i = 0; i < products.length; i += 1) {
906
+ var p = products[i];
907
+ if (!p || typeof p !== "object" || Array.isArray(p)) {
908
+ out.push({
909
+ row_index: i, sku: "",
910
+ error: { code: "row_shape", detail: "product entry must be an object" },
911
+ });
912
+ continue;
913
+ }
914
+ if (typeof p.slug !== "string" || !p.slug.length) {
915
+ out.push({
916
+ row_index: i, sku: "",
917
+ error: { code: "row_shape", detail: "native product missing `slug`" },
918
+ });
919
+ continue;
920
+ }
921
+ if (!Array.isArray(p.variants) || p.variants.length === 0) {
922
+ out.push({
923
+ row_index: i, sku: "",
924
+ error: { code: "row_shape", detail: "native product missing `variants` array" },
925
+ });
926
+ continue;
927
+ }
928
+ var variants = [];
929
+ var rowError = null;
930
+ for (var v = 0; v < p.variants.length; v += 1) {
931
+ var nv = p.variants[v];
932
+ if (!nv || typeof nv !== "object" || typeof nv.sku !== "string") {
933
+ rowError = { code: "row_shape", detail: "native variant " + v + " missing `sku`" };
934
+ break;
935
+ }
936
+ var weight = nv.weight_grams == null ? 0 : nv.weight_grams;
937
+ if (!Number.isInteger(weight) || weight < 0) {
938
+ rowError = { code: "invalid_number", detail: "variant.weight_grams must be a non-negative integer" };
939
+ break;
940
+ }
941
+ var price = nv.price;
942
+ if (price && (typeof price !== "object" || typeof price.currency !== "string" ||
943
+ !Number.isInteger(price.amount_minor))) {
944
+ rowError = { code: "row_shape", detail: "variant.price must be { currency, amount_minor }" };
945
+ break;
946
+ }
947
+ variants.push({
948
+ sku: nv.sku,
949
+ title: nv.title || "",
950
+ weight_grams: weight,
951
+ price: price ? { currency: price.currency, amount_minor: price.amount_minor } : null,
952
+ inventory_qty: Number.isInteger(nv.inventory_qty) && nv.inventory_qty >= 0
953
+ ? nv.inventory_qty : 0,
954
+ media: Array.isArray(nv.media) ? nv.media.slice() : [],
955
+ });
956
+ }
957
+ if (rowError) {
958
+ out.push({ row_index: i, sku: "", error: rowError });
959
+ continue;
960
+ }
961
+ out.push({
962
+ row_index: i,
963
+ slug: p.slug,
964
+ title: p.title || p.slug,
965
+ status: p.status || "draft",
966
+ description: p.description == null ? "" : p.description,
967
+ variants: variants,
968
+ media: Array.isArray(p.media) ? p.media.slice() : [],
969
+ });
970
+ }
971
+ return out;
972
+ }
973
+
974
+ // ---- read surfaces ------------------------------------------------------
975
+
976
+ async function _listImports(query, listOpts) {
977
+ var status = listOpts.status;
978
+ if (status != null) {
979
+ if (STATUSES.indexOf(status) === -1) {
980
+ throw new TypeError(
981
+ "product-import.listImports: status must be one of " + STATUSES.join(", ")
982
+ );
983
+ }
984
+ }
985
+ var from = listOpts.from;
986
+ var to = listOpts.to;
987
+ if (from != null && !Number.isInteger(from)) {
988
+ throw new TypeError("product-import.listImports: from must be an integer epoch-ms");
989
+ }
990
+ if (to != null && !Number.isInteger(to)) {
991
+ throw new TypeError("product-import.listImports: to must be an integer epoch-ms");
992
+ }
993
+
994
+ var clauses = [];
995
+ var params = [];
996
+ var idx = 1;
997
+ if (status != null) { clauses.push("status = ?" + idx); params.push(status); idx += 1; }
998
+ if (from != null) { clauses.push("started_at >= ?" + idx); params.push(from); idx += 1; }
999
+ if (to != null) { clauses.push("started_at <= ?" + idx); params.push(to); idx += 1; }
1000
+ var sql = "SELECT * FROM product_imports";
1001
+ if (clauses.length) sql += " WHERE " + clauses.join(" AND ");
1002
+ sql += " ORDER BY started_at DESC, id DESC LIMIT 500";
1003
+ var r = await query(sql, params);
1004
+ return { rows: r.rows };
1005
+ }
1006
+
1007
+ async function _errorsForImport(query, importId) {
1008
+ // Strict UUID gate keeps a hand-crafted id from reaching the SQL
1009
+ // layer; matches the catalog.products.get pattern.
1010
+ try {
1011
+ _b().guardUuid.sanitize(importId, { profile: "strict" });
1012
+ } catch (e) {
1013
+ throw new TypeError(
1014
+ "product-import.errorsForImport: import_id — " +
1015
+ ((e && e.message) || "invalid UUID")
1016
+ );
1017
+ }
1018
+ var r = await query(
1019
+ "SELECT id, import_id, row_index, sku, error_code, error_detail " +
1020
+ "FROM product_import_errors WHERE import_id = ?1 " +
1021
+ "ORDER BY row_index ASC, id ASC",
1022
+ [importId],
1023
+ );
1024
+ return { rows: r.rows };
1025
+ }
1026
+
1027
+ module.exports = {
1028
+ create: create,
1029
+ FORMATS: FORMATS,
1030
+ ON_CONFLICTS: ON_CONFLICTS,
1031
+ FLAT_CSV_HEADER: FLAT_CSV_HEADER,
1032
+ DEFAULT_MAX_BYTES: DEFAULT_MAX_BYTES,
1033
+ DEFAULT_MAX_ROWS: DEFAULT_MAX_ROWS,
1034
+ };