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
@@ -293,6 +293,31 @@ function formatElapsedMs(elapsedMs) {
293
293
  }
294
294
 
295
295
  export default class VelociousDatabaseDriversBase {
296
+ /**
297
+ * Process-wide state for the **process-local advisory-lock pre-filter
298
+ * tier** shared by every driver (SQLite, MySQL/MariaDB, PostgreSQL,
299
+ * MSSQL). It serializes concurrent acquisitions of the same lock name
300
+ * inside this process through a FIFO waiter queue before any caller
301
+ * contacts the driver-native tier, so a contended same-process waiter
302
+ * never parks a pooled database connection in a driver lock state
303
+ * (MariaDB `User lock`, `sp_getapplock` session hold, ...).
304
+ *
305
+ * This tier is a pre-filter, **never the authority**: a grant here only
306
+ * means "you are next in this process." Cross-process/cross-host
307
+ * exclusion is confirmed by the driver-native tier (or, once the
308
+ * STC-210 backend chain lands, the configured cross-host backend)
309
+ * before the acquisition succeeds.
310
+ *
311
+ * `ownersByName` maps each held lock name to the driver instance that
312
+ * acquired it so `releaseAdvisoryLock` can reject releases from drivers
313
+ * that do not own the lock.
314
+ * @type {{ownersByName: Map<string, VelociousDatabaseDriversBase>, waitersByName: Map<string, Array<() => void>>}}
315
+ */
316
+ static _advisoryLockState = {
317
+ ownersByName: new Map(),
318
+ waitersByName: new Map()
319
+ }
320
+
296
321
  /**
297
322
  * Id seq.
298
323
  * @type {number | undefined} */
@@ -2940,18 +2965,66 @@ export default class VelociousDatabaseDriversBase {
2940
2965
  * Advisory locks are connection-scoped and do not interact with row or
2941
2966
  * table locks; they are purely cooperative between callers that use the
2942
2967
  * same name and let you serialize functionality without blocking readers
2943
- * or writers that do not participate in the same lock.
2968
+ * or writers that do not participate in the same named lock.
2969
+ *
2970
+ * Acquisition runs as a two-tier chain:
2971
+ * 1. The **process-local pre-filter tier** (this class' FIFO waiter
2972
+ * queue) serializes same-process callers for the lock name without
2973
+ * issuing any driver-native lock query and without parking a
2974
+ * connection in a driver lock state. A queued caller never reaches
2975
+ * tier 2 until the in-process holder releases.
2976
+ * 2. The **driver-native tier** (`_acquireAdvisoryLock`: MariaDB
2977
+ * `GET_LOCK`, PostgreSQL `pg_advisory_lock`, SQL Server
2978
+ * `sp_getapplock`, SQLite filesystem lock on Node) confirms the
2979
+ * cross-process/cross-host exclusion.
2980
+ *
2981
+ * The process-local tier is a pre-filter, **never the authority**: a
2982
+ * lock name that other processes or hosts can request is only granted
2983
+ * after the driver-native tier (or, once the STC-210 backend chain
2984
+ * lands, the configured cross-host backend) confirms it. If the native
2985
+ * tier denies or times out, the in-process hold is rolled back.
2944
2986
  * @param {string} name - Lock name.
2945
- * @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.
2987
+ * @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. The bound covers the whole two-tier acquisition.
2946
2988
  * @returns {Promise<boolean>} - Resolves to true when the lock has been acquired, false if the timeout elapsed.
2947
2989
  */
2948
2990
  async acquireAdvisoryLock(name, args = {}) {
2949
2991
  const timeoutMs = this.advisoryLockAcquireTimeoutMs(args)
2992
+ const deadlineMs = typeof timeoutMs === "number" && timeoutMs >= 0 ? Date.now() + timeoutMs : null
2993
+ const localAcquisition = await this._acquireProcessLocalAdvisoryLock(name, {timeoutMs})
2994
+
2995
+ if (!localAcquisition.acquired) return false
2996
+
2997
+ if (!this.supportsDriverNativeAdvisoryLock()) {
2998
+ // No driver-native tier exists for this driver (plain SQLite): the
2999
+ // process-local tier is the whole implementation, and callers must
3000
+ // not read it as a cross-process guarantee.
3001
+ this._trackAdvisoryLock(name)
3002
+
3003
+ return true
3004
+ }
3005
+
3006
+ // Only a caller that actually queued in the process-local tier spends
3007
+ // part of the acquire budget; an uncontended acquisition passes the
3008
+ // full bound on to the driver-native tier.
3009
+ const nativeTimeoutMs = localAcquisition.waited && deadlineMs !== null
3010
+ ? Math.max(0, deadlineMs - Date.now())
3011
+ : timeoutMs
3012
+
2950
3013
  const startedAt = Date.now()
2951
- const acquired = await this._acquireAdvisoryLock(name, {timeoutMs})
3014
+
3015
+ /** @type {boolean} */
3016
+ let nativeAcquired
3017
+
3018
+ try {
3019
+ nativeAcquired = await this._acquireAdvisoryLock(name, {timeoutMs: nativeTimeoutMs})
3020
+ } catch (error) {
3021
+ this._releaseProcessLocalAdvisoryLock(name)
3022
+ throw error
3023
+ }
3024
+
2952
3025
  const durationMs = Date.now() - startedAt
2953
3026
 
2954
- if (acquired) {
3027
+ if (nativeAcquired) {
2955
3028
  this._trackAdvisoryLock(name)
2956
3029
 
2957
3030
  const slowAcquireMs = advisoryLockTelemetryThresholds(this.getArgs()).slowAcquireMs
@@ -2959,13 +3032,17 @@ export default class VelociousDatabaseDriversBase {
2959
3032
  if (slowAcquireMs !== null && durationMs > slowAcquireMs) {
2960
3033
  await this._reportAdvisoryLockTelemetry({event: "advisory-lock-slow-acquire", lockName: name, durationMs, outcome: "acquired", timeoutMs})
2961
3034
  }
2962
- } else {
2963
- // The wait exceeded its bound: attribute the lock to the holder that
2964
- // blocked it, captured at this moment on this tier.
2965
- await this._reportAdvisoryLockTelemetry({event: "advisory-lock-acquire-timeout", lockName: name, durationMs, outcome: "timeout", timeoutMs, captureHolder: true})
3035
+
3036
+ return true
2966
3037
  }
2967
3038
 
2968
- return acquired
3039
+ this._releaseProcessLocalAdvisoryLock(name)
3040
+
3041
+ // The wait exceeded its bound: attribute the lock to the holder that
3042
+ // blocked it, captured at this moment on this tier.
3043
+ await this._reportAdvisoryLockTelemetry({event: "advisory-lock-acquire-timeout", lockName: name, durationMs, outcome: "timeout", timeoutMs, captureHolder: true})
3044
+
3045
+ return false
2969
3046
  }
2970
3047
 
2971
3048
  /**
@@ -2988,8 +3065,146 @@ export default class VelociousDatabaseDriversBase {
2988
3065
  return DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS
2989
3066
  }
2990
3067
 
3068
+ /**
3069
+ * Whether this driver has a driver-native advisory-lock tier behind the
3070
+ * process-local pre-filter. MariaDB (`GET_LOCK`), PostgreSQL
3071
+ * (`pg_advisory_lock`) and SQL Server (`sp_getapplock`) do; SQLite has
3072
+ * no native primitive (the Node SQLite driver substitutes its
3073
+ * filesystem lock directory, the web/native builds have nothing), so
3074
+ * for plain SQLite the process-local tier IS the whole implementation
3075
+ * and callers must not read it as a cross-process guarantee.
3076
+ * @returns {boolean} - Whether the driver-native tier exists.
3077
+ */
3078
+ supportsDriverNativeAdvisoryLock() {
3079
+ return true
3080
+ }
3081
+
3082
+ /**
3083
+ * Whether the driver's native tier is re-entrant within the same
3084
+ * session. `GET_LOCK`, `pg_advisory_lock` and `sp_getapplock` grant the
3085
+ * same session repeatedly, so the process-local tier must let a
3086
+ * re-acquisition by its own owner pass through to the native tier.
3087
+ * SQLite's emulation (including the Node filesystem lock) is not
3088
+ * re-entrant, so the tier keeps treating a re-acquisition as contention
3089
+ * exactly as the SQLite emulation always has.
3090
+ * @returns {boolean} - Whether same-instance re-acquisition is honored.
3091
+ */
3092
+ supportsReentrantAdvisoryLock() {
3093
+ return true
3094
+ }
3095
+
3096
+ /**
3097
+ * Process-local pre-filter tier: FIFO waiter queue for one lock name
3098
+ * inside this process. This is the promoted SQLite emulation from
3099
+ * STC-211 — it now runs in front of **every** driver's native tier so a
3100
+ * same-process waiter never issues a driver-native lock query and never
3101
+ * parks a pooled connection in a driver lock state while queued.
3102
+ *
3103
+ * A grant here means only "you are the next caller in this process."
3104
+ * It is never the authority for cross-process/cross-host exclusion; the
3105
+ * caller must still pass the driver-native tier before the lock counts
3106
+ * as held. A re-entrant acquisition by the owning driver instance is
3107
+ * granted immediately, mirroring the session-scoped re-entrancy of
3108
+ * `GET_LOCK` / `pg_advisory_lock` / `sp_getapplock`.
3109
+ * @param {string} name - Lock name.
3110
+ * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null`, `undefined`, or negative blocks forever.
3111
+ * @returns {Promise<{acquired: boolean, waited: boolean}>} - Whether the tier granted this caller, and whether it had to queue for it.
3112
+ */
3113
+ async _acquireProcessLocalAdvisoryLock(name, {timeoutMs} = {}) {
3114
+ const state = VelociousDatabaseDriversBase._advisoryLockState
3115
+ let waited = false
3116
+
3117
+ while (state.ownersByName.has(name)) {
3118
+ if (state.ownersByName.get(name) === this && this.supportsReentrantAdvisoryLock()) break
3119
+
3120
+ waited = true
3121
+
3122
+ let remainingMs = null
3123
+
3124
+ if (typeof timeoutMs === "number" && timeoutMs >= 0) {
3125
+ remainingMs = timeoutMs
3126
+
3127
+ if (remainingMs <= 0) return {acquired: false, waited}
3128
+ }
3129
+
3130
+ await new Promise((resolve) => {
3131
+ const waiters = state.waitersByName.get(name) || []
3132
+ /**
3133
+ * Timeout handle.
3134
+ * @type {ReturnType<typeof setTimeout> | null} */
3135
+ let timeoutHandle = null
3136
+ /**
3137
+ * Remove and resolve.
3138
+ * @type {(() => void) | null} */
3139
+ let removeAndResolve = null
3140
+
3141
+ removeAndResolve = () => {
3142
+ if (timeoutHandle) clearTimeout(timeoutHandle)
3143
+
3144
+ const current = state.waitersByName.get(name) || []
3145
+ const index = current.indexOf(/** @type {() => void} */ (removeAndResolve))
3146
+
3147
+ if (index >= 0) current.splice(index, 1)
3148
+ if (current.length === 0) state.waitersByName.delete(name)
3149
+
3150
+ resolve(undefined)
3151
+ }
3152
+
3153
+ waiters.push(removeAndResolve)
3154
+ state.waitersByName.set(name, waiters)
3155
+
3156
+ if (remainingMs !== null) {
3157
+ timeoutHandle = setTimeout(() => {
3158
+ if (removeAndResolve) removeAndResolve()
3159
+ }, remainingMs)
3160
+ }
3161
+ })
3162
+
3163
+ if (typeof timeoutMs === "number" && timeoutMs >= 0 && state.ownersByName.has(name) && !(state.ownersByName.get(name) === this && this.supportsReentrantAdvisoryLock())) {
3164
+ return {acquired: false, waited}
3165
+ }
3166
+ }
3167
+
3168
+ state.ownersByName.set(name, this)
3169
+
3170
+ return {acquired: true, waited}
3171
+ }
3172
+
3173
+ /**
3174
+ * Hands the process-local tier for one lock name to the next FIFO waiter,
3175
+ * but only when **this** driver instance currently owns it. A release by
3176
+ * a non-owner is a no-op, matching the "you can only release locks you
3177
+ * own" contract of `RELEASE_LOCK` / `pg_advisory_unlock`.
3178
+ * @param {string} name - Lock name.
3179
+ * @returns {boolean} - Whether this instance owned the tier and released it.
3180
+ */
3181
+ _releaseProcessLocalAdvisoryLock(name) {
3182
+ const state = VelociousDatabaseDriversBase._advisoryLockState
3183
+ const owner = state.ownersByName.get(name)
3184
+
3185
+ if (owner !== this) return false
3186
+
3187
+ state.ownersByName.delete(name)
3188
+
3189
+ const waiters = state.waitersByName.get(name)
3190
+
3191
+ if (waiters && waiters.length > 0) {
3192
+ const nextWaiter = waiters.shift()
3193
+
3194
+ if (waiters.length === 0) state.waitersByName.delete(name)
3195
+ if (nextWaiter) nextWaiter()
3196
+ }
3197
+
3198
+ return true
3199
+ }
3200
+
2991
3201
  /**
2992
3202
  * Driver-specific blocking advisory-lock acquisition hook.
3203
+ * This is the **driver-native tier** of the acquisition chain: MariaDB
3204
+ * `GET_LOCK`, PostgreSQL `pg_advisory_lock`, SQL Server `sp_getapplock`,
3205
+ * the Node SQLite filesystem lock. It runs only after the process-local
3206
+ * pre-filter tier has granted this caller its turn, and its result is
3207
+ * what actually confirms cross-process/cross-host exclusion.
2993
3208
  * @abstract
2994
3209
  * @param {string} name - Lock name.
2995
3210
  * @param {{timeoutMs?: number | null}} [_args] - Lock timeout options.
@@ -3001,15 +3216,55 @@ export default class VelociousDatabaseDriversBase {
3001
3216
 
3002
3217
  /**
3003
3218
  * Attempts to acquire a named advisory lock without blocking.
3219
+ *
3220
+ * Same two-tier chain as `acquireAdvisoryLock`: the process-local tier
3221
+ * short-circuits contention between callers in this process (a queued
3222
+ * name never reaches the driver-native hook), and the driver-native tier
3223
+ * confirms cross-process/cross-host exclusion. A free in-process tier is
3224
+ * NOT a grant — the native tier still has to agree.
3004
3225
  * @param {string} name - Lock name.
3005
3226
  * @returns {Promise<boolean>} - Resolves to true if the lock was acquired, false if it was already held.
3006
3227
  */
3007
3228
  async tryAcquireAdvisoryLock(name) {
3008
- const acquired = await this._tryAcquireAdvisoryLock(name)
3229
+ const owner = VelociousDatabaseDriversBase._advisoryLockState.ownersByName.get(name)
3230
+
3231
+ if (owner !== undefined && owner !== this) return false
3232
+
3233
+ // Same-instance re-acquisition mirrors the re-entrant semantics of the
3234
+ // session-scoped driver-native locks; a non-re-entrant driver (SQLite)
3235
+ // keeps treating it as contention, exactly as the emulation always did.
3236
+ const reentrant = owner === this
3009
3237
 
3010
- if (acquired) this._trackAdvisoryLock(name)
3238
+ if (reentrant && !this.supportsReentrantAdvisoryLock()) return false
3011
3239
 
3012
- return acquired
3240
+ if (!reentrant) VelociousDatabaseDriversBase._advisoryLockState.ownersByName.set(name, this)
3241
+
3242
+ if (!this.supportsDriverNativeAdvisoryLock()) {
3243
+ this._trackAdvisoryLock(name)
3244
+
3245
+ return true
3246
+ }
3247
+
3248
+ /** @type {boolean} */
3249
+ let acquired
3250
+
3251
+ try {
3252
+ acquired = await this._tryAcquireAdvisoryLock(name)
3253
+ } catch (error) {
3254
+ if (!reentrant) this._releaseProcessLocalAdvisoryLock(name)
3255
+
3256
+ throw error
3257
+ }
3258
+
3259
+ if (!acquired) {
3260
+ if (!reentrant) this._releaseProcessLocalAdvisoryLock(name)
3261
+
3262
+ return false
3263
+ }
3264
+
3265
+ this._trackAdvisoryLock(name)
3266
+
3267
+ return true
3013
3268
  }
3014
3269
 
3015
3270
  /**
@@ -3024,10 +3279,36 @@ export default class VelociousDatabaseDriversBase {
3024
3279
 
3025
3280
  /**
3026
3281
  * Releases a named advisory lock previously acquired on this connection.
3282
+ * The driver-native release runs first; the process-local tier is handed
3283
+ * to the next waiter only once this connection's counted registry no
3284
+ * longer holds the name, so re-entrant acquisitions release one level at
3285
+ * a time exactly like the session-scoped driver locks.
3027
3286
  * @param {string} name - Lock name.
3028
3287
  * @returns {Promise<boolean>} - Resolves to true if the lock was held by this session and has now been released.
3029
3288
  */
3030
3289
  async releaseAdvisoryLock(name) {
3290
+ const owner = VelociousDatabaseDriversBase._advisoryLockState.ownersByName.get(name)
3291
+
3292
+ // Another driver instance in this process owns the tier for this name,
3293
+ // so this connection can never be the native holder either — refuse
3294
+ // before issuing a release statement that could disturb the owner.
3295
+ if (owner !== undefined && owner !== this) return false
3296
+
3297
+ if (!this.supportsDriverNativeAdvisoryLock()) {
3298
+ // Plain SQLite: the process-local tier IS the lock, so the first
3299
+ // release by the owner releases it fully, exactly as the SQLite
3300
+ // emulation always has.
3301
+ const released = this._releaseProcessLocalAdvisoryLock(name)
3302
+
3303
+ if (released) {
3304
+ this._untrackAdvisoryLock(name)
3305
+ } else {
3306
+ this._heldAdvisoryLocks.delete(name)
3307
+ }
3308
+
3309
+ return released
3310
+ }
3311
+
3031
3312
  const released = await this._releaseAdvisoryLock(name)
3032
3313
 
3033
3314
  if (released) {
@@ -3049,6 +3330,10 @@ export default class VelociousDatabaseDriversBase {
3049
3330
  await this._reportAdvisoryLockTelemetry({event: "advisory-lock-slow-hold", lockName: name, durationMs: holdDurationMs, outcome: null, timeoutMs: null})
3050
3331
  }
3051
3332
  }
3333
+
3334
+ // Last connection-level release: hand the process-local tier to the
3335
+ // next queued caller in this process.
3336
+ this._releaseProcessLocalAdvisoryLock(name)
3052
3337
  }
3053
3338
 
3054
3339
  return released
@@ -3117,12 +3402,30 @@ export default class VelociousDatabaseDriversBase {
3117
3402
  * Checks whether a named advisory lock is currently held by any session.
3118
3403
  * Intended as an introspection helper; callers who need to act on the
3119
3404
  * result should prefer `tryAcquireAdvisoryLock` to avoid a TOCTOU race.
3405
+ *
3406
+ * The process-local tier answers first (a same-process holder is a
3407
+ * holder, full stop); otherwise the question goes to the driver-native
3408
+ * tier's introspection (`IS_USED_LOCK` / `pg_locks` / `APPLOCK_TEST`).
3409
+ * @param {string} name - Lock name.
3410
+ * @returns {Promise<boolean>} - Resolves to true if the lock is held by any session.
3411
+ */
3412
+ async isAdvisoryLockHeld(name) {
3413
+ if (VelociousDatabaseDriversBase._advisoryLockState.ownersByName.has(name)) return true
3414
+
3415
+ if (!this.supportsDriverNativeAdvisoryLock()) return false
3416
+
3417
+ return await this._isAdvisoryLockHeldNative(name)
3418
+ }
3419
+
3420
+ /**
3421
+ * Driver-native advisory-lock introspection hook behind
3422
+ * `isAdvisoryLockHeld`.
3120
3423
  * @abstract
3121
3424
  * @param {string} name - Lock name.
3122
- * @returns {Promise<boolean>} - Resolves to true if the lock is held by ? session.
3425
+ * @returns {Promise<boolean>} - Resolves to true if the native tier reports the lock held.
3123
3426
  */
3124
- isAdvisoryLockHeld(name) { // eslint-disable-line no-unused-vars
3125
- throw new Error(`'isAdvisoryLockHeld' not implemented for ${this.constructor.name}`)
3427
+ _isAdvisoryLockHeldNative(name) { // eslint-disable-line no-unused-vars
3428
+ throw new Error(`'_isAdvisoryLockHeldNative' not implemented for ${this.constructor.name}`)
3126
3429
  }
3127
3430
 
3128
3431
  /**
@@ -939,7 +939,7 @@ export default class VelociousDatabaseDriversMssql extends Base{
939
939
  * @param {string} name - Lock name.
940
940
  * @returns {Promise<boolean>} - True if any session currently holds the lock.
941
941
  */
942
- async isAdvisoryLockHeld(name) {
942
+ async _isAdvisoryLockHeldNative(name) {
943
943
  const rows = await this.query(
944
944
  `SELECT ` +
945
945
  `APPLOCK_MODE('public', ${this.quote(name)}, 'Session') AS velocious_advisory_self_mode, ` +
@@ -882,6 +882,7 @@ export default class VelociousDatabaseDriversMysql extends Base{
882
882
  return Number(result) === 1
883
883
  }
884
884
 
885
+ /**
885
886
  /**
886
887
  * Attributes the current holder of the named lock to its physical session
887
888
  * connection id via `IS_USED_LOCK(name)`, captured at the telemetry event
@@ -897,11 +898,12 @@ export default class VelociousDatabaseDriversMysql extends Base{
897
898
  }
898
899
 
899
900
  /**
900
- * Runs is advisory lock held.
901
+ * Driver-native view for the base driver's `isAdvisoryLockHeld`, which
902
+ * answers from the process-local tier first.
901
903
  * @param {string} name - Lock name.
902
904
  * @returns {Promise<boolean>} - True if any session currently holds the lock.
903
905
  */
904
- async isAdvisoryLockHeld(name) {
906
+ async _isAdvisoryLockHeldNative(name) {
905
907
  const rows = await this.query(`SELECT IS_USED_LOCK(${this.quote(name)}) AS velocious_advisory_lock_holder`)
906
908
  const holder = rows?.[0]?.velocious_advisory_lock_holder
907
909
 
@@ -505,6 +505,7 @@ export default class VelociousDatabaseDriversPgsql extends Base{
505
505
  return result === true || result === "t" || result === 1 || result === "1"
506
506
  }
507
507
 
508
+ /**
508
509
  /**
509
510
  * Attributes the current holder of the named advisory lock to its backend
510
511
  * process id from `pg_locks`, captured at the telemetry event moment
@@ -523,11 +524,12 @@ export default class VelociousDatabaseDriversPgsql extends Base{
523
524
  }
524
525
 
525
526
  /**
526
- * Runs is advisory lock held.
527
+ * Driver-native view for the base driver's `isAdvisoryLockHeld`, which
528
+ * answers from the process-local tier first.
527
529
  * @param {string} name - Lock name.
528
530
  * @returns {Promise<boolean>} - True if any session currently holds the lock.
529
531
  */
530
- async isAdvisoryLockHeld(name) {
532
+ async _isAdvisoryLockHeldNative(name) {
531
533
  const key = this.advisoryLockKey(name)
532
534
  const rows = await this.query(
533
535
  `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND ((classid::bigint << 32) | (objid::bigint & 4294967295)) = ${key}) AS velocious_advisory_lock_held`
@@ -19,21 +19,6 @@ import Upsert from "./sql/upsert.js"
19
19
  import Update from "./sql/update.js"
20
20
 
21
21
  export default class VelociousDatabaseDriversSqliteBase extends Base {
22
- /**
23
- * Process-wide state for the SQLite advisory lock emulation. Shared across
24
- * every SQLite driver instance (native, web, sql.js) because there is no
25
- * concept of "connection" to distinguish them at the SQLite level.
26
- *
27
- * `ownersByName` maps each held lock name to the driver instance that
28
- * acquired it so `releaseAdvisoryLock` can reject releases from drivers
29
- * that do not own the lock.
30
- * @type {{ownersByName: Map<string, VelociousDatabaseDriversSqliteBase>, waitersByName: Map<string, Array<() => void>>}}
31
- */
32
- static _advisoryLockState = {
33
- ownersByName: new Map(),
34
- waitersByName: new Map()
35
- }
36
-
37
22
  /**
38
23
  * Version major.
39
24
  * @type {number | undefined} */
@@ -404,125 +389,24 @@ export default class VelociousDatabaseDriversSqliteBase extends Base {
404
389
  }
405
390
 
406
391
  /**
407
- * Blocks until an in-process advisory lock with the given name is
408
- * acquired. SQLite has no built-in advisory lock primitive, so this is
409
- * implemented as a process-local waiter queue. Typical SQLite deployments
410
- * run inside a single Node process, which is exactly the scope this
411
- * emulation covers; multi-process SQLite setups should not rely on this
412
- * for cross-process mutual exclusion.
413
- *
414
- * The owning driver instance is recorded so that `releaseAdvisoryLock`
415
- * can refuse to release a lock that was acquired by someone else.
416
- * @param {string} name - Lock name.
417
- * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null`, `undefined`, or negative blocks forever.
418
- * @returns {Promise<boolean>} - True if the lock was acquired, false if the timeout elapsed.
419
- */
420
- async _acquireAdvisoryLock(name, {timeoutMs} = {}) {
421
- const state = VelociousDatabaseDriversSqliteBase._advisoryLockState
422
-
423
- while (state.ownersByName.has(name)) {
424
- let remainingMs = null
425
-
426
- if (typeof timeoutMs === "number" && timeoutMs >= 0) {
427
- remainingMs = timeoutMs
428
-
429
- if (remainingMs <= 0) return false
430
- }
431
-
432
- await new Promise((resolve) => {
433
- const waiters = state.waitersByName.get(name) || []
434
- /**
435
- * Timeout handle.
436
- * @type {ReturnType<typeof setTimeout> | null} */
437
- let timeoutHandle = null
438
- /**
439
- * Remove and resolve.
440
- * @type {(() => void) | null} */
441
- let removeAndResolve = null
442
-
443
- removeAndResolve = () => {
444
- if (timeoutHandle) clearTimeout(timeoutHandle)
445
-
446
- const current = state.waitersByName.get(name) || []
447
- const index = current.indexOf(/** @type {() => void} */ (removeAndResolve))
448
-
449
- if (index >= 0) current.splice(index, 1)
450
- if (current.length === 0) state.waitersByName.delete(name)
451
-
452
- resolve(undefined)
453
- }
454
-
455
- waiters.push(removeAndResolve)
456
- state.waitersByName.set(name, waiters)
457
-
458
- if (remainingMs !== null) {
459
- timeoutHandle = setTimeout(() => {
460
- if (removeAndResolve) removeAndResolve()
461
- }, remainingMs)
462
- }
463
- })
464
-
465
- if (typeof timeoutMs === "number" && timeoutMs >= 0 && state.ownersByName.has(name)) {
466
- return false
467
- }
468
- }
469
-
470
- state.ownersByName.set(name, this)
471
-
472
- return true
473
- }
474
-
475
- /**
476
- * Runs try acquire advisory lock.
477
- * @param {string} name - Lock name.
478
- * @returns {Promise<boolean>} - True if the lock was acquired, false if it was already held.
392
+ * SQLite has no native advisory-lock primitive, so the shared
393
+ * process-local pre-filter tier in the base driver is the whole
394
+ * implementation for the web/sql.js and Expo native builds. The Node
395
+ * driver layers its filesystem lock on top as the driver-native tier.
396
+ * @returns {boolean} - Always false for plain SQLite.
479
397
  */
480
- async _tryAcquireAdvisoryLock(name) {
481
- const state = VelociousDatabaseDriversSqliteBase._advisoryLockState
482
-
483
- if (state.ownersByName.has(name)) return false
484
-
485
- state.ownersByName.set(name, this)
486
-
487
- return true
488
- }
489
-
490
- /**
491
- * Releases the lock only if **this** driver instance owns it. Calling
492
- * release for a lock owned by another driver instance is a no-op that
493
- * returns `false`, matching the "you can only release locks you own"
494
- * contract of MySQL's `RELEASE_LOCK` and PostgreSQL's
495
- * `pg_advisory_unlock`.
496
- * @param {string} name - Lock name.
497
- * @returns {Promise<boolean>} - True if the lock was held by this driver and has now been released.
498
- */
499
- async _releaseAdvisoryLock(name) {
500
- const state = VelociousDatabaseDriversSqliteBase._advisoryLockState
501
- const owner = state.ownersByName.get(name)
502
-
503
- if (owner !== this) return false
504
-
505
- state.ownersByName.delete(name)
506
-
507
- const waiters = state.waitersByName.get(name)
508
-
509
- if (waiters && waiters.length > 0) {
510
- const nextWaiter = waiters.shift()
511
-
512
- if (waiters.length === 0) state.waitersByName.delete(name)
513
- if (nextWaiter) nextWaiter()
514
- }
515
-
516
- return true
398
+ supportsDriverNativeAdvisoryLock() {
399
+ return false
517
400
  }
518
401
 
519
402
  /**
520
- * Runs is advisory lock held.
521
- * @param {string} name - Lock name.
522
- * @returns {Promise<boolean>} - True if any driver instance currently holds the lock.
403
+ * The SQLite emulation is not re-entrant: a re-acquisition by the same
404
+ * driver instance is contention, not a grant, exactly as the in-process
405
+ * queue always behaved.
406
+ * @returns {boolean} - Always false for SQLite.
523
407
  */
524
- async isAdvisoryLockHeld(name) {
525
- return VelociousDatabaseDriversSqliteBase._advisoryLockState.ownersByName.has(name)
408
+ supportsReentrantAdvisoryLock() {
409
+ return false
526
410
  }
527
411
 
528
412
  /**