velocious 1.0.691 → 1.0.693

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 (106) hide show
  1. package/README.md +6 -1
  2. package/build/background-jobs/scheduled-enqueue-bound.js +18 -0
  3. package/build/background-jobs/scheduled-enqueue-timeout-error.js +25 -0
  4. package/build/background-jobs/scheduler.js +121 -4
  5. package/build/background-jobs/store.js +61 -0
  6. package/build/cli/commands/mcp.js +7 -0
  7. package/build/cli/index.js +1 -1
  8. package/build/configuration-types.js +3 -0
  9. package/build/configuration.js +16 -1
  10. package/build/database/advisory-lock-bound.js +16 -0
  11. package/build/database/drivers/base.js +77 -4
  12. package/build/database/drivers/mysql/index.js +23 -0
  13. package/build/database/drivers/mysql/query.js +1 -1
  14. package/build/database/drivers/pgsql/index.js +27 -4
  15. package/build/environment-handlers/base.js +9 -0
  16. package/build/environment-handlers/node/cli/commands/mcp.js +87 -0
  17. package/build/environment-handlers/node.js +10 -0
  18. package/build/mcp/dispatcher.js +271 -0
  19. package/build/mcp/json-rpc-server.js +167 -0
  20. package/build/mcp/manifest-tools.js +310 -0
  21. package/build/routes/catch-all-route.js +58 -0
  22. package/build/routes/resolver.js +21 -8
  23. package/build/routes/root-route.js +26 -1
  24. package/build/src/background-jobs/scheduled-enqueue-bound.d.ts +15 -0
  25. package/build/src/background-jobs/scheduled-enqueue-bound.d.ts.map +1 -0
  26. package/build/src/background-jobs/scheduled-enqueue-bound.js +17 -0
  27. package/build/src/background-jobs/scheduled-enqueue-timeout-error.d.ts +24 -0
  28. package/build/src/background-jobs/scheduled-enqueue-timeout-error.d.ts.map +1 -0
  29. package/build/src/background-jobs/scheduled-enqueue-timeout-error.js +23 -0
  30. package/build/src/background-jobs/scheduler.d.ts +40 -0
  31. package/build/src/background-jobs/scheduler.d.ts.map +1 -1
  32. package/build/src/background-jobs/scheduler.js +111 -5
  33. package/build/src/background-jobs/store.d.ts +10 -0
  34. package/build/src/background-jobs/store.d.ts.map +1 -1
  35. package/build/src/background-jobs/store.js +55 -1
  36. package/build/src/cli/commands/mcp.d.ts +5 -0
  37. package/build/src/cli/commands/mcp.d.ts.map +1 -0
  38. package/build/src/cli/commands/mcp.js +7 -0
  39. package/build/src/cli/index.js +2 -2
  40. package/build/src/configuration-types.d.ts +15 -0
  41. package/build/src/configuration-types.d.ts.map +1 -1
  42. package/build/src/configuration-types.js +4 -1
  43. package/build/src/configuration.d.ts.map +1 -1
  44. package/build/src/configuration.js +13 -2
  45. package/build/src/database/advisory-lock-bound.d.ts +15 -0
  46. package/build/src/database/advisory-lock-bound.d.ts.map +1 -0
  47. package/build/src/database/advisory-lock-bound.js +16 -0
  48. package/build/src/database/drivers/base.d.ts +34 -1
  49. package/build/src/database/drivers/base.d.ts.map +1 -1
  50. package/build/src/database/drivers/base.js +74 -5
  51. package/build/src/database/drivers/mysql/index.d.ts +9 -0
  52. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  53. package/build/src/database/drivers/mysql/index.js +20 -1
  54. package/build/src/database/drivers/mysql/query.js +2 -2
  55. package/build/src/database/drivers/pgsql/index.d.ts +15 -3
  56. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  57. package/build/src/database/drivers/pgsql/index.js +23 -5
  58. package/build/src/environment-handlers/base.d.ts +6 -0
  59. package/build/src/environment-handlers/base.d.ts.map +1 -1
  60. package/build/src/environment-handlers/base.js +9 -1
  61. package/build/src/environment-handlers/node/cli/commands/mcp.d.ts +19 -0
  62. package/build/src/environment-handlers/node/cli/commands/mcp.d.ts.map +1 -0
  63. package/build/src/environment-handlers/node/cli/commands/mcp.js +70 -0
  64. package/build/src/environment-handlers/node.d.ts +6 -0
  65. package/build/src/environment-handlers/node.d.ts.map +1 -1
  66. package/build/src/environment-handlers/node.js +10 -1
  67. package/build/src/mcp/dispatcher.d.ts +133 -0
  68. package/build/src/mcp/dispatcher.d.ts.map +1 -0
  69. package/build/src/mcp/dispatcher.js +232 -0
  70. package/build/src/mcp/json-rpc-server.d.ts +47 -0
  71. package/build/src/mcp/json-rpc-server.d.ts.map +1 -0
  72. package/build/src/mcp/json-rpc-server.js +142 -0
  73. package/build/src/mcp/manifest-tools.d.ts +76 -0
  74. package/build/src/mcp/manifest-tools.d.ts.map +1 -0
  75. package/build/src/mcp/manifest-tools.js +273 -0
  76. package/build/src/routes/catch-all-route.d.ts +48 -0
  77. package/build/src/routes/catch-all-route.d.ts.map +1 -0
  78. package/build/src/routes/catch-all-route.js +50 -0
  79. package/build/src/routes/resolver.d.ts.map +1 -1
  80. package/build/src/routes/resolver.js +23 -9
  81. package/build/src/routes/root-route.d.ts +15 -1
  82. package/build/src/routes/root-route.d.ts.map +1 -1
  83. package/build/src/routes/root-route.js +24 -2
  84. package/package.json +1 -1
  85. package/src/background-jobs/scheduled-enqueue-bound.js +18 -0
  86. package/src/background-jobs/scheduled-enqueue-timeout-error.js +25 -0
  87. package/src/background-jobs/scheduler.js +121 -4
  88. package/src/background-jobs/store.js +61 -0
  89. package/src/cli/commands/mcp.js +7 -0
  90. package/src/cli/index.js +1 -1
  91. package/src/configuration-types.js +3 -0
  92. package/src/configuration.js +16 -1
  93. package/src/database/advisory-lock-bound.js +16 -0
  94. package/src/database/drivers/base.js +77 -4
  95. package/src/database/drivers/mysql/index.js +23 -0
  96. package/src/database/drivers/mysql/query.js +1 -1
  97. package/src/database/drivers/pgsql/index.js +27 -4
  98. package/src/environment-handlers/base.js +9 -0
  99. package/src/environment-handlers/node/cli/commands/mcp.js +87 -0
  100. package/src/environment-handlers/node.js +10 -0
  101. package/src/mcp/dispatcher.js +271 -0
  102. package/src/mcp/json-rpc-server.js +167 -0
  103. package/src/mcp/manifest-tools.js +310 -0
  104. package/src/routes/catch-all-route.js +58 -0
  105. package/src/routes/resolver.js +21 -8
  106. package/src/routes/root-route.js +26 -1
