velocious 1.0.698 → 1.0.700

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 (95) hide show
  1. package/README.md +2 -2
  2. package/build/configuration-types.js +23 -0
  3. package/build/configuration.js +136 -1
  4. package/build/database/advisory-lock-backend-policy.js +204 -0
  5. package/build/database/advisory-lock-file-backend.js +348 -0
  6. package/build/database/advisory-lock-runner.js +239 -16
  7. package/build/database/drivers/base.js +318 -15
  8. package/build/database/drivers/mssql/index.js +1 -1
  9. package/build/database/drivers/mysql/index.js +4 -2
  10. package/build/database/drivers/pgsql/index.js +4 -2
  11. package/build/database/drivers/sqlite/base.js +13 -129
  12. package/build/database/drivers/sqlite/index.js +41 -62
  13. package/build/database/migrator.js +120 -50
  14. package/build/database/record/attachments/store.js +84 -1
  15. package/build/environment-handlers/base.js +13 -0
  16. package/build/environment-handlers/node.js +11 -0
  17. package/build/http-server/server-lock.js +21 -115
  18. package/build/src/configuration-types.d.ts +68 -0
  19. package/build/src/configuration-types.d.ts.map +1 -1
  20. package/build/src/configuration-types.js +21 -1
  21. package/build/src/configuration.d.ts +100 -1
  22. package/build/src/configuration.d.ts.map +1 -1
  23. package/build/src/configuration.js +124 -2
  24. package/build/src/database/advisory-lock-backend-policy.d.ts +145 -0
  25. package/build/src/database/advisory-lock-backend-policy.d.ts.map +1 -0
  26. package/build/src/database/advisory-lock-backend-policy.js +184 -0
  27. package/build/src/database/advisory-lock-file-backend.d.ts +194 -0
  28. package/build/src/database/advisory-lock-file-backend.d.ts.map +1 -0
  29. package/build/src/database/advisory-lock-file-backend.js +313 -0
  30. package/build/src/database/advisory-lock-runner.d.ts +116 -15
  31. package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
  32. package/build/src/database/advisory-lock-runner.js +190 -17
  33. package/build/src/database/drivers/base.d.ts +125 -4
  34. package/build/src/database/drivers/base.d.ts.map +1 -1
  35. package/build/src/database/drivers/base.js +282 -17
  36. package/build/src/database/drivers/mssql/index.d.ts +1 -1
  37. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  38. package/build/src/database/drivers/mssql/index.js +2 -2
  39. package/build/src/database/drivers/mysql/index.d.ts +4 -2
  40. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  41. package/build/src/database/drivers/mysql/index.js +5 -3
  42. package/build/src/database/drivers/pgsql/index.d.ts +4 -2
  43. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  44. package/build/src/database/drivers/pgsql/index.js +5 -3
  45. package/build/src/database/drivers/sqlite/base.d.ts +11 -49
  46. package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
  47. package/build/src/database/drivers/sqlite/base.js +14 -114
  48. package/build/src/database/drivers/sqlite/index.d.ts +28 -19
  49. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  50. package/build/src/database/drivers/sqlite/index.js +41 -59
  51. package/build/src/database/migrator.d.ts.map +1 -1
  52. package/build/src/database/migrator.js +110 -56
  53. package/build/src/database/record/attachments/store.d.ts +0 -3
  54. package/build/src/database/record/attachments/store.d.ts.map +1 -1
  55. package/build/src/database/record/attachments/store.js +71 -2
  56. package/build/src/environment-handlers/base.d.ts +14 -0
  57. package/build/src/environment-handlers/base.d.ts.map +1 -1
  58. package/build/src/environment-handlers/base.js +13 -1
  59. package/build/src/environment-handlers/node.d.ts +11 -0
  60. package/build/src/environment-handlers/node.d.ts.map +1 -1
  61. package/build/src/environment-handlers/node.js +11 -1
  62. package/build/src/http-server/server-lock.d.ts +13 -37
  63. package/build/src/http-server/server-lock.d.ts.map +1 -1
  64. package/build/src/http-server/server-lock.js +22 -109
  65. package/build/src/testing/test-runner.d.ts +74 -0
  66. package/build/src/testing/test-runner.d.ts.map +1 -1
  67. package/build/src/testing/test-runner.js +140 -1
  68. package/build/src/testing/test-tenant-database-pool.d.ts +183 -0
  69. package/build/src/testing/test-tenant-database-pool.d.ts.map +1 -0
  70. package/build/src/testing/test-tenant-database-pool.js +388 -0
  71. package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -1
  72. package/build/src/testing/velocious-attempt-executor.js +43 -1
  73. package/build/testing/test-runner.js +152 -0
  74. package/build/testing/test-tenant-database-pool.js +438 -0
  75. package/build/testing/velocious-attempt-executor.js +44 -0
  76. package/package.json +1 -1
  77. package/src/configuration-types.js +23 -0
  78. package/src/configuration.js +136 -1
  79. package/src/database/advisory-lock-backend-policy.js +204 -0
  80. package/src/database/advisory-lock-file-backend.js +348 -0
  81. package/src/database/advisory-lock-runner.js +239 -16
  82. package/src/database/drivers/base.js +318 -15
  83. package/src/database/drivers/mssql/index.js +1 -1
  84. package/src/database/drivers/mysql/index.js +4 -2
  85. package/src/database/drivers/pgsql/index.js +4 -2
  86. package/src/database/drivers/sqlite/base.js +13 -129
  87. package/src/database/drivers/sqlite/index.js +41 -62
  88. package/src/database/migrator.js +120 -50
  89. package/src/database/record/attachments/store.js +84 -1
  90. package/src/environment-handlers/base.js +13 -0
  91. package/src/environment-handlers/node.js +11 -0
  92. package/src/http-server/server-lock.js +21 -115
  93. package/src/testing/test-runner.js +152 -0
  94. package/src/testing/test-tenant-database-pool.js +438 -0
  95. package/src/testing/velocious-attempt-executor.js +44 -0
