velocious 1.0.509 → 1.0.511

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 (49) hide show
  1. package/README.md +1 -0
  2. package/build/configuration-types.js +7 -0
  3. package/build/configuration.js +74 -2
  4. package/build/database/advisory-lock-runner.js +192 -0
  5. package/build/database/drivers/mysql/index.js +3 -0
  6. package/build/database/record/index.js +23 -111
  7. package/build/database/tenants/schema-cloner.js +49 -13
  8. package/build/frontend-models/resource-definition.js +144 -0
  9. package/build/routes/built-in/api-manifest/controller.js +14 -0
  10. package/build/src/configuration-types.d.ts +21 -1
  11. package/build/src/configuration-types.d.ts.map +1 -1
  12. package/build/src/configuration-types.js +7 -1
  13. package/build/src/configuration.d.ts +35 -2
  14. package/build/src/configuration.d.ts.map +1 -1
  15. package/build/src/configuration.js +68 -3
  16. package/build/src/database/advisory-lock-runner.d.ts +124 -0
  17. package/build/src/database/advisory-lock-runner.d.ts.map +1 -0
  18. package/build/src/database/advisory-lock-runner.js +176 -0
  19. package/build/src/database/drivers/mysql/index.d.ts +2 -2
  20. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  21. package/build/src/database/drivers/mysql/index.js +3 -1
  22. package/build/src/database/record/attachments/handle.d.ts +1 -1
  23. package/build/src/database/record/index.d.ts +13 -61
  24. package/build/src/database/record/index.d.ts.map +1 -1
  25. package/build/src/database/record/index.js +24 -108
  26. package/build/src/database/tenants/schema-cloner.d.ts +11 -1
  27. package/build/src/database/tenants/schema-cloner.d.ts.map +1 -1
  28. package/build/src/database/tenants/schema-cloner.js +45 -13
  29. package/build/src/frontend-models/resource-definition.d.ts +9 -0
  30. package/build/src/frontend-models/resource-definition.d.ts.map +1 -1
  31. package/build/src/frontend-models/resource-definition.js +130 -1
  32. package/build/src/routes/built-in/api-manifest/controller.d.ts +9 -0
  33. package/build/src/routes/built-in/api-manifest/controller.d.ts.map +1 -0
  34. package/build/src/routes/built-in/api-manifest/controller.js +13 -0
  35. package/build/src/sync/sync-envelope-replay-service.d.ts +1 -1
  36. package/build/src/sync/sync-replay-upsert-applier.d.ts +2 -2
  37. package/build/src/sync/sync-resource-base.js +3 -3
  38. package/build/sync/sync-resource-base.js +2 -2
  39. package/build/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +1 -1
  41. package/src/configuration-types.js +7 -0
  42. package/src/configuration.js +74 -2
  43. package/src/database/advisory-lock-runner.js +192 -0
  44. package/src/database/drivers/mysql/index.js +3 -0
  45. package/src/database/record/index.js +23 -111
  46. package/src/database/tenants/schema-cloner.js +49 -13
  47. package/src/frontend-models/resource-definition.js +144 -0
  48. package/src/routes/built-in/api-manifest/controller.js +14 -0
  49. package/src/sync/sync-resource-base.js +2 -2
package/README.md CHANGED
@@ -30,6 +30,7 @@
30
30
  * Planned local-first shared-resource sync architecture (see [docs/offline-sync.md](docs/offline-sync.md))
31
31
  * Named database connection checkouts, bounded pool waits, and debugging held connections (see [docs/database-connections.md](docs/database-connections.md))
32
32
  * Optional built-in debug endpoint for inspecting server and database connection state (see [docs/debug-endpoint.md](docs/debug-endpoint.md))
33
+ * Optional built-in API manifest endpoint describing every registered frontend-model resource as human- and machine-readable JSON (see [docs/api-manifest-endpoint.md](docs/api-manifest-endpoint.md))
33
34
 
34
35
  # Setup
35
36
 
@@ -554,6 +554,12 @@
554
554
  * @typedef {function({configuration: import("./configuration.js").default, databaseConfiguration: DatabaseConfigurationType, identifier: string, tenant: ?}) : DatabaseConfigurationType | Partial<DatabaseConfigurationType> | void} TenantDatabaseResolverType
555
555
  */
556
556
 
