velocious 1.0.601 → 1.0.603

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.
Files changed (80) hide show
  1. package/README.md +18 -7
  2. package/build/database/drivers/base.js +191 -39
  3. package/build/database/drivers/mysql/deadlock-diagnostic-parser.js +227 -0
  4. package/build/database/drivers/mysql/index.js +21 -30
  5. package/build/database/live-query.js +4 -1
  6. package/build/database/operation.js +9 -0
  7. package/build/database/pool/async-tracked-multi-connection.js +10 -15
  8. package/build/database/pool/base.js +19 -2
  9. package/build/database/record/index.js +50 -2
  10. package/build/database/record-changes.js +18 -4
  11. package/build/src/database/drivers/base.d.ts +110 -3
  12. package/build/src/database/drivers/base.d.ts.map +1 -1
  13. package/build/src/database/drivers/base.js +175 -37
  14. package/build/src/database/drivers/mysql/deadlock-diagnostic-parser.d.ts +80 -0
  15. package/build/src/database/drivers/mysql/deadlock-diagnostic-parser.d.ts.map +1 -0
  16. package/build/src/database/drivers/mysql/deadlock-diagnostic-parser.js +196 -0
  17. package/build/src/database/drivers/mysql/index.d.ts +21 -2
  18. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  19. package/build/src/database/drivers/mysql/index.js +19 -28
  20. package/build/src/database/live-query.d.ts +5 -0
  21. package/build/src/database/live-query.d.ts.map +1 -1
  22. package/build/src/database/live-query.js +4 -2
  23. package/build/src/database/operation.d.ts +5 -0
  24. package/build/src/database/operation.d.ts.map +1 -1
  25. package/build/src/database/operation.js +9 -1
  26. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  27. package/build/src/database/pool/async-tracked-multi-connection.js +11 -16
  28. package/build/src/database/pool/base.d.ts +7 -0
  29. package/build/src/database/pool/base.d.ts.map +1 -1
  30. package/build/src/database/pool/base.js +17 -2
  31. package/build/src/database/record/index.d.ts +36 -0
  32. package/build/src/database/record/index.d.ts.map +1 -1
  33. package/build/src/database/record/index.js +43 -3
  34. package/build/src/database/record-changes.d.ts +9 -1
  35. package/build/src/database/record-changes.d.ts.map +1 -1
  36. package/build/src/database/record-changes.js +18 -5
  37. package/build/src/sync/local-mutation-log.d.ts +7 -0
  38. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  39. package/build/src/sync/local-mutation-log.js +17 -1
  40. package/build/src/sync/sync-api-client-types.d.ts +8 -4
  41. package/build/src/sync/sync-api-client-types.d.ts.map +1 -1
  42. package/build/src/sync/sync-api-client-types.js +3 -3
  43. package/build/src/sync/sync-client-types.d.ts +25 -0
  44. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  45. package/build/src/sync/sync-client-types.js +6 -1
  46. package/build/src/sync/sync-client.d.ts +43 -1
  47. package/build/src/sync/sync-client.d.ts.map +1 -1
  48. package/build/src/sync/sync-client.js +187 -37
  49. package/build/src/sync/sync-realtime-bridge.js +2 -2
  50. package/build/src/sync/sync-scope-store.d.ts +15 -1
  51. package/build/src/sync/sync-scope-store.d.ts.map +1 -1
  52. package/build/src/sync/sync-scope-store.js +31 -4
  53. package/build/src/tenants/tenant-handle.d.ts +81 -0
  54. package/build/src/tenants/tenant-handle.d.ts.map +1 -1
  55. package/build/src/tenants/tenant-handle.js +143 -1
  56. package/build/sync/local-mutation-log.js +17 -0
  57. package/build/sync/sync-api-client-types.js +2 -2
  58. package/build/sync/sync-client-types.js +5 -0
  59. package/build/sync/sync-client.js +198 -38
  60. package/build/sync/sync-realtime-bridge.js +1 -1
  61. package/build/sync/sync-scope-store.js +32 -3
  62. package/build/tenants/tenant-handle.js +165 -0
  63. package/build/tsconfig.tsbuildinfo +1 -1
  64. package/package.json +1 -1
  65. package/src/database/drivers/base.js +191 -39
  66. package/src/database/drivers/mysql/deadlock-diagnostic-parser.js +227 -0
  67. package/src/database/drivers/mysql/index.js +21 -30
  68. package/src/database/live-query.js +4 -1
  69. package/src/database/operation.js +9 -0
  70. package/src/database/pool/async-tracked-multi-connection.js +10 -15
  71. package/src/database/pool/base.js +19 -2
  72. package/src/database/record/index.js +50 -2
  73. package/src/database/record-changes.js +18 -4
  74. package/src/sync/local-mutation-log.js +17 -0
  75. package/src/sync/sync-api-client-types.js +2 -2
  76. package/src/sync/sync-client-types.js +5 -0
  77. package/src/sync/sync-client.js +198 -38
  78. package/src/sync/sync-realtime-bridge.js +1 -1
  79. package/src/sync/sync-scope-store.js +32 -3
  80. package/src/tenants/tenant-handle.js +165 -0
package/README.md CHANGED
@@ -15,8 +15,8 @@
15
15
  * Controllers and views for HTTP endpoints
16
16
  * Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md) and [docs/websocket-channels.md](docs/websocket-channels.md))
17
17
  * Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see the [shared-resource sync developer guide](docs/shared-resource-sync-guide.md) and [offline sync architecture](docs/offline-sync.md))
