@blamejs/blamejs-shop 0.5.10 → 0.5.12
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/SECURITY.md +6 -1
- package/lib/admin.js +56 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/customer-import.js +31 -8
- package/lib/operator-sessions.js +68 -5
- package/lib/product-import.js +22 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.5.x
|
|
10
10
|
|
|
11
|
+
- v0.5.12 (2026-07-03) — **Operator password sign-in now has a brute-force lockout keyed on both the client IP and the target account.** Wires a brute-force lockout into the operator email+password sign-in (/admin/operators/signin), which previously relied only on the global per-IP rate limit and Argon2 verification. The sign-in now records each failed attempt and, before checking the password, refuses with 429 once the failure count for the request's IP or for the target operator account exceeds its threshold within a rolling 15-minute window. Keying on the account as well as the IP bounds a distributed guess — an attacker rotating IPs against one operator — that a per-IP limit alone can't see. The account threshold is deliberately higher than the per-IP threshold, so a legitimate operator is only ever account-locked under a genuine multi-IP attack, and the lock is a temporary cooldown that auto-expires with the window rather than a permanent lock an attacker could weaponize into a denial of service. The target account is resolved up front for the account key regardless of whether the email matches a real operator, and a lockout returns the same generic sign-in refusal, so no account-existence oracle is added. A successful sign-in clears the account's recent failures so an operator who mistyped isn't held under the window. The lockout composes the existing operator-session primitive and its already-deployed failed-login table — no schema change and no configuration; the failed-attempt log also feeds the operator security timeline. **Security:** *Brute-force lockout on the operator password sign-in* — The operator email+password sign-in had no per-account brute-force protection — only the global per-IP rate limit and Argon2 slowed guessing, so a distributed attack rotating IPs against a single operator account was unbounded. The sign-in now records each failed attempt and refuses with 429 (before verifying the password) once the failures for the request IP or the target account exceed their thresholds in a rolling 15-minute window. The account threshold is higher than the per-IP one, so a legitimate operator is only account-locked under a real multi-IP attack, and the lock auto-expires with the window (a cooldown, not a weaponizable permanent lock). The account is resolved whether or not the email is real and a lockout returns the same generic refusal, so there is no account-existence oracle; a successful sign-in clears the account's recent failures. Composes the existing operator-session primitive and its deployed failed-login table — no schema change, no configuration, and the failed-attempt log continues to feed the operator security timeline.
|
|
12
|
+
|
|
13
|
+
- v0.5.11 (2026-07-03) — **A second customer or product import started while one is already running is refused instead of clobbering the first run's cancel handle.** Fixes a small concurrency defect in the customer and product CSV/row import runners. Each runner tracks a single in-flight run so the operator console's cancel action knows which run to stop. The run opener set that handle after writing the run row, so two runs started at nearly the same moment — an operator double-submitting the form or opening two tabs — could both open, and the second would overwrite the first's in-flight handle. A subsequent cancel would then target the wrong run, leaving the other running. The opener now reserves the single in-flight slot synchronously, before any await, so a second run is refused with a clear "an import is already in progress" error while the first proceeds; the reservation is released if the run fails to open, and the console can start a fresh import as soon as the running one finishes. **Fixed:** *Overlapping import runs no longer clobber the in-flight run handle* — The customer and product import runners keep one in-flight run handle so cancel targets the right run. Because the opener set that handle only after inserting the run row, two imports started concurrently could both open and the second would overwrite the first's handle, so a later cancel stopped the wrong run. The opener now claims the in-flight slot synchronously (before the first await, so the check-and-set can't interleave) and refuses a second concurrent run with an "import already in progress" error; the slot is released if the open fails, and a new import can start once the running one completes.
|
|
14
|
+
|
|
11
15
|
- v0.5.10 (2026-07-03) — **Redirect rules with a regex source are screened for nested-quantifier catastrophic backtracking before they run on the request path.** Closes the last catastrophic-backtracking (ReDoS) gap in operator-authored redirect rules. A site-redirect of kind "regex" compiles the operator's source into a pattern that resolveForPath runs against the request path on every request. The define-time screen already refused backreferences and lookahead/lookbehind, but not a nested unbounded quantifier — a group whose body carries an unbounded quantifier that is itself repeated by one, such as (a+)+, (a*)*, (?:a+)+ or (.+)+ — so an operator could register a rule whose match time is super-linear in the path length, turning every matching request into a denial of service. The screen now also refuses that shape at define time, at any group depth, while continuing to accept linear patterns that merely use a quantified non-capturing group ((?:/page/[0-9]+)?, (?:section)*) or an optional quantified group. The match is exact — only a nested UNBOUNDED quantifier is refused; a single quantifier, an optional group, and bounded repetition are all allowed — so no well-formed redirect regex that worked before is rejected. **Security:** *Redirect regex sources refuse nested unbounded quantifiers at define time* — A redirect rule of kind "regex" runs its compiled pattern against the request path in resolveForPath. The define-time screen rejected backreferences and lookaround but not a nested unbounded quantifier ((a+)+, (a*)*, (?:a+)+, (.+)+, ((ab)+)+ at any depth) — the canonical ReDoS shape — so an operator could land a rule whose match is super-linear in the path length, letting a crafted request path stall the redirect resolver. The screen now refuses that shape when the rule is defined, with an error pointing the operator at a prefix/exact rule or splitting into multiple rows. Linear patterns are unaffected: a quantified non-capturing group, an optional quantified group, a single quantifier, and bounded repetition are all still accepted, so no redirect regex that worked before is rejected.
|
|
12
16
|
|
|
13
17
|
- v0.5.9 (2026-07-03) — **A federated sign-in no longer auto-merges into a pre-existing account whose email was never proven, closing an account pre-hijacking path.** Closes an account pre-hijacking weakness in federated (Google / Apple) sign-in. When a verified OIDC sign-in found a pre-existing account with the same email, it linked the federated identity into that account on the strength of the incoming email being verified alone — without requiring the pre-existing account to have ever proven control of that email. Because a passkey (or otherwise passwordless) account is created with whatever email address was typed at registration and no ownership check, an attacker could register an account under a victim's email before the victim's first federated sign-in, and the victim's later Google/Apple sign-in would then be folded into the attacker's account — pulling the victim's guest orders in and leaving the attacker's credential enrolled. The merge now requires the pre-existing account to already carry a verified federated identity (the only account-creation path that proves the email); an account whose email was never proven falls through to the existing duplicate-email refusal, so the customer signs in with their existing method and links the provider from account settings. A first federated sign-in still creates a fresh account, and a second provider still links to an account whose email is already proven, so legitimate multi-provider sign-in is unchanged. **Security:** *Federated sign-in refuses to merge into an unverified pre-existing account* — A verified Google/Apple sign-in that matched a pre-existing account by email hash previously linked the federated identity into that account whenever the incoming identity-provider email was verified — even if the pre-existing account had never proven control of the email. A passkey/passwordless account stores the email typed at registration with no verification, so an attacker could pre-seed an account under a victim's email and capture the victim's later federated sign-in (account pre-hijacking, CWE-287). The merge now proceeds only when the pre-existing account already holds a verified federated identity — the one account-creation path that proves the email. An unverified match is refused with the existing duplicate-email conflict, directing the customer to sign in with their existing method and link the provider from account settings. First-time federated sign-in (new account) and second-provider linking into an already-proven account are unchanged.
|
package/SECURITY.md
CHANGED
|
@@ -130,7 +130,12 @@ node -e "
|
|
|
130
130
|
bootstrap / break-glass owner credential; once staff accounts exist,
|
|
131
131
|
treat it like a recovery key — store it offline and use the
|
|
132
132
|
per-operator credentials day to day. Every operator-management action
|
|
133
|
-
and every role-denied attempt lands in the audit chain.
|
|
133
|
+
and every role-denied attempt lands in the audit chain. The password
|
|
134
|
+
sign-in throttles brute force: repeated failures from one IP, or a
|
|
135
|
+
distributed guess against one account, are refused with a temporary
|
|
136
|
+
lockout (keyed on both the IP and the account, higher threshold on the
|
|
137
|
+
account, auto-expiring on a rolling window) on top of the global
|
|
138
|
+
rate limit and Argon2id.
|
|
134
139
|
- **Guest order pages are access-gated, not capability URLs.** A guest
|
|
135
140
|
order's confirmation page (name, address, items) requires the placing
|
|
136
141
|
browser's sealed device cookie, the signed `?k=` token carried by the
|
package/lib/admin.js
CHANGED
|
@@ -927,6 +927,21 @@ function mount(router, deps) {
|
|
|
927
927
|
// booted) chains every operator-management action + role-denied attempt.
|
|
928
928
|
var operatorAccounts = deps.operatorAccounts || null;
|
|
929
929
|
var operatorAuditLog = deps.operatorAuditLog || null;
|
|
930
|
+
// Optional brute-force lockout for the operator password sign-in. When wired,
|
|
931
|
+
// the sign-in records failed attempts and refuses once the per-IP or (higher)
|
|
932
|
+
// per-account failure threshold trips within the rolling window. Best-effort:
|
|
933
|
+
// absent this dep the sign-in behaves exactly as before (rate-limit + Argon2).
|
|
934
|
+
var operatorSessions = deps.operatorSessions || null;
|
|
935
|
+
|
|
936
|
+
// Hash of the request's client IP, domain-separated for the operator-login
|
|
937
|
+
// lockout so it can't be correlated with any other IP-keyed store. Returns
|
|
938
|
+
// null when the IP can't be resolved (the lockout then keys on the account
|
|
939
|
+
// only, or is skipped for an unresolved-account attempt).
|
|
940
|
+
function _clientIpHash(req) {
|
|
941
|
+
var ip = b.requestHelpers.clientIp(req);
|
|
942
|
+
if (!ip) return null;
|
|
943
|
+
return b.crypto.namespaceHash("operator-login-ip", String(ip));
|
|
944
|
+
}
|
|
930
945
|
|
|
931
946
|
// Unified order-event feed for the order-detail screen. When wired, the
|
|
932
947
|
// /admin/orders/:id page renders a chronological timeline aggregated
|
|
@@ -14373,12 +14388,53 @@ function mount(router, deps) {
|
|
|
14373
14388
|
var body = req.body || {};
|
|
14374
14389
|
var email = typeof body.email === "string" ? body.email : "";
|
|
14375
14390
|
var pass = typeof body.password === "string" ? body.password : "";
|
|
14391
|
+
|
|
14392
|
+
// Brute-force lockout (best-effort — only when operatorSessions is
|
|
14393
|
+
// wired). Resolve the target account up front for the per-account key:
|
|
14394
|
+
// the lookup runs whether or not the email matches a real operator, and
|
|
14395
|
+
// the refusal is the SAME generic form the bad-credential path returns,
|
|
14396
|
+
// so it adds no account-existence oracle. The lockout keys on BOTH the
|
|
14397
|
+
// client IP and the target account so a distributed (IP-rotating) guess
|
|
14398
|
+
// against one operator is bounded, not only per-IP.
|
|
14399
|
+
var ipHash = null, targetId = null;
|
|
14400
|
+
if (operatorSessions) {
|
|
14401
|
+
try { ipHash = _clientIpHash(req); } catch (_e) { ipHash = null; }
|
|
14402
|
+
try {
|
|
14403
|
+
var pre = await operatorAccounts.getByEmail(email);
|
|
14404
|
+
targetId = (pre && pre.id) ? pre.id : null;
|
|
14405
|
+
} catch (_e) { targetId = null; }
|
|
14406
|
+
if (ipHash) {
|
|
14407
|
+
var lock = null;
|
|
14408
|
+
try { lock = await operatorSessions.lockoutCheck(ipHash, { operator_id: targetId }); }
|
|
14409
|
+
catch (_e) { lock = null; }
|
|
14410
|
+
if (lock && lock.locked) {
|
|
14411
|
+
if (res.setHeader) res.setHeader("retry-after", String(lock.window_seconds || 900));
|
|
14412
|
+
return _sendHtml(res, 429, renderOperatorSignin({ shop_name: deps.shop_name, error: true, locked: true }));
|
|
14413
|
+
}
|
|
14414
|
+
}
|
|
14415
|
+
}
|
|
14416
|
+
|
|
14376
14417
|
var account = null;
|
|
14377
14418
|
try { account = await operatorAccounts.verifyPassword({ email: email, password: pass }); }
|
|
14378
14419
|
catch (_e) { account = null; }
|
|
14379
14420
|
if (!account) {
|
|
14421
|
+
// Record the failed attempt for the lockout (drop-silent — the lockout
|
|
14422
|
+
// is best-effort; a logging failure must not break the sign-in path).
|
|
14423
|
+
if (operatorSessions && ipHash) {
|
|
14424
|
+
try {
|
|
14425
|
+
await operatorSessions.recordFailedVerify({ ip_hash: ipHash, operator_id: targetId, reason: "bad-password" });
|
|
14426
|
+
} catch (_e) { /* drop-silent */ }
|
|
14427
|
+
}
|
|
14380
14428
|
return _sendHtml(res, 401, renderOperatorSignin({ shop_name: deps.shop_name, error: true }));
|
|
14381
14429
|
}
|
|
14430
|
+
// Successful sign-in clears THIS account's recent failures so a legit
|
|
14431
|
+
// operator who mistyped isn't held under the rolling window next time.
|
|
14432
|
+
// Scoped strictly to the signed-in account (never the IP) so signing in
|
|
14433
|
+
// can't erase another operator's failures logged from the same IP.
|
|
14434
|
+
if (operatorSessions) {
|
|
14435
|
+
try { await operatorSessions.clearFailures({ operator_id: account.id }); }
|
|
14436
|
+
catch (_e) { /* drop-silent */ }
|
|
14437
|
+
}
|
|
14382
14438
|
try { _setAdminCookie(req, res, account); }
|
|
14383
14439
|
catch (e) {
|
|
14384
14440
|
if (e && e.code === "vault/not-initialized") {
|
package/lib/asset-manifest.json
CHANGED
package/lib/customer-import.js
CHANGED
|
@@ -182,16 +182,32 @@ function _addError(errors, maxErrors, rowIndex, message) {
|
|
|
182
182
|
// ---- run-row persistence -----------------------------------------------
|
|
183
183
|
|
|
184
184
|
async function _openRun(query, state, source, inputByteCount) {
|
|
185
|
+
// Reserve the single in-flight slot SYNCHRONOUSLY — before the first await —
|
|
186
|
+
// so two concurrent runs (an operator double-submitting) can't both open. A
|
|
187
|
+
// second run is refused rather than silently overwriting the first's inflight
|
|
188
|
+
// handle, which would leave `cancelInflight` targeting the wrong run. The
|
|
189
|
+
// check + set have no await between them, so the reservation is race-free in
|
|
190
|
+
// the single-threaded event loop.
|
|
191
|
+
if (state.inflight) {
|
|
192
|
+
var busy = new Error("customer-import: an import is already in progress");
|
|
193
|
+
busy.code = "IMPORT_IN_PROGRESS";
|
|
194
|
+
throw busy;
|
|
195
|
+
}
|
|
185
196
|
var runId = b.uuid.v7();
|
|
186
197
|
var startedAt = Date.now();
|
|
187
|
-
await query(
|
|
188
|
-
"INSERT INTO customer_imports " +
|
|
189
|
-
"(id, started_at, completed_at, status, source, input_byte_count, " +
|
|
190
|
-
" rows_processed, rows_created, rows_updated, rows_skipped, rows_errored, errors_json) " +
|
|
191
|
-
"VALUES (?1, ?2, NULL, 'running', ?3, ?4, 0, 0, 0, 0, 0, '[]')",
|
|
192
|
-
[runId, startedAt, source, inputByteCount],
|
|
193
|
-
);
|
|
194
198
|
state.inflight = { runId: runId, cancelled: false };
|
|
199
|
+
try {
|
|
200
|
+
await query(
|
|
201
|
+
"INSERT INTO customer_imports " +
|
|
202
|
+
"(id, started_at, completed_at, status, source, input_byte_count, " +
|
|
203
|
+
" rows_processed, rows_created, rows_updated, rows_skipped, rows_errored, errors_json) " +
|
|
204
|
+
"VALUES (?1, ?2, NULL, 'running', ?3, ?4, 0, 0, 0, 0, 0, '[]')",
|
|
205
|
+
[runId, startedAt, source, inputByteCount],
|
|
206
|
+
);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
state.inflight = null; // release the reservation if the open failed
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
195
211
|
return { runId: runId, startedAt: startedAt };
|
|
196
212
|
}
|
|
197
213
|
|
|
@@ -205,7 +221,14 @@ async function _closeRun(query, state, runId, status, counters, errors) {
|
|
|
205
221
|
"SELECT status FROM customer_imports WHERE id = ?1",
|
|
206
222
|
[runId],
|
|
207
223
|
)).rows[0];
|
|
208
|
-
|
|
224
|
+
// Treat the run as cancelled if EITHER the row already says so OR the
|
|
225
|
+
// in-memory flag for THIS run is set. The flag path covers a cancel that
|
|
226
|
+
// landed in the reservation→INSERT window: `cancelInflight` flipped the flag
|
|
227
|
+
// and ran an UPDATE that matched no row yet (the run row wasn't inserted), so
|
|
228
|
+
// reading the DB status alone would finalize a cancelled run as complete.
|
|
229
|
+
var cancelled = (current && current.status === "cancelled") ||
|
|
230
|
+
!!(state.inflight && state.inflight.runId === runId && state.inflight.cancelled);
|
|
231
|
+
var finalStatus = cancelled ? "cancelled" : status;
|
|
209
232
|
var errorsJson = b.safeJson.stringify(errors, { canonical: true });
|
|
210
233
|
await query(
|
|
211
234
|
"UPDATE customer_imports SET " +
|
package/lib/operator-sessions.js
CHANGED
|
@@ -148,7 +148,14 @@ var MAX_UA_CLASS_LEN = 64;
|
|
|
148
148
|
var MAX_IP_HASH_LEN = 256;
|
|
149
149
|
var MIN_IP_HASH_LEN = 1;
|
|
150
150
|
var DEFAULT_LOCKOUT_WINDOW = 15 * 60; // allow:raw-time-literal — lockout window stored in seconds; C.TIME returns ms (15-minute rolling window)
|
|
151
|
-
var DEFAULT_LOCKOUT_THRESH = 5; // 5 failures trips lockout
|
|
151
|
+
var DEFAULT_LOCKOUT_THRESH = 5; // 5 failures (per IP) trips lockout
|
|
152
|
+
// Per-account threshold is deliberately HIGHER than the per-IP one: a
|
|
153
|
+
// distributed (IP-rotating) brute force against a single account is the thing
|
|
154
|
+
// the account key catches, and keeping it well above the per-IP threshold
|
|
155
|
+
// means a legitimate operator is only ever account-locked under a genuine
|
|
156
|
+
// multi-IP attack — and only for the rolling window (a temporary cooldown, not
|
|
157
|
+
// a permanent lock an attacker could weaponize into a denial of service).
|
|
158
|
+
var DEFAULT_ACCOUNT_LOCKOUT_THRESH = 20;
|
|
152
159
|
var MAX_LOCKOUT_WINDOW = 24 * 60 * 60; // allow:raw-time-literal — lockout window stored in seconds; C.TIME returns ms (sanity cap — one day)
|
|
153
160
|
var MIN_LOCKOUT_THRESHOLD = 1;
|
|
154
161
|
var MAX_LOCKOUT_THRESHOLD = 1000;
|
|
@@ -647,14 +654,70 @@ function create(opts) {
|
|
|
647
654
|
[ip, since],
|
|
648
655
|
);
|
|
649
656
|
var count = Number((r.rows[0] && r.rows[0].n) || 0);
|
|
657
|
+
|
|
658
|
+
// Optional per-account keying. The per-IP count above can't see a
|
|
659
|
+
// distributed brute force (one attacker rotating IPs against one
|
|
660
|
+
// account); counting failures for the target operator across ALL IPs
|
|
661
|
+
// in the same window catches it. The account threshold is higher (see
|
|
662
|
+
// DEFAULT_ACCOUNT_LOCKOUT_THRESH) so a legitimate operator isn't locked
|
|
663
|
+
// by a handful of their own mistypes, only by a genuine multi-IP attack.
|
|
664
|
+
var accountThreshold = _lockoutThreshold(
|
|
665
|
+
lopts.account_threshold == null ? DEFAULT_ACCOUNT_LOCKOUT_THRESH : lopts.account_threshold);
|
|
666
|
+
var acctCount = 0;
|
|
667
|
+
if (lopts.operator_id != null) {
|
|
668
|
+
var oid = _uuid(lopts.operator_id, "operator_id");
|
|
669
|
+
var ar = await query(
|
|
670
|
+
"SELECT COUNT(*) AS n FROM operator_failed_logins " +
|
|
671
|
+
"WHERE operator_id = ?1 AND occurred_at >= ?2",
|
|
672
|
+
[oid, since],
|
|
673
|
+
);
|
|
674
|
+
acctCount = Number((ar.rows[0] && ar.rows[0].n) || 0);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
var lockedByIp = count >= threshold;
|
|
678
|
+
var lockedByAccount = lopts.operator_id != null && acctCount >= accountThreshold;
|
|
650
679
|
return {
|
|
651
|
-
locked:
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
680
|
+
locked: lockedByIp || lockedByAccount,
|
|
681
|
+
locked_by: lockedByIp ? "ip" : (lockedByAccount ? "account" : null),
|
|
682
|
+
count: count,
|
|
683
|
+
threshold: threshold,
|
|
684
|
+
account_count: acctCount,
|
|
685
|
+
account_threshold: accountThreshold,
|
|
686
|
+
window_seconds: windowSec,
|
|
655
687
|
};
|
|
656
688
|
},
|
|
657
689
|
|
|
690
|
+
// ---- clearFailures --------------------------------------------------
|
|
691
|
+
//
|
|
692
|
+
// Delete the recent failed-attempt rows for the signed-in ACCOUNT after a
|
|
693
|
+
// SUCCESSFUL sign-in, so a legitimate operator who mistyped a few times
|
|
694
|
+
// isn't held under the rolling lockout window on the next attempt.
|
|
695
|
+
//
|
|
696
|
+
// Scoped to `operator_id` — which is REQUIRED. An optional `ip_hash` only
|
|
697
|
+
// NARROWS the deletion to that IP (AND), never widens it. This matters:
|
|
698
|
+
// clearing by IP (or account-OR-IP) would wipe OTHER operators' failed
|
|
699
|
+
// rows from the same IP, so a shared-IP attacker could brute-force a
|
|
700
|
+
// victim, sign in as themselves, and reset the victim's lockout. Clearing
|
|
701
|
+
// strictly the caller's own account can't touch another account's rows.
|
|
702
|
+
// Returns the row count cleared; a no-op when `operator_id` is absent.
|
|
703
|
+
clearFailures: async function (input) {
|
|
704
|
+
input = input || {};
|
|
705
|
+
if (typeof input !== "object") {
|
|
706
|
+
throw new TypeError("operator-sessions.clearFailures: input must be an object");
|
|
707
|
+
}
|
|
708
|
+
if (input.operator_id == null) return { cleared: 0 };
|
|
709
|
+
var oid = _uuid(input.operator_id, "operator_id");
|
|
710
|
+
var params = [oid];
|
|
711
|
+
var sql = "DELETE FROM operator_failed_logins WHERE operator_id = ?1";
|
|
712
|
+
if (input.ip_hash != null) {
|
|
713
|
+
var ip = _requiredString(input.ip_hash, "ip_hash", MIN_IP_HASH_LEN, MAX_IP_HASH_LEN);
|
|
714
|
+
params.push(ip);
|
|
715
|
+
sql += " AND ip_hash = ?2";
|
|
716
|
+
}
|
|
717
|
+
var res = await query(sql, params);
|
|
718
|
+
return { cleared: Number((res && res.rowCount) || 0) };
|
|
719
|
+
},
|
|
720
|
+
|
|
658
721
|
// ---- recordFailedVerify ---------------------------------------------
|
|
659
722
|
//
|
|
660
723
|
// Append-only event log. The login middleware calls this on every
|
package/lib/product-import.js
CHANGED
|
@@ -327,20 +327,35 @@ async function _drive(query, catalog, state, input, driveOpts) {
|
|
|
327
327
|
// Per-row normalization errors land in the error list at the
|
|
328
328
|
// matching row_index; the row is skipped for catalog writes but
|
|
329
329
|
// still counted in `rows_processed`.
|
|
330
|
+
// Reserve the single in-flight slot SYNCHRONOUSLY — before the INSERT await —
|
|
331
|
+
// so two concurrent runs (an operator double-submitting) can't both open. A
|
|
332
|
+
// second run is refused rather than overwriting the first's inflight id,
|
|
333
|
+
// which would leave `cancelInflight` targeting the wrong import. The check +
|
|
334
|
+
// set have no await between them, so the reservation is race-free.
|
|
335
|
+
if (state.inflightId) {
|
|
336
|
+
var busy = new Error("product-import: an import is already in progress");
|
|
337
|
+
busy.code = "IMPORT_IN_PROGRESS";
|
|
338
|
+
throw busy;
|
|
339
|
+
}
|
|
330
340
|
var importId = b.uuid.v7();
|
|
331
341
|
var startedAt = Date.now();
|
|
332
342
|
var inputByteCount = driveOpts.inputByteCount == null ? 0 : driveOpts.inputByteCount;
|
|
343
|
+
state.inflightId = importId;
|
|
344
|
+
state.cancelFlag = false;
|
|
333
345
|
|
|
334
346
|
// Run row goes in at `running`. The driver updates the counters +
|
|
335
347
|
// status at the end. The error rows go in as they're encountered
|
|
336
348
|
// — operators tailing `errorsForImport` see progress mid-run.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
349
|
+
try {
|
|
350
|
+
await query(
|
|
351
|
+
"INSERT INTO product_imports (id, format, on_conflict, started_at, status, input_byte_count) " +
|
|
352
|
+
"VALUES (?1, ?2, ?3, ?4, 'running', ?5)",
|
|
353
|
+
[importId, format, onConflict, startedAt, inputByteCount],
|
|
354
|
+
);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
state.inflightId = null; // release the reservation if the open failed
|
|
357
|
+
throw e;
|
|
358
|
+
}
|
|
344
359
|
|
|
345
360
|
var counters = {
|
|
346
361
|
rows_processed: 0,
|
package/package.json
CHANGED