@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.
- package/CHANGELOG.md +2 -0
- package/lib/backorder.js +452 -0
- package/lib/bundles.js +587 -0
- package/lib/fraud-screen.js +808 -0
- package/lib/index.js +10 -0
- package/lib/inventory-locations.js +774 -0
- package/lib/order-export.js +724 -0
- package/lib/order-notes.js +563 -0
- package/lib/payment-methods.js +522 -0
- package/lib/print-on-demand.js +709 -0
- package/lib/save-for-later.js +667 -0
- package/lib/variants.js +726 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|