@@ -116,6 +116,17 @@ const SCHEDULE_KEYS_TABLE = "background_job_schedule_keys"
116
116
  const SCHEDULE_ORDER_WATERMARKS_TABLE = "background_job_schedule_order_watermarks"
117
117
  const SCHEDULE_ORDER_WATERMARK_MIGRATION_VERSION = "20260911120000"
118
118
  const SCHEDULE_HISTORY_ORDER_INDEX = "index_background_jobs_schedule_history_order"
119
+ // Composite retention indexes bound the terminal-status discovery scan to the expired
120
+ // (status, terminal_timestamp) range, so retention candidate pages stay cheap on
121
+ // multi-million-row tables. Activation was staged separately from the lock-scope fix
122
+ // because MariaDB online index DDL makes concurrent metadata inspection fail with
123
+ // ER_WARN_I_S_SKIPPED_TABLE; the driver now outlasts that skip with a bounded retry.
124
+ const RETENTION_INDEX_MIGRATION_VERSION = "20261012120000"
125
+ const RETENTION_INDEX_DEFINITIONS = [
126
+ {columns: ["status", "completed_at_ms", "id"], name: "index_background_jobs_completed_retention"},
127
+ {columns: ["status", "failed_at_ms", "id"], name: "index_background_jobs_failed_retention"},
128
+ {columns: ["status", "orphaned_at_ms", "id"], name: "index_background_jobs_orphaned_retention"}
129
+ ]
119
130
  const CONCURRENCY_TABLE = "background_job_concurrency"
120
131
  const COUNTS_REVISION_TABLE = "background_job_count_revisions"
121
132
  const COUNTS_REVISION_KEY = "counts"
@@ -2155,6 +2166,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2155
2166
  await this._ensureScheduleKeysTable(db)
2156
2167
  await this._ensureConcurrencyTable(db)
2157
2168
  await this._ensureCountRevisionTable(db)
2169
+ await this._ensureRetentionIndexesOnce(db)
2158
2170
 
2159
2171
  return
2160
2172
  }
@@ -2360,6 +2372,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2360
2372
  await this._ensureJobTimeoutColumn(db)
2361
2373
  await this._ensureChildAcceptanceColumns(db)
2362
2374
  await this._ensureJobsTableIndexesOnce(db)
2375
+ await this._ensureRetentionIndexesOnce(db)
2363
2376
  }
2364
2377
 