18
- * Declarative client sync scopes with per-scope cursors, automatic mutation tracking, opt-in durable base-version conflict replay, realtime delivery whose per-recipient authorization hook receives the complete persisted sync envelope, and `sync`/`pull` progress reporting for "X of Y" import screens (see [docs/sync-client.md](docs/sync-client.md) and [docs/offline-sync.md](docs/offline-sync.md))
19
- * Reactive `useLiveQuery(Model.where(...))` queries that stay current from committed local model changes across local writes, pulls, and realtime (see [docs/live-queries.md](docs/live-queries.md))
18
+ * Declarative client sync scopes with per-scope cursors, automatic mutation tracking, opt-in durable base-version conflict replay, realtime delivery, and immutable-handle project clients whose queues, scopes, cursors, receipts, conflicts, pulls, and reconnect catch-up stay in one physical tenant database (see [docs/sync-client.md](docs/sync-client.md) and [docs/offline-sync.md](docs/offline-sync.md))
19
+ * Reactive `useLiveQuery(Model.where(...))` queries for default databases plus immutable-handle tenant live-query sources whose committed events and refreshes stay on the captured physical tenant (see [docs/live-queries.md](docs/live-queries.md))
20
20
  * Server-side sync envelope replay orchestration for app-owned sync receivers, including allowlisted authoritative values for conflict resolution (see [docs/sync-envelope-replay-service.md](docs/sync-envelope-replay-service.md))
21
21
  * Self-sustaining sync feeds: upstream imports triggered by the changes pull itself, with framework-owned coalescing and throttling (see [docs/sync-upstream-imports.md](docs/sync-upstream-imports.md))
22
22
  * AwesomeTasks-shaped offline sync proof using routed resources, domain commands, signed offline grants, and peer-forwarded mutations (see the [developer guide](docs/shared-resource-sync-guide.md) and [proof](docs/awesome-tasks-offline-sync-proof.md))
@@ -1921,10 +1921,21 @@ Genuinely unexpected frontend-model command failures reach this bus too. The fro
1921
1921
 
1922
1922
  Unexpected inbound decoded WebSocket dispatch failures emit one `framework-error` and one matching `all-error`. Established expected client-flow errors remain excluded from both events.
1923
1923
 
