@blamejs/blamejs-shop 0.0.56 → 0.0.57

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,724 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.orderExport
4
+ * @title Order export — streaming CSV / NDJSON dump of the orders table
5
+ *
6
+ * @intro
7
+ * Operator export of orders to CSV or NDJSON for offline analysis,
8
+ * accounting reconciliation, or 3PL handoff. Big-result-safe: the
9
+ * row generator streams in batches via a (created_at, id) cursor
10
+ * so the full set never has to fit in memory.
11
+ *
12
+ * Lifecycle / surfaces:
13
+ *
14
+ * csvForRange({ from, to, columns?, batch_size?, cursor?, bom? })
15
+ * — async-iterable. Yields the header row on first iteration,
16
+ * then one chunk per database batch. RFC-4180 quoted on every
17
+ * cell, CRLF (`\r\n`) line endings. `columns` is an allowlist
18
+ * against the 24-column built-in schema; default is all 24.
19
+ * The optional `cursor` resumes a previous stream — operator
20
+ * re-tries after a network drop without redumping rows.
21
+ *
22
+ * ndjsonForRange({ from, to, columns?, batch_size?, cursor? })
23
+ * — same shape but newline-delimited JSON. One JSON object per
24
+ * line; no enclosing array. Each line ends with `\n`.
25
+ *
26
+ * summaryForRange({ from, to })
27
+ * — operator-dashboard aggregation:
28
+ * { order_count, revenue_minor, average_order_minor,
29
+ * by_status: { ... }, by_currency: { ... } }
30
+ * Walks the date range once, totals server-side.
31
+ *
32
+ * scheduleExport({ format, from, to, columns?, deliver_to_url? })
33
+ * cancelExport(id) / getExport(id) / listExports({ status?, limit? })
34
+ * markExportRunning(id)
35
+ * markExportComplete({ export_id, row_count, byte_size, file_sha3_512 })
36
+ * markExportFailed({ export_id, error })
37
+ * — FSM-driven queue of operator-filed export jobs that a
38
+ * background worker picks up. States:
39
+ * queued → running → complete | failed
40
+ * queued → cancelled
41
+ * Re-cancel of an already-running job is refused (the worker
42
+ * has the bytes mid-flight; the operator manages reversal out
43
+ * of band).
44
+ *
45
+ * Privacy:
46
+ *
47
+ * Customer email is hashed via `b.crypto.namespaceHash(
48
+ * 'order-export-email', ...)` — the raw address NEVER appears in
49
+ * export output. A 3PL handoff inheriting the CSV gets a stable
50
+ * pseudonymous identifier per customer, not a reusable
51
+ * contact-list payload. Operators that want to ship raw email
52
+ * compose their own export downstream of this primitive (they
53
+ * have the orders table directly).
54
+ *
55
+ * CSV-injection defense:
56
+ *
57
+ * Cell content beginning with `=`, `+`, `-`, or `@` is the
58
+ * classic CSV-injection vector — opening the file in a
59
+ * spreadsheet evaluates the cell as a formula. Per OWASP, every
60
+ * such cell is prefixed with `'` to neutralize the leading
61
+ * metacharacter — UNLESS it parses as a signed numeric (e.g.
62
+ * `+15.00` or `-3.50` — those are legitimate amounts). The
63
+ * primitive errs on the side of escaping; the consumer that
64
+ * genuinely wants raw formulas opts in by post-processing.
65
+ *
66
+ * Composition:
67
+ * - b.crypto.namespaceHash — email pseudonymization
68
+ * - b.uuid.v7 — scheduled-exports PK
69
+ * - b.guardUuid — strict UUID validation
70
+ * - b.fsm — scheduled-export status transitions
71
+ */
72
+
73
+ var bShop;
74
+ function _b() {
75
+ if (!bShop) bShop = require("./index");
76
+ return bShop.framework;
77
+ }
78
+
79
+ // ---- column schema ------------------------------------------------------
80
+ //
81
+ // The 24-column built-in shape. Operators selecting `columns: [...]`
82
+ // pick a subset; order in the produced output follows COLUMN_ORDER,
83
+ // not the operator's input order, so consumers can rely on a stable
84
+ // header layout independent of how the request was filed.
85
+
86
+ var COLUMN_ORDER = Object.freeze([
87
+ "order_id",
88
+ "order_number",
89
+ "created_at",
90
+ "status",
91
+ "customer_id",
92
+ "customer_email_hash",
93
+ "customer_name",
94
+ "shipping_country",
95
+ "shipping_postal",
96
+ "shipping_region",
97
+ "line_count",
98
+ "items_total_minor",
99
+ "shipping_total_minor",
100
+ "tax_total_minor",
101
+ "discount_total_minor",
102
+ "grand_total_minor",
103
+ "currency",
104
+ "payment_processor",
105
+ "payment_method_brand",
106
+ "payment_method_last4",
107
+ "fulfillment_status",
108
+ "refund_total_minor",
109
+ "has_returns",
110
+ "has_chargebacks",
111
+ ]);
112
+
113
+ var COLUMN_SET = Object.freeze(COLUMN_ORDER.reduce(function (acc, c) {
114
+ acc[c] = true; return acc;
115
+ }, {}));
116
+
117
+ var DEFAULT_BATCH_SIZE = 500;
118
+ var MAX_BATCH_SIZE = 5000;
119
+ var MAX_LIST_LIMIT = 200;
120
+ var EMAIL_HASH_PREFIX = "order-export-email";
121
+ var FORMATS = Object.freeze(["csv", "ndjson"]);
122
+ var EXPORT_STATUSES = Object.freeze(["queued", "running", "complete", "failed", "cancelled"]);
123
+
124
+ // ---- FSM definition (scheduled exports) ---------------------------------
125
+
126
+ var _exportFsm = null;
127
+ function _getExportFsm() {
128
+ if (_exportFsm) return _exportFsm;
129
+ // The scheduled-exports lifecycle is small enough to be expressed
130
+ // directly; reusing b.fsm keeps the audit-trail emission consistent
131
+ // with the rest of the primitive surface (order, inventory-receive)
132
+ // and gives us guard-driven transition refusal for free.
133
+ try { _b().audit.registerNamespace("fsm"); } catch (_e) { /* idempotent */ }
134
+ _exportFsm = _b().fsm.define({
135
+ name: "scheduled_export",
136
+ initial: "queued",
137
+ states: {
138
+ queued: {},
139
+ running: {},
140
+ complete: {},
141
+ failed: {},
142
+ cancelled: {},
143
+ },
144
+ transitions: [
145
+ { from: "queued", to: "running", on: "start" },
146
+ { from: "running", to: "complete", on: "complete" },
147
+ { from: "running", to: "failed", on: "fail" },
148
+ { from: "queued", to: "cancelled", on: "cancel" },
149
+ ],
150
+ });
151
+ return _exportFsm;
152
+ }
153
+
154
+ // ---- validators ---------------------------------------------------------
155
+
156
+ function _id(s, label) {
157
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
158
+ catch (e) { throw new TypeError("order-export: " + label + " — " + (e && e.message || "invalid UUID")); }
159
+ }
160
+ function _nonNegInt(n, label) {
161
+ if (!Number.isInteger(n) || n < 0) {
162
+ throw new TypeError("order-export: " + label + " must be a non-negative integer");
163
+ }
164
+ }
165
+ function _range(from, to) {
166
+ _nonNegInt(from, "from");
167
+ _nonNegInt(to, "to");
168
+ if (to < from) throw new TypeError("order-export: to must be >= from");
169
+ }
170
+ function _batchSize(n) {
171
+ if (n == null) return DEFAULT_BATCH_SIZE;
172
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
173
+ throw new TypeError("order-export: batch_size must be an integer in 1..." + MAX_BATCH_SIZE);
174
+ }
175
+ return n;
176
+ }
177
+ function _columns(cols) {
178
+ if (cols == null) return COLUMN_ORDER.slice();
179
+ if (!Array.isArray(cols) || cols.length === 0) {
180
+ throw new TypeError("order-export: columns must be a non-empty array of column names");
181
+ }
182
+ var picked = {};
183
+ for (var i = 0; i < cols.length; i += 1) {
184
+ var c = cols[i];
185
+ if (typeof c !== "string" || !COLUMN_SET[c]) {
186
+ throw new TypeError("order-export: unknown column " + JSON.stringify(c) +
187
+ " — allowlist is " + COLUMN_ORDER.join(", "));
188
+ }
189
+ picked[c] = true;
190
+ }
191
+ // Stable header order — COLUMN_ORDER projection, not caller order.
192
+ var out = [];
193
+ for (var j = 0; j < COLUMN_ORDER.length; j += 1) {
194
+ if (picked[COLUMN_ORDER[j]]) out.push(COLUMN_ORDER[j]);
195
+ }
196
+ return out;
197
+ }
198
+ function _format(f) {
199
+ if (typeof f !== "string" || FORMATS.indexOf(f) === -1) {
200
+ throw new TypeError("order-export: format must be one of " + FORMATS.join(", "));
201
+ }
202
+ }
203
+ function _limit(n) {
204
+ if (n == null) return 50;
205
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
206
+ throw new TypeError("order-export: limit must be an integer in 1..." + MAX_LIST_LIMIT);
207
+ }
208
+ return n;
209
+ }
210
+
211
+ // ---- CSV helpers --------------------------------------------------------
212
+
213
+ // RFC-4180 quoting: every cell wrapped in `"`, embedded `"` doubled.
214
+ // We quote unconditionally — the cost is a few extra bytes per cell;
215
+ // the win is that a downstream parser never has to track quote-vs-
216
+ // bare-cell state for a column with mixed shapes.
217
+ function _csvCell(value) {
218
+ var s = _coerceCell(value);
219
+ s = _neutralizeInjection(s);
220
+ return '"' + s.replace(/"/g, '""') + '"';
221
+ }
222
+
223
+ function _coerceCell(value) {
224
+ if (value == null) return "";
225
+ if (typeof value === "string") return value;
226
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
227
+ if (Array.isArray(value)) return value.join(",");
228
+ return JSON.stringify(value);
229
+ }
230
+
231
+ // Numeric-with-sign detector — `+15.00` / `-3.50` / `+0` parse as
232
+ // legitimate amounts and should pass through unmolested. The
233
+ // detector rejects anything with embedded whitespace or trailing
234
+ // non-numeric tail so `+15 SUM(A1)` still gets escaped.
235
+ var _NUMERIC_SIGN_RE = /^[+-](?:\d+(?:\.\d+)?|\.\d+)$/;
236
+
237
+ // CSV injection neutralization — see OWASP "CSV Injection". A cell
238
+ // beginning with `=`, `+`, `-`, or `@` is interpreted as a formula
239
+ // by most spreadsheet renderers. The defense is to prefix with `'`
240
+ // so the renderer treats the cell as literal text. Signed numerics
241
+ // are the deliberate exception (legitimate amount strings).
242
+ function _neutralizeInjection(s) {
243
+ if (s.length === 0) return s;
244
+ var first = s.charAt(0);
245
+ if (first !== "=" && first !== "+" && first !== "-" && first !== "@") return s;
246
+ if ((first === "+" || first === "-") && _NUMERIC_SIGN_RE.test(s)) return s;
247
+ return "'" + s;
248
+ }
249
+
250
+ function _csvRow(cells) {
251
+ var parts = [];
252
+ for (var i = 0; i < cells.length; i += 1) parts.push(_csvCell(cells[i]));
253
+ return parts.join(",") + "\r\n";
254
+ }
255
+
256
+ // ---- row projection -----------------------------------------------------
257
+
258
+ // Map a hydrated order row into the 24-column shape. Every value the
259
+ // CSV / NDJSON consumer sees flows through this single projection —
260
+ // the email-hash, the line-count, the fulfillment-status derivation
261
+ // all happen here so the two formats stay byte-for-byte aligned on
262
+ // column semantics. The `opts` arg is reserved for a future "include
263
+ // raw email" toggle; left open intentionally so the projection
264
+ // signature doesn't change when that lands.
265
+ function _projectOrder(row, _opts) {
266
+ var emailHash = "";
267
+ if (row.customer_email && typeof row.customer_email === "string") {
268
+ emailHash = _b().crypto.namespaceHash(EMAIL_HASH_PREFIX, row.customer_email);
269
+ }
270
+ var shipTo = {};
271
+ if (row.ship_to_json) {
272
+ try { shipTo = JSON.parse(row.ship_to_json) || {}; }
273
+ catch (_e) { shipTo = {}; } // drop-silent — bad ship_to_json shows up as empty fields, not a refused export
274
+ } else if (row.ship_to && typeof row.ship_to === "object") {
275
+ shipTo = row.ship_to;
276
+ }
277
+ var lineCount = row.line_count == null
278
+ ? (Array.isArray(row.lines) ? row.lines.length : 0)
279
+ : row.line_count;
280
+ return {
281
+ order_id: row.id || "",
282
+ order_number: row.order_number || row.id || "",
283
+ created_at: row.created_at == null ? "" : row.created_at,
284
+ status: row.status || "",
285
+ customer_id: row.customer_id || "",
286
+ customer_email_hash: emailHash,
287
+ customer_name: row.customer_name || "",
288
+ shipping_country: shipTo.country || "",
289
+ shipping_postal: shipTo.postal || "",
290
+ shipping_region: shipTo.state || shipTo.region || "",
291
+ line_count: lineCount,
292
+ items_total_minor: row.subtotal_minor == null ? 0 : row.subtotal_minor,
293
+ shipping_total_minor: row.shipping_minor == null ? 0 : row.shipping_minor,
294
+ tax_total_minor: row.tax_minor == null ? 0 : row.tax_minor,
295
+ discount_total_minor: row.discount_minor == null ? 0 : row.discount_minor,
296
+ grand_total_minor: row.grand_total_minor == null ? 0 : row.grand_total_minor,
297
+ currency: row.currency || "",
298
+ payment_processor: row.payment_processor || "",
299
+ payment_method_brand: row.payment_method_brand || "",
300
+ payment_method_last4: row.payment_method_last4 || "",
301
+ fulfillment_status: row.fulfillment_status || row.status || "",
302
+ refund_total_minor: row.refund_total_minor == null ? 0 : row.refund_total_minor,
303
+ has_returns: !!row.has_returns,
304
+ has_chargebacks: !!row.has_chargebacks,
305
+ };
306
+ }
307
+
308
+ // ---- factory ------------------------------------------------------------
309
+
310
+ function create(opts) {
311
+ opts = opts || {};
312
+ var query = opts.query;
313
+ if (!query) {
314
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
315
+ }
316
+ // order primitive handle is optional — the export reads directly
317
+ // from the orders table via SQL. When supplied, it lets the caller
318
+ // inject a test-tailored read path; otherwise the SQL below assumes
319
+ // the canonical orders / order_lines schema.
320
+ var order = opts.order || null;
321
+
322
+ // Cursor encoding for export streams + scheduled-export pagination.
323
+ // HMAC-tagged via b.pagination so an operator can't tamper one to
324
+ // skip rows or replay across deployments.
325
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
326
+ if (process.env.NODE_ENV === "production") {
327
+ throw new Error("order-export.create: opts.cursorSecret is required in production");
328
+ }
329
+ opts.cursorSecret = "order-export-cursor-secret-dev-only";
330
+ }
331
+ var cursorSecret = opts.cursorSecret;
332
+ var EXPORT_ORDER_KEY = ["created_at:asc", "id:asc"];
333
+
334
+ // Stream a single batch of orders inside the [from, to) range using
335
+ // a (created_at, id) cursor for stable pagination. Joins are kept
336
+ // narrow — we only pull the columns the projection consumes, even
337
+ // when the operator subselects (so the wire-pull stays bounded by
338
+ // the schema, not the filter).
339
+ async function _readBatch(from, to, cursorVals, batchSize) {
340
+ var sql, params;
341
+ if (cursorVals) {
342
+ sql = "SELECT * FROM orders WHERE created_at >= ?1 AND created_at < ?2 AND " +
343
+ "(created_at > ?3 OR (created_at = ?3 AND id > ?4)) " +
344
+ "ORDER BY created_at ASC, id ASC LIMIT ?5";
345
+ params = [from, to, cursorVals[0], cursorVals[1], batchSize];
346
+ } else {
347
+ sql = "SELECT * FROM orders WHERE created_at >= ?1 AND created_at < ?2 " +
348
+ "ORDER BY created_at ASC, id ASC LIMIT ?3";
349
+ params = [from, to, batchSize];
350
+ }
351
+ var rows = (await query(sql, params)).rows;
352
+ // Hydrate line_count on each row — the projection prefers a
353
+ // pre-computed count over a fan-out per-row line fetch. The
354
+ // single aggregate query stays cheap on an indexed FK.
355
+ for (var i = 0; i < rows.length; i += 1) {
356
+ if (rows[i].line_count == null) {
357
+ var c = await query(
358
+ "SELECT COUNT(*) AS n FROM order_lines WHERE order_id = ?1",
359
+ [rows[i].id],
360
+ );
361
+ rows[i].line_count = (c.rows[0] && (c.rows[0].n || c.rows[0]["COUNT(*)"])) || 0;
362
+ }
363
+ }
364
+ return rows;
365
+ }
366
+
367
+ function _decodeCursor(cursor) {
368
+ if (cursor == null) return null;
369
+ if (typeof cursor !== "string") {
370
+ throw new TypeError("order-export: cursor must be an opaque string or null");
371
+ }
372
+ try {
373
+ var state = _b().pagination.decodeCursor(cursor, cursorSecret);
374
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(EXPORT_ORDER_KEY)) {
375
+ throw new TypeError("order-export: cursor orderKey mismatch");
376
+ }
377
+ return state.vals;
378
+ } catch (e) {
379
+ if (e instanceof TypeError) throw e;
380
+ throw new TypeError("order-export: cursor — " + (e && e.message || "malformed"));
381
+ }
382
+ }
383
+
384
+ // Async iterable for CSV. Yields the header on the first call,
385
+ // then one row per database row. The cursor's next-page value is
386
+ // accessible via the consumer-visible `_streamCursor` symbol on
387
+ // the iterator — operators driving resumable downloads pull the
388
+ // cursor after each chunk and persist it server-side.
389
+ function _csvIterator(input) {
390
+ input = input || {};
391
+ _range(input.from, input.to);
392
+ var columns = _columns(input.columns);
393
+ var batch = _batchSize(input.batch_size);
394
+ var cursor = _decodeCursor(input.cursor);
395
+ var includeBom = !!input.bom;
396
+ var done = false;
397
+ var headerEmitted = false;
398
+
399
+ return {
400
+ [Symbol.asyncIterator]: function () { return this; },
401
+ next: async function () {
402
+ if (done) return { value: undefined, done: true };
403
+ if (!headerEmitted) {
404
+ headerEmitted = true;
405
+ var headerLine = _csvRow(columns);
406
+ if (includeBom) headerLine = "" + headerLine;
407
+ return { value: headerLine, done: false };
408
+ }
409
+ var rows = await _readBatch(input.from, input.to, cursor, batch);
410
+ if (rows.length === 0) { done = true; return { value: undefined, done: true }; }
411
+ var out = "";
412
+ for (var i = 0; i < rows.length; i += 1) {
413
+ var projected = _projectOrder(rows[i], { columns: columns });
414
+ var cells = [];
415
+ for (var j = 0; j < columns.length; j += 1) {
416
+ cells.push(projected[columns[j]]);
417
+ }
418
+ out += _csvRow(cells);
419
+ }
420
+ var last = rows[rows.length - 1];
421
+ cursor = [last.created_at, last.id];
422
+ if (rows.length < batch) done = true;
423
+ return { value: out, done: false };
424
+ },
425
+ // Expose the most-recently-consumed cursor for callers that
426
+ // want to persist resumable progress between iterations. Not a
427
+ // standard async-iterator hook; operator opts in.
428
+ streamCursor: function () {
429
+ if (!cursor) return null;
430
+ return _b().pagination.encodeCursor({
431
+ orderKey: EXPORT_ORDER_KEY,
432
+ vals: cursor,
433
+ forward: true,
434
+ }, cursorSecret);
435
+ },
436
+ };
437
+ }
438
+
439
+ function _ndjsonIterator(input) {
440
+ input = input || {};
441
+ _range(input.from, input.to);
442
+ var columns = _columns(input.columns);
443
+ var batch = _batchSize(input.batch_size);
444
+ var cursor = _decodeCursor(input.cursor);
445
+ var done = false;
446
+
447
+ return {
448
+ [Symbol.asyncIterator]: function () { return this; },
449
+ next: async function () {
450
+ if (done) return { value: undefined, done: true };
451
+ var rows = await _readBatch(input.from, input.to, cursor, batch);
452
+ if (rows.length === 0) { done = true; return { value: undefined, done: true }; }
453
+ var out = "";
454
+ for (var i = 0; i < rows.length; i += 1) {
455
+ var projected = _projectOrder(rows[i], { columns: columns });
456
+ var picked = {};
457
+ for (var j = 0; j < columns.length; j += 1) {
458
+ picked[columns[j]] = projected[columns[j]];
459
+ }
460
+ out += JSON.stringify(picked) + "\n";
461
+ }
462
+ var last = rows[rows.length - 1];
463
+ cursor = [last.created_at, last.id];
464
+ if (rows.length < batch) done = true;
465
+ return { value: out, done: false };
466
+ },
467
+ streamCursor: function () {
468
+ if (!cursor) return null;
469
+ return _b().pagination.encodeCursor({
470
+ orderKey: EXPORT_ORDER_KEY,
471
+ vals: cursor,
472
+ forward: true,
473
+ }, cursorSecret);
474
+ },
475
+ };
476
+ }
477
+
478
+ // Hydrate one row from scheduled_exports. Returns null on miss so
479
+ // the HTTP handler can map cleanly to 404.
480
+ async function _getExport(id) {
481
+ var r = await query("SELECT * FROM scheduled_exports WHERE id = ?1", [id]);
482
+ if (!r.rows.length) return null;
483
+ return r.rows[0];
484
+ }
485
+
486
+ function _now() { return Date.now(); }
487
+
488
+ return {
489
+ COLUMN_ORDER: COLUMN_ORDER,
490
+ EXPORT_STATUSES: EXPORT_STATUSES,
491
+ FORMATS: FORMATS,
492
+
493
+ csvForRange: _csvIterator,
494
+ ndjsonForRange: _ndjsonIterator,
495
+
496
+ summaryForRange: async function (input) {
497
+ input = input || {};
498
+ _range(input.from, input.to);
499
+ // Single-pass aggregation. SQL does the heavy lifting so the
500
+ // primitive doesn't have to materialize the row set on the JS
501
+ // heap. Three queries: top-line totals, by-status, by-currency.
502
+ var top = await query(
503
+ "SELECT COUNT(*) AS order_count, " +
504
+ " COALESCE(SUM(grand_total_minor), 0) AS revenue_minor " +
505
+ "FROM orders WHERE created_at >= ?1 AND created_at < ?2",
506
+ [input.from, input.to],
507
+ );
508
+ var byStatus = await query(
509
+ "SELECT status, COUNT(*) AS n, COALESCE(SUM(grand_total_minor), 0) AS revenue_minor " +
510
+ "FROM orders WHERE created_at >= ?1 AND created_at < ?2 " +
511
+ "GROUP BY status",
512
+ [input.from, input.to],
513
+ );
514
+ var byCurrency = await query(
515
+ "SELECT currency, COUNT(*) AS n, COALESCE(SUM(grand_total_minor), 0) AS revenue_minor " +
516
+ "FROM orders WHERE created_at >= ?1 AND created_at < ?2 " +
517
+ "GROUP BY currency",
518
+ [input.from, input.to],
519
+ );
520
+ var orderCount = (top.rows[0] && top.rows[0].order_count) || 0;
521
+ var revenueMinor = (top.rows[0] && top.rows[0].revenue_minor) || 0;
522
+ var avg = orderCount > 0 ? Math.floor(revenueMinor / orderCount) : 0;
523
+ var statusMap = {};
524
+ for (var i = 0; i < byStatus.rows.length; i += 1) {
525
+ var sr = byStatus.rows[i];
526
+ statusMap[sr.status] = { count: sr.n, revenue_minor: sr.revenue_minor };
527
+ }
528
+ var currencyMap = {};
529
+ for (var k = 0; k < byCurrency.rows.length; k += 1) {
530
+ var cr = byCurrency.rows[k];
531
+ currencyMap[cr.currency] = { count: cr.n, revenue_minor: cr.revenue_minor };
532
+ }
533
+ return {
534
+ order_count: orderCount,
535
+ revenue_minor: revenueMinor,
536
+ average_order_minor: avg,
537
+ by_status: statusMap,
538
+ by_currency: currencyMap,
539
+ };
540
+ },
541
+
542
+ scheduleExport: async function (input) {
543
+ if (!input || typeof input !== "object") {
544
+ throw new TypeError("order-export.scheduleExport: input object required");
545
+ }
546
+ _format(input.format);
547
+ _range(input.from, input.to);
548
+ var columnsJson = null;
549
+ if (input.columns != null) {
550
+ // Validate against allowlist + persist the operator's
551
+ // selection (not the COLUMN_ORDER projection — the worker
552
+ // re-projects at run time).
553
+ var picked = _columns(input.columns);
554
+ columnsJson = JSON.stringify(picked);
555
+ }
556
+ var deliver = null;
557
+ if (input.deliver_to_url != null) {
558
+ if (typeof input.deliver_to_url !== "string" || !input.deliver_to_url.length) {
559
+ throw new TypeError("order-export.scheduleExport: deliver_to_url must be a non-empty string");
560
+ }
561
+ deliver = input.deliver_to_url;
562
+ }
563
+ var id = _b().uuid.v7();
564
+ var ts = _now();
565
+ await query(
566
+ "INSERT INTO scheduled_exports (id, format, from_ts, to_ts, columns_json, " +
567
+ "deliver_to_url, status, queued_at) " +
568
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'queued', ?7)",
569
+ [id, input.format, input.from, input.to, columnsJson, deliver, ts],
570
+ );
571
+ return await _getExport(id);
572
+ },
573
+
574
+ cancelExport: async function (exportId) {
575
+ _id(exportId, "export_id");
576
+ var row = await _getExport(exportId);
577
+ if (!row) throw new TypeError("order-export.cancelExport: " + exportId + " not found");
578
+ var fsm = _getExportFsm();
579
+ var inst = fsm.restore({ state: row.status, history: [], context: {} });
580
+ try { await inst.transition("cancel", null); }
581
+ catch (e) {
582
+ var err = new Error("order-export.cancelExport: refused — " + (e && e.message || e));
583
+ err.code = (e && e.code) || "ORDER_EXPORT_CANCEL_REFUSED";
584
+ err.cause = e;
585
+ throw err;
586
+ }
587
+ var ts = _now();
588
+ await query(
589
+ "UPDATE scheduled_exports SET status = 'cancelled', completed_at = ?1 WHERE id = ?2",
590
+ [ts, exportId],
591
+ );
592
+ return await _getExport(exportId);
593
+ },
594
+
595
+ getExport: async function (exportId) {
596
+ _id(exportId, "export_id");
597
+ return await _getExport(exportId);
598
+ },
599
+
600
+ listExports: async function (listOpts) {
601
+ listOpts = listOpts || {};
602
+ var status = listOpts.status;
603
+ if (status !== undefined && status !== null) {
604
+ if (EXPORT_STATUSES.indexOf(status) === -1) {
605
+ throw new TypeError("order-export.listExports: status must be one of " +
606
+ EXPORT_STATUSES.join(", "));
607
+ }
608
+ }
609
+ var limit = _limit(listOpts.limit);
610
+ var sql, params;
611
+ if (status !== undefined && status !== null) {
612
+ sql = "SELECT * FROM scheduled_exports WHERE status = ?1 " +
613
+ "ORDER BY queued_at DESC, id DESC LIMIT ?2";
614
+ params = [status, limit];
615
+ } else {
616
+ sql = "SELECT * FROM scheduled_exports " +
617
+ "ORDER BY queued_at DESC, id DESC LIMIT ?1";
618
+ params = [limit];
619
+ }
620
+ var rows = (await query(sql, params)).rows;
621
+ return { rows: rows };
622
+ },
623
+
624
+ markExportRunning: async function (exportId) {
625
+ _id(exportId, "export_id");
626
+ var row = await _getExport(exportId);
627
+ if (!row) throw new TypeError("order-export.markExportRunning: " + exportId + " not found");
628
+ var fsm = _getExportFsm();
629
+ var inst = fsm.restore({ state: row.status, history: [], context: {} });
630
+ try { await inst.transition("start", null); }
631
+ catch (e) {
632
+ var err = new Error("order-export.markExportRunning: refused — " + (e && e.message || e));
633
+ err.code = (e && e.code) || "ORDER_EXPORT_START_REFUSED";
634
+ err.cause = e;
635
+ throw err;
636
+ }
637
+ var ts = _now();
638
+ await query(
639
+ "UPDATE scheduled_exports SET status = 'running', started_at = ?1 WHERE id = ?2",
640
+ [ts, exportId],
641
+ );
642
+ return await _getExport(exportId);
643
+ },
644
+
645
+ markExportComplete: async function (input) {
646
+ if (!input || typeof input !== "object") {
647
+ throw new TypeError("order-export.markExportComplete: input object required");
648
+ }
649
+ _id(input.export_id, "export_id");
650
+ _nonNegInt(input.row_count, "row_count");
651
+ _nonNegInt(input.byte_size, "byte_size");
652
+ if (typeof input.file_sha3_512 !== "string" || !/^[0-9a-f]{128}$/i.test(input.file_sha3_512)) {
653
+ throw new TypeError("order-export.markExportComplete: file_sha3_512 must be a 128-hex-char digest");
654
+ }
655
+ var row = await _getExport(input.export_id);
656
+ if (!row) throw new TypeError("order-export.markExportComplete: " + input.export_id + " not found");
657
+ var fsm = _getExportFsm();
658
+ var inst = fsm.restore({ state: row.status, history: [], context: {} });
659
+ try { await inst.transition("complete", null); }
660
+ catch (e) {
661
+ var err = new Error("order-export.markExportComplete: refused — " + (e && e.message || e));
662
+ err.code = (e && e.code) || "ORDER_EXPORT_COMPLETE_REFUSED";
663
+ err.cause = e;
664
+ throw err;
665
+ }
666
+ var ts = _now();
667
+ await query(
668
+ "UPDATE scheduled_exports SET status = 'complete', row_count = ?1, " +
669
+ "byte_size = ?2, file_sha3_512 = ?3, completed_at = ?4 WHERE id = ?5",
670
+ [input.row_count, input.byte_size, input.file_sha3_512, ts, input.export_id],
671
+ );
672
+ return await _getExport(input.export_id);
673
+ },
674
+
675
+ markExportFailed: async function (input) {
676
+ if (!input || typeof input !== "object") {
677
+ throw new TypeError("order-export.markExportFailed: input object required");
678
+ }
679
+ _id(input.export_id, "export_id");
680
+ if (typeof input.error !== "string" || !input.error.length) {
681
+ throw new TypeError("order-export.markExportFailed: error must be a non-empty string");
682
+ }
683
+ // Cap the error text — operators store error blobs that
684
+ // occasionally include a wrapped stack trace; bound the
685
+ // persisted column so a runaway stack doesn't bloat the row.
686
+ var errorText = input.error.length > 4000 ? input.error.slice(0, 4000) : input.error;
687
+ var row = await _getExport(input.export_id);
688
+ if (!row) throw new TypeError("order-export.markExportFailed: " + input.export_id + " not found");
689
+ var fsm = _getExportFsm();
690
+ var inst = fsm.restore({ state: row.status, history: [], context: {} });
691
+ try { await inst.transition("fail", null); }
692
+ catch (e) {
693
+ var err = new Error("order-export.markExportFailed: refused — " + (e && e.message || e));
694
+ err.code = (e && e.code) || "ORDER_EXPORT_FAIL_REFUSED";
695
+ err.cause = e;
696
+ throw err;
697
+ }
698
+ var ts = _now();
699
+ await query(
700
+ "UPDATE scheduled_exports SET status = 'failed', error = ?1, completed_at = ?2 WHERE id = ?3",
701
+ [errorText, ts, input.export_id],
702
+ );
703
+ return await _getExport(input.export_id);
704
+ },
705
+
706
+ // Exposed for the test suite so it can assert column semantics
707
+ // without having to thread orders through a full DB round-trip.
708
+ _projectOrder: _projectOrder,
709
+ _csvCell: _csvCell,
710
+ _neutralizeInjection: _neutralizeInjection,
711
+ // Exposed only to keep the linter quiet about the unused `order`
712
+ // binding when an integrator wires it later — the export reads
713
+ // directly today, but the indirection is preserved.
714
+ _order: order,
715
+ };
716
+ }
717
+
718
+ module.exports = {
719
+ create: create,
720
+ COLUMN_ORDER: COLUMN_ORDER,
721
+ EXPORT_STATUSES: EXPORT_STATUSES,
722
+ FORMATS: FORMATS,
723
+ _getExportFsm: _getExportFsm,
724
+ };