2365
2378
  /**
@@ -2451,6 +2464,54 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2451
2464
  }
2452
2465
  }
2453
2466
 
2467
+ /**
2468
+ * Idempotently creates the ordered composite retention indexes. An index
2469
+ * with the expected name but a different column order is a manual-repair
2470
+ * situation and fails readiness loudly instead of being rebuilt or
2471
+ * re-recorded; the migration ledger is written only once every expected
2472
+ * index exists with its exact ordered columns.
2473
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
2474
+ * @returns {Promise<void>} - Resolves when all retention indexes exist.
2475
+ */
2476
+ async _ensureRetentionIndexesOnce(db) {
2477
+ const migrationVersion = RETENTION_INDEX_MIGRATION_VERSION
2478
+ const migrationKey = this._migrationKey(migrationVersion)
2479
+
2480
+ if (await this._hasMigration(db, migrationVersion)) return
2481
+
2482
+ const acquired = await db.acquireAdvisoryLock(migrationKey)
2483
+
2484
+ if (!acquired) throw new Error("Failed to acquire background jobs retention index lock")
2485
+
2486
+ try {
2487
+ if (await this._hasMigration(db, migrationVersion)) return
2488
+
2489
+ db.clearSchemaCache()
2490
+ const table = await db.getTableByNameOrFail(JOBS_TABLE)
2491
+ const existingIndexColumns = new Map(
2492
+ (await table.getIndexes()).map((index) => [index.getName(), index.getColumnNames()])
2493
+ )
2494
+
2495
+ for (const {columns, name} of RETENTION_INDEX_DEFINITIONS) {
2496
+ const existingColumns = existingIndexColumns.get(name)
2497
+
2498
+ if (existingColumns) {
2499
+ if (existingColumns.join(",") === columns.join(",")) continue
2500
+ throw new Error(`Background jobs retention index ${name} exists with unexpected columns: ${existingColumns.join(",")}`)
2501
+ }
2502
+
2503
+ for (const sql of await db.createIndexSQLs({columns, ifNotExists: db.getType() === "sqlite", name, tableName: JOBS_TABLE})) {
2504
+ await db.query(sql)
2505
+ }
2506
+ }
2507
+
2508
+ db.clearSchemaCache()
2509
+ await this._recordMigration(db, migrationVersion)
2510
+ } finally {
2511
+ await db.releaseAdvisoryLock(migrationKey)
2512
+ }
2513
+ }
2514
+
2454
2515
  /**
2455
2516
  * Idempotently adds the per-job wall-clock timeout to existing job tables.
2456
2517
  * @param {import("../database/drivers/base.js").default} db - Database connection.
@@ -0,0 +1,7 @@
1
+ import BaseCommand from "../base-command.js"
2
+
3
+ export default class VelociousCliCommandsMcp extends BaseCommand{
4
+ async execute() {
5
+ return await this.getConfiguration().getEnvironmentHandler().cliCommandsMcp(this)
6
+ }
7
+ }
package/src/cli/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- const COMMAND_VALUE_OPTIONS = new Set(["--generation", "--initial-generation-state", "--lifecycle-socket", "--phase", "--socket", "--tenant"])
3
+ const COMMAND_VALUE_OPTIONS = new Set(["--generation", "--initial-generation-state", "--lifecycle-socket", "--phase", "--request-context", "--socket", "--tenant"])
4
4
 
5
5
  export default class VelociousCli {
6
6
  /**
@@ -68,6 +68,7 @@
68
68
 
69
69
  /**
70
70
  * @typedef {object} DatabaseConfigurationType
71
+ * @property {number | null} [advisoryLockAcquireTimeoutMs] - Bound (ms) a blocking advisory-lock acquisition waits for a contended lock before reporting failure. Default: `900000` (15 minutes). `null` opts out and restores the driver's indefinite wait. A per-call `timeoutMs` argument always overrides this.
71
72
  * @property {string} [databaseCharset] - Default character set applied by `db:create` via mysql/mariadb `CREATE DATABASE ... CHARACTER SET`. Distinct from `charset`, which is the client connection charset forwarded to the mysql2 driver.
72
73
  * @property {string} [databaseCollation] - Default collation applied by `db:create` via mysql/mariadb `CREATE DATABASE ... COLLATE`.
73
74
  * @property {string} [database] - Database name for this connection.
@@ -93,6 +94,7 @@
93
94
  * @property {boolean} [readOnly] - Whether writes should be blocked for this database.
94
95
  * @property {string} [schema] - Default schema for unqualified table lookups (MSSQL).
95
96
  * @property {boolean} [schemaCache] - Whether schema metadata should be cached on the driver. Defaults to true.
97
+ * @property {number} [schemaMetadataRetryDeadlineMs] - Bounded deadline (ms) for outlasting a cached schema-metadata read the server skipped because concurrent online DDL is modifying the table. Defaults to 60000.
96
98
  * @property {object} [record] - Record-level configuration.
97
99
  * @property {boolean} [record.transactions] - Whether record operations should use transactions.
98
100
  * @property {boolean} [reset] - Whether to reset the database on startup.
@@ -221,6 +223,7 @@
221
223
  * Priorities may be any number, including negative to sink a queue below the
222
224
  * default. Jobs within the same priority keep FIFO (`scheduled_at`, then
223
225
  * `created_at`) order. Default: `{}` (no queue caps, all queues priority `0`).
226
+ * @property {number | null} [scheduledEnqueueTimeoutMs] - Bounds one scheduled enqueue attempt in the main process. A durable enqueue that never settles (wedged DB connection, contended advisory lock) would otherwise keep its schedule key permanently in flight, so every later tick skipped itself and shutdown could never drain. On timeout the attempt is reported as `background-jobs-scheduled-enqueue-stall` and the key clears for the next tick. Default: 20 minutes — deliberately 5 minutes above `advisoryLockAcquireTimeoutMs` so the precise lock error surfaces first. `null` opts out.
224
227
  * @property {BackgroundJobsDispatchStrategy} [dispatchStrategy] - How the main process
225
228
  * detects new work. Defaults to `"beacon"` (event-driven). Set to `"polling"`
226
229
  * to restore the legacy fixed-interval poll.
@@ -26,6 +26,7 @@ import UUID from "pure-uuid"
26
26
  import translate from "gettext-universal/build/src/translate.js"
27
27
  import Ability from "./authorization/ability.js"
28
28
  import BackgroundJobsAdapter from "./background-jobs/adapter.js"
29
+ import {DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS} from "./background-jobs/scheduled-enqueue-bound.js"
29
30
  import DatabaseOperation from "./database/operation.js"
30
31
  import { initializeAuditedModelRelationships } from "./database/record/auditing.js"
31
32
  import EventEmitter from "./utils/event-emitter.js"
@@ -1707,6 +1708,20 @@ export default class VelociousConfiguration {
1707
1708
  const drainStoreOperationTimeoutMs = typeof configured.drainStoreOperationTimeoutMs === "number" && configured.drainStoreOperationTimeoutMs >= 1
1708
1709
  ? configured.drainStoreOperationTimeoutMs
1709
1710
  : 60_000
1711
+ // `null` is an explicit opt-out of the scheduled-enqueue bound. A present
1712
+ // but non-positive number is a mistake, not an opt-out, so it falls back to
1713
+ // the framework default rather than silently disabling the watchdog.
1714
+ const scheduledEnqueueTimeoutMs = "scheduledEnqueueTimeoutMs" in configured
1715
+ ? (
1716
+ configured.scheduledEnqueueTimeoutMs === null ?
1717
+ null
1718
+ : (
1719
+ typeof configured.scheduledEnqueueTimeoutMs === "number" && configured.scheduledEnqueueTimeoutMs > 0 ?
1720
+ configured.scheduledEnqueueTimeoutMs
1721
+ : DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS
1722
+ )
1723
+ )
1724
+ : DEFAULT_SCHEDULED_ENQUEUE_TIMEOUT_MS
1710
1725
  const queues = configured.queues && typeof configured.queues === "object" ? configured.queues : {}
1711
1726
  // An explicit config value wins over the env var — including `null`/`0`,
1712
1727
  // which disable the backstop even when the environment sets a default.
@@ -1732,7 +1747,7 @@ export default class VelociousConfiguration {
1732
1747
 
1733
1748
  const jobClasses = this.getBackgroundJobClasses()
1734
1749
 
1735
- return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, runnerMaxOldSpaceSizeMb, runnerMallocArenaMax, dispatchStrategy, pollIntervalMs, drainStoreOperationTimeoutMs, queues, jobClasses, jobTimeoutMs, retention, generationId, initialGenerationState, lifecycleSocketPath}
1750
+ return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, runnerMaxOldSpaceSizeMb, runnerMallocArenaMax, dispatchStrategy, pollIntervalMs, drainStoreOperationTimeoutMs, scheduledEnqueueTimeoutMs, queues, jobClasses, jobTimeoutMs, retention, generationId, initialGenerationState, lifecycleSocketPath}
1736
1751
  }
1737
1752
 
1738
1753
  /**
@@ -0,0 +1,16 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Default bound for one blocking advisory-lock acquisition.
5
+ *
6
+ * A blocking `acquireAdvisoryLock(name)` call with no explicit timeout used to park a pooled
7
+ * connection for the driver's indefinite wait (MySQL/MariaDB: one year). One wedged holder — a
8
+ * killed process, a runner that died while holding the lock, a connection that never returned —
9
+ * then convoyed every later caller on the same lock name: the queue kept growing while every pool
10
+ * connection sat in MariaDB's `User lock` state and no dispatch happened anywhere.
11
+ *
12
+ * The default is deliberately far above any contention observed in healthy production operation, so
13
+ * a legitimate lock handover is never cut off, while a wedged holder stops stealing pool capacity
14
+ * forever. Pass `null` explicitly to opt back in to the indefinite wait.
15
+ * @type {number} */
16
+ export const DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS = 15 * 60 * 1000
@@ -160,6 +160,7 @@ import BacktraceCleaner from "../../utils/backtrace-cleaner.js"
160
160
  import { getDatabaseAnnotations } from "../annotations.js"