1924
- MySQL/MariaDB transaction deadlock retries emit a structured `database-deadlock-retry` event (also
1925
- mirrored to `all-error`) with retry metadata, a redacted SQL fingerprint when available, and a
1926
- best-effort bounded/redacted InnoDB deadlock excerpt. Diagnostic capture never joins the retry
1927
- control flow. See [deadlock retry diagnostics](docs/logging.md#deadlock-retry-diagnostics).
1924
+ MySQL/MariaDB transaction contention that will run another outer attempt emits a structured
1925
+ `database-deadlock-retry` event through `configuration.getErrorEvents()` and mirrors it to `all-error` with
1926
+ `errorType: "database-deadlock-retry"`. Required context fields are `stage`, `driverType`,
1927
+ `contentionKind`, `attempt`, `maxAttempts`, `willRetry` (always `true` for this retry-only event), and
1928
+ `transactionAttemptDurationMs`. Exhausted/non-retried contention emits no retry event.
1929
+ Pool-owned connections optionally add logical `databaseIdentifier` and opaque
1930
+ `databaseIdentifierFingerprint`/`databaseIdentityFingerprint` fields; the logical identifier itself
1931
+ is always redacted. Named checkouts always redact `operationName` and add only its bounded opaque
1932
+ `operationNameFingerprint` for correlation. Query failures add
1933
+ `sqlOperation`/`sqlFingerprint`. True deadlocks may add `statusCapture` and the bounded structural
1934
+ `innodbDeadlockSummary`, including explicit bounded MariaDB counterparty conflict edges whose owner
1935
+ is intentionally not inferred; lock-wait timeouts report `statusCapture: "not-applicable"` and never attach a
1936
+ historical graph. Capture, parsing, and listeners remain detached from rollback and retry control
1937
+ flow. See the exact identity, redaction, structural bounds, failure-channel, and optional-field
1938
+ contract in [deadlock retry diagnostics](docs/logging.md#deadlock-retry-diagnostics).
1928
1939
 
1929
1940
  ## Use the Websocket client API (HTTP-like)
1930
1941
 
@@ -2687,7 +2698,7 @@ Tenant lifecycle commands print start and final counts, report each completed te
2687
2698
 
2688
2699
  `afterMigrateTenant` hooks run inside the active default and tenant database connection scope for the tenant being migrated.
2689
2700
 
2690
- At runtime, the apartment-style `Tenant` façade (`velocious/build/src/tenants/tenant.js`) is the single entry point: `Tenant.with(tenant, callback)` / `Tenant.current()` to switch into and read a Node async context, `Tenant.handle(tenant)` to deeply capture immutable application and physical database identity for overlapping browser/native work, `Tenant.each({identifier, callback, parallel?, filter?})` to run a callback within every provider-listed tenant, and `Tenant.drop({identifier, tenant})` (plus the `db:tenants:drop` CLI command) to drop a tenant's database through the provider's `dropDatabase` hook. SQLite handles additionally expose framework-owned `open`, `flush`, `close`, `delete`, `inspect`, and `withPin` lifecycle methods; `frontendTenantSqlite.maxOpenHandles` bounds resident identities and clean, idle, unpinned handles are evicted least-recently-used. `Tenant.handle(...).databaseOperation(...)` and `.transaction(...)` use bounded pool-owned checkouts and pin model/query/write/association/preload/audit/attachment/raw work to the captured database even if a later UI project switch changes ambient tenant state. `Model.usingTenant(tenant)` uses the same safe core and adds eager helpers plus general `databaseOperation`/`transaction` model callbacks; eager records preserve legacy ambient Node follow-up semantics, while browser/native follow-up database work belongs inside the callback APIs. Inactive identifiers, mixed physical tenants, unsupported/cyclic descriptors, and expired operations fail closed. `Tenant.with` and `Tenant.each` retain their connection-establishing and model-initializing ambient behavior for Node request/job flows. `Tenant.aggregateAcross({identifier, aggregates, keyColumns, subquery, tenants?, filter?})` runs one aggregate over the same table across many tenant databases and returns the merged result — grouping tenants by server and using a single cross-database `UNION ALL` where the driver supports two-part `` `database`.`table` `` references (MySQL/MariaDB) or one query per tenant otherwise (PostgreSQL/SQLite/MSSQL).
2701
+ At runtime, the apartment-style `Tenant` façade (`velocious/build/src/tenants/tenant.js`) is the single entry point: `Tenant.with(tenant, callback)` / `Tenant.current()` to switch into and read a Node async context, `Tenant.handle(tenant)` to deeply capture immutable application and physical database identity for overlapping browser/native work, `Tenant.each({identifier, callback, parallel?, filter?})` to run a callback within every provider-listed tenant, and `Tenant.drop({identifier, tenant})` (plus the `db:tenants:drop` CLI command) to drop a tenant's database through the provider's `dropDatabase` hook. SQLite handles additionally expose framework-owned `open`, `flush`, `close`, `delete`, `inspect`, and `withPin` lifecycle methods; `frontendTenantSqlite.maxOpenHandles` bounds resident identities and clean, idle, unpinned handles are evicted least-recently-used. `Tenant.handle(...).databaseOperation(...)` and `.transaction(...)` use bounded pool-owned checkouts and pin model/query/write/association/preload/audit/attachment/raw work to the captured database even if a later UI project switch changes ambient tenant state. The handle also builds tenant-bound live-query sources, exposes an opaque physical identity for filtered record-change subscriptions, and binds project `SyncClient` instances. `Model.usingTenant(tenant)` uses the same safe core and adds eager helpers plus general `databaseOperation`/`transaction` model callbacks; eager records preserve legacy ambient Node follow-up semantics, while browser/native follow-up database work belongs inside the callback APIs. Inactive identifiers, mixed physical tenants, unsupported/cyclic descriptors, expired operations, unscoped tenant event subscriptions, and stale/cross-tenant sync state fail closed. `Tenant.with` and `Tenant.each` retain their connection-establishing and model-initializing ambient behavior for Node request/job flows. `Tenant.aggregateAcross({identifier, aggregates, keyColumns, subquery, tenants?, filter?})` runs one aggregate over the same table across many tenant databases and returns the merged result — grouping tenants by server and using a single cross-database `UNION ALL` where the driver supports two-part `` `database`.`table` `` references (MySQL/MariaDB) or one query per tenant otherwise (PostgreSQL/SQLite/MSSQL).
2691
2702
 
2692
2703
  `SchemaCloner` adds a missing auto-increment column and its separate source unique index in one schema alteration, including on MySQL/MariaDB where an auto-increment column must be keyed when it is created.
2693
2704
 
@@ -54,6 +54,7 @@
54
54
  * @property {boolean} retry - Whether the error should be retried.
55
55
  * @property {boolean} reconnect - Whether to reconnect before retrying.
56
56
  * @property {boolean} [deadlock] - Whether the error is a transaction deadlock/lock-wait-timeout that should retry the whole transaction.
57
+ * @property {"deadlock" | "lock-wait-timeout"} [contentionKind] - Classified transaction contention kind.
57
58
  * @property {number} [maxTries] - Override the max retry attempts.
58
59
  * @property {number} [waitMs] - Wait time before retrying in milliseconds.
59
60
  */
@@ -71,6 +72,25 @@
71
72
  * @property {symbol} [operationOwner] - Opaque owner for an operation-leased connection.
72
73
  */
73
74
 
75
+ /**
76
+ * DeadlockRetryDiagnosticSnapshot type.
77
+ * @typedef {object} DeadlockRetryDiagnosticSnapshot
78
+ * @property {number} attempt - One-based transaction attempt.
79
+ * @property {"deadlock" | "lock-wait-timeout"} contentionKind - Classified contention kind.
80
+ * @property {string} [databaseIdentifier] - Redacted logical database pool identifier marker.
81
+ * @property {string} [databaseIdentifierFingerprint] - Opaque logical database pool identity.
82
+ * @property {string} [databaseIdentityFingerprint] - Opaque physical database identity.
83
+ * @property {string} driverType - Driver type.
84
+ * @property {number} maxAttempts - Configured transaction attempt budget.
85
+ * @property {string} [operationName] - Redacted operation-name marker.
86
+ * @property {string} [operationNameFingerprint] - Opaque operation-name identity.
87
+ * @property {string} [sqlFingerprint] - Normalized SQL-shape fingerprint.
88
+ * @property {string} [sqlOperation] - SQL verb.
89
+ * @property {string} stage - Error-event stage.
90
+ * @property {number} transactionAttemptDurationMs - Duration of the failed outer attempt.
91
+ * @property {boolean} willRetry - Whether another outer transaction attempt will run.
92
+ */
93
+
74
94
  /**
75
95
  * ActiveQueryDebugSnapshot type.
76
96
  * @typedef {object} ActiveQueryDebugSnapshot
@@ -144,19 +164,23 @@ import TableForeignKey from "../table-data/table-foreign-key.js"
144
164
  import wait from "awaitery/build/wait.js"
145
165
  import { optionalPositiveInteger } from "typanic"
146
166
  import { coordinateSharedTransactionConnection } from "../../testing/shared-transaction-connection-coordinator.js"
167
+ import sha256Hex from "../../utils/sha256-hex.js"
147
168
 
148
169
  /** Maximum characters inspected when building the debug SQL preview. */
149
170
  const SQL_PREVIEW_SCAN_LIMIT = 4096
150
171
  /** Maximum characters inspected when deciding whether a statement invalidates schema metadata. */
151
172
  const SCHEMA_INVALIDATION_SCAN_LIMIT = 8192
173
+ /** Maximum checkout-name characters inspected by retry diagnostics. */
174
+ const OPERATION_NAME_SCAN_LIMIT = 1024
175
+ const REDACTED_DIAGNOSTIC_LABEL = "[REDACTED]"
152
176
 
153
177
  /**
154
178
  * Builds a non-reversible, stable SQL fingerprint without retaining SQL text. Literal spelling is
155
179
  * normalized first so the same statement shape produces the same fingerprint across values.
156
180
  * @param {string} sql - SQL to fingerprint.
157
- * @returns {string} - Bounded fingerprint.
181
+ * @returns {{sqlFingerprint: string, sqlOperation: string}} - Bounded query diagnostic.
158
182
  */
159
- function sqlFingerprint(sql) {
183
+ function sqlDiagnostic(sql) {
160
184
  let fingerprintInput = ""
161
185
 
162
186
  for (let index = 0; index < sql.length;) {
@@ -206,7 +230,12 @@ function sqlFingerprint(sql) {
206
230
  hash = BigInt.asUintN(64, hash * 0x100000001b3n)
207
231
  }
208
232
 
209
- return `fnv1a64:${hash.toString(16).padStart(16, "0")}`
233
+ const operationMatch = /^([a-z]+)/.exec(normalized)
234
+
235
+ return {
236
+ sqlFingerprint: `fnv1a64:${hash.toString(16).padStart(16, "0")}`,
237
+ sqlOperation: operationMatch ? operationMatch[1].toUpperCase() : "UNKNOWN"
238
+ }
210
239
  }
211
240
 
212
241
  /**
@@ -270,6 +299,10 @@ export default class VelociousDatabaseDriversBase {
270
299
  * Narrows the runtime value to the documented type.
271
300
  * @type {string | undefined} */
272
301
  _connectionCheckoutName
302
+ /** @type {string | undefined} */
303
+ _databaseIdentifier
304
+ /** @type {string | undefined} */
305
+ _databaseIdentityFingerprint
273
306
  /**
274
307
  * Active query.
275
308
  * @type {ActiveQueryState | null} */
@@ -465,6 +498,16 @@ export default class VelociousDatabaseDriversBase {
465
498
  this._connectionCheckedOutAtUnixMs = undefined
466
499
  }
467
500
 
501
+ /**
502
+ * Sets the pool-owned identity used by safe database diagnostics.
503
+ * @param {{databaseIdentifier: string, databaseIdentityFingerprint: string}} identity - Pool-stamped identity redacted at diagnostic snapshot time.
504
+ * @returns {void}
505
+ */
506
+ setPoolDiagnosticIdentity({databaseIdentifier, databaseIdentityFingerprint}) {
507
+ this._databaseIdentifier = databaseIdentifier
508
+ this._databaseIdentityFingerprint = databaseIdentityFingerprint
509
+ }
510
+
468
511
  /**
469
512
  * Runs reconnect.
470
513
  * @returns {Promise<void>} - Resolves when complete.
@@ -1304,6 +1347,7 @@ export default class VelociousDatabaseDriversBase {
1304
1347
 
1305
1348
  while (true) {
1306
1349
  attempt++
1350
+ const attemptStartedAtMs = this._nowMs()
1307
1351
 
1308
1352
  try {
1309
1353
  return await this._runTransactionAttempt(callback, options)
@@ -1312,9 +1356,17 @@ export default class VelociousDatabaseDriversBase {
1312
1356
  if (!(error instanceof Error)) throw error
1313
1357
 
1314
1358
  const retryInfo = this.retryableDatabaseError(error)
1315
-
1316
- if (retryInfo.deadlock && attempt < maxAttempts && this._transactionsCount == 0) {
1317
- this._reportDeadlockRetryDiagnostic({attempt, error, maxAttempts})
1359
+ const willRetry = Boolean(retryInfo.deadlock && attempt < maxAttempts && this._transactionsCount == 0)
1360
+
1361
+ if (willRetry) {
1362
+ this._reportDeadlockRetryDiagnostic({
1363
+ attempt,
1364
+ contentionKind: retryInfo.contentionKind || "deadlock",
1365
+ error,
1366
+ maxAttempts,
1367
+ transactionAttemptDurationMs: Math.max(0, this._nowMs() - attemptStartedAtMs),
1368
+ willRetry
1369
+ })
1318
1370
 
1319
1371
  // An explicitly-configured base wins so the tuning knob is effective even on drivers
1320
1372
  // whose classifier supplies its own `waitMs` (MySQL/MariaDB return a fixed 50ms for
@@ -1330,7 +1382,9 @@ export default class VelociousDatabaseDriversBase {
1330
1382
  const ceilingWaitMs = Math.min(baseWaitMs * (2 ** (attempt - 1)), deadlockMaxWaitMs)
1331
1383
  const jitteredWaitMs = Math.floor(Math.random() * (ceilingWaitMs + 1))
1332
1384
 
1333
- this.logger.warn(`Retrying transaction after deadlock (attempt ${attempt}/${maxAttempts})`)
1385
+ const loggedContentionKind = retryInfo.contentionKind || "transaction contention"
1386
+
1387
+ this.logger.warn(`Retrying transaction after ${loggedContentionKind} (attempt ${attempt}/${maxAttempts})`)
1334
1388
  await this._waitMs(jitteredWaitMs)
1335
1389
  continue
1336
1390
  }
@@ -1350,29 +1404,67 @@ export default class VelociousDatabaseDriversBase {
1350
1404
  await wait(ms)
1351
1405
  }
1352
1406
 
1407
+ /**
1408
+ * Returns the clock used for transaction-attempt diagnostics.
1409
+ * @returns {number} - Monotonic milliseconds where available.
1410
+ */
1411
+ _nowMs() {
1412
+ return nowMs()
1413
+ }
1414
+
1353
1415
  /**
1354
1416
  * Starts best-effort deadlock diagnostics without joining the retry control flow. Subclasses may
1355
1417
  * add bounded driver-specific context; capture and event-listener failures cannot affect retry.
1356
- * @param {{attempt: number, error: Error, maxAttempts: number}} args - Retry metadata.
1418
+ * @param {{attempt: number, contentionKind: "deadlock" | "lock-wait-timeout", error: Error, maxAttempts: number, transactionAttemptDurationMs: number, willRetry: boolean}} args - Retry metadata.
1357
1419
  * @returns {void}
1358
1420
  */
1359
- _reportDeadlockRetryDiagnostic({attempt, error, maxAttempts}) {
1360
- const queryDiagnostic = this._failedQueryDiagnostics.get(error)
1421
+ _reportDeadlockRetryDiagnostic({attempt, contentionKind, error, maxAttempts, transactionAttemptDurationMs, willRetry}) {
1422
+ let snapshot
1361
1423
 
1362
- void this._deadlockDiagnosticContext()
1424
+ try {
1425
+ const queryDiagnostic = this._failedQueryDiagnostics.get(error)
1426
+
1427
+ snapshot = Object.freeze({
1428
+ attempt,
1429
+ contentionKind,
1430
+ driverType: this.getType(),
1431
+ maxAttempts,
1432
+ stage: "database-deadlock-retry",
1433
+ transactionAttemptDurationMs,
1434
+ willRetry,
1435
+ ...this._poolDiagnosticIdentityContext(),
1436
+ ...this._operationDiagnosticContext(),
1437
+ ...queryDiagnostic
1438
+ })
1439
+ } catch (diagnosticError) {
1440
+ this._reportDeadlockDiagnosticPipelineFailure(diagnosticError)
1441
+ return
1442
+ }
1443
+
1444
+ let driverContextResult
1445
+
1446
+ try {
1447
+ driverContextResult = this._deadlockDiagnosticContext(snapshot)
1448
+ } catch (diagnosticError) {
1449
+ this._reportDeadlockDiagnosticPipelineFailure(diagnosticError)
1450
+ return
1451
+ }
1452
+
1453
+ const hasPromiseContract = driverContextResult instanceof Promise
1454
+
1455
+ void Promise.resolve(driverContextResult)
1363
1456
  .then((driverContext) => {
1457
+ if (!hasPromiseContract) throw new Error("Database deadlock diagnostic context must return a Promise")
1458
+
1364
1459
  const context = {
1365
- attempt,
1366
- driverType: this.getType(),
1367
- maxAttempts,
1368
- stage: "database-deadlock-retry",
1369
- willRetry: true,
1370
- ...queryDiagnostic,
1460
+ ...snapshot,
1371
1461
  ...driverContext
1372
1462
  }
1373
1463
  const payload = {
1374
1464
  context,
1375
- error: new Error("Database transaction deadlock will be retried")
1465
+ error: new Error(willRetry
1466
+ ? `Database transaction ${contentionKind} will be retried`
1467
+ : `Database transaction ${contentionKind} exhausted its retry budget`)
1376
1468
  }
1377
1469
  const errorEvents = this.configuration.getErrorEvents()
1378
1470
 
@@ -1388,30 +1480,93 @@ export default class VelociousDatabaseDriversBase {
1388
1480
  this.logger.warn("Database deadlock retry all-error listener failed", {error: eventError})
1389
1481
  }
1390
1482
  })
1391
- .catch((diagnosticError) => {
1392
- const normalizedError = diagnosticError instanceof Error
1393
- ? diagnosticError
1394
- : new Error("Database deadlock retry diagnostic failed", {cause: diagnosticError})
1395
- const payload = {
1396
- context: {stage: "database-deadlock-retry-diagnostic"},
1397
- error: normalizedError
1398
- }
1483
+ .catch((diagnosticError) => this._reportDeadlockDiagnosticPipelineFailure(diagnosticError))
1484
+ }
1399
1485
 
1400
- try {
1401
- const errorEvents = this.configuration.getErrorEvents()
1402
- errorEvents.emit("framework-error", payload)
1403
- errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
1404
- } catch (reportingError) {
1405
- this.logger.warn("Database deadlock retry diagnostic pipeline failed", {error: normalizedError, reportingError})
1406
- }
1407
- })
1486
+ /**
1487
+ * Returns pool identity only when this driver was stamped by a pool.
1488
+ * @returns {{databaseIdentifier?: string, databaseIdentifierFingerprint?: string, databaseIdentityFingerprint?: string}} - Safe pool identity.
1489
+ */
1490
+ _poolDiagnosticIdentityContext() {
1491
+ if (this._databaseIdentifier === undefined || !this._databaseIdentityFingerprint) return {}
1492
+
1493
+ const identifierFingerprintInput = typeof this._databaseIdentifier === "string"
1494
+ ? this._databaseIdentifier
1495
+ : `invalid:${typeof this._databaseIdentifier}`
1496
+ const databaseIdentifierFingerprint = `sha256:${sha256Hex(`database-logical-identifier:v1\0${identifierFingerprintInput}`)}`
1497
+
1498
+ return {
1499
+ databaseIdentifier: REDACTED_DIAGNOSTIC_LABEL,
1500
+ databaseIdentifierFingerprint,
1501
+ databaseIdentityFingerprint: this._databaseIdentityFingerprint
1502
+ }
1503
+ }
1504
+
1505
+ /**
1506
+ * Builds the bounded operation portion of an immutable retry snapshot.
1507
+ * @returns {{operationName?: string, operationNameFingerprint?: string}} - Safe operation fields.
1508
+ */
1509
+ _operationDiagnosticContext() {
1510
+ const rawOperationName = this._connectionCheckoutName
1511
+
1512
+ if (rawOperationName === undefined) return {}
1513
+ if (typeof rawOperationName !== "string") {
1514
+ return {
1515
+ operationName: REDACTED_DIAGNOSTIC_LABEL,
1516
+ operationNameFingerprint: `sha256:${sha256Hex(`database-operation:v1\0invalid:${typeof rawOperationName}`)}`
1517
+ }
1518
+ }
1519
+
1520
+ const scannedOperationName = rawOperationName.slice(0, OPERATION_NAME_SCAN_LIMIT)
1521
+ const operationNameFingerprint = `sha256:${sha256Hex(`database-operation:v1\0${scannedOperationName}\0length:${rawOperationName.length}`)}`
1522
+
1523
+ return {
1524
+ operationName: REDACTED_DIAGNOSTIC_LABEL,
1525
+ operationNameFingerprint
1526
+ }
1527
+ }
1528
+
1529
+ /**
1530
+ * Reports an unexpected detached diagnostics failure without changing transaction control flow.
1531
+ * @param {ReturnType<typeof JSON.parse>} diagnosticError - Diagnostics failure.
1532
+ * @returns {void}
1533
+ */
1534
+ _reportDeadlockDiagnosticPipelineFailure(diagnosticError) {
1535
+ const normalizedError = diagnosticError instanceof Error
1536
+ ? diagnosticError
1537
+ : new Error("Database deadlock retry diagnostic failed", {cause: diagnosticError})
1538
+ const payload = {
1539
+ context: {stage: "database-deadlock-retry-diagnostic"},
1540
+ error: normalizedError
1541
+ }
1542
+ let errorEvents
1543
+
1544
+ try {
1545
+ errorEvents = this.configuration.getErrorEvents()
1546
+ } catch (reportingError) {
1547
+ this.logger.warn("Database deadlock retry diagnostic pipeline reporting failed", {error: normalizedError, reportingError})
1548
+ return
1549
+ }
1550
+
1551
+ try {
1552
+ errorEvents.emit("framework-error", payload)
1553
+ } catch (reportingError) {
1554
+ this.logger.warn("Database deadlock retry framework-error listener failed", {error: normalizedError, reportingError})
1555
+ }
1556
+
1557
+ try {
1558
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
1559
+ } catch (reportingError) {
1560
+ this.logger.warn("Database deadlock retry all-error listener failed", {error: normalizedError, reportingError})
1561
+ }
1408
1562
  }
1409
1563
 
1410
1564
  /**
1411
1565
  * Builds driver-specific deadlock context. The base driver has no server diagnostic source.
1566
+ * @param {DeadlockRetryDiagnosticSnapshot} _snapshot - Immutable retry snapshot.
1412
1567
  * @returns {Promise<Record<string, ReturnType<typeof JSON.parse>>>} - Safe context fields.
1413
1568
  */
1414
- async _deadlockDiagnosticContext() {
1569
+ async _deadlockDiagnosticContext(_snapshot) {
1415
1570
  return {}
1416
1571
  }
1417
1572
 
@@ -1716,10 +1871,7 @@ export default class VelociousDatabaseDriversBase {
1716
1871
  } catch (error) {
1717
1872
  if (!(error instanceof Error)) throw error
1718
1873
 
1719
- this._failedQueryDiagnostics.set(error, {
1720
- sqlFingerprint: sqlFingerprint(sql),
1721
- sqlOperation: sql.trim().split(/\s+/, 1)[0]?.toUpperCase() || "UNKNOWN"
1722
- })
1874
+ this._failedQueryDiagnostics.set(error, sqlDiagnostic(sql))
1723
1875
 
1724
1876
  // A deliberately-aborted query must never be silently re-run — its
1725
1877
  // connection was destroyed on purpose, so treat it as terminal.
@@ -0,0 +1,227 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * InnodbDeadlockLockNode type.
5
+ * @typedef {object} InnodbDeadlockLockNode
6
+ * @property {string} indexFingerprint - Opaque index identity.
7
+ * @property {string} lockMode - Allowlisted lock mode.
8
+ * @property {"conflicting" | "held" | "waiting"} state - Lock relationship to its transaction.
9
+ * @property {string} tableFingerprint - Opaque table identity.
10
+ */
11
+
12
+ /**
13
+ * InnodbDeadlockTransactionNode type.
14
+ * @typedef {object} InnodbDeadlockTransactionNode
15
+ * @property {InnodbDeadlockLockNode[]} conflictingLocks - Bounded counterparty conflict edges whose owner is unavailable.
16
+ * @property {InnodbDeadlockLockNode[]} locks - Bounded locks owned or awaited by this transaction.
17
+ * @property {number} ordinal - Report-local transaction ordinal.
18
+ */
19
+
20
+ /**
21
+ * InnodbDeadlockParserState type.
22
+ * @typedef {object} InnodbDeadlockParserState
23
+ * @property {InnodbDeadlockTransactionNode | undefined} currentTransaction - Current bounded transaction node.
24
+ * @property {"conflicting" | "held" | "waiting" | undefined} currentLockState - Current lock section state.
25
+ * @property {number} lockRecordCount - Total emitted lock nodes.
26
+ * @property {boolean} lockRecordsTruncated - Whether a lock-node bound was reached.
27
+ * @property {InnodbDeadlockTransactionNode[]} transactionNodes - Bounded transaction nodes.
28
+ * @property {boolean} transactionNodesTruncated - Whether the transaction-node bound was reached.
29
+ * @property {number} transactions - Transaction headers observed in the bounded section.
30
+ * @property {number | null} victimTransaction - Victim ordinal.
31
+ */
32
+
33
+ import sha256Hex from "../../../utils/sha256-hex.js"
34
+
35
+ const INNODB_STATUS_SCAN_MAX_CHARS = 65536
36
+ const INNODB_DEADLOCK_SECTION_MAX_CHARS = 16384
37
+ const INNODB_DEADLOCK_TRANSACTION_MAX = 8
38
+ const INNODB_DEADLOCK_LOCKS_PER_TRANSACTION_MAX = 8
39
+ const INNODB_DEADLOCK_LOCK_RECORD_MAX = 32
40
+ const INNODB_DEADLOCK_LINE_MAX_CHARS = 1024
41
+
42
+ /**
43
+ * Parses a bounded InnoDB latest-deadlock section into safe structural context.
44
+ * @param {string} status - SHOW ENGINE INNODB STATUS text.
45
+ * @returns {{lockRecordsTruncated: boolean, sectionTruncated: boolean, transactionNodes: InnodbDeadlockTransactionNode[], transactionNodesTruncated: boolean, transactions: number, victimTransaction: number | null}} - Structural deadlock summary.
46
+ */
47
+ export default function parseInnodbDeadlockSummary(status) {
48
+ const {candidate, sectionTruncated} = boundedDeadlockSection(status)
49
+ /** @type {InnodbDeadlockParserState} */
50
+ const state = {
51
+ currentTransaction: undefined,
52
+ currentLockState: undefined,
53
+ lockRecordCount: 0,
54
+ lockRecordsTruncated: false,
55
+ transactionNodes: [],
56
+ transactionNodesTruncated: false,
57
+ transactions: 0,
58
+ victimTransaction: null
59
+ }
60
+
61
+ for (const line of candidate.split(/\r?\n/)) {
62
+ const trimmed = line.slice(0, INNODB_DEADLOCK_LINE_MAX_CHARS).trim()
63
+ const transactionOrdinal = transactionHeaderOrdinal(trimmed)
64
+
65
+ if (transactionOrdinal !== undefined) {
66
+ startTransactionNode(state, transactionOrdinal)
67
+ continue
68
+ }
69
+
70
+ const lockStateMarker = transactionLockStateMarker(trimmed, state.currentTransaction)
71
+
72
+ if (lockStateMarker.matched) {
73
+ state.currentLockState = lockStateMarker.state
74
+ continue
75
+ }
76
+
77
+ const victimOrdinal = victimTransactionOrdinal(trimmed)
78
+
79
+ if (victimOrdinal !== undefined) state.victimTransaction = victimOrdinal
80
+ appendLockNode(state, trimmed)
81
+ }
82
+
83
+ return {
84
+ lockRecordsTruncated: state.lockRecordsTruncated,
85
+ sectionTruncated,
86
+ transactionNodes: state.transactionNodes,
87
+ transactionNodesTruncated: state.transactionNodesTruncated,
88
+ transactions: state.transactions,
89
+ victimTransaction: state.victimTransaction
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Extracts and caps the latest-deadlock section.
95
+ * @param {string} status - Raw server status.
96
+ * @returns {{candidate: string, sectionTruncated: boolean}} - Bounded candidate and truncation state.
97
+ */
98
+ function boundedDeadlockSection(status) {
99
+ const scannedStatus = status.slice(0, INNODB_STATUS_SCAN_MAX_CHARS)
100
+ const deadlockStart = scannedStatus.indexOf("LATEST DETECTED DEADLOCK")
101
+ const availableSection = deadlockStart >= 0 ? scannedStatus.slice(deadlockStart) : ""
102
+ const boundedSection = availableSection.slice(0, INNODB_DEADLOCK_SECTION_MAX_CHARS)
103
+ const sectionEndMatch = /\n-{10,}\r?\nTRANSACTIONS\r?\n-{10,}/.exec(boundedSection)
104
+ const scannedStatusTruncated = status.length > scannedStatus.length
105
+
106
+ return {
107
+ candidate: sectionEndMatch ? boundedSection.slice(0, sectionEndMatch.index) : boundedSection,
108
+ sectionTruncated: !sectionEndMatch && deadlockStart >= 0 && (
109
+ availableSection.length > boundedSection.length || scannedStatusTruncated
110
+ )
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Returns a fixed-format transaction header ordinal.
116
+ * @param {string} line - Bounded status line.
117
+ * @returns {number | undefined} - Transaction ordinal.
118
+ */
119
+ function transactionHeaderOrdinal(line) {
120
+ const match = /^\*\*\* \((\d{1,6})\) TRANSACTION:$/.exec(line)
121
+
122
+ return match ? Number(match[1]) : undefined
123
+ }
124
+
125
+ /**
126
+ * Starts a bounded transaction node.
127
+ * @param {InnodbDeadlockParserState} state - Parser state.
128
+ * @param {number} ordinal - Transaction ordinal.
129
+ * @returns {void}
130
+ */
131
+ function startTransactionNode(state, ordinal) {
132
+ state.transactions++
133
+ state.currentLockState = undefined
134
+
135
+ if (state.transactionNodes.length >= INNODB_DEADLOCK_TRANSACTION_MAX) {
136
+ state.currentTransaction = undefined
137
+ state.transactionNodesTruncated = true
138
+ return
139
+ }
140
+
141
+ state.currentTransaction = {conflictingLocks: [], locks: [], ordinal}
142
+ state.transactionNodes.push(state.currentTransaction)
143
+ }
144
+
145
+ /**
146
+ * Parses a fixed-format held/waiting marker for the current transaction.
147
+ * @param {string} line - Bounded status line.
148
+ * @param {InnodbDeadlockTransactionNode | undefined} currentTransaction - Current transaction.
149
+ * @returns {{matched: boolean, state: "conflicting" | "held" | "waiting" | undefined}} - Marker result.
150
+ */
151
+ function transactionLockStateMarker(line, currentTransaction) {
152
+ const numberedMatch = /^\*\*\* \((\d{1,6})\) (HOLDS THE LOCK\(S\)|WAITING FOR THIS LOCK TO BE GRANTED):$/.exec(line)
153
+
154
+ if (numberedMatch) {
155
+ if (!currentTransaction || currentTransaction.ordinal != Number(numberedMatch[1])) return {matched: true, state: undefined}
156
+
157
+ return {matched: true, state: numberedMatch[2] == "HOLDS THE LOCK(S)" ? "held" : "waiting"}
158
+ }
159
+
160
+ const unnumberedMatch = /^\*\*\* (WAITING FOR THIS LOCK TO BE GRANTED|CONFLICTING WITH):$/.exec(line)
161
+
162
+ if (!unnumberedMatch) return {matched: false, state: undefined}
163
+ if (!currentTransaction) return {matched: true, state: undefined}
164
+
165
+ return {matched: true, state: unnumberedMatch[1] == "CONFLICTING WITH" ? "conflicting" : "waiting"}
166
+ }
167
+
168
+ /**
169
+ * Returns a fixed-format victim ordinal.
170
+ * @param {string} line - Bounded status line.
171
+ * @returns {number | undefined} - Victim ordinal.
172
+ */
173
+ function victimTransactionOrdinal(line) {
174
+ const match = /^\*\*\* WE ROLL BACK TRANSACTION \((\d{1,6})\)$/.exec(line)
175
+
176
+ return match ? Number(match[1]) : undefined
177
+ }
178
+
179
+ /**
180
+ * Appends one bounded, fixed-format lock node when the line is eligible.
181
+ * @param {InnodbDeadlockParserState} state - Parser state.
182
+ * @param {string} line - Bounded status line.
183
+ * @returns {void}
184
+ */
185
+ function appendLockNode(state, line) {
186
+ if (!state.currentTransaction || !state.currentLockState || !line.startsWith("RECORD LOCKS ")) return
187
+
188
+ if (
189
+ state.lockRecordCount >= INNODB_DEADLOCK_LOCK_RECORD_MAX ||
190
+ state.currentTransaction.locks.length + state.currentTransaction.conflictingLocks.length >= INNODB_DEADLOCK_LOCKS_PER_TRANSACTION_MAX
191
+ ) {
192
+ state.lockRecordsTruncated = true
193
+ return
194
+ }
195
+
196
+ const lock = deadlockLockNode(line, state.currentLockState)
197
+
198
+ if (!lock) return
199
+
200
+ if (state.currentLockState == "conflicting") {
201
+ state.currentTransaction.conflictingLocks.push(lock)
202
+ } else {
203
+ state.currentTransaction.locks.push(lock)
204
+ }
205
+ state.lockRecordCount++
206
+ }
207
+
208
+ /**
209
+ * Parses one fixed-format RECORD LOCKS line into safe structural fields.
210
+ * @param {string} line - One bounded InnoDB status line.
211
+ * @param {"conflicting" | "held" | "waiting"} state - Lock relationship to its transaction.
212
+ * @returns {InnodbDeadlockLockNode | undefined} - Safe lock node.
213
+ */
214
+ function deadlockLockNode(line, state) {
215
+ const identifier = "(?:`(?:``|[^`\\r\\n]){1,128}`|[A-Za-z0-9_$-]{1,128})"
216
+ const tableIdentifier = `(?:${identifier}\\.)?${identifier}`
217
+ const lockMatch = new RegExp(`^RECORD LOCKS .{1,512}? index (${identifier}) of table (${tableIdentifier}) trx id \\S{1,64}(?: \\S{1,64})? lock_mode (X|S|IX|IS|AUTO_INC)(?:\\s|$)`).exec(line)
218
+
219
+ if (!lockMatch) return undefined
220
+
221
+ return {
222
+ indexFingerprint: `sha256:${sha256Hex(`innodb-index:v1\0${lockMatch[1]}`)}`,
223
+ lockMode: lockMatch[3],
224
+ state,
225
+ tableFingerprint: `sha256:${sha256Hex(`innodb-table:v1\0${lockMatch[2]}`)}`
226
+ }
227
+ }