package/README.md CHANGED
@@ -4,8 +4,7 @@
4
4
  * Database framework with familiar MVC concepts
5
5
  * Database models with migrations and validations
6
6
  * Database models that work almost the same in frontend and backend, including online CRUD for [composite primary keys](docs/composite-primary-keys.md)
7
- * Connection-scoped advisory locks with automatic cleanup before pooled connections are reused or closed, a non-throwing `withAdvisoryLockOrDefer` result for contended hot paths, and event-driven slow acquire/hold duration telemetry with holder attribution (see [docs/advisory-locks.md](docs/advisory-locks.md)) (see [docs/advisory-locks.md](docs/advisory-locks.md))
8
- * Built-in record auditing for model lifecycle changes (see [docs/auditing.md](docs/auditing.md))
7
+ * Connection-scoped advisory locks with automatic cleanup before pooled connections are reused or closed, a non-throwing `withAdvisoryLockOrDefer` result for contended hot paths, event-driven slow acquire/hold duration telemetry with holder attribution, and an explicitly opt-in file-lock fallback tier with per-lock-name minimum-tier enforcement and acquisition provenance (see [docs/advisory-locks.md](docs/advisory-locks.md) and [docs/advisory-lock-backends.md](docs/advisory-lock-backends.md))* Built-in record auditing for model lifecycle changes (see [docs/auditing.md](docs/auditing.md))
9
8
  * Declarative state machines for models, with typed event methods generated into the base model (see [docs/state-machine.md](docs/state-machine.md))
10
9
  * Migrations for schema changes and UTC datetime storage, including caller-selected `pre-runtime` and `post-publication` execution sets and recorded `changeTable` batches that combine operations into one `ALTER` on bulk-capable drivers (see [docs/database-migrations.md](docs/database-migrations.md), [docs/migration-execution-phases.md](docs/migration-execution-phases.md), and [docs/change-table.md](docs/change-table.md))
11
10
  * Tenant-selected base-model and structure generation with one immutable, fail-closed physical database context; tenant-only model metadata initializes only after that context is active (see [docs/tenant-selected-database-generation.md](docs/tenant-selected-database-generation.md))
@@ -27,6 +26,7 @@
27
26
  * Rails-style nested-attribute writes on frontend-model `save()` (see [docs/nested-attributes.md](docs/nested-attributes.md))
28
27
  * Async-aware test-data factories with inherited traits, graph-first native association autosave, metadata-aware override precedence, callbacks, sequences, linting, and a process-global reload-retention budget that bounds cache-busted re-import memory (see [docs/factories.md](docs/factories.md))
29
28
  * Opt-in Benchmark-style test profiling with privacy-safe rich JSON, directly reusable duration-aware shard manifests, and strict generic shard-profile aggregation (see [docs/test-profiling.md](docs/test-profiling.md))
29
+ * Opt-in reusable test tenant-database pools (`testTenantPools`) with truncate + migration-ledger reset between tests instead of per-test create/structure-load/drop, first-class `registerTenantForCleanup` tenant-cleanup registration on test args, and automatic lease release at attempt end (see [docs/test-tenant-pools.md](docs/test-tenant-pools.md))
30
30
  * Per-row association counts via `.withCount(...)`, including cohort-safe intersected filters, safe batching of structurally identical aggregates, and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/with-count.md](docs/with-count.md))