161
161
  import { formatDateForDatabase } from "../datetime-storage.js"
162
162
  import isDate from "../../utils/is-date.js"
163
+ import {DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS} from "../advisory-lock-bound.js"
163
164
  import Logger from "../../logger.js"
164
165
  import Query from "../query/index.js"
165
166
  import QueryAbortedError from "../query-aborted-error.js"
@@ -183,6 +184,12 @@ const SCHEMA_INVALIDATION_SCAN_LIMIT = 8192
183
184
  /** Maximum checkout-name characters inspected by retry diagnostics. */
184
185
  const OPERATION_NAME_SCAN_LIMIT = 1024
185
186
  const REDACTED_DIAGNOSTIC_LABEL = "[REDACTED]"
187
+ /** First backoff between retries of a schema-metadata read blocked by concurrent online DDL. */
188
+ const SCHEMA_METADATA_RETRY_INITIAL_DELAY_MS = 250
189
+ /** Largest backoff between retries of a schema-metadata read blocked by concurrent online DDL. */
190
+ const SCHEMA_METADATA_RETRY_MAX_DELAY_MS = 2000
191
+ /** Default bounded deadline for outlasting a concurrent online-DDL metadata skip. */
192
+ const SCHEMA_METADATA_RETRY_DEADLINE_MS = 60000
186
193
 
