@kontextmind/kxm 0.7.50 → 0.7.52

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.
@@ -17645,12 +17645,76 @@ function openDatabase(file, description, spec) {
17645
17645
  }
17646
17646
  }
17647
17647
  var activeTransactions = /* @__PURE__ */ new WeakSet();
17648
- function withDatabaseTransaction(database, work, mode = "IMMEDIATE") {
17648
+ var TRANSACTION_BUSY_BACKOFF_MS = 1e3;
17649
+ function monotonicNowMs() {
17650
+ return Number(process.hrtime.bigint() / 1000000n);
17651
+ }
17652
+ var transactionThrottles = /* @__PURE__ */ new WeakMap();
17653
+ function finiteNow(clock, label) {
17654
+ const now = clock();
17655
+ if (typeof now !== "number" || !Number.isFinite(now)) {
17656
+ throw databaseError(
17657
+ "runtime_transaction_clock_invalid",
17658
+ "transaction",
17659
+ `${label} must return a finite monotonic number; got ${String(now)}`
17660
+ );
17661
+ }
17662
+ return now;
17663
+ }
17664
+ var CONTENTION_PRIMARY_CODES = [5, 6, 15];
17665
+ var CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
17666
+ var SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
17667
+ var CONTENTION_MESSAGES = /^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;
17668
+ function isTransactionContention(error) {
17669
+ const carrier = error;
17670
+ for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
17671
+ if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 255);
17672
+ }
17673
+ for (const value of [carrier?.code, carrier?.name]) {
17674
+ if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
17675
+ return CONTENTION_SYMBOLIC_NAMES.test(value);
17676
+ }
17677
+ }
17678
+ return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
17679
+ }
17680
+ function withDatabaseTransaction(database, work, mode = "IMMEDIATE", clock = monotonicNowMs) {
17649
17681
  if (activeTransactions.has(database)) {
17650
17682
  throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
17651
17683
  }
17684
+ if (mode !== "DEFERRED") {
17685
+ const deadlines = transactionThrottles.get(database);
17686
+ const until = deadlines?.get(clock);
17687
+ if (until !== void 0) {
17688
+ const remaining = until - finiteNow(clock, "the transaction clock");
17689
+ if (remaining > 0) {
17690
+ throw databaseError(
17691
+ "runtime_transaction_busy",
17692
+ "transaction",
17693
+ `a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`
17694
+ );
17695
+ }
17696
+ deadlines?.delete(clock);
17697
+ }
17698
+ }
17699
+ try {
17700
+ database.exec(`BEGIN ${mode}`);
17701
+ } catch (error) {
17702
+ if (!isTransactionContention(error)) throw error;
17703
+ const now = finiteNow(clock, "the transaction clock");
17704
+ let deadlines = transactionThrottles.get(database);
17705
+ if (deadlines === void 0) {
17706
+ deadlines = /* @__PURE__ */ new WeakMap();
17707
+ transactionThrottles.set(database, deadlines);
17708
+ }
17709
+ deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
17710
+ throw databaseError(
17711
+ "runtime_transaction_busy",
17712
+ "transaction",
17713
+ `BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`
17714
+ );
17715
+ }
17652
17716
  activeTransactions.add(database);
17653
- database.exec(`BEGIN ${mode}`);
17717
+ if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
17654
17718
  try {
17655
17719
  const result = work();
17656
17720
  database.exec("COMMIT");
@@ -18558,7 +18622,14 @@ var KxmRunEventStore = class {
18558
18622
  `).get(projectId, coordinatorId, idempotencyKey);
18559
18623
  return row ? intakeFromRow(row) : void 0;
18560
18624
  }
18561
- /** Intake rows in the given dispatch states, in arrival order (replay-safe). */
18625
+ /**
18626
+ * Intake rows in the given dispatch states, in arrival order (replay-safe).
18627
+ *
18628
+ * `rowid` gives same-store arrival order, which is what queue priority needs
18629
+ * here. It is **not** a durable sequence: this repository backs stores up with
18630
+ * `VACUUM INTO`, and a vacuum may renumber implicit rowids. An explicit
18631
+ * immutable arrival sequence is tracked in the plan's schema-v6 follow-ups.
18632
+ */
18562
18633
  intakeInStates(projectId, states, limit = 100) {
18563
18634
  if (states.length === 0) return [];
18564
18635
  const placeholders = states.map(() => "?").join(", ");
@@ -17816,12 +17816,76 @@ function openDatabase(file, description, spec) {
17816
17816
  }
17817
17817
  }
17818
17818
  var activeTransactions = /* @__PURE__ */ new WeakSet();
17819
- function withDatabaseTransaction(database, work, mode = "IMMEDIATE") {
17819
+ var TRANSACTION_BUSY_BACKOFF_MS = 1e3;
17820
+ function monotonicNowMs() {
17821
+ return Number(process.hrtime.bigint() / 1000000n);
17822
+ }
17823
+ var transactionThrottles = /* @__PURE__ */ new WeakMap();
17824
+ function finiteNow(clock, label) {
17825
+ const now = clock();
17826
+ if (typeof now !== "number" || !Number.isFinite(now)) {
17827
+ throw databaseError(
17828
+ "runtime_transaction_clock_invalid",
17829
+ "transaction",
17830
+ `${label} must return a finite monotonic number; got ${String(now)}`
17831
+ );
17832
+ }
17833
+ return now;
17834
+ }
17835
+ var CONTENTION_PRIMARY_CODES = [5, 6, 15];
17836
+ var CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
17837
+ var SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
17838
+ var CONTENTION_MESSAGES = /^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;
17839
+ function isTransactionContention(error) {
17840
+ const carrier = error;
17841
+ for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
17842
+ if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 255);
17843
+ }
17844
+ for (const value of [carrier?.code, carrier?.name]) {
17845
+ if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
17846
+ return CONTENTION_SYMBOLIC_NAMES.test(value);
17847
+ }
17848
+ }
17849
+ return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
17850
+ }
17851
+ function withDatabaseTransaction(database, work, mode = "IMMEDIATE", clock = monotonicNowMs) {
17820
17852
  if (activeTransactions.has(database)) {
17821
17853
  throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
17822
17854
  }
17855
+ if (mode !== "DEFERRED") {
17856
+ const deadlines = transactionThrottles.get(database);
17857
+ const until = deadlines?.get(clock);
17858
+ if (until !== void 0) {
17859
+ const remaining = until - finiteNow(clock, "the transaction clock");
17860
+ if (remaining > 0) {
17861
+ throw databaseError(
17862
+ "runtime_transaction_busy",
17863
+ "transaction",
17864
+ `a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`
17865
+ );
17866
+ }
17867
+ deadlines?.delete(clock);
17868
+ }
17869
+ }
17870
+ try {
17871
+ database.exec(`BEGIN ${mode}`);
17872
+ } catch (error) {
17873
+ if (!isTransactionContention(error)) throw error;
17874
+ const now = finiteNow(clock, "the transaction clock");
17875
+ let deadlines = transactionThrottles.get(database);
17876
+ if (deadlines === void 0) {
17877
+ deadlines = /* @__PURE__ */ new WeakMap();
17878
+ transactionThrottles.set(database, deadlines);
17879
+ }
17880
+ deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
17881
+ throw databaseError(
17882
+ "runtime_transaction_busy",
17883
+ "transaction",
17884
+ `BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`
17885
+ );
17886
+ }
17823
17887
  activeTransactions.add(database);
17824
- database.exec(`BEGIN ${mode}`);
17888
+ if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
17825
17889
  try {
17826
17890
  const result = work();
17827
17891
  database.exec("COMMIT");
@@ -18998,7 +19062,14 @@ var KxmRunEventStore = class {
18998
19062
  `).get(projectId, coordinatorId, idempotencyKey);
18999
19063
  return row ? intakeFromRow(row) : void 0;
19000
19064
  }
19001
- /** Intake rows in the given dispatch states, in arrival order (replay-safe). */
19065
+ /**
19066
+ * Intake rows in the given dispatch states, in arrival order (replay-safe).
19067
+ *
19068
+ * `rowid` gives same-store arrival order, which is what queue priority needs
19069
+ * here. It is **not** a durable sequence: this repository backs stores up with
19070
+ * `VACUUM INTO`, and a vacuum may renumber implicit rowids. An explicit
19071
+ * immutable arrival sequence is tracked in the plan's schema-v6 follow-ups.
19072
+ */
19002
19073
  intakeInStates(projectId, states, limit = 100) {
19003
19074
  if (states.length === 0) return [];
19004
19075
  const placeholders = states.map(() => "?").join(", ");
@@ -30289,6 +30360,7 @@ export {
30289
30360
  SUBAGENT_TYPES,
30290
30361
  SteelClient,
30291
30362
  SubagentManager,
30363
+ TRANSACTION_BUSY_BACKOFF_MS,
30292
30364
  VIEWPORT_PRESETS,
30293
30365
  WIN_NPM_INNER_EXE,
30294
30366
  acceptKxmRun,
@@ -30344,6 +30416,7 @@ export {
30344
30416
  hashKxmTokenProof,
30345
30417
  isKnownHarnessId,
30346
30418
  isKxmRuntimeContextClosed,
30419
+ isTransactionContention,
30347
30420
  isWindowsHarnessShim,
30348
30421
  kxmDeclaredExecutorIds,
30349
30422
  kxmDeclaredRepositoryIds,
@@ -11537,12 +11537,76 @@ function openDatabase(file, description, spec) {
11537
11537
  }
11538
11538
  }
11539
11539
  var activeTransactions = /* @__PURE__ */ new WeakSet();
11540
- function withDatabaseTransaction(database, work, mode = "IMMEDIATE") {
11540
+ var TRANSACTION_BUSY_BACKOFF_MS = 1e3;
11541
+ function monotonicNowMs() {
11542
+ return Number(process.hrtime.bigint() / 1000000n);
11543
+ }
11544
+ var transactionThrottles = /* @__PURE__ */ new WeakMap();
11545
+ function finiteNow(clock, label) {
11546
+ const now = clock();
11547
+ if (typeof now !== "number" || !Number.isFinite(now)) {
11548
+ throw databaseError(
11549
+ "runtime_transaction_clock_invalid",
11550
+ "transaction",
11551
+ `${label} must return a finite monotonic number; got ${String(now)}`
11552
+ );
11553
+ }
11554
+ return now;
11555
+ }
11556
+ var CONTENTION_PRIMARY_CODES = [5, 6, 15];
11557
+ var CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
11558
+ var SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
11559
+ var CONTENTION_MESSAGES = /^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;
11560
+ function isTransactionContention(error) {
11561
+ const carrier = error;
11562
+ for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
11563
+ if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 255);
11564
+ }
11565
+ for (const value of [carrier?.code, carrier?.name]) {
11566
+ if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
11567
+ return CONTENTION_SYMBOLIC_NAMES.test(value);
11568
+ }
11569
+ }
11570
+ return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
11571
+ }
11572
+ function withDatabaseTransaction(database, work, mode = "IMMEDIATE", clock = monotonicNowMs) {
11541
11573
  if (activeTransactions.has(database)) {
11542
11574
  throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
11543
11575
  }
11576
+ if (mode !== "DEFERRED") {
11577
+ const deadlines = transactionThrottles.get(database);
11578
+ const until = deadlines?.get(clock);
11579
+ if (until !== void 0) {
11580
+ const remaining = until - finiteNow(clock, "the transaction clock");
11581
+ if (remaining > 0) {
11582
+ throw databaseError(
11583
+ "runtime_transaction_busy",
11584
+ "transaction",
11585
+ `a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`
11586
+ );
11587
+ }
11588
+ deadlines?.delete(clock);
11589
+ }
11590
+ }
11591
+ try {
11592
+ database.exec(`BEGIN ${mode}`);
11593
+ } catch (error) {
11594
+ if (!isTransactionContention(error)) throw error;
11595
+ const now = finiteNow(clock, "the transaction clock");
11596
+ let deadlines = transactionThrottles.get(database);
11597
+ if (deadlines === void 0) {
11598
+ deadlines = /* @__PURE__ */ new WeakMap();
11599
+ transactionThrottles.set(database, deadlines);
11600
+ }
11601
+ deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
11602
+ throw databaseError(
11603
+ "runtime_transaction_busy",
11604
+ "transaction",
11605
+ `BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`
11606
+ );
11607
+ }
11544
11608
  activeTransactions.add(database);
11545
- database.exec(`BEGIN ${mode}`);
11609
+ if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
11546
11610
  try {
11547
11611
  const result = work();
11548
11612
  database.exec("COMMIT");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.50",
3
+ "version": "0.7.52",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync
3
3
  import { join, resolve } from "node:path";
4
4
  import { redactSecrets } from "../redact.ts";
5
5
  import { defaultProjectName } from "../project-name.ts";
6
- import { resolveClientHubAuthToken } from "../hub-env.ts";
6
+ import { hasClientHubCredential, resolveClientHubAuthToken } from "../hub-env.ts";
7
7
  import { agentWorker, type Worker } from "../envelope.ts";
8
8
  import { MESH_TUI_PANELS, runMeshTui, type MeshTuiPanel } from "../tui.ts";
9
9
  import { formatSessionBriefText, loadSessionBriefAsync, type SessionHubStatus } from "../session-work.ts";
@@ -12,6 +12,7 @@ import {
12
12
  HubBindingError,
13
13
  hubBindingFile,
14
14
  probeHubHealth,
15
+ hubBindingScope,
15
16
  readHubBinding,
16
17
  removeHubBinding,
17
18
  validateHubUrl,
@@ -104,8 +105,22 @@ export async function refreshKxmUpdateNotice(runtime: Runtime, config?: KxmUpdat
104
105
  export async function cmdStatus(runtime: Runtime): Promise<number> {
105
106
  const health = await hubGet(`${runtime.serverUrl}/health`, runtime.fetchImpl);
106
107
  const ready = await hubGet(`${runtime.serverUrl}/ready`, runtime.fetchImpl);
107
- const payload = { ok: health.ok && ready.ok, command: "hub view", health: health.body, ready: ready.body };
108
- print(runtime.io, runtime.json, payload, `hub health=${health.ok} ready=${ready.ok}`);
108
+ // Scope on the status line deliberately: "attached across a network" and "attached on
109
+ // this box" are otherwise indistinguishable, and only one of them ships a token.
110
+ // Scope is a property of the URL actually contacted, not of whichever file the
111
+ // binding came from: KXM_SERVER_URL overrides the binding, and labelling the binding
112
+ // while probing an override would report "loopback" about a remote request.
113
+ const effectiveScope = hubBindingScope(runtime.serverUrl);
114
+ const overridden = Boolean(runtime.boundHubUrl && runtime.boundHubUrl !== runtime.serverUrl);
115
+ const payload = {
116
+ ok: health.ok && ready.ok,
117
+ command: "hub view",
118
+ target: { url: runtime.serverUrl, scope: effectiveScope, ...(overridden ? { source: "env" } : {}) },
119
+ health: health.body,
120
+ ready: ready.body,
121
+ };
122
+ print(runtime.io, runtime.json, payload,
123
+ `hub health=${health.ok} ready=${ready.ok} · ${effectiveScope} hub${overridden ? " (KXM_SERVER_URL)" : ""}`);
109
124
  return payload.ok ? 0 : 1;
110
125
  }
111
126
 
@@ -191,6 +206,10 @@ export function formatHubBindHealth(health: HubHealth): string {
191
206
  return "health=unknown (no reply within 300 ms)";
192
207
  }
193
208
 
209
+ /** Kept in one place so the JSON payload and the prose line cannot drift apart. */
210
+ const HUB_BIND_UNAUTHENTICATED_HINT =
211
+ "export KXM_AUTH_TOKEN (or point KXM_STATE_HOME at the hub-env record that already holds one), then re-run; the hub itself requires a token beyond loopback";
212
+
194
213
  export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<number> {
195
214
  let url: string;
196
215
  try {
@@ -207,14 +226,73 @@ export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<numb
207
226
  }
208
227
  throw error;
209
228
  }
229
+ const scope = hubBindingScope(url);
230
+ // Scope-scoped on purpose, and only for **this command**. A remote binding puts a
231
+ // bearer on a network path, so it is refused when nothing can authenticate it; a
232
+ // stored-but-unusable URL otherwise reads later like a network fault and gets debugged
233
+ // as one. Loopback is not consulted here because a loopback URL puts nothing on a wire —
234
+ // not because credentials are never resolved locally: other client paths still call
235
+ // `resolveClientHubAuthToken` regardless of scope, so a damaged host record can still
236
+ // fail `kxm peer list` on loopback. Narrowing the claim is the point; the first version
237
+ // of this guard checked the record *before* the scope and refused loopback binds that had
238
+ // always worked, which is a regression against behaviour predating this slice.
239
+ if (scope === "remote") {
240
+ // The project that will actually authenticate: a record holding only another
241
+ // project's token cannot authorise this one.
242
+ const bindProject = defaultProjectName(runtime.dirs.workdir, runtime.env) || "project";
243
+ let credentialReady = false;
244
+ try {
245
+ credentialReady = hasClientHubCredential(runtime.env, bindProject);
246
+ } catch (error) {
247
+ // A malformed record is a readable configuration failure, not an uncaught throw
248
+ // past a user-facing entry point.
249
+ print(
250
+ runtime.io,
251
+ runtime.json,
252
+ {
253
+ ok: false,
254
+ command: "hub bind",
255
+ error: "hub_credential_unreadable",
256
+ url,
257
+ scope,
258
+ nextAction: "repair_hub_env_record",
259
+ hint: `${error instanceof Error ? error.message : String(error)}; no binding was written`,
260
+ },
261
+ `cannot read the hub credential: ${error instanceof Error ? error.message : String(error)}`,
262
+ );
263
+ return 2;
264
+ }
265
+ if (!credentialReady) {
266
+ print(
267
+ runtime.io,
268
+ runtime.json,
269
+ {
270
+ ok: false,
271
+ command: "hub bind",
272
+ error: "hub_bind_unauthenticated",
273
+ url,
274
+ scope,
275
+ project: bindProject,
276
+ // The hint belongs in the payload, not only the prose line: under --json the
277
+ // prose is suppressed, and a refusal that names no next step gets debugged by
278
+ // reading source.
279
+ nextAction: "export_kxm_auth_token",
280
+ hint: `${HUB_BIND_UNAUTHENTICATED_HINT} (needs a token for project ${bindProject})`,
281
+ },
282
+ `refusing to bind remote hub ${url} with no credential for project ${bindProject}; ${HUB_BIND_UNAUTHENTICATED_HINT}`,
283
+ );
284
+ return 2;
285
+ }
286
+ }
210
287
  const file = hubBindingFile(runtime.env);
211
288
  if (runtime.dryRun) {
212
- print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, file }, `would bind hub ${url}`);
289
+ print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, scope, file }, `would bind hub ${url} (${scope})`);
213
290
  return 0;
214
291
  }
215
292
  writeHubBinding({ schema: HUB_BINDING_SCHEMA, url, boundAt: new Date().toISOString() }, runtime.env);
216
293
  const { health, probeMs } = await probeHubHealth(url, runtime.fetchImpl);
217
- print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, file, health, probeMs }, `bound hub ${url} · ${formatHubBindHealth(health)}`);
294
+ print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, scope, file, health, probeMs },
295
+ `bound hub ${url} · ${scope} · ${formatHubBindHealth(health)}${scope === "remote" ? " · token leaves this machine" : ""}`);
218
296
  return 0;
