@blamejs/blamejs-shop 0.0.60 → 0.0.62

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,590 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.customerImport
4
+ * @title Customer bulk-import — CSV / NDJSON migration loader
5
+ *
6
+ * @intro
7
+ * Operators run this once during platform migration: a CSV or
8
+ * NDJSON export of customer rows from a prior storefront is fed
9
+ * through the primitive, which validates each row (email via
10
+ * `b.guardEmail` at strict profile, display_name shape, control-
11
+ * byte refusal), dedupes against the existing `customers` table by
12
+ * the SHA3-512 email_hash that the customers primitive already
13
+ * owns, and lands creates / updates per row. CSV cell content
14
+ * additionally runs through `b.guardCsv` strict profile — a
15
+ * formula-injection payload (`=cmd|...`, `+SUM(...)`,
16
+ * `WEBSERVICE(...)`) in a migrated row refuses the entire upload
17
+ * before any write.
18
+ *
19
+ * `on_conflict` decides what happens when a row's email collides
20
+ * with an existing customer (the dedupe lookup is by email_hash,
21
+ * so canonical-equivalent addresses collide):
22
+ *
23
+ * update — overwrite display_name on the existing row; the row
24
+ * counts as `updated`.
25
+ * skip — leave the existing customer untouched; the row
26
+ * counts as `skipped`.
27
+ * error — surface a row-error; counts as `errored`.
28
+ *
29
+ * `dryRun(rows)` returns the per-row outcome breakdown WITHOUT
30
+ * writing — same shape as `importRows`. A real run of the same
31
+ * rows then reproduces the same outcome breakdown (modulo
32
+ * conflicts introduced by the writes themselves).
33
+ *
34
+ * The factory writes a `customer_imports` run row per
35
+ * `importRows` / `importFromCsv` / `importFromNdjson` call so the
36
+ * operator console has a per-job audit trail. `cancelInflight()`
37
+ * flips the run row to `cancelled` and the driver stops consuming
38
+ * the input stream at its next iteration boundary.
39
+ *
40
+ * var imp = bShop.customerImport.create({
41
+ * query: externalDbD1Query,
42
+ * customers: bShop.customers.create({ query: externalDbD1Query }),
43
+ * });
44
+ * var report = await imp.importRows({
45
+ * rows: [{ email: "a@example.com", display_name: "A" }, ...],
46
+ * on_conflict: "skip",
47
+ * });
48
+ * report.created; report.updated; report.skipped; report.errored;
49
+ * report.errors; // [{ row_index, message }, ...]
50
+ */
51
+
52
+ var bShop;
53
+ function _b() {
54
+ if (!bShop) bShop = require("./index");
55
+ return bShop.framework;
56
+ }
57
+
58
+ var DEFAULT_MAX_ROWS = 200000;
59
+ var DEFAULT_MAX_ERRORS = 1000;
60
+ var SOURCES = Object.freeze(["csv", "ndjson", "api"]);
61
+ var ON_CONFLICT_MODES = Object.freeze(["update", "skip", "error"]);
62
+ var CSV_EXPECTED_HEADERS = Object.freeze(["email", "display_name"]);
63
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
64
+ var MAX_DISPLAY_NAME_LEN = 128;
65
+
66
+ // ---- factory ------------------------------------------------------------
67
+
68
+ function create(opts) {
69
+ opts = opts || {};
70
+ if (!opts.customers) {
71
+ throw new TypeError("customer-import.create: opts.customers required (bShop.customers instance)");
72
+ }
73
+ var customers = opts.customers;
74
+ var query = opts.query;
75
+ if (!query) {
76
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
77
+ }
78
+ var maxRows = opts.maxRows == null ? DEFAULT_MAX_ROWS : opts.maxRows;
79
+ var maxErrors = opts.maxErrors == null ? DEFAULT_MAX_ERRORS : opts.maxErrors;
80
+ if (!Number.isInteger(maxRows) || maxRows <= 0) {
81
+ throw new TypeError("customer-import.create: maxRows must be a positive integer");
82
+ }
83
+ if (!Number.isInteger(maxErrors) || maxErrors <= 0) {
84
+ throw new TypeError("customer-import.create: maxErrors must be a positive integer");
85
+ }
86
+
87
+ // Per-factory run state. `inflight` tracks the currently-running
88
+ // job so `cancelInflight()` knows what row id to flip. `lastReport`
89
+ // exposes the most recently completed run's summary; it survives
90
+ // until the next run starts.
91
+ var state = {
92
+ inflight: null, // { runId, cancelled: bool }
93
+ lastReport: null,
94
+ };
95
+
96
+ return {
97
+ SOURCES: SOURCES,
98
+ ON_CONFLICT_MODES: ON_CONFLICT_MODES,
99
+ CSV_EXPECTED_HEADERS: CSV_EXPECTED_HEADERS,
100
+
101
+ dryRun: function (rows) {
102
+ return _dryRun(customers, rows);
103
+ },
104
+
105
+ importRows: function (input) {
106
+ return _importRows(query, customers, state, maxRows, maxErrors, input || {}, "api");
107
+ },
108
+
109
+ importFromCsv: function (stream) {
110
+ return _importFromCsv(query, customers, state, maxRows, maxErrors, stream);
111
+ },
112
+
113
+ importFromNdjson: function (stream) {
114
+ return _importFromNdjson(query, customers, state, maxRows, maxErrors, stream);
115
+ },
116
+
117
+ lastReport: function () { return state.lastReport; },
118
+
119
+ cancelInflight: async function () {
120
+ if (!state.inflight) return false;
121
+ state.inflight.cancelled = true;
122
+ var ts = Date.now();
123
+ await query(
124
+ "UPDATE customer_imports SET status = ?1, completed_at = ?2 " +
125
+ "WHERE id = ?3 AND status = 'running'",
126
+ ["cancelled", ts, state.inflight.runId],
127
+ );
128
+ return true;
129
+ },
130
+ };
131
+ }
132
+
133
+ // ---- validation helpers ------------------------------------------------
134
+
135
+ function _normalizeRow(raw, rowIndex) {
136
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
137
+ throw new TypeError("row " + rowIndex + ": must be a plain object with email + display_name");
138
+ }
139
+ var email = raw.email;
140
+ var displayName = raw.display_name;
141
+ if (typeof email !== "string" || !email.length) {
142
+ throw new TypeError("row " + rowIndex + ": email must be a non-empty string");
143
+ }
144
+ if (typeof displayName !== "string" || !displayName.length) {
145
+ throw new TypeError("row " + rowIndex + ": display_name must be a non-empty string");
146
+ }
147
+ if (displayName.length > MAX_DISPLAY_NAME_LEN) {
148
+ throw new TypeError("row " + rowIndex + ": display_name must be <= " + MAX_DISPLAY_NAME_LEN + " characters");
149
+ }
150
+ if (CONTROL_BYTE_RE.test(displayName)) {
151
+ throw new TypeError("row " + rowIndex + ": display_name contains control bytes");
152
+ }
153
+ // guardEmail strict profile — refuses smuggling / header-injection /
154
+ // multi-@ / mixed-script. The customers primitive will re-validate
155
+ // at write time; we validate here so dryRun surfaces the same error
156
+ // shape without touching the DB.
157
+ var guardEmail = _b().guardEmail;
158
+ var report;
159
+ try {
160
+ report = guardEmail.validate(email, { profile: "strict" });
161
+ } catch (e) {
162
+ throw new TypeError("row " + rowIndex + ": email — " + (e && e.message || "invalid email"));
163
+ }
164
+ if (!report || report.ok === false) {
165
+ var first = (report && report.issues && report.issues[0]) || {};
166
+ throw new TypeError("row " + rowIndex + ": email — " + (first.snippet || first.ruleId || "refused at strict profile"));
167
+ }
168
+ return { email: email, display_name: displayName };
169
+ }
170
+
171
+ function _validateOnConflict(mode) {
172
+ if (ON_CONFLICT_MODES.indexOf(mode) === -1) {
173
+ throw new TypeError(
174
+ "customer-import: on_conflict must be one of " + ON_CONFLICT_MODES.join(", ") +
175
+ ", got " + JSON.stringify(mode)
176
+ );
177
+ }
178
+ }
179
+
180
+ function _addError(errors, maxErrors, rowIndex, message) {
181
+ if (errors.length < maxErrors) {
182
+ errors.push({ row_index: rowIndex, message: message });
183
+ }
184
+ }
185
+
186
+ // ---- run-row persistence -----------------------------------------------
187
+
188
+ async function _openRun(query, state, source, inputByteCount) {
189
+ var runId = _b().uuid.v7();
190
+ var startedAt = Date.now();
191
+ await query(
192
+ "INSERT INTO customer_imports " +
193
+ "(id, started_at, completed_at, status, source, input_byte_count, " +
194
+ " rows_processed, rows_created, rows_updated, rows_skipped, rows_errored, errors_json) " +
195
+ "VALUES (?1, ?2, NULL, 'running', ?3, ?4, 0, 0, 0, 0, 0, '[]')",
196
+ [runId, startedAt, source, inputByteCount],
197
+ );
198
+ state.inflight = { runId: runId, cancelled: false };
199
+ return { runId: runId, startedAt: startedAt };
200
+ }
201
+
202
+ async function _closeRun(query, state, runId, status, counters, errors) {
203
+ var ts = Date.now();
204
+ // The run might already be in `cancelled` — cancelInflight flips
205
+ // it under us. Don't downgrade a cancelled run to complete; the
206
+ // status column has a 4-value CHECK enum and the operator console
207
+ // cares about the distinction.
208
+ var current = (await query(
209
+ "SELECT status FROM customer_imports WHERE id = ?1",
210
+ [runId],
211
+ )).rows[0];
212
+ var finalStatus = (current && current.status === "cancelled") ? "cancelled" : status;
213
+ var errorsJson = _b().safeJson.stringify(errors, { canonical: true });
214
+ await query(
215
+ "UPDATE customer_imports SET " +
216
+ " status = ?1, completed_at = ?2, " +
217
+ " rows_processed = ?3, rows_created = ?4, rows_updated = ?5, " +
218
+ " rows_skipped = ?6, rows_errored = ?7, errors_json = ?8 " +
219
+ "WHERE id = ?9",
220
+ [
221
+ finalStatus, ts,
222
+ counters.processed, counters.created, counters.updated,
223
+ counters.skipped, counters.errored,
224
+ errorsJson,
225
+ runId,
226
+ ],
227
+ );
228
+ state.inflight = null;
229
+ return finalStatus;
230
+ }
231
+
232
+ // ---- dryRun ------------------------------------------------------------
233
+
234
+ async function _dryRun(customers, rows) {
235
+ if (!Array.isArray(rows)) {
236
+ throw new TypeError("customer-import.dryRun: rows must be an array");
237
+ }
238
+ var counters = { processed: 0, created: 0, updated: 0, skipped: 0, errored: 0 };
239
+ var errors = [];
240
+ var perRow = [];
241
+ // Track hashes seen in THIS batch so two identical emails inside
242
+ // the same dryRun input collide (the second is reported as a
243
+ // would-be conflict). Mirrors what importRows sees once the first
244
+ // row writes.
245
+ var seenHashesThisRun = Object.create(null);
246
+
247
+ for (var i = 0; i < rows.length; i += 1) {
248
+ var rowIndex = i + 1;
249
+ counters.processed += 1;
250
+ try {
251
+ var normalized = _normalizeRow(rows[i], rowIndex);
252
+ var hash = customers.hashEmail(normalized.email);
253
+ var existing = await customers.byEmailHash(hash);
254
+ var outcome;
255
+ if (existing || seenHashesThisRun[hash]) {
256
+ // dryRun reports the conflict but doesn't pick an
257
+ // on_conflict mode — the caller decides at import time. We
258
+ // surface it as `would_conflict` so the operator sees how
259
+ // many rows the on_conflict policy will branch on.
260
+ outcome = "would_conflict";
261
+ } else {
262
+ outcome = "would_create";
263
+ counters.created += 1;
264
+ seenHashesThisRun[hash] = true;
265
+ }
266
+ perRow.push({ row_index: rowIndex, outcome: outcome, email_hash: hash });
267
+ } catch (e) {
268
+ counters.errored += 1;
269
+ var msg = (e && e.message) || String(e);
270
+ errors.push({ row_index: rowIndex, message: msg });
271
+ perRow.push({ row_index: rowIndex, outcome: "error", message: msg });
272
+ }
273
+ }
274
+ // dryRun rolls `would_conflict` into `skipped` for the headline
275
+ // counters — the caller can dig into `per_row` if they need the
276
+ // distinction. This keeps the dryRun + importRows breakdown shapes
277
+ // identical (created / updated / skipped / errored), which is the
278
+ // contract the test pins.
279
+ counters.skipped = perRow.filter(function (r) { return r.outcome === "would_conflict"; }).length;
280
+ return {
281
+ dry_run: true,
282
+ rows: rows.length,
283
+ processed: counters.processed,
284
+ created: counters.created,
285
+ updated: counters.updated,
286
+ skipped: counters.skipped,
287
+ errored: counters.errored,
288
+ errors: errors,
289
+ per_row: perRow,
290
+ };
291
+ }
292
+
293
+ // ---- importRows --------------------------------------------------------
294
+
295
+ async function _importRows(query, customers, state, maxRows, maxErrors, input, source) {
296
+ if (!input || typeof input !== "object") {
297
+ throw new TypeError("customer-import.importRows: input object required");
298
+ }
299
+ if (!Array.isArray(input.rows)) {
300
+ throw new TypeError("customer-import.importRows: input.rows must be an array");
301
+ }
302
+ if (input.rows.length > maxRows) {
303
+ throw new TypeError("customer-import.importRows: rows exceeds maxRows (" + maxRows + ")");
304
+ }
305
+ var onConflict = input.on_conflict;
306
+ _validateOnConflict(onConflict);
307
+
308
+ // input byte count: rough JSON-stringify of the rows so the audit
309
+ // row records something honest. Programmatic api callers don't
310
+ // hand us a byte stream, but a canonical-JSON encode is a stable
311
+ // size proxy.
312
+ var inputByteCount;
313
+ try {
314
+ inputByteCount = Buffer.byteLength(_b().safeJson.stringify(input.rows, { canonical: true }), "utf8");
315
+ } catch (_e) {
316
+ inputByteCount = 0;
317
+ }
318
+
319
+ var run = await _openRun(query, state, source, inputByteCount);
320
+ var counters = { processed: 0, created: 0, updated: 0, skipped: 0, errored: 0 };
321
+ var errors = [];
322
+
323
+ try {
324
+ for (var i = 0; i < input.rows.length; i += 1) {
325
+ if (state.inflight && state.inflight.cancelled) break;
326
+ var rowIndex = i + 1;
327
+ counters.processed += 1;
328
+ try {
329
+ var normalized = _normalizeRow(input.rows[i], rowIndex);
330
+ var hash = customers.hashEmail(normalized.email);
331
+ var existing = await customers.byEmailHash(hash);
332
+ if (existing) {
333
+ if (onConflict === "skip") {
334
+ counters.skipped += 1;
335
+ } else if (onConflict === "update") {
336
+ await query(
337
+ "UPDATE customers SET display_name = ?1, updated_at = ?2 WHERE id = ?3",
338
+ [normalized.display_name, Date.now(), existing.id],
339
+ );
340
+ counters.updated += 1;
341
+ } else {
342
+ // "error" — surface as row-error, don't write.
343
+ _addError(errors, maxErrors, rowIndex,
344
+ "duplicate email_hash for existing customer");
345
+ counters.errored += 1;
346
+ }
347
+ } else {
348
+ await customers.register({
349
+ email: normalized.email,
350
+ display_name: normalized.display_name,
351
+ });
352
+ counters.created += 1;
353
+ }
354
+ } catch (e) {
355
+ _addError(errors, maxErrors, rowIndex, (e && e.message) || String(e));
356
+ counters.errored += 1;
357
+ }
358
+ }
359
+ } catch (driverErr) {
360
+ // Non-row driver failure (DB went away, etc.) — flip the run to
361
+ // failed with the error captured. Don't swallow.
362
+ await _closeRun(query, state, run.runId, "failed", counters,
363
+ errors.concat([{ row_index: 0, message: (driverErr && driverErr.message) || String(driverErr) }]));
364
+ throw driverErr;
365
+ }
366
+
367
+ var finalStatus = await _closeRun(query, state, run.runId, "complete", counters, errors);
368
+ var report = {
369
+ run_id: run.runId,
370
+ status: finalStatus,
371
+ source: source,
372
+ rows: input.rows.length,
373
+ processed: counters.processed,
374
+ created: counters.created,
375
+ updated: counters.updated,
376
+ skipped: counters.skipped,
377
+ errored: counters.errored,
378
+ errors: errors,
379
+ };
380
+ state.lastReport = report;
381
+ return report;
382
+ }
383
+
384
+ // ---- importFromCsv -----------------------------------------------------
385
+
386
+ async function _importFromCsv(query, customers, state, maxRows, maxErrors, stream) {
387
+ if (stream === undefined || stream === null) {
388
+ throw new TypeError("customer-import.importFromCsv: stream required (string, Buffer, Uint8Array, or async-iterable)");
389
+ }
390
+ var input = await _drainToString(stream);
391
+ var byteLen = Buffer.byteLength(input, "utf8");
392
+
393
+ // Content-safety gate: refuse formula-injection / bidi / control
394
+ // bytes in any cell BEFORE we parse the records. Strict profile
395
+ // refuses rather than sanitizing — the importer would otherwise
396
+ // silently smuggle the payload into a customer record.
397
+ var guardRv = _b().guardCsv.validate(input, { profile: "strict" });
398
+ if (!guardRv.ok) {
399
+ var kinds = guardRv.issues.map(function (i) { return i.kind || i.ruleId || "issue"; });
400
+ throw new TypeError(
401
+ "customer-import.importFromCsv: csv refused by content-safety guard — " + kinds.join(", ")
402
+ );
403
+ }
404
+
405
+ var records;
406
+ try {
407
+ records = _b().csv.parse(input, { header: true, maxRows: maxRows + 1 });
408
+ } catch (e) {
409
+ throw new TypeError("customer-import.importFromCsv: csv parse failed — " + (e && e.message || "malformed"));
410
+ }
411
+ if (!records.length) {
412
+ throw new TypeError("customer-import.importFromCsv: csv contained no data rows");
413
+ }
414
+ // header validation — first record's keys must match expected.
415
+ var keys = Object.keys(records[0]);
416
+ for (var i = 0; i < CSV_EXPECTED_HEADERS.length; i += 1) {
417
+ if (keys.indexOf(CSV_EXPECTED_HEADERS[i]) === -1) {
418
+ throw new TypeError(
419
+ "customer-import.importFromCsv: csv missing required column " +
420
+ JSON.stringify(CSV_EXPECTED_HEADERS[i]) +
421
+ " (expected columns: " + CSV_EXPECTED_HEADERS.join(", ") + ")"
422
+ );
423
+ }
424
+ }
425
+ if (records.length > maxRows) {
426
+ throw new TypeError("customer-import.importFromCsv: csv exceeds maxRows (" + maxRows + ")");
427
+ }
428
+ // Build the row list with just the expected fields — extra columns
429
+ // are tolerated but ignored.
430
+ var rows = records.map(function (r) {
431
+ return { email: r.email, display_name: r.display_name };
432
+ });
433
+
434
+ return await _runFromSource(query, customers, state, maxRows, maxErrors,
435
+ { rows: rows, on_conflict: "skip" }, "csv", byteLen);
436
+ }
437
+
438
+ // ---- importFromNdjson --------------------------------------------------
439
+
440
+ async function _importFromNdjson(query, customers, state, maxRows, maxErrors, stream) {
441
+ if (stream === undefined || stream === null) {
442
+ throw new TypeError("customer-import.importFromNdjson: stream required");
443
+ }
444
+ var input = await _drainToString(stream);
445
+ var byteLen = Buffer.byteLength(input, "utf8");
446
+
447
+ // NDJSON: one JSON object per line. Blank lines tolerated.
448
+ // Records get parsed through b.safeJson.parse — the strict default
449
+ // refuses oversize / proto-pollution / depth-overflow before the
450
+ // row hits the importer.
451
+ var lines = input.split(/\r?\n/);
452
+ var rows = [];
453
+ var lineErrors = Object.create(null);
454
+ var anyNonBlank = false;
455
+ for (var i = 0; i < lines.length; i += 1) {
456
+ var line = lines[i];
457
+ if (!line.length) continue;
458
+ anyNonBlank = true;
459
+ try {
460
+ var obj = _b().safeJson.parse(line);
461
+ rows.push(obj);
462
+ } catch (e) {
463
+ // We surface NDJSON parse errors as row-errors keyed off the
464
+ // row index (1-based, post-blank-stripping). The placeholder
465
+ // null pushes into rows so the normalizer flags it later with
466
+ // a more specific message via lineErrors lookup.
467
+ lineErrors[rows.length + 1] = (e && e.message) || String(e);
468
+ rows.push(null);
469
+ }
470
+ }
471
+ if (!anyNonBlank) {
472
+ throw new TypeError("customer-import.importFromNdjson: stream contained no data rows");
473
+ }
474
+ if (rows.length > maxRows) {
475
+ throw new TypeError("customer-import.importFromNdjson: rows exceeds maxRows (" + maxRows + ")");
476
+ }
477
+
478
+ var report = await _runFromSource(query, customers, state, maxRows, maxErrors,
479
+ { rows: rows, on_conflict: "skip" }, "ndjson", byteLen);
480
+
481
+ // Substitute the precise JSON-parse error message for the generic
482
+ // "must be a plain object" entry the normalizer produced for null
483
+ // placeholders. Same row_index either way.
484
+ if (Object.keys(lineErrors).length) {
485
+ report.errors = report.errors.map(function (e) {
486
+ if (lineErrors[e.row_index]) {
487
+ return { row_index: e.row_index, message: lineErrors[e.row_index] };
488
+ }
489
+ return e;
490
+ });
491
+ }
492
+ return report;
493
+ }
494
+
495
+ // ---- shared driver -----------------------------------------------------
496
+
497
+ async function _runFromSource(query, customers, state, maxRows, maxErrors, input, source, byteLen) {
498
+ _validateOnConflict(input.on_conflict);
499
+ var run = await _openRun(query, state, source, byteLen);
500
+ var counters = { processed: 0, created: 0, updated: 0, skipped: 0, errored: 0 };
501
+ var errors = [];
502
+
503
+ try {
504
+ for (var i = 0; i < input.rows.length; i += 1) {
505
+ if (state.inflight && state.inflight.cancelled) break;
506
+ var rowIndex = i + 1;
507
+ counters.processed += 1;
508
+ try {
509
+ var normalized = _normalizeRow(input.rows[i], rowIndex);
510
+ var hash = customers.hashEmail(normalized.email);
511
+ var existing = await customers.byEmailHash(hash);
512
+ if (existing) {
513
+ if (input.on_conflict === "skip") {
514
+ counters.skipped += 1;
515
+ } else if (input.on_conflict === "update") {
516
+ await query(
517
+ "UPDATE customers SET display_name = ?1, updated_at = ?2 WHERE id = ?3",
518
+ [normalized.display_name, Date.now(), existing.id],
519
+ );
520
+ counters.updated += 1;
521
+ } else {
522
+ _addError(errors, maxErrors, rowIndex, "duplicate email_hash for existing customer");
523
+ counters.errored += 1;
524
+ }
525
+ } else {
526
+ await customers.register({
527
+ email: normalized.email,
528
+ display_name: normalized.display_name,
529
+ });
530
+ counters.created += 1;
531
+ }
532
+ } catch (e) {
533
+ _addError(errors, maxErrors, rowIndex, (e && e.message) || String(e));
534
+ counters.errored += 1;
535
+ }
536
+ }
537
+ } catch (driverErr) {
538
+ await _closeRun(query, state, run.runId, "failed", counters,
539
+ errors.concat([{ row_index: 0, message: (driverErr && driverErr.message) || String(driverErr) }]));
540
+ throw driverErr;
541
+ }
542
+
543
+ var finalStatus = await _closeRun(query, state, run.runId, "complete", counters, errors);
544
+ var report = {
545
+ run_id: run.runId,
546
+ status: finalStatus,
547
+ source: source,
548
+ rows: input.rows.length,
549
+ processed: counters.processed,
550
+ created: counters.created,
551
+ updated: counters.updated,
552
+ skipped: counters.skipped,
553
+ errored: counters.errored,
554
+ errors: errors,
555
+ };
556
+ state.lastReport = report;
557
+ return report;
558
+ }
559
+
560
+ // ---- stream → string ---------------------------------------------------
561
+
562
+ async function _drainToString(input) {
563
+ if (typeof input === "string") return input;
564
+ if (Buffer.isBuffer(input)) return input.toString("utf8");
565
+ if (input instanceof Uint8Array) return Buffer.from(input).toString("utf8");
566
+ // async-iterable (a Node Readable stream is async-iterable; an
567
+ // operator-provided AsyncGenerator also qualifies). Concatenate
568
+ // every chunk; chunks may be Buffer | Uint8Array | string.
569
+ if (input && typeof input[Symbol.asyncIterator] === "function") {
570
+ var parts = [];
571
+ for await (var chunk of input) {
572
+ if (typeof chunk === "string") parts.push(Buffer.from(chunk, "utf8"));
573
+ else if (Buffer.isBuffer(chunk)) parts.push(chunk);
574
+ else if (chunk instanceof Uint8Array) parts.push(Buffer.from(chunk));
575
+ else throw new TypeError("customer-import: stream chunk must be string, Buffer, or Uint8Array");
576
+ }
577
+ return Buffer.concat(parts).toString("utf8");
578
+ }
579
+ throw new TypeError("customer-import: stream must be string, Buffer, Uint8Array, or async-iterable");
580
+ }
581
+
582
+ module.exports = {
583
+ create: create,
584
+ SOURCES: SOURCES,
585
+ ON_CONFLICT_MODES: ON_CONFLICT_MODES,
586
+ CSV_EXPECTED_HEADERS: CSV_EXPECTED_HEADERS,
587
+ DEFAULT_MAX_ROWS: DEFAULT_MAX_ROWS,
588
+ DEFAULT_MAX_ERRORS: DEFAULT_MAX_ERRORS,
589
+ MAX_DISPLAY_NAME_LEN: MAX_DISPLAY_NAME_LEN,
590
+ };