187
194
  /**
188
195
  * Builds a non-reversible, stable SQL fingerprint without retaining SQL text. Literal spelling is
@@ -777,6 +784,52 @@ export default class VelociousDatabaseDriversBase {
777
784
  return this.getArgs().schemaCache !== false
778
785
  }
779
786
 
787
+ /**
788
+ * Runs schema metadata retry deadline ms.
789
+ * @returns {number} - Bounded deadline for outlasting transient schema-metadata contention.
790
+ */
791
+ _schemaMetadataRetryDeadlineMs() {
792
+ const configured = this.getArgs().schemaMetadataRetryDeadlineMs
793
+
794
+ return typeof configured === "number" ? configured : SCHEMA_METADATA_RETRY_DEADLINE_MS
795
+ }
796
+
797
+ /**
798
+ * Whether an error raised by a schema-metadata read is transient contention that a bounded
799
+ * retry can outlast. Drivers override this for their own contention signatures; the default
800
+ * treats every failure as final so only drivers that prove the transient shape retry it.
801
+ * @param {unknown} _error - Error raised by a schema-metadata read.
802
+ * @returns {boolean} - Whether the read may be retried.
803
+ */
804
+ _isTransientSchemaMetadataError(_error) {
805
+ return false
806
+ }
807
+
808
+ /**
809
+ * Runs a schema-metadata read, retrying transient concurrent-DDL contention until the bounded
810
+ * deadline. Non-transient failures and exhausted deadlines propagate the original error.
811
+ * @template T
812
+ * @param {() => Promise<T>} callback - Cache-miss schema read.
813
+ * @returns {Promise<T>} - Resolves with the read result.
814
+ */
815
+ async _withSchemaMetadataDdlRetry(callback) {
816
+ const deadlineMs = this._schemaMetadataRetryDeadlineMs()
817
+ const startedAt = Date.now()
818
+ let delayMs = SCHEMA_METADATA_RETRY_INITIAL_DELAY_MS
819
+
820
+ for (;;) {
821
+ try {
822
+ return await callback()
823
+ } catch (error) {
824
+ if (!this._isTransientSchemaMetadataError(error)) throw error
825
+ if (Date.now() - startedAt + delayMs > deadlineMs) throw error
826
+
827
+ await wait(delayMs)
828
+ delayMs = Math.min(delayMs * 2, SCHEMA_METADATA_RETRY_MAX_DELAY_MS)
829
+ }
830
+ }
831
+ }
832
+
780
833
  /**
781
834
  * Runs cached schema metadata.
782
835
  * @template T
@@ -785,7 +838,7 @@ export default class VelociousDatabaseDriversBase {
785
838
  * @returns {Promise<T>} - Resolves with the cached metadata.
786
839
  */