31
31
  * Consumer-defined per-row SQL aggregates/computations via `.queryData(...)`, with compatible projections sharing a roundtrip while preserving declared alias-overwrite order and automatic IN-list chunking for large parent sets, on frontend and backend queries (see [docs/query-data.md](docs/query-data.md))
32
32
  * Per-record ability checks via `.abilities(...)` on frontend queries + `record.can(action)` (see [docs/abilities.md](docs/abilities.md))
@@ -758,6 +758,17 @@
758
758
  * @property {string} [token] - Bearer token required in the `Authorization: Bearer <token>` header. When set, requests without a matching token are not routed (404), so the endpoint stays hidden.
759
759
  */
760
760
 
761
+ /**
762
+ * @typedef {object} TestTenantPoolConfiguration
763
+ * @property {Array<ReturnType<typeof JSON.parse>>} tenants - Pool members: the tenant descriptors whose databases the pool pre-provisions and re-uses. Pool size is the array length.
764
+ * @property {(args: {configuration: import("./configuration.js").default, databaseIdentifier: string, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [provisionTenant] - App-owned provisioning override for one member (runs instead of the framework create + structure-load + ledger-baseline default).
765
+ * @property {(args: {configuration: import("./configuration.js").default, databaseIdentifier: string, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [resetTenant] - App-owned per-test reset override (runs instead of the framework truncate + ledger-baseline default).
766
+ */
767
+
768
+ /**
769
+ * @typedef {Record<string, TestTenantPoolConfiguration>} TestTenantPoolsConfiguration
770
+ */
771
+
761
772
  /**
762
773
  * @typedef {object} TenantDatabaseProviderType
763
774
  * @property {(args: {configuration: import("./configuration.js").default, identifier: string}) => Array<ReturnType<typeof JSON.parse>> | Promise<Array<ReturnType<typeof JSON.parse>>>} listTenants - Lists tenants that should be created, checked, or migrated for this database identifier.
@@ -769,6 +780,16 @@
769
780
  * @property {(args: {configuration: import("./configuration.js").default, databaseConfiguration: DatabaseConfigurationType, identifier: string, migrationsApplied: number, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [afterMigrateTenant] - Runs app-owned tenant work after generic migrations for one tenant. `migrationsApplied` is how many migrations actually ran (0 when the tenant was already up to date), so the app can skip expensive per-tenant work on no-op deploys.
770
781
  */
771
782
 
783
+ /**
784
+ * Advisory-lock backend chain configuration (STC-210 design §10, file tier
785
+ * implemented by STC-213). Everything defaults to today's driver-native-only
786
+ * behavior; the file backend is disabled by default and only selectable by
787
+ * explicit configuration.
788
+ * @typedef {object} AdvisoryLocksConfiguration
789
+ * @property {{file?: {directory?: string, enabled?: boolean, leaseMs?: number, renewIntervalMs?: number}}} [backends] - Backend enablement. `file.enabled` defaults to false; enabling it is the deployment's explicit single-host declaration.
790
+ * @property {import("./database/advisory-lock-backend-policy.js").AdvisoryLockBackendPolicyEntry[]} [policy] - Per-lock-name minimum/allowed/preferred tier rules (design §5).
791
+ */
792
+
772
793
  /**
773
794
  * @typedef {object} ConfigurationArgsType
774
795
  * @property {boolean} [autoload] - Globally enable auto-batch-preload of relationships on lazy access. Default true.
@@ -776,6 +797,7 @@
776
797
  * @property {string} [cookieSecret] - Secret for encrypting cookies.
777
798
  * @property {AbilityResourceClassType[]} [abilityResources] - Resource classes used to define abilities per model.
778
799
  * @property {AbilityResolverType} [abilityResolver] - Resolver for creating request-scoped ability instances.
800
+ * @property {AdvisoryLocksConfiguration} [advisoryLocks] - Advisory-lock backend chain configuration (backends + per-lock-name tier policy). Defaults to today's driver-native-only behavior.
779
801
  * @property {AttachmentsConfiguration} [attachments] - Attachment storage configuration.
780
802
  * @property {BackendProjectConfiguration[]} [backendProjects] - Backend project definitions used for frontend model generation.
781
803
  * @property {VelociousPackageConfiguration[]} [packages] - External Velocious packages that contribute models, frontend-model resources and migrations.
@@ -807,6 +829,7 @@
807
829
  * @property {TenantResolverType} [tenantResolver] - Resolver for creating request-scoped tenant context objects.
808
830
  * @property {TenantDatabaseResolverType} [tenantDatabaseResolver] - Resolver for deriving tenant-specific database config overrides.
809
831
  * @property {Record<string, TenantDatabaseProviderType>} [tenantDatabaseProviders] - Tenant database lifecycle providers keyed by database identifier.
832
+ * @property {TestTenantPoolsConfiguration} [testTenantPools] - Opt-in reusable test tenant-database pools keyed by tenant database identifier. Consumed only by the test harness; ordinary runtime ignores it.
810
833
  * @property {string} [testing] - Path to the testing configuration file.
811
834
  * @property {string | (() => string | undefined)} [timeZone] - Default timezone for timezone-less datetime strings.
812
835
  * @property {number | (() => number)} [timezoneOffsetMinutes] - Default timezone offset in minutes.
@@ -45,6 +45,7 @@ import VelociousPackage from "./packages/velocious-package.js"
45
45
  import FrontendTenantSqliteLifecycle from "./tenants/frontend-tenant-sqlite-lifecycle.js"
46
46
  import { resolveGenerationId, resolveInitialGenerationState, resolveLifecycleSocketPath } from "./background-jobs/generation-identity.js"
47
47
  import { runShutdownSteps } from "./utils/shutdown-lifecycle.js"
48
+ import { resolveAdvisoryLockBackendPolicy } from "./database/advisory-lock-backend-policy.js"
48
49
 
49
50
  export { CurrentConfigurationNotSetError }
50
51
 
@@ -245,6 +246,20 @@ export default class VelociousConfiguration {
245
246
  * @type {Set<import("./database/drivers/base.js").default>} */
