@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.
- package/CHANGELOG.md +4 -0
- package/lib/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-import.js +590 -0
- package/lib/customer-portal.js +359 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/experiments.js +697 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +25 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|