787
840
  async _cachedSchemaMetadata(cacheKey, callback) {
788
- if (!this._schemaCacheEnabled()) return await callback()
841
+ if (!this._schemaCacheEnabled()) return await this._withSchemaMetadataDdlRetry(callback)
789
842
 
790
843
  const existingPromise = this._schemaCache.get(cacheKey)
791
844
 
@@ -793,7 +846,7 @@ export default class VelociousDatabaseDriversBase {
793
846
  return /** @type {T} */ (this._schemaCacheReturnValue(await existingPromise))
794
847
  }
795
848
 
796
- const promise = (async () => await callback())()
849
+ const promise = (async () => await this._withSchemaMetadataDdlRetry(callback))()
797
850
 
798
851
  this._schemaCache.set(cacheKey, promise)
799
852
 
@@ -2876,17 +2929,37 @@ export default class VelociousDatabaseDriversBase {
2876
2929
  * same name and let you serialize functionality without blocking readers
2877
2930
  * or writers that do not participate in the same lock.
2878
2931
  * @param {string} name - Lock name.
2879
- * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null` or undefined blocks forever.
2932
+ * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null` blocks forever, and an omitted value uses the database's `advisoryLockAcquireTimeoutMs` or the framework default.
2880
2933
  * @returns {Promise<boolean>} - Resolves to true when the lock has been acquired, false if the timeout elapsed.
2881
2934
  */
2882
2935
  async acquireAdvisoryLock(name, args = {}) {
2883
- const acquired = await this._acquireAdvisoryLock(name, args)
2936
+ const acquired = await this._acquireAdvisoryLock(name, {timeoutMs: this.advisoryLockAcquireTimeoutMs(args)})
2884
2937
 
2885
2938
  if (acquired) this._trackAdvisoryLock(name)
2886
2939
 
2887
2940
  return acquired
2888
2941
  }
2889
2942
 
2943
+ /**
2944
+ * Resolves the wait bound for one advisory-lock acquisition.
2945
+ * An explicit `timeoutMs` — including an explicit `null` opt-out — always
2946
+ * wins. Otherwise the database configuration's `advisoryLockAcquireTimeoutMs`
2947
+ * applies, and a null there opts out too. Only when neither is configured does
2948
+ * the framework default bound the wait.
2949
+ * @param {{timeoutMs?: number | null}} [args] - Acquisition options from the caller.
2950
+ * @returns {number | null} - Wait bound in milliseconds, or `null` for an indefinite wait.
2951
+ */
2952
+ advisoryLockAcquireTimeoutMs(args = {}) {
2953
+ if (args.timeoutMs !== undefined) return args.timeoutMs
2954
+
2955
+ const configured = this.getArgs()?.advisoryLockAcquireTimeoutMs
2956
+
2957
+ if (configured === null) return null
2958
+ if (typeof configured === "number" && configured >= 0) return configured
2959
+
2960
+ return DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS
2961
+ }
2962
+
2890
2963
  /**
2891
2964
  * Driver-specific blocking advisory-lock acquisition hook.
2892
2965
  * @abstract
@@ -723,6 +723,29 @@ export default class VelociousDatabaseDriversMysql extends Base{
723
723
  return insert.toSql()
724
724
  }
725
725
 
726
+ /**
727
+ * MariaDB skips information-schema/SHOW reads for a table whose definition is being modified
728
+ * by concurrent online DDL (ER_WARN_I_S_SKIPPED_TABLE). The build completes in seconds, so the
729
+ * skip is transient contention that the bounded schema-metadata retry can outlast. Query
730
+ * helpers wrap the driver error, so the code is searched through the cause chain.
731
+ * @param {unknown} error - Error raised by a schema-metadata read.
732
+ * @returns {boolean} - Whether the read hit concurrent-DDL metadata contention.
733
+ */
734
+ _isTransientSchemaMetadataError(error) {
735
+ let currentError = /** @type {{code?: string, cause?: unknown} | undefined} */ (
736
+ /** @type {unknown} */ (error)
737
+ )
738
+
739
+ for (let depth = 0; depth < 5 && currentError; depth++) {
740
+ if (currentError.code === "ER_WARN_I_S_SKIPPED_TABLE") return true
741
+ currentError = /** @type {{code?: string, cause?: unknown} | undefined} */ (
742
+ /** @type {unknown} */ (currentError.cause)
743
+ )
744
+ }
745
+
746
+ return false
747
+ }
748
+
726
749
  /**
727
750
  * Runs get tables.
728
751
  * @returns {Promise<Array<import("../base-table.js").default>>} - Resolves with the tables.
@@ -171,7 +171,7 @@ export default async function query(pool, sql, {signal} = {}) {
171
171
  connection.release()
172
172
  }
173
173
 
174
- reject(new Error(`Query failed because of ${error}: ${sql}`))
174
+ reject(new Error(`Query failed because of ${error}: ${sql}`, {cause: error}))
175
175
 
176
176
  return
177
177
  }
@@ -22,6 +22,11 @@ import Update from "./sql/update.js"
22
22
 
23
23
  const PG_TIMESTAMP_WITHOUT_TIMEZONE_OID = 1114
24
24
 
25
+ /** Smallest wait between `pg_try_advisory_lock` probes; preserves the historical 50ms cadence for short bounds. */
26
+ const PGSQL_ADVISORY_LOCK_POLL_MIN_INTERVAL_MS = 50
27
+ /** Largest wait between `pg_try_advisory_lock` probes; keeps a long bounded wait from probing tens of thousands of times. */
28
+ const PGSQL_ADVISORY_LOCK_POLL_MAX_INTERVAL_MS = 2000
29
+
25
30
  pgTypes.setTypeParser(PG_TIMESTAMP_WITHOUT_TIMEZONE_OID, (value) => new Date(`${value.replace(" ", "T")}Z`))
26
31
 
27
32
  export default class VelociousDatabaseDriversPgsql extends Base{
@@ -425,14 +430,32 @@ export default class VelociousDatabaseDriversPgsql extends Base{
425
430
  return signed.toString()
426
431
  }
427
432
 
433
+ /**
434
+ * Resolves the `pg_try_advisory_lock` probe cadence for one bounded wait.
435
+ *
436
+ * `pg_advisory_lock` has no native timeout, so a bounded acquisition polls. Scaling the interval
437
+ * with the bound keeps the historical 50ms cadence — and its fast acquisition — for the short
438
+ * explicit `timeoutMs` values callers already pass, while a long framework default stays
439
+ * responsive without turning one contended lock into tens of thousands of probe queries.
440
+ * @param {number} timeoutMs - Bounded wait in milliseconds.
441
+ * @returns {number} - Probe interval in milliseconds.
442
+ */
443
+ static advisoryLockPollIntervalMs(timeoutMs) {
444
+ return Math.min(
445
+ Math.max(Math.floor(timeoutMs / 100), PGSQL_ADVISORY_LOCK_POLL_MIN_INTERVAL_MS),
446
+ PGSQL_ADVISORY_LOCK_POLL_MAX_INTERVAL_MS
447
+ )
448
+ }
449
+
428
450
  /**
429
451
  * Blocks until a PostgreSQL session-level advisory lock is acquired on
430
452
  * this connection. Implemented via `pg_advisory_lock(bigint)`, which has
431
453
  * no native timeout — the `timeoutMs` argument is emulated by racing a
432
- * `pg_try_advisory_lock` poll loop so callers on MySQL and Postgres see
433
- * the same contract.
454
+ * `pg_try_advisory_lock` poll loop so callers on MySQL and Postgres see the
455
+ * same contract. The poll interval backs off to a cap so a long bounded wait
456
+ * does not turn one contended lock into thousands of probe queries.
434
457
  * @param {string} name - Lock name.
435
- * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null`, `undefined`, or negative blocks forever.
458
+ * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null` or negative blocks forever, and an omitted value uses the database's `advisoryLockAcquireTimeoutMs` or the framework default.
436
459
  * @returns {Promise<boolean>} - True if the lock was acquired, false if the timeout elapsed.
437
460
  */
438
461
  async _acquireAdvisoryLock(name, {timeoutMs} = {}) {
@@ -444,7 +467,7 @@ export default class VelociousDatabaseDriversPgsql extends Base{
444
467
  }
445
468
 
446
469
  const deadline = Date.now() + timeoutMs
447
- const pollIntervalMs = 50
470
+ const pollIntervalMs = VelociousDatabaseDriversPgsql.advisoryLockPollIntervalMs(timeoutMs)
448
471
 
449
472
  while (true) {
450
473
  if (await this._tryAcquireAdvisoryLock(name)) return true
@@ -518,6 +518,15 @@ export default class VelociousEnvironmentHandlerBase {
518
518
  throw new Error("cliCommandsConsole not implemented")
519
519
  }
520
520
 
521
+ /**
522
+ * Runs cli commands mcp.
523
+ * @param {import("../cli/base-command.js").default} _command - Command.
524
+ * @returns {Promise<void>} - Resolves when the command completes.
525
+ */
526
+ async cliCommandsMcp(_command) {
527
+ throw new Error("cliCommandsMcp not implemented")
528
+ }
529
+
521
530
  /**
522
531
  * Runs cli commands server.
523
532
  * @param {import("../cli/base-command.js").default} _command - Command.
@@ -0,0 +1,87 @@
1
+ // @ts-check
2
+
3
+ import Application from "../../../../application.js"
4
+ import BaseCommand from "../../../../cli/base-command.js"
5
+ import {startMcpStdioServer} from "../../../../mcp/json-rpc-server.js"
6
+
7
+ /**
8
+ * Parses `--request-context key=value` occurrences from raw process args and the
9
+ * `MCP_REQUEST_CONTEXT` environment variable (JSON object) into one context hash.
10
+ * @param {object} args - Arguments.
11
+ * @param {string[]} [args.processArgs] - Raw CLI process args.
12
+ * @returns {Record<string, string | number | boolean> | undefined} - Parsed request context, or undefined when none configured.
13
+ */
14
+ export function parseMcpRequestContext({processArgs = []}) {
15
+ /** @type {Record<string, string | number | boolean>} */
16
+ const context = {}
17
+
18
+ const envValue = process.env.MCP_REQUEST_CONTEXT
19
+
20
+ if (envValue) {
21
+ const parsedEnv = /** @type {Record<string, unknown>} */ (JSON.parse(envValue))
22
+
23
+ for (const [key, value] of Object.entries(parsedEnv)) {
24
+ assignContextValue(context, key, value)
25
+ }
26
+ }
27
+
28
+ for (let i = 0; i < processArgs.length; i++) {
29
+ if (processArgs[i] !== "--request-context") continue
30
+
31
+ const pair = processArgs[i + 1]
32
+
33
+ if (typeof pair !== "string") throw new Error("--request-context requires a key=value pair")
34
+
35
+ const separatorIndex = pair.indexOf("=")
36
+
37
+ if (separatorIndex < 1) throw new Error(`--request-context pair '${pair}' must be key=value`)
38
+
39
+ assignContextValue(context, pair.slice(0, separatorIndex), pair.slice(separatorIndex + 1))
40
+ i++
41
+ }
42
+
43
+ return Object.keys(context).length > 0 ? context : undefined
44
+ }
45
+
46
+ /**
47
+ * Assigns one scalar context value, converting numeric strings to numbers.
48
+ * @param {Record<string, string | number | boolean>} context - Target context hash.
49
+ * @param {string} key - Context key.
50
+ * @param {unknown} value - Raw value.
51
+ * @returns {void} - No return value.
52
+ */
53
+ function assignContextValue(context, key, value) {
54
+ if (typeof value === "number" || typeof value === "boolean") {
55
+ context[key] = value
56
+
57
+ return
58
+ }
59
+
60
+ if (typeof value !== "string") throw new Error(`Request context value for '${key}' must be a scalar`)
61
+
62
+ const asNumber = Number(value)
63
+
64
+ context[key] = value.length > 0 && Number.isFinite(asNumber) ? asNumber : value
65
+ }
66
+
67
+ export default class VelociousCliCommandsMcp extends BaseCommand{
68
+ /**
69
+ * Runs execute.
70
+ * @returns {Promise<void>} - Starts the MCP stdio server and waits until stdin closes.
71
+ */
72
+ async execute() {
73
+ // Identify this process in `ps`/`top` instead of a generic "node" entry.
74
+ process.title = "velocious mcp"
75
+
76
+ const configuration = this.getConfiguration()
77
+ const requestContext = parseMcpRequestContext({processArgs: this.processArgs})
78
+ const application = new Application({configuration, type: "mcp"})
79
+
80
+ await application.initialize()
81
+
82
+ const server = startMcpStdioServer({configuration, requestContext})
83
+
84
+ await server.done
85
+ await application.stop()
86
+ }
87
+ }
@@ -11,6 +11,7 @@ import CliCommandsGenerateFrontendModels from "./node/cli/commands/generate/fron
11
11
  import CliCommandsGenerateMigration from "./node/cli/commands/generate/migration.js"
12
12
  import CliCommandsGenerateModel from "./node/cli/commands/generate/model.js"
13
13
  import CliCommandsLintRelationships from "./node/cli/commands/lint/relationships.js"
14
+ import CliCommandsMcp from "./node/cli/commands/mcp.js"
14
15
  import CliCommandsRoutes from "./node/cli/commands/routes.js"
15
16
  import CliCommandsServer from "./node/cli/commands/server.js"
16
17
  import CliCommandsTest from "./node/cli/commands/test.js"
@@ -859,6 +860,15 @@ export default class VelociousEnvironmentHandlerNode extends Base{
859
860
  return await this.forwardCommand(command, CliCommandsServer)
860
861
  }
861
862
 
863
+ /**
864
+ * Runs cli commands mcp.
865
+ * @param {import("../cli/base-command.js").default} command - Command.
866
+ * @returns {Promise<void>} - Resolves when the command completes.
867
+ */
868
+ async cliCommandsMcp(command) {
869
+ return await this.forwardCommand(command, CliCommandsMcp)
870
+ }
871
+
862
872
  /**
863
873
  * Runs cli commands test.
864
874
  * @param {import("../cli/base-command.js").default} command - Command.