219
297
  }
220
298
 
@@ -540,6 +618,7 @@ export async function cmdSessionBrief(runtime: Runtime, options: { status?: bool
540
618
  evidence: health === "unknown" ? "timeout" : "probed",
541
619
  online: health === "on",
542
620
  url: targetUrl,
621
+ scope: hubBindingScope(targetUrl),
543
622
  };
544
623
  } else {
545
624
  hub = { state: "off", evidence: "unconfigured", online: false };
@@ -213,21 +213,226 @@ export function openDatabase(file: string, description: string, spec: DatabaseSc
213
213
 
214
214
  const activeTransactions = new WeakSet<DatabaseSync>();
215
215
 
216
+ /**
217
+ * How long a connection refuses to retry a `BEGIN` that lost the write race.
218
+ *
219
+ * SQLite's busy timeout is per connection, so a `BEGIN` against a locked
220
+ * database waits the full timeout before failing. Callers that recover from a
221
+ * lost write open several transactions in a row; without this window each of
222
+ * them pays the timeout, and a suite run showed that turning one 4-second test
223
+ * into 17 minutes. The old code got that speed by accident — it left the
224
+ * connection permanently marked as in-transaction after a failed `BEGIN`, which
225
+ * is the defect fixed below — so the backoff replaces the fast-fail without
226
+ * re-introducing the poison.
227
+ *
228
+ * Two limits, both deliberate:
229
+ * - It is a **throttle, not a queue**. A caller whose lock cleared 1 ms later is
230
+ * still refused for the rest of the window; the refusal is explicit
231
+ * (`runtime_transaction_busy`, message says `retry deferred`) and bounded by
232
+ * this constant. A retry after the window may pay the busy timeout again.
233
+ * - It is **per connection object, in this process**. It is not a cross-process
234
+ * backoff and does not leak to another connection to the same file.
235
+ */
236
+ export const TRANSACTION_BUSY_BACKOFF_MS = 1_000;
237
+
238
+ /**
239
+ * Monotonic elapsed time, deliberately not `Date.now()`.
240
+ *
241
+ * A wall-clock step backwards would otherwise keep a long-gone write lock
242
+ * refusing transactions until real time caught up, and a step forward would end
243
+ * the throttle early. `hrtime.bigint()` is relative to an arbitrary past origin
244
+ * and never moves backwards, so the window is bounded by
245
+ * {@link TRANSACTION_BUSY_BACKOFF_MS} no matter what the system clock does.
246
+ *
247
+ * Injectable: production callers never pass it, and a test drives it directly so
248
+ * the window is stepped rather than raced or slept through.
249
+ */
250
+ export type MonotonicClock = () => number;
251
+
252
+ function monotonicNowMs(): number {
253
+ return Number(process.hrtime.bigint() / 1_000_000n);
254
+ }
255
+
256
+ /**
257
+ * Pending throttle per connection, **keyed by the clock that armed it**.
258
+ *
259
+ * The nesting is the fix, not tidiness. A deadline is a number from *some* clock,
260
+ * and the injectable `clock` exists only so a test can step the window; comparing
261
+ * a deadline armed by one clock against a reading taken from another is how an
262
+ * injected "one hour from now" throttles a production caller that never passed a
263
+ * clock at all — and how that same caller could clear a deadline it never armed.
264
+ * Each clock domain therefore gets its own deadline and can only read, expire or
265
+ * replace its own. The inner map is **weak in the clock**: it keeps no otherwise
266
+ * unreachable clock function alive, so an attempt that builds a fresh closure per
267
+ * call leaves nothing behind once that closure is collected. A clock that *is*
268
+ * retained keeps its entry until it expires or is replaced — collection is neither
269
+ * immediate nor size-bounded, and no committed test measures any of this, because no
270
+ * production caller passes a clock.
271
+ */
272
+ const transactionThrottles = new WeakMap<DatabaseSync, WeakMap<MonotonicClock, number>>();
273
+
274
+ /**
275
+ * A clock reading this helper can reason about.
276
+ *
277
+ * Scope, stated as measured rather than as a slogan. The clock is read at **two call
278
+ * sites**: checking an existing deadline on a non-`DEFERRED` attempt, and arming after a
279
+ * contended `BEGIN` failure. Reads per call, each reproduced by an assertion in
280
+ * `test/core/intake.test.ts`: clean success with no pending entry 0; successful
281
+ * `DEFERRED`, pending entry present or not, 0; fresh contention 1; refusal inside a window
282
+ * 1; an expired deadline followed by renewed contention 2 in that call; a permanent
283
+ * `BEGIN` failure with no pending entry 0; a nested-transaction rejection 0.
284
+ *
285
+ * Which means it is wrong in both directions to say either "every `BEGIN` validates the
286
+ * clock" or "the *only* transaction that skips it is a clean uncontended success" — the
287
+ * second was mine, twice, and the second correction to it was still a slogan. The tests
288
+ * count; the prose only points at them. Production cannot reach the guard at all, because
289
+ * the default is `hrtime`; it exists so the injectable seam cannot become a silent
290
+ * bypass.
291
+ */
292
+ function finiteNow(clock: MonotonicClock, label: string): number {
293
+ const now = clock();
294
+ if (typeof now !== "number" || !Number.isFinite(now)) {
295
+ throw databaseError(
296
+ "runtime_transaction_clock_invalid",
297
+ "transaction",
298
+ `${label} must return a finite monotonic number; got ${String(now)}`,
299
+ );
300
+ }
301
+ return now;
302
+ }
303
+
304
+ /**
305
+ * Is this `BEGIN` failure contention for the write lock, as opposed to a
306
+ * programming or environment error?
307
+ *
308
+ * Only contention may be retried, and only contention earns the throttle window.
309
+ * Anything else — "cannot start a transaction within a transaction", a closed
310
+ * connection, a miscompiled statement — must surface unchanged, or a permanent
311
+ * bug looks like a transient one and the caller retries forever.
312
+ *
313
+ * SQLite's **numeric result code decides** where one exists, because it is stable
314
+ * across versions while message text is not: the primary code is `code & 0xff`, so
315
+ * extended forms land on their primaries — `SQLITE_BUSY_RECOVERY` (261) and
316
+ * `SQLITE_BUSY_SNAPSHOT` (517) on `SQLITE_BUSY` (5), `SQLITE_LOCKED_SHAREDCACHE`
317
+ * (262) on `SQLITE_LOCKED` (6). `SQLITE_PROTOCOL` (15) is included deliberately:
318
+ * SQLite raises it when repeated attempts to start a transaction under WAL exhaust
319
+ * the retry count, which is a lock-acquisition retry condition, not a broken
320
+ * database.
321
+ *
322
+ * Where both runtimes expose one, **the number wins over the text**: Node spells it
323
+ * `errcode`, `bun:sqlite` spells it `errno` (verified on Bun 1.3.14, where a shared-
324
+ * cache `BEGIN` arrives as `errno: 262`, `code: "SQLITE_LOCKED_SHAREDCACHE"`,
325
+ * message "database schema is locked: shared"). A symbolic `code`/`name` of the form
326
+ * `SQLITE_BUSY*`/`SQLITE_LOCKED*`/`SQLITE_PROTOCOL*` is accepted next, and bare
327
+ * message matching is the last resort — applied only when neither exists, so a
328
+ * wrapper that merely quotes "database is locked" alongside a permanent code is not
329
+ * mistaken for contention.
330
+ *
331
+ * Deliberately **not** contention: `SQLITE_FULL` / "database or disk is full",
332
+ * "unable to open database file", and WAL shared-memory I/O failures. Those stay
333
+ * broken until something outside this connection changes.
334
+ */
335
+ /** Node: `errcode`. Bun: `errno`. Both spell the extended code as a number. */
336
+ const CONTENTION_PRIMARY_CODES: readonly number[] = [5, 6, 15];
337
+ /** `bun:sqlite` puts the symbolic name in `code`; Node puts its own kind there. */
338
+ const CONTENTION_SYMBOLIC_NAMES = /^SQLITE_(?:BUSY|LOCKED|PROTOCOL)(?:_[A-Z0-9]+)?$/;
339
+ /** Any SQLite result name. If one is present it decides, so text cannot argue. */
340
+ const SQLITE_RESULT_NAMES = /^SQLITE_[A-Z][A-Z0-9_]*$/;
341
+ const CONTENTION_MESSAGES =
342
+ /^(?:database is locked|database table is locked|locking protocol|SQLITE_BUSY|SQLITE_LOCKED|SQLITE_PROTOCOL)(?:$|[\s.:])/i;
343
+
344
+ export function isTransactionContention(error: unknown): boolean {
345
+ const carrier = error as { errcode?: unknown; errCode?: unknown; errno?: unknown; code?: unknown; name?: unknown }
346
+ | undefined;
347
+ // A numeric code wins first, then a symbolic result name, and both win **over the
348
+ // message**: `errno` is what `bun:sqlite` exposes for the extended result code while
349
+ // its `code` field holds the symbolic name, and Node spells the number `errcode`.
350
+ // All three properties are read; what the list orders is which **integer value
351
+ // decides** — a present `errcode` outranks `errCode`, which outranks `errno`, and a
352
+ // later number is never consulted. Text is consulted only when the error carries
353
+ // neither a number nor a SQLite result name, so no wrapper quoting an older
354
+ // "database is locked" can outvote a code on either runtime.
355
+ for (const value of [carrier?.errcode, carrier?.errCode, carrier?.errno]) {
356
+ if (typeof value === "number" && Number.isInteger(value)) return CONTENTION_PRIMARY_CODES.includes(value & 0xff);
357
+ }
358
+ for (const value of [carrier?.code, carrier?.name]) {
359
+ // A result **name** is as authoritative as a number, and in both directions:
360
+ // `SQLITE_FULL` wearing a "database is locked" message is not contention. Node
361
+ // spells its own error kind `ERR_SQLITE_ERROR`, which is deliberately not a
362
+ // SQLite result name and so never reaches a verdict here.
363
+ if (typeof value === "string" && SQLITE_RESULT_NAMES.test(value)) {
364
+ return CONTENTION_SYMBOLIC_NAMES.test(value);
365
+ }
366
+ }
367
+ return CONTENTION_MESSAGES.test(error instanceof Error ? error.message : String(error));
368
+ }
369
+
216
370
  export function withDatabaseTransaction<T>(
217
371
  database: DatabaseSync,
218
372
  work: () => T,
219
373
  mode: "IMMEDIATE" | "DEFERRED" | "EXCLUSIVE" = "IMMEDIATE",
374
+ clock: MonotonicClock = monotonicNowMs,
220
375
  ): T {
221
376
  if (activeTransactions.has(database)) {
222
377
  throw databaseError("runtime_transaction_nested", "transaction", "nested transactions are not allowed");
223
378
  }
379
+ // A DEFERRED BEGIN takes no write lock, so it is exempt from the *check*: refusing
380
+ // it would deny legitimate work over a contention it did not ask for. Exempt from
381
+ // the check only — shared-cache schema locks can still make a deferred `BEGIN`
382
+ // fail, and that failure arms a deadline like any other, because the next attempt
383
+ // would stall the same way.
384
+ if (mode !== "DEFERRED") {
385
+ const deadlines = transactionThrottles.get(database);
386
+ const until = deadlines?.get(clock);
387
+ if (until !== undefined) {
388
+ const remaining = until - finiteNow(clock, "the transaction clock");
389
+ if (remaining > 0) {
390
+ throw databaseError(
391
+ "runtime_transaction_busy",
392
+ "transaction",
393
+ `a previous BEGIN was blocked on this database; retry deferred ${String(remaining)}ms`,
394
+ );
395
+ }
396
+ deadlines?.delete(clock);
397
+ }
398
+ }
399
+ // Claim the marker only once BEGIN has succeeded. If BEGIN throws — another
400
+ // writer held the database past the busy timeout — a connection that never
401
+ // entered a transaction must not be left permanently marked as inside one,
402
+ // which would fail every later transaction on it with a misleading
403
+ // "nested" error. `finally` cannot cover this: it only runs after the try.
404
+ try {
405
+ database.exec(`BEGIN ${mode}`);
406
+ } catch (error) {
407
+ if (!isTransactionContention(error)) throw error;
408
+ const now = finiteNow(clock, "the transaction clock");
409
+ let deadlines = transactionThrottles.get(database);
410
+ if (deadlines === undefined) {
411
+ deadlines = new WeakMap<MonotonicClock, number>();
412
+ transactionThrottles.set(database, deadlines);
413
+ }
414
+ deadlines.set(clock, now + TRANSACTION_BUSY_BACKOFF_MS);
415
+ throw databaseError(
416
+ "runtime_transaction_busy",
417
+ "transaction",
418
+ `BEGIN ${mode} blocked by another transaction: ${error instanceof Error ? error.message : String(error)}`,
419
+ );
420
+ }
224
421
  activeTransactions.add(database);
225
- database.exec(`BEGIN ${mode}`);
422
+ // A successful write-mode BEGIN means this connection is holding the slot, so any
423
+ // earlier contention is over. A DEFERRED success proves nothing about the write
424
+ // lock and must not clear a throttle that another caller's contention armed.
425
+ if (mode !== "DEFERRED") transactionThrottles.get(database)?.delete(clock);
226
426
  try {
227
427
  const result = work();
228
428
  database.exec("COMMIT");
229
429
  return result;
230
430
  } catch (error) {
431
+ // A failed ROLLBACK usually means the connection is gone. Clearing the marker
432
+ // is bookkeeping, not proof: it does **not** show SQLite exited the
433
+ // transaction, and nothing here invalidates a handle whose rollback failed.
434
+ // Pre-existing, named rather than papered over — the caller receives the
435
+ // original error.
231
436
  try { database.exec("ROLLBACK"); } catch { /* ignore rollback error if connection dead */ }
232
437
  throw error;
233
438
  } finally {