246
247
  _advisoryLockConnections = new Set()
247
248
 
249
+ /**
250
+ * Lazily created file-tier advisory-lock backend (STC-213). Stays `undefined`
251
+ * unless `advisoryLocks.backends.file.enabled: true` is explicitly configured —
252
+ * the file tier is never auto-enabled, and a non-Node environment handler
253
+ * (browser/Expo, no `fs`) cannot create one at all.
254
+ * @type {import("./database/advisory-lock-file-backend.js").default | undefined} */
255
+ _advisoryLockFileBackend = undefined
256
+
257
+ /**
258
+ * Most recent advisory-lock acquisition per lock name — the "which backend
259
+ * served this lock" surface (design §3).
260
+ * @type {Map<string, {backend: import("./database/advisory-lock-backend-policy.js").AdvisoryLockTier, fencingSupported: boolean, servedAt: string}>} */
261
+ _advisoryLockAcquisitions = new Map()
262
+
248
263
  /** @type {Map<string, number>} */
249
264
  _schemaCacheGenerationsByReuseKey = new Map()
250
265
 
@@ -260,11 +275,12 @@ export default class VelociousConfiguration {
260
275
  * Runs constructor.
261
276
  * @param {import("./configuration-types.js").ConfigurationArgsType} args - Configuration arguments.
262
277
  */
263
- constructor({abilityResolver, abilityResources, attachments, autoload = true, backgroundJobs, backendProjects, beacon, cookieSecret, cors, database, debug = false, debugEndpoint = false, apiManifest = false, directory, enforceTenantDatabaseScopes = true, environment, environmentHandler, exposeInternalErrorsToClients, frontendTenantSqlite, httpServer, initializeModels, initializers, locale, localeFallbacks, locales, logging, mailerBackend, mcp = false, packages, requestTimeoutMs, routeResolverHooks, scheduledBackgroundJobs, secureFrontendModelErrors, structureSql, sync, tenantDatabaseProviders, tenantDatabaseResolver, tenantResolver, testing, timeZone, timezoneOffsetMinutes, trustedProxies, websocketChannelResolver, websocketMessageHandlerResolver, ...restArgs}) {
278
+ constructor({abilityResolver, abilityResources, advisoryLocks, attachments, autoload = true, backgroundJobs, backendProjects, beacon, cookieSecret, cors, database, debug = false, debugEndpoint = false, apiManifest = false, directory, enforceTenantDatabaseScopes = true, environment, environmentHandler, exposeInternalErrorsToClients, frontendTenantSqlite, httpServer, initializeModels, initializers, locale, localeFallbacks, locales, logging, mailerBackend, mcp = false, packages, requestTimeoutMs, routeResolverHooks, scheduledBackgroundJobs, secureFrontendModelErrors, structureSql, sync, tenantDatabaseProviders, tenantDatabaseResolver, tenantResolver, testTenantPools, testing, timeZone, timezoneOffsetMinutes, trustedProxies, websocketChannelResolver, websocketMessageHandlerResolver, ...restArgs}) {
264
279
  restArgsError(restArgs)
265
280
 
266
281
  this._abilityResolver = abilityResolver
267
282
  this._abilityResources = abilityResources || []
283
+ this._advisoryLocks = advisoryLocks
268
284
  this._autoload = autoload
269
285
  this._backgroundJobs = backgroundJobs
270
286
  this._beacon = beacon
@@ -399,6 +415,7 @@ export default class VelociousConfiguration {
399
415
  this._tenantDatabaseProviders = tenantDatabaseProviders || {}
400
416
  this._tenantDatabaseResolver = tenantDatabaseResolver
401
417
  this._tenantResolver = tenantResolver
418
+ this._testTenantPools = testTenantPools
402
419
  this._websocketEvents = undefined
403
420
  /**
404
421
  * Stores the websocket channel subscribers value.
@@ -1445,6 +1462,12 @@ export default class VelociousConfiguration {
1445
1462
  */
1446
1463
  getTenantDatabaseResolver() { return this._tenantDatabaseResolver }
1447
1464
 
1465
+ /**
1466
+ * Runs get test tenant pool configurations.
1467
+ * @returns {import("./configuration-types.js").TestTenantPoolsConfiguration | undefined} - Opt-in test tenant database pools keyed by database identifier.
1468
+ */
1469
+ getTestTenantPools() { return this._testTenantPools }
1470
+
1448
1471
  /**
1449
1472
  * Runs get enforce tenant database scopes.
1450
1473
  * @returns {boolean} - Whether tenant-switched models require a resolved tenant database identifier.
@@ -1514,6 +1537,13 @@ export default class VelociousConfiguration {
1514
1537
  */
1515
1538
  setTenantDatabaseResolver(resolver) { this._tenantDatabaseResolver = resolver }
1516
1539
 
1540
+ /**
1541
+ * Runs set test tenant pool configurations.
1542
+ * @param {import("./configuration-types.js").TestTenantPoolsConfiguration | undefined} pools - Opt-in test tenant database pools.
1543
+ * @returns {void} - No return value.
1544
+ */
1545
+ setTestTenantPools(pools) { this._testTenantPools = pools }
1546
+
1517
1547
  /**
1518
1548
  * Runs set enforce tenant database scopes.
1519
1549
  * @param {boolean} newValue - Whether tenant-switched models require a resolved tenant database identifier.
@@ -4062,6 +4092,102 @@ export default class VelociousConfiguration {
4062
4092
  })
4063
4093
  }
4064
4094
 
4095
+ /**
4096
+ * Resolves the `advisoryLocks` configuration block (STC-210 design §10).
4097
+ * Full backward compatibility: an app that configures nothing gets today's
4098
+ * driver-native-only behavior — the file backend is **disabled by default**
4099
+ * and only ever selectable by explicit configuration.
4100
+ * @returns {{backends: {file: {directory?: string, enabled: boolean, leaseMs: number, renewIntervalMs: number}}, policy: import("./database/advisory-lock-backend-policy.js").AdvisoryLockBackendPolicyEntry[]}} - Advisory-lock configuration with defaults applied.
4101
+ */
4102
+ getAdvisoryLockConfig() {
4103
+ const configured = this._advisoryLocks || {}
4104
+ const fileConfigured = configured.backends?.file || {}
4105
+
4106
+ return {
4107
+ backends: {
4108
+ file: {
4109
+ directory: fileConfigured.directory,
4110
+ enabled: fileConfigured.enabled === true,
4111
+ leaseMs: typeof fileConfigured.leaseMs === "number" ? fileConfigured.leaseMs : 30_000,
4112
+ renewIntervalMs: typeof fileConfigured.renewIntervalMs === "number" ? fileConfigured.renewIntervalMs : 10_000
4113
+ }
4114
+ },
4115
+ policy: Array.isArray(configured.policy) ? configured.policy : []
4116
+ }
4117
+ }
4118
+
4119
+ /**
4120
+ * Runs get advisory lock policy.
4121
+ * @returns {import("./database/advisory-lock-backend-policy.js").AdvisoryLockBackendPolicyEntry[]} - Per-lock-name backend policy entries.
4122
+ */
4123
+ getAdvisoryLockPolicy() {
4124
+ return this.getAdvisoryLockConfig().policy
4125
+ }
4126
+
4127
+ /**
4128
+ * Resolves the effective backend policy for one lock name (design §5).
4129
+ * @param {string} lockName - Concrete lock name.
4130
+ * @returns {import("./database/advisory-lock-backend-policy.js").ResolvedAdvisoryLockBackendPolicy} - Resolved policy.
4131
+ */
4132
+ resolveAdvisoryLockPolicy(lockName) {
4133
+ return resolveAdvisoryLockBackendPolicy(lockName, this.getAdvisoryLockPolicy())
4134
+ }
4135
+
4136
+ /**
4137
+ * Returns the file-tier advisory-lock backend when this deployment has
4138
+ * explicitly enabled it (`advisoryLocks.backends.file.enabled: true`),
4139
+ * creating it on first use. Returns `undefined` when the tier is disabled
4140
+ * (the default) — callers must never fall back to it implicitly. Creation
4141
+ * goes through the environment handler so browser/Expo configurations
4142
+ * without `fs` fail loudly instead of half-working.
4143
+ * @returns {Promise<import("./database/advisory-lock-file-backend.js").default | undefined>} - Resolves with the enabled file backend, or undefined.
4144
+ */
4145
+ async getAdvisoryLockFileBackend() {
4146
+ const config = this.getAdvisoryLockConfig()
4147
+
4148
+ if (!config.backends.file.enabled) return undefined
4149
+ if (this._advisoryLockFileBackend) return this._advisoryLockFileBackend
4150
+
4151
+ const directory = config.backends.file.directory || `${this.getDirectory()}/tmp/advisory-locks`
4152
+
4153
+ this._advisoryLockFileBackend = await this.getEnvironmentHandler().createAdvisoryLockFileBackend({
4154
+ directory,
4155
+ leaseMs: config.backends.file.leaseMs,
4156
+ renewIntervalMs: config.backends.file.renewIntervalMs
4157
+ })
4158
+
4159
+ return this._advisoryLockFileBackend
4160
+ }
4161
+
4162
+ /**
4163
+ * Records which backend tier served one advisory-lock acquisition so
4164
+ * `getAdvisoryLockBackendStatus()` can answer "which backend served this
4165
+ * lock" (design §3).
4166
+ * @param {object} args - Acquisition record.
4167
+ * @param {string} args.lockName - Lock name.
4168
+ * @param {import("./database/advisory-lock-backend-policy.js").AdvisoryLockTier} args.backend - Tier that served the acquisition.
4169
+ * @param {boolean} args.fencingSupported - Whether the serving tier carries fencing tokens.
4170
+ * @returns {void}
4171
+ */
4172
+ recordAdvisoryLockAcquisition({lockName, backend, fencingSupported}) {
4173
+ this._advisoryLockAcquisitions.set(lockName, {backend, fencingSupported, servedAt: new Date().toISOString()})
4174
+ }
4175
+
4176
+ /**
4177
+ * The "which backend served this lock" diagnostics surface (design §3):
4178
+ * per lock name, the tier of the most recent acquisition, whether that tier
4179
+ * carries fencing tokens, and the resolved policy (minimum/allowed/preferred
4180
+ * tiers) for the name.
4181
+ * @param {string} lockName - Lock name.
4182
+ * @returns {{acquisition: {backend: import("./database/advisory-lock-backend-policy.js").AdvisoryLockTier, fencingSupported: boolean, servedAt: string} | null, policy: import("./database/advisory-lock-backend-policy.js").ResolvedAdvisoryLockBackendPolicy}} - Backend status for the name.
4183
+ */
4184
+ getAdvisoryLockBackendStatus(lockName) {
4185
+ return {
4186
+ acquisition: this._advisoryLockAcquisitions.get(lockName) ?? null,
4187
+ policy: this.resolveAdvisoryLockPolicy(lockName)
4188
+ }
4189
+ }
4190
+
4065
4191
  /**
4066
4192
  * Registers a dedicated connection that currently holds an advisory lock, so a
4067
4193
  * shutdown can close it and release the lock. See `_advisoryLockConnections`.
@@ -4141,6 +4267,15 @@ export default class VelociousConfiguration {
4141
4267
  // leave the rest of the connections open.
4142
4268
  await this._closeAdvisoryLockConnections()
4143
4269
  } finally {
4270
+ try {
4271
+ // Release file-tier advisory-lock holds explicitly (STC-213): the
4272
+ // lease would make them reclaimable anyway, but a clean shutdown
4273
+ // should not leave lock directories behind for the staleness path.
4274
+ await this._advisoryLockFileBackend?.releaseAllHeld()
4275
+ } catch (error) {
4276
+ closeErrors.push(error instanceof Error ? error : new Error(String(error)))
4277
+ }
4278
+
4144
4279
  for (const pool of Object.values(this.databasePools)) {
4145
4280
  if (!pool) continue
4146
4281
 
@@ -0,0 +1,204 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Advisory-lock backend tiers (STC-210 design, `docs/advisory-lock-backends.md` §1/§9).
5
+ * `process-local` and `file` are host-local; `driver-native` and `lock-server` are
6
+ * cross-host. Tier strength order (strongest first):
7
+ * `lock-server` ≡ `driver-native` > `file` > `process-local`.
8
+ * @typedef {"process-local" | "file" | "driver-native" | "lock-server"} AdvisoryLockTier
9
+ */
10
+ /**
11
+ * One per-lock-name backend policy entry as declared in configuration (§5).
12
+ * @typedef {object} AdvisoryLockBackendPolicyEntry
13
+ * @property {string} name - Lock name or `*`-suffixed glob pattern (e.g. `background_jobs:*`).
14
+ * @property {AdvisoryLockTier} [minTier] - Weakest tier this name may ever be served by. Defaults to `driver-native` (default-deny).
15
+ * @property {AdvisoryLockTier[]} [allowedTiers] - Explicit whitelist of servable tiers. Defaults to every tier at or above `minTier`.
16
+ * @property {AdvisoryLockTier} [preferredTier] - Tier used when available; falling below it triggers the fallback report (§3). Defaults to `driver-native`.
17
+ */
18
+ /**
19
+ * Policy resolved for one concrete lock name, with tier gaps already closed:
20
+ * `allowedTiers` always contains every tier at or above `minTier`, so a
21
+ * cross-host-minimum name can never be served by `file` or `process-local`.
22
+ * @typedef {object} ResolvedAdvisoryLockBackendPolicy
23
+ * @property {AdvisoryLockTier} minTier - Weakest tier this name may be served by.
24
+ * @property {AdvisoryLockTier[]} allowedTiers - Tiers that may serve this name (minimum-closed).
25
+ * @property {AdvisoryLockTier} preferredTier - Tier preferred when available.
26
+ * @property {AdvisoryLockBackendPolicyEntry | null} entry - The matched configuration entry, or null for the default policy.
27
+ */
28
+
29
+ /**
30
+ * Thrown when an acquisition would have to be served by a tier below the
31
+ * lock name's declared minimum tier. Fail-closed refusal (§9): the name is
32
+ * never served by the weaker tier, and the refusal is reported on the
33
+ * framework-error channel as `advisory-lock-min-tier-refused`.
34
+ */
35
+ export class AdvisoryLockMinTierRefusedError extends Error {
36
+ /**
37
+ * Runs constructor.
38
+ * @param {string} message - Error message.
39
+ * @param {{lockName: string, requestedBackend: AdvisoryLockTier, minTier: AdvisoryLockTier}} args - Refusal details.
40
+ */
41
+ constructor(message, {lockName, requestedBackend, minTier}) {
42
+ super(message)
43
+ this.name = "AdvisoryLockMinTierRefusedError"
44
+ this.lockName = lockName
45
+ this.requestedBackend = requestedBackend
46
+ this.minTier = minTier
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Thrown when no enabled backend that satisfies the lock name's minimum tier
52
+ * is available (§9). Distinct from `AdvisoryLockTimeoutError` (a backend
53
+ * served the wait but the holder never released).
54
+ */
55
+ export class AdvisoryLockBackendUnavailableError extends Error {
56
+ /**
57
+ * Runs constructor.
58
+ * @param {string} message - Error message.
59
+ * @param {{lockName: string, minTier: AdvisoryLockTier, reasons: string[]}} args - Unavailability details.
60
+ */
61
+ constructor(message, {lockName, minTier, reasons}) {
62
+ super(message)
63
+ this.name = "AdvisoryLockBackendUnavailableError"
64
+ this.lockName = lockName
65
+ this.minTier = minTier
66
+ this.reasons = reasons
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Tier strength ranks (§9): higher rank is stronger. `lock-server` and
72
+ * `driver-native` are both cross-host and rank equal.
73
+ * @type {Record<AdvisoryLockTier, number>} */
74
+ const TIER_RANKS = {
75
+ "driver-native": 2,
76
+ file: 1,
77
+ "lock-server": 3,
78
+ "process-local": 0
79
+ }
80
+
81
+ /** All tiers ordered strongest-first. */
82
+ const TIERS_STRONGEST_FIRST = /** @type {AdvisoryLockTier[]} */ (["lock-server", "driver-native", "file", "process-local"])
83
+
84
+ /**
85
+ * Runs tier rank.
86
+ * @param {AdvisoryLockTier} tier - Tier to rank.
87
+ * @returns {number} - Strength rank (higher is stronger).
88
+ */
89
+ export function advisoryLockTierRank(tier) {
90
+ const rank = TIER_RANKS[tier]
91
+
92
+ if (rank === undefined) throw new Error(`Unknown advisory-lock tier ${JSON.stringify(String(tier))}`)
93
+
94
+ return rank
95
+ }
96
+
97
+ /**
98
+ * Runs tier is at least as strong as.
99
+ * @param {AdvisoryLockTier} tier - Candidate serving tier.
100
+ * @param {AdvisoryLockTier} minimum - Required minimum tier.
101
+ * @returns {boolean} - Whether `tier` satisfies the minimum.
102
+ */
103
+ export function advisoryLockTierSatisfies(tier, minimum) {
104
+ return advisoryLockTierRank(tier) >= advisoryLockTierRank(minimum)
105
+ }
106
+
107
+ /**
108
+ * Resolves the effective backend policy for one concrete lock name:
109
+ * longest-prefix glob match over the configured entries (exact names win),
110
+ * default-deny for undeclared names (`minTier: driver-native`,
111
+ * `preferredTier: driver-native`), and `allowedTiers` closed upward so a
112
+ * cross-host minimum can never be served by a host-local tier.
113
+ * @param {string} lockName - Concrete lock name.
114
+ * @param {AdvisoryLockBackendPolicyEntry[] | undefined} policy - Configured policy entries.
115
+ * @returns {ResolvedAdvisoryLockBackendPolicy} - Resolved policy.
116
+ */
117
+ export function resolveAdvisoryLockBackendPolicy(lockName, policy) {
118
+ const entry = matchAdvisoryLockPolicyEntry(lockName, policy)
119
+ const minTier = entry?.minTier ?? "driver-native"
120
+ const preferredTier = entry?.preferredTier ?? "driver-native"
121
+
122
+ // §9: eligibility filters to tier strength >= minTier even when a weaker
123
+ // tier was declared in `allowedTiers` — a cross-host minimum can never be
124
+ // served by `file` or `process-local`. A `preferredTier` below the minimum
125
+ // stays visible here so the runner can refuse it loudly before contacting
126
+ // the weaker backend.
127
+ const declaredAllowed = entry?.allowedTiers
128
+ const allowedTiers = TIERS_STRONGEST_FIRST.filter((tier) => {
129
+ if (!advisoryLockTierSatisfies(tier, minTier)) return false
130
+
131
+ if (!Array.isArray(declaredAllowed)) return true
132
+
133
+ return declaredAllowed.includes(tier)
134
+ })
135
+
136
+ return {allowedTiers, entry: entry ?? null, minTier, preferredTier}
137
+ }
138
+
139
+ /**
140
+ * Matches one policy entry for a lock name: exact `name` matches win;
141
+ * otherwise the longest `*`-suffixed prefix pattern wins.
142
+ * @param {string} lockName - Concrete lock name.
143
+ * @param {AdvisoryLockBackendPolicyEntry[] | undefined} policy - Configured policy entries.
144
+ * @returns {AdvisoryLockBackendPolicyEntry | null} - Matched entry or null.
145
+ */
146
+ function matchAdvisoryLockPolicyEntry(lockName, policy) {
147
+ if (!Array.isArray(policy)) return null
148
+
149
+ let bestPrefixMatch = null
150
+ let bestPrefixLength = -1
151
+
152
+ for (const entry of policy) {
153
+ if (typeof entry?.name !== "string") continue
154
+
155
+ if (entry.name === lockName) return entry
156
+
157
+ if (entry.name.endsWith("*")) {
158
+ const prefix = entry.name.slice(0, -1)
159
+
160
+ if (lockName.startsWith(prefix) && prefix.length > bestPrefixLength) {
161
+ bestPrefixMatch = entry
162
+ bestPrefixLength = prefix.length
163
+ }
164
+ }
165
+ }
166
+
167
+ return bestPrefixMatch
168
+ }
169
+
170
+ /**
171
+ * Refuses a downgrade below the lock name's minimum tier before any contact
172
+ * with the weaker backend (§9), reporting the refusal on the framework-error
173
+ * channel so a silent downgrade is impossible to hide.
174
+ * @param {object} args - Refusal args.
175
+ * @param {import("../configuration.js").default} args.configuration - Configuration owning the error channel.
176
+ * @param {string} args.lockName - Lock name.
177
+ * @param {AdvisoryLockTier} args.requestedBackend - Tier the acquisition would fall to.
178
+ * @param {AdvisoryLockTier} args.minTier - Declared minimum tier.
179
+ * @returns {never} - Always throws.
180
+ */
181
+ export function refuseAdvisoryLockTierBelowMinimum({configuration, lockName, requestedBackend, minTier}) {
182
+ const error = new AdvisoryLockMinTierRefusedError(
183
+ `Advisory lock ${JSON.stringify(lockName)} requires minimum tier ${JSON.stringify(minTier)}; the ${JSON.stringify(requestedBackend)} tier is refused. Enable a cross-host backend or declare minTier: "file" for this lock name (single-host deployments only).`,
184
+ {lockName, minTier, requestedBackend}
185
+ )
186
+
187
+ configuration.getErrorEvents().emit("framework-error", {
188
+ context: "advisory-lock-min-tier-refused",
189
+ error,
190
+ lockName,
191
+ minTier,
192
+ requestedBackend
193
+ })
194
+ configuration.getErrorEvents().emit("all-error", {
195
+ context: "advisory-lock-min-tier-refused",
196
+ error,
197
+ errorType: "framework-error",
198
+ lockName,
199
+ minTier,
200
+ requestedBackend
201
+ })
202
+
203
+ throw error
204
+ }