557
+ /**
558
+ * @typedef {object} ApiManifestConfiguration
559
+ * @property {string} [path] - HTTP path for the built-in API manifest endpoint. Defaults to `/api/manifest`.
560
+ * @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.
561
+ */
562
+
557
563
  /**
558
564
  * @typedef {object} DebugEndpointConfiguration
559
565
  * @property {string} [path] - HTTP path for the built-in debug endpoint. Defaults to `/velocious/debug`.
@@ -583,6 +589,7 @@
583
589
  * @property {{[key: string]: {[key: string]: DatabaseConfigurationType}}} database - Database configurations keyed by environment and identifier.
584
590
  * @property {boolean} [debug] - Enable debug logging.
585
591
  * @property {boolean | DebugEndpointConfiguration} [debugEndpoint] - Enable the built-in debug endpoint. Defaults to false.
592
+ * @property {boolean | ApiManifestConfiguration} [apiManifest] - Enable the built-in API manifest endpoint. Defaults to false.
586
593
  * @property {string} [directory] - Base directory for the project.
587
594
  * @property {boolean} [enforceTenantDatabaseScopes] - Require tenant-switched model queries to resolve a tenant database identifier. Defaults to true.
588
595
  * @property {string} [environment] - Current environment name.
@@ -20,7 +20,7 @@ import EventEmitter from "./utils/event-emitter.js"
20
20
  import VelociousWebsocketChannelSubscribers from "./http-server/websocket-channel-subscribers.js"
21
21
  import {CurrentConfigurationNotSetError, currentConfiguration, setCurrentConfiguration} from "./current-configuration.js"
22
22
  import {requestDetails} from "./error-reporting/request-details.js"
23
- import {frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
23
+ import {frontendModelApiManifest, frontendModelResourceClassFromDefinition, frontendModelResourceConfigurationFromDefinition, frontendModelResourcesForBackendProject} from "./frontend-models/resource-definition.js"
24
24
  import {currentOfflineGrantSigningKey, normalizeOfflineGrantSigningKey} from "./sync/offline-grant.js"
25
25
  import PluginRoutes from "./routes/plugin-routes.js"
26
26
  import restArgsError from "./utils/rest-args-error.js"
@@ -129,7 +129,7 @@ export default class VelociousConfiguration {
129
129
  * Runs constructor.
130
130
  * @param {import("./configuration-types.js").ConfigurationArgsType} args - Configuration arguments.
131
131
  */
132
- constructor({abilityResolver, abilityResources, attachments, autoload = true, backgroundJobs, backendProjects, beacon, cookieSecret, cors, database, debug = false, debugEndpoint = false, directory, enforceTenantDatabaseScopes = true, environment, environmentHandler, exposeInternalErrorsToClients = false, httpServer, initializeModels, initializers, locale, localeFallbacks, locales, logging, mailerBackend, packages, requestTimeoutMs, routeResolverHooks, scheduledBackgroundJobs, structureSql, sync, tenantDatabaseProviders, tenantDatabaseResolver, tenantResolver, testing, timeZone, timezoneOffsetMinutes, trustedProxies, websocketChannelResolver, websocketMessageHandlerResolver, ...restArgs}) {
132
+ constructor({abilityResolver, abilityResources, attachments, autoload = true, backgroundJobs, backendProjects, beacon, cookieSecret, cors, database, debug = false, debugEndpoint = false, apiManifest = false, directory, enforceTenantDatabaseScopes = true, environment, environmentHandler, exposeInternalErrorsToClients = false, httpServer, initializeModels, initializers, locale, localeFallbacks, locales, logging, mailerBackend, packages, requestTimeoutMs, routeResolverHooks, scheduledBackgroundJobs, structureSql, sync, tenantDatabaseProviders, tenantDatabaseResolver, tenantResolver, testing, timeZone, timezoneOffsetMinutes, trustedProxies, websocketChannelResolver, websocketMessageHandlerResolver, ...restArgs}) {
133
133
  restArgsError(restArgs)
134
134
 
135
135
  this._abilityResolver = abilityResolver
@@ -172,6 +172,7 @@ export default class VelociousConfiguration {
172
172
  this.database = database
173
173
  this.debug = debug
174
174
  this._debugEndpoint = this._normalizeDebugEndpoint(debugEndpoint)
175
+ this._apiManifest = this._normalizeApiManifest(apiManifest)
175
176
  this._environment = environment || process.env.VELOCIOUS_ENV || process.env.NODE_ENV || "development"
176
177
  this._environmentHandler = environmentHandler
177
178
  this._enforceTenantDatabaseScopes = enforceTenantDatabaseScopes
@@ -273,6 +274,7 @@ export default class VelociousConfiguration {
273
274
  this._mailerBackend = mailerBackend
274
275
  this._routeResolverHooks = [...(routeResolverHooks || [])]
275
276
  this._addDebugEndpointRouteHook()
277
+ this._addApiManifestRouteHook()
276
278
 
277
279
  /**
278
280
  * Stores the applied route mounts value.
@@ -351,6 +353,59 @@ export default class VelociousConfiguration {
351
353
  return {enabled: true, path, token: token === null ? null : token.trim()}
352
354
  }
353
355
 
356
+ /**
357
+ * Runs normalize api manifest.
358
+ * @param {boolean | {path?: string, token?: string}} value - API manifest configuration.
359
+ * @returns {{enabled: boolean, path: string, token: string | null}} - Normalized API manifest configuration.
360
+ */
361
+ _normalizeApiManifest(value) {
362
+ if (value === false || value === undefined) return {enabled: false, path: "/api/manifest", token: null}
363
+ if (value === true) return {enabled: true, path: "/api/manifest", token: null}
364
+
365
+ if (typeof value !== "object" || value === null) {
366
+ throw new Error(`Expected apiManifest to be a boolean or object, got: ${String(value)}`)
367
+ }
368
+
369
+ const path = value.path || "/api/manifest"
370
+
371
+ if (typeof path !== "string" || !path.startsWith("/")) {
372
+ throw new Error(`Expected apiManifest.path to be a string starting with '/', got: ${String(path)}`)
373
+ }
374
+
375
+ const token = value.token === undefined || value.token === null ? null : value.token
376
+
377
+ if (token !== null && (typeof token !== "string" || !token.trim())) {
378
+ throw new Error(`Expected apiManifest.token to be a non-empty string, got: ${String(token)}`)
379
+ }
380
+
381
+ return {enabled: true, path, token: token === null ? null : token.trim()}
382
+ }
383
+
384
+ /**
385
+ * Runs add api manifest route hook.
386
+ * @returns {void} - No return value.
387
+ */
388
+ _addApiManifestRouteHook() {
389
+ if (!this._apiManifest.enabled) return
390
+
391
+ this.addRouteResolverHook(({currentPath, request}) => {
392
+ if (request.httpMethod() !== "GET") return null
393
+ if (currentPath !== this._apiManifest.path) return null
394
+
395
+ if (this._apiManifest.token && !this.debugEndpointRequestAuthorized(request, this._apiManifest.token)) return null
396
+
397
+ return {
398
+ action: "show",
399
+ controller: "velociousApiManifest",
400
+ controllerPath: "./built-in/api-manifest/controller.js",
401
+ skipControllerConnections: true,
402
+ skipAbilityResolution: true,
403
+ skipTenantResolution: true,
404
+ viewPath: "./built-in/api-manifest"
405
+ }
406
+ })
407
+ }
408
+
354
409
  /**
355
410
  * Runs add debug endpoint route hook.
356
411
  * @returns {void} - No return value.
@@ -701,6 +756,7 @@ export default class VelociousConfiguration {
701
756
  */
702
757
  _debugConfigurationSnapshot() {
703
758
  return {
759
+ apiManifest: this._apiManifestEnabled() ? {enabled: true, path: this._apiManifest.path, tokenConfigured: Boolean(this._apiManifest.token)} : {enabled: false},
704
760
  autoload: this.getAutoload(),
705
761
  debug: this.debug === true,
706
762
  debugEndpoint: this._debugEndpointSnapshot(),
@@ -2875,4 +2931,20 @@ export default class VelociousConfiguration {
2875
2931
 
2876
2932
  return this.getEnvironmentHandler().debugEndpointTokenMatches(match[1], expectedToken)
2877
2933
  }
2934
+
2935
+ /**
2936
+ * Runs get api manifest.
2937
+ * @returns {Promise<Record<string, unknown>>} - API manifest for all registered frontend-model resources.
2938
+ */
2939
+ async getApiManifest() {
2940
+ return frontendModelApiManifest(this._backendProjects)
2941
+ }
2942
+
2943
+ /**
2944
+ * Runs whether API manifest is enabled.
2945
+ * @returns {boolean} - Whether the API manifest endpoint is enabled.
2946
+ */
2947
+ _apiManifestEnabled() {
2948
+ return this._apiManifest.enabled
2949
+ }
2878
2950
  }
@@ -0,0 +1,192 @@
1
+ // @ts-check
2
+
3
+ import timeout from "awaitery/build/timeout.js"
4
+
5
+ /**
6
+ * Thrown when an advisory lock could not be acquired before `timeoutMs` elapsed.
7
+ */
8
+ class AdvisoryLockTimeoutError extends Error {
9
+ /**
10
+ * Runs constructor.
11
+ * @param {string} message - Error message.
12
+ * @param {{name: string}} args - The advisory lock name that timed out.
13
+ */
14
+ constructor(message, {name}) {
15
+ super(message)
16
+ this.name = "AdvisoryLockTimeoutError"
17
+ this.lockName = name
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Thrown when `withAdvisoryLockOrFail` finds the lock already held.
23
+ */
24
+ class AdvisoryLockBusyError extends Error {
25
+ /**
26
+ * Runs constructor.
27
+ * @param {string} message - Error message.
28
+ * @param {{name: string}} args - The advisory lock name that was already held.
29
+ */
30
+ constructor(message, {name}) {
31
+ super(message)
32
+ this.name = "AdvisoryLockBusyError"
33
+ this.lockName = name
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Thrown when a callback holds an advisory lock longer than `holdTimeoutMs`.
39
+ */
40
+ class AdvisoryLockHoldTimeoutError extends Error {
41
+ /**
42
+ * Runs constructor.
43
+ * @param {string} message - Error message.
44
+ * @param {{name: string}} args - The advisory lock name whose hold timed out.
45
+ */
46
+ constructor(message, {name}) {
47
+ super(message)
48
+ this.name = "AdvisoryLockHoldTimeoutError"
49
+ this.lockName = name
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Runs advisory locks on the caller connection by default, using a dedicated
55
+ * lock connection only when a positive hold timeout needs separate ownership.
56
+ */
57
+ export default class AdvisoryLockRunner {
58
+ /**
59
+ * Creates an advisory-lock runner for one model database identifier.
60
+ * @param {{configuration: import("../configuration.js").default, connectionProvider: () => import("./drivers/base.js").default, databaseIdentifier: string}} args - Runner dependencies.
61
+ */
62
+ constructor({configuration, connectionProvider, databaseIdentifier}) {
63
+ this.configuration = configuration
64
+ this.connectionProvider = connectionProvider
65
+ this.databaseIdentifier = databaseIdentifier
66
+ }
67
+
68
+ /**
69
+ * Runs a callback after acquiring the advisory lock, waiting up to `timeoutMs`.
70
+ * @template T
71
+ * @param {string} name - Lock name.
72
+ * @param {() => Promise<T>} callback - Callback to invoke while the lock is held.
73
+ * @param {{timeoutMs?: number | null, holdTimeoutMs?: number | null}} [args] - Lock and hold timeout options.
74
+ * @returns {Promise<T>} - Resolves with the callback result.
75
+ */
76
+ async withAdvisoryLock(name, callback, args = {}) {
77
+ return await this.withLockConnection(args.holdTimeoutMs, async (connection) => {
78
+ const acquired = await connection.acquireAdvisoryLock(name, args)
79
+
80
+ if (!acquired) {
81
+ throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)}`, {name})
82
+ }
83
+
84
+ return await this.runLockedCallback({callback, connection, holdTimeoutMs: args.holdTimeoutMs, name})
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Runs a callback only if the advisory lock can be acquired immediately.
90
+ * @template T
91
+ * @param {string} name - Lock name.
92
+ * @param {() => Promise<T>} callback - Callback to invoke while the lock is held.
93
+ * @param {{holdTimeoutMs?: number | null}} [args] - Hold timeout options.
94
+ * @returns {Promise<T>} - Resolves with the callback result.
95
+ */
96
+ async withAdvisoryLockOrFail(name, callback, args = {}) {
97
+ return await this.withLockConnection(args.holdTimeoutMs, async (connection) => {
98
+ const acquired = await connection.tryAcquireAdvisoryLock(name)
99
+
100
+ if (!acquired) {
101
+ throw new AdvisoryLockBusyError(`Advisory lock ${JSON.stringify(name)} is already held`, {name})
102
+ }
103
+
104
+ return await this.runLockedCallback({callback, connection, holdTimeoutMs: args.holdTimeoutMs, name})
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Runs the lock holder callback and releases the lock from its owning connection.
110
+ * @template T
111
+ * @param {{callback: () => Promise<T>, connection: import("./drivers/base.js").default, holdTimeoutMs?: number | null, name: string}} args - Locked callback args.
112
+ * @returns {Promise<T>} - Resolves with the callback result.
113
+ */
114
+ async runLockedCallback({callback, connection, holdTimeoutMs, name}) {
115
+ try {
116
+ return await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs)
117
+ } finally {
118
+ await connection.releaseAdvisoryLock(name)
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Runs lock work on the caller connection unless a positive hold timeout needs
124
+ * its own lock connection.
125
+ * @template T
126
+ * @param {number | null | undefined} holdTimeoutMs - Max hold time; positive values use a dedicated lock connection.
127
+ * @param {(connection: import("./drivers/base.js").default) => Promise<T>} callback - Callback receiving the connection that owns the advisory lock.
128
+ * @returns {Promise<T>} - Resolves with the callback result.
129
+ */
130
+ async withLockConnection(holdTimeoutMs, callback) {
131
+ if (holdTimeoutMs && holdTimeoutMs > 0) {
132
+ return await this.withDedicatedConnection(callback)
133
+ }
134
+
135
+ return await callback(this.connectionProvider())
136
+ }
137
+
138
+ /**
139
+ * Spawns a hold-timeout lock connection and closes it after lock work completes when
140
+ * the spawned driver owns the underlying physical connection.
141
+ * @template T
142
+ * @param {(connection: import("./drivers/base.js").default) => Promise<T>} callback - Callback that receives the dedicated lock connection.
143
+ * @returns {Promise<T>} - Resolves with the callback result.
144
+ */
145
+ async withDedicatedConnection(callback) {
146
+ const connection = await this.configuration.getDatabasePool(this.databaseIdentifier).spawnConnection()
147
+
148
+ try {
149
+ return await callback(connection)
150
+ } finally {
151
+ if (!connection.getArgs().getConnection) {
152
+ await connection.close()
153
+ }
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Runs `callback`, rejecting with `AdvisoryLockHoldTimeoutError` if it has
159
+ * not settled within `holdTimeoutMs`. The callback is not cancelled; callers
160
+ * use a dedicated advisory-lock connection so the lock can still be released.
161
+ * @template T
162
+ * @param {string} name - Lock name (for the error message).
163
+ * @param {() => Promise<T>} callback - Callback holding the lock.
164
+ * @param {number | null} [holdTimeoutMs] - Max hold time; falsy disables the timeout.
165
+ * @returns {Promise<T>} - Resolves with the callback result.
166
+ */
167
+ static async runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs) {
168
+ if (!holdTimeoutMs || holdTimeoutMs <= 0) {
169
+ return await callback()
170
+ }
171
+
172
+ let callbackSettled = false
173
+
174
+ try {
175
+ return await timeout({timeout: holdTimeoutMs}, async () => {
176
+ try {
177
+ return await callback()
178
+ } finally {
179
+ callbackSettled = true
180
+ }
181
+ })
182
+ } catch (error) {
183
+ if (!callbackSettled) {
184
+ throw new AdvisoryLockHoldTimeoutError(`Advisory lock ${JSON.stringify(name)} held longer than ${holdTimeoutMs}ms`, {name})
185
+ }
186
+
187
+ throw error
188
+ }
189
+ }
190
+ }
191
+
192
+ export {AdvisoryLockBusyError, AdvisoryLockHoldTimeoutError, AdvisoryLockTimeoutError}
@@ -31,6 +31,9 @@ import Update from "./sql/update.js"
31
31
  const MYSQL_INDEFINITE_LOCK_TIMEOUT_SECONDS = 60 * 60 * 24 * 365
32
32
 
33
33
  export default class VelociousDatabaseDriversMysql extends Base{
34
+ /** @type {import("mysql").Pool | undefined} */
35
+ pool = undefined
36
+
34
37
  /** @type {string | null} */
35
38
  _desiredSessionTimeZone = "+00:00"
36
39
 
@@ -24,7 +24,7 @@
24
24
 
25
25
  /** @typedef {import("../../configuration-types.js").TenantDatabaseProviderType} TenantDatabaseProviderType */
26
26
 
27
- import timeout from "awaitery/build/timeout.js"
27
+ import AdvisoryLockRunner, {AdvisoryLockBusyError, AdvisoryLockHoldTimeoutError, AdvisoryLockTimeoutError} from "../advisory-lock-runner.js"
28
28
  import BelongsToInstanceRelationship from "./instance-relationships/belongs-to.js"
29
29
  import BelongsToRelationship from "./relationships/belongs-to.js"
30
30
  import Configuration from "../../configuration.js"
@@ -164,60 +164,6 @@ function buildRelatedRecordWithInverse(parent, relationshipName, attributes, all
164
164
  return record
165
165
  }
166
166
 
167
- /**
168
- * Thrown by `Record.withAdvisoryLock` when the caller supplied a
169
- * `timeoutMs` and the lock was not granted before it elapsed.
170
- */
171
- class AdvisoryLockTimeoutError extends Error {
172
- /**
173
- * Runs constructor.
174
- * @param {string} message - Error message.
175
- * @param {{name: string}} args - The advisory lock name that timed out.
176
- */
177
- constructor(message, {name}) {
178
- super(message)
179
- this.name = "AdvisoryLockTimeoutError"
180
- this.lockName = name
181
- }
182
- }
183
-
184
- /**
185
- * Thrown by `Record.withAdvisoryLockOrFail` when the lock is already held
186
- * by another session at the moment of the call.
187
- */
188
- class AdvisoryLockBusyError extends Error {
189
- /**
190
- * Runs constructor.
191
- * @param {string} message - Error message.
192
- * @param {{name: string}} args - The advisory lock name that was already held.
193
- */
194
- constructor(message, {name}) {
195
- super(message)
196
- this.name = "AdvisoryLockBusyError"
197
- this.lockName = name
198
- }
199
- }
200
-
201
- /**
202
- * Thrown by `Record.withAdvisoryLock` / `withAdvisoryLockOrFail` when the
203
- * caller supplied a `holdTimeoutMs` and the callback ran longer than it. The
204
- * lock is released before this is thrown, so a hung holder can't block other
205
- * sessions indefinitely. Note: the callback itself is not cancelled — pass an
206
- * AbortSignal to the work if it needs to stop.
207
- */
208
- class AdvisoryLockHoldTimeoutError extends Error {
209
- /**
210
- * Runs constructor.
211
- * @param {string} message - Error message.
212
- * @param {{name: string}} args - The advisory lock name whose hold timed out.
213
- */
214
- constructor(message, {name}) {
215
- super(message)
216
- this.name = "AdvisoryLockHoldTimeoutError"
217
- this.lockName = name
218
- }
219
- }
220
-
221
167
  class TenantDatabaseScopeError extends Error {
222
168
  /**
223
169
  * Runs constructor.
@@ -2675,10 +2621,13 @@ class VelociousDatabaseRecord {
2675
2621
  }
2676
2622
 
2677
2623
  /**
2678
- * Runs the callback while holding a named advisory lock on the current
2679
- * connection. Advisory locks are cooperative and connection-scoped: they
2680
- * serialize callers that opt into the same `name`, without touching row
2681
- * or table locks, so unrelated traffic is free to proceed.
2624
+ * Runs the callback while holding a named advisory lock. Calls without
2625
+ * a positive `holdTimeoutMs` use the caller connection; calls with a positive
2626
+ * `holdTimeoutMs` use a dedicated lock connection so timeout cleanup can
2627
+ * release the lock even when callback database work is stuck. Advisory locks
2628
+ * are cooperative and session-scoped: they serialize callers that opt into
2629
+ * the same `name`, without touching row or table locks, so unrelated traffic
2630
+ * is free to proceed.
2682
2631
  *
2683
2632
  * The lock is acquired before the callback runs and released in a
2684
2633
  * `finally` block afterwards, so the callback's return value is
@@ -2694,18 +2643,13 @@ class VelociousDatabaseRecord {
2694
2643
  static async withAdvisoryLock(name, callback, args = {}) {
2695
2644
  await this.ensureInitialized()
2696
2645
 
2697
- const connection = this.connection()
2698
- const acquired = await connection.acquireAdvisoryLock(name, args)
2699
-
2700
- if (!acquired) {
2701
- throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)}`, {name})
2702
- }
2646
+ const runner = new AdvisoryLockRunner({
2647
+ configuration: this._getConfiguration(),
2648
+ connectionProvider: () => this.connection(),
2649
+ databaseIdentifier: this.getDatabaseIdentifier()
2650
+ })
2703
2651
 
2704
- try {
2705
- return await this.runWithAdvisoryLockHoldTimeout(name, callback, args.holdTimeoutMs)
2706
- } finally {
2707
- await connection.releaseAdvisoryLock(name)
2708
- }
2652
+ return await runner.withAdvisoryLock(name, callback, args)
2709
2653
  }
2710
2654
 
2711
2655
  /**
@@ -2725,31 +2669,19 @@ class VelociousDatabaseRecord {
2725
2669
  static async withAdvisoryLockOrFail(name, callback, args = {}) {
2726
2670
  await this.ensureInitialized()
2727
2671
 
2728
- const connection = this.connection()
2729
- const acquired = await connection.tryAcquireAdvisoryLock(name)
2730
-
2731
- if (!acquired) {
2732
- throw new AdvisoryLockBusyError(`Advisory lock ${JSON.stringify(name)} is already held`, {name})
2733
- }
2672
+ const runner = new AdvisoryLockRunner({
2673
+ configuration: this._getConfiguration(),
2674
+ connectionProvider: () => this.connection(),
2675
+ databaseIdentifier: this.getDatabaseIdentifier()
2676
+ })
2734
2677
 
2735
- try {
2736
- return await this.runWithAdvisoryLockHoldTimeout(name, callback, args.holdTimeoutMs)
2737
- } finally {
2738
- await connection.releaseAdvisoryLock(name)
2739
- }
2678
+ return await runner.withAdvisoryLockOrFail(name, callback, args)
2740
2679
  }
2741
2680
 
2742
2681
  /**
2743
2682
  * Runs `callback`, rejecting with `AdvisoryLockHoldTimeoutError` if it has
2744
- * not settled within `holdTimeoutMs`. The caller's `finally` then releases
2745
- * the lock, so a hung holder can't block other sessions forever. The
2746
- * callback is not cancelled — this is a safety net, not cancellation.
2747
- *
2748
- * Uses `awaitery`'s shared `timeout` helper for the hard timeout, then
2749
- * translates its timeout into the typed `AdvisoryLockHoldTimeoutError` so
2750
- * callers can catch it like the other advisory-lock errors. A `callbackSettled`
2751
- * flag distinguishes the timeout from a rejection thrown by the callback
2752
- * itself, which is rethrown unchanged.
2683
+ * not settled within `holdTimeoutMs`. The callback is not cancelled — this is
2684
+ * a safety net, not cancellation.
2753
2685
  * @template T
2754
2686
  * @param {string} name - Lock name (for the error message).
2755
2687
  * @param {() => Promise<T>} callback - Callback holding the lock.
@@ -2757,27 +2689,7 @@ class VelociousDatabaseRecord {
2757
2689
  * @returns {Promise<T>}
2758
2690
  */
2759
2691
  static async runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs) {
2760
- if (!holdTimeoutMs || holdTimeoutMs <= 0) {
2761
- return await callback()
2762
- }
2763
-
2764
- let callbackSettled = false
2765
-
2766
- try {
2767
- return await timeout({timeout: holdTimeoutMs}, async () => {
2768
- try {
2769
- return await callback()
2770
- } finally {
2771
- callbackSettled = true
2772
- }
2773
- })
2774
- } catch (error) {
2775
- if (!callbackSettled) {
2776
- throw new AdvisoryLockHoldTimeoutError(`Advisory lock ${JSON.stringify(name)} held longer than ${holdTimeoutMs}ms`, {name})
2777
- }
2778
-
2779
- throw error
2780
- }
2692
+ return await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs)
2781
2693
  }
2782
2694
 
2783
2695
  /**
@@ -152,7 +152,8 @@ export default class SchemaCloner {
152
152
 
153
153
  /**
154
154
  * Creates non-primary-key indexes present on the source but missing from the target,
155
- * and throws if an existing target index diverges from the source.
155
+ * and replaces target indexes whose definition (columns or uniqueness) drifted from
156
+ * the source.
156
157
  * @param {{sourceTable: import("../drivers/base-table.js").default, tableName: string}} args
157
158
  * @returns {Promise<void>}
158
159
  */
@@ -161,7 +162,7 @@ export default class SchemaCloner {
161
162
  /** @type {Map<string, import("../drivers/base-columns-index.js").default>} */
162
163
  const targetIndexesByName = new Map()
163
164
  const targetIndexSignatures = new Set()
164
- let createdIndex = false
165
+ let dirty = false
165
166
 
166
167
  for (const targetIndex of await targetTable.getIndexes()) {
167
168
  targetIndexesByName.set(targetIndex.getName(), targetIndex)
@@ -183,28 +184,63 @@ export default class SchemaCloner {
183
184
 
184
185
  const targetIndex = this.targetDb.getType() === "sqlite" ? undefined : targetIndexesByName.get(sourceIndex.getName())
185
186
 
186
- if (!targetIndex) {
187
- const createIndexSqls = await this.targetDb.createIndexSQLs(this.createIndexArgsFromSourceIndex({sourceIndex, tableName}))
188
-
189
- for (const createIndexSql of createIndexSqls) {
190
- await this.targetDb.query(createIndexSql)
187
+ if (targetIndex) {
188
+ if (!this.indexesMatch(sourceIndex, targetIndex)) {
189
+ // Replacing a non-unique index with a unique one is unsafe because
190
+ // the target may have duplicate values that will reject the new
191
+ // unique constraint, and the old index was already dropped by this
192
+ // point. The opposite direction (unique → non-unique) is always
193
+ // safe — non-unique indexes never fail on duplicate values.
194
+ if (sourceIndex.isUnique() && !targetIndex.isUnique()) {
195
+ throw new Error(`Schema clone index drift for ${tableName}.${sourceIndex.getName()}: cannot safely replace a non-unique index with a unique one.`)
196
+ }
197
+
198
+ await this.dropTargetIndex({tableName, targetIndex})
199
+ targetIndexesByName.delete(targetIndex.getName())
200
+ targetIndexSignatures.delete(this.indexSignature(targetIndex))
201
+ } else {
202
+ continue
191
203
  }
204
+ }
192
205
 
193
- createdIndex = true
194
- targetIndexSignatures.add(sourceIndexSignature)
195
- continue
206
+ // Drop any target index that shares the source name but survived the
207
+ // drift check above because the driver was skipped (SQLite).
208
+ const sameNameTargetIndex = targetIndexesByName.get(sourceIndex.getName())
209
+
210
+ if (sameNameTargetIndex) {
211
+ await this.dropTargetIndex({tableName, targetIndex: sameNameTargetIndex})
212
+ targetIndexesByName.delete(sameNameTargetIndex.getName())
213
+ targetIndexSignatures.delete(this.indexSignature(sameNameTargetIndex))
196
214
  }
197
215
 
198
- if (!this.indexesMatch(sourceIndex, targetIndex)) {
199
- throw new Error(`Schema clone index drift for ${tableName}.${sourceIndex.getName()}`)
216
+ const createIndexSqls = await this.targetDb.createIndexSQLs(this.createIndexArgsFromSourceIndex({sourceIndex, tableName}))
217
+
218
+ for (const createIndexSql of createIndexSqls) {
219
+ await this.targetDb.query(createIndexSql)
200
220
  }
221
+
222
+ dirty = true
223
+ targetIndexSignatures.add(sourceIndexSignature)
201
224
  }
202
225
 
203
- if (createdIndex) {
226
+ if (dirty) {
204
227
  this.targetDb.clearSchemaCache()
205
228
  }
206
229
  }
207
230
 
231
+ /**
232
+ * Drops an index on the target database.
233
+ * @param {{tableName: string, targetIndex: import("../drivers/base-columns-index.js").default}} args
234
+ * @returns {Promise<void>}
235
+ */
236
+ async dropTargetIndex({tableName, targetIndex}) {
237
+ const dropSqls = await this.targetDb.removeIndexSQLs({name: targetIndex.getName(), tableName})
238
+
239
+ for (const sql of dropSqls) {
240
+ await this.targetDb.query(sql)
241
+ }
242
+ }
243
+
208
244
  /**
209
245
  * Baselines the target ledger so the cloned schema is recorded as already-applied.
210
246
  * @returns {Promise<string[]>} The versions newly recorded on the target.