velocious 1.0.699 → 1.0.701

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 (42) hide show
  1. package/README.md +1 -2
  2. package/build/configuration-types.js +12 -1
  3. package/build/configuration.js +174 -18
  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/environment-handlers/base.js +13 -0
  8. package/build/environment-handlers/node.js +11 -0
  9. package/build/http-server/server-lock.js +21 -115
  10. package/build/src/configuration-types.d.ts +33 -2
  11. package/build/src/configuration-types.d.ts.map +1 -1
  12. package/build/src/configuration-types.js +12 -2
  13. package/build/src/configuration.d.ts +99 -1
  14. package/build/src/configuration.d.ts.map +1 -1
  15. package/build/src/configuration.js +161 -18
  16. package/build/src/database/advisory-lock-backend-policy.d.ts +145 -0
  17. package/build/src/database/advisory-lock-backend-policy.d.ts.map +1 -0
  18. package/build/src/database/advisory-lock-backend-policy.js +184 -0
  19. package/build/src/database/advisory-lock-file-backend.d.ts +194 -0
  20. package/build/src/database/advisory-lock-file-backend.d.ts.map +1 -0
  21. package/build/src/database/advisory-lock-file-backend.js +313 -0
  22. package/build/src/database/advisory-lock-runner.d.ts +116 -15
  23. package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
  24. package/build/src/database/advisory-lock-runner.js +190 -17
  25. package/build/src/environment-handlers/base.d.ts +14 -0
  26. package/build/src/environment-handlers/base.d.ts.map +1 -1
  27. package/build/src/environment-handlers/base.js +13 -1
  28. package/build/src/environment-handlers/node.d.ts +11 -0
  29. package/build/src/environment-handlers/node.d.ts.map +1 -1
  30. package/build/src/environment-handlers/node.js +11 -1
  31. package/build/src/http-server/server-lock.d.ts +13 -37
  32. package/build/src/http-server/server-lock.d.ts.map +1 -1
  33. package/build/src/http-server/server-lock.js +22 -109
  34. package/package.json +1 -1
  35. package/src/configuration-types.js +12 -1
  36. package/src/configuration.js +174 -18
  37. package/src/database/advisory-lock-backend-policy.js +204 -0
  38. package/src/database/advisory-lock-file-backend.js +348 -0
  39. package/src/database/advisory-lock-runner.js +239 -16
  40. package/src/environment-handlers/base.js +13 -0
  41. package/src/environment-handlers/node.js +11 -0
  42. package/src/http-server/server-lock.js +21 -115
@@ -1,6 +1,24 @@
1
1
  // @ts-check
2
2
 
3
3
  import timeout, {TimeoutError} from "awaitery/build/timeout.js"
4
+ import {DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS} from "./advisory-lock-bound.js"
5
+ import {
6
+ AdvisoryLockBackendUnavailableError,
7
+ resolveAdvisoryLockBackendPolicy,
8
+ refuseAdvisoryLockTierBelowMinimum
9
+ } from "./advisory-lock-backend-policy.js"
10
+
11
+ /**
12
+ * Provenance of one advisory-lock acquisition (STC-210 design §3): every
13
+ * held lock reports the tier that actually served it, so a fallback is
14
+ * visible to the callback, in logs, and in `getAdvisoryLockBackendStatus()`.
15
+ * @typedef {object} AdvisoryLockBackendProvenance
16
+ * @property {import("./advisory-lock-backend-policy.js").AdvisoryLockTier} backend - Tier that served the acquisition.
17
+ * @property {string} [driverType] - Database driver type when the driver tier served it.
18
+ * @property {boolean} fencingSupported - Whether this tier carries fencing tokens (design §8); the file tier reports false rather than pretending.
19
+ * @property {string} [holderId] - Backend holder id for the file tier.
20
+ * @property {string} [lockName] - Lock name this provenance belongs to.
21
+ */
4
22
 
5
23
  /**
6
24
  * Thrown when an advisory lock could not be acquired before `timeoutMs` elapsed.
@@ -77,19 +95,41 @@ export default class AdvisoryLockRunner {
77
95
  * cleanup can release the lock even if callback database work is stuck.
78
96
  * @template T
79
97
  * @param {string} name - Lock name.
80
- * @param {(args?: {control: import("awaitery/build/timeout.js").TimeoutControl}) => Promise<T>} callback - Callback to invoke while the lock is held.
98
+ * @param {(args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>} callback - Callback to invoke while the lock is held.
81
99
  * @param {{timeoutMs?: number | null, holdTimeoutMs?: number | null, dedicatedConnection?: boolean}} [args] - Lock and hold timeout options.
82
100
  * @returns {Promise<T>} - Resolves with the callback result.
83
101
  */
84
102
  async withAdvisoryLock(name, callback, args = {}) {
103
+ const fileBackend = await this.fileBackendForName(name)
104
+
105
+ if (fileBackend) {
106
+ const acquired = await fileBackend.acquire(name, {timeoutMs: this.fileAcquireTimeoutMs(args)})
107
+
108
+ if (!acquired) {
109
+ throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)} on the file tier`, {name})
110
+ }
111
+
112
+ const lockBackend = this.fileProvenance(fileBackend, name)
113
+
114
+ this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
115
+
116
+ return await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await fileBackend.release(name)})
117
+ }
118
+
85
119
  return await this.withLockConnection(args, async (connection) => {
120
+ const lockBackend = this.driverProvenance(connection)
121
+
122
+ this.assertDriverTierEligible(name, lockBackend)
123
+
86
124
  const acquired = await connection.acquireAdvisoryLock(name, args)
87
125
 
88
126
  if (!acquired) {
89
127
  throw new AdvisoryLockTimeoutError(`Timed out waiting for advisory lock ${JSON.stringify(name)}`, {name})
90
128
  }
91
129
 
92
- return await this.runLockedCallback({callback, connection, holdTimeoutMs: args.holdTimeoutMs, name})
130
+ this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
131
+
132
+ return await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await connection.releaseAdvisoryLock(name)})
93
133
  })
94
134
  }
95
135
 
@@ -102,22 +142,45 @@ export default class AdvisoryLockRunner {
102
142
  * cancellation.
103
143
  * @template T
104
144
  * @param {string} name - Lock name.
105
- * @param {(args?: {control: import("awaitery/build/timeout.js").TimeoutControl}) => Promise<T>} callback - Callback to invoke while the lock is held.
145
+ * @param {(args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>} callback - Callback to invoke while the lock is held.
106
146
  * @param {{holdTimeoutMs?: number | null, dedicatedConnection?: boolean}} [args] - Hold timeout options.
107
147
  * @returns {Promise<T>} - Resolves with the callback result.
108
148
  */
109
149
  async withAdvisoryLockOrFail(name, callback, args = {}) {
150
+ const fileBackend = await this.fileBackendForName(name)
151
+
152
+ if (fileBackend) {
153
+ const acquired = await fileBackend.tryAcquire(name)
154
+
155
+ if (!acquired) {
156
+ throw new AdvisoryLockBusyError(`Advisory lock ${JSON.stringify(name)} is already held on the file tier`, {name})
157
+ }
158
+
159
+ const lockBackend = this.fileProvenance(fileBackend, name)
160
+
161
+ this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
162
+
163
+ return await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await fileBackend.release(name)})
164
+ }
165
+
110
166
  return await this.withLockConnection(args, async (connection) => {
167
+ const lockBackend = this.driverProvenance(connection)
168
+
169
+ this.assertDriverTierEligible(name, lockBackend)
170
+
111
171
  const acquired = await connection.tryAcquireAdvisoryLock(name)
112
172
 
113
173
  if (!acquired) {
114
174
  throw new AdvisoryLockBusyError(`Advisory lock ${JSON.stringify(name)} is already held`, {name})
115
175
  }
116
176
 
117
- return await this.runLockedCallback({callback, connection, holdTimeoutMs: args.holdTimeoutMs, name})
177
+ this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
178
+
179
+ return await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await connection.releaseAdvisoryLock(name)})
118
180
  })
119
181
  }
120
182
 
183
+ /**
121
184
  /**
122
185
  * Runs a callback if the advisory lock can be acquired without waiting;
123
186
  * otherwise returns a `{acquired: false}` defer result instead of throwing.
@@ -133,33 +196,172 @@ export default class AdvisoryLockRunner {
133
196
  * file lock), so a caller never branches on the backend.
134
197
  * @template T
135
198
  * @param {string} name - Lock name.
136
- * @param {(args?: {control: import("awaitery/build/timeout.js").TimeoutControl}) => Promise<T>} callback - Callback to invoke while the lock is held.
199
+ * @param {(args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>} callback - Callback to invoke while the lock is held.
137
200
  * @param {{holdTimeoutMs?: number | null, dedicatedConnection?: boolean}} [args] - Hold timeout options.
138
201
  * @returns {Promise<{acquired: true, result: T} | {acquired: false, reason: "contended", lockName: string}>} - Either the held-callback result or the defer outcome.
139
202
  */
140
203
  async withAdvisoryLockOrDefer(name, callback, args = {}) {
204
+ const fileBackend = await this.fileBackendForName(name)
205
+
206
+ if (fileBackend) {
207
+ const acquired = await fileBackend.acquire(name, {timeoutMs: 0})
208
+
209
+ if (!acquired) return {acquired: false, reason: "contended", lockName: name}
210
+
211
+ const lockBackend = this.fileProvenance(fileBackend, name)
212
+
213
+ this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
214
+
215
+ const result = await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await fileBackend.release(name)})
216
+
217
+ return {acquired: true, result}
218
+ }
219
+
141
220
  return await this.withLockConnection(args, async (connection) => {
142
221
  const acquired = await connection.tryAcquireAdvisoryLock(name)
143
222
 
144
223
  if (!acquired) return {acquired: false, reason: "contended", lockName: name}
145
224
 
146
- const result = await this.runLockedCallback({callback, connection, holdTimeoutMs: args.holdTimeoutMs, name})
225
+ const lockBackend = this.driverProvenance(connection)
226
+
227
+ this.assertDriverTierEligible(name, lockBackend)
228
+
229
+ this.configuration.recordAdvisoryLockAcquisition({backend: lockBackend.backend, fencingSupported: lockBackend.fencingSupported, lockName: name})
230
+
231
+ const result = await this.runLockedCallback({callback, holdTimeoutMs: args.holdTimeoutMs, lockBackend, name, release: async () => await connection.releaseAdvisoryLock(name)})
147
232
 
148
233
  return {acquired: true, result}
149
234
  })
150
235
  }
151
236
 
152
237
  /**
153
- * Runs the lock holder callback and releases the lock from its owning connection.
238
+ * Runs the lock holder callback and releases the lock from its owning backend.
154
239
  * @template T
155
- * @param {{callback: (args?: {control: import("awaitery/build/timeout.js").TimeoutControl}) => Promise<T>, connection: import("./drivers/base.js").default, holdTimeoutMs?: number | null, name: string}} args - Locked callback args.
240
+ * @param {{callback: (args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>, holdTimeoutMs?: number | null, lockBackend: AdvisoryLockBackendProvenance, name: string, release: () => Promise<unknown>}} args - Locked callback args.
156
241
  * @returns {Promise<T>} - Resolves with the callback result.
157
242
  */
158
- async runLockedCallback({callback, connection, holdTimeoutMs, name}) {
243
+ async runLockedCallback({callback, holdTimeoutMs, lockBackend, name, release}) {
159
244
  try {
160
- return await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs)
245
+ return await AdvisoryLockRunner.runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs, lockBackend)
161
246
  } finally {
162
- await connection.releaseAdvisoryLock(name)
247
+ await release()
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Resolves the backend policy for one lock name and returns the file
253
+ * backend when the name explicitly opts into the file tier
254
+ * (`preferredTier: "file"` plus `advisoryLocks.backends.file.enabled: true`).
255
+ * A name whose declared minimum tier is cross-host is refused loudly
256
+ * (`AdvisoryLockMinTierRefusedError`) before any contact with the file
257
+ * tier. A name that prefers the file tier while the backend is disabled
258
+ * moves to the driver path (a stronger-or-equal tier), never the other way
259
+ * around. Nothing here ever auto-enables the file tier.
260
+ * @param {string} name - Lock name.
261
+ * @returns {Promise<import("./advisory-lock-file-backend.js").default | null>} - Resolves with the file backend serving this name, or null for the driver path.
262
+ */
263
+ async fileBackendForName(name) {
264
+ const policy = resolveAdvisoryLockBackendPolicy(name, this.configuration.getAdvisoryLockPolicy())
265
+
266
+ if (policy.preferredTier !== "file") return null
267
+
268
+ if (!advisoryLockPolicyAllowsFileTier(policy)) {
269
+ refuseAdvisoryLockTierBelowMinimum({
270
+ configuration: this.configuration,
271
+ lockName: name,
272
+ minTier: policy.minTier,
273
+ requestedBackend: "file"
274
+ })
275
+ }
276
+
277
+ const backend = await this.configuration.getAdvisoryLockFileBackend()
278
+
279
+ if (!backend) {
280
+ // Preferred tier unavailable: fail *upward* to the driver path (a
281
+ // stronger-or-equal tier), never downward. If the driver cannot satisfy
282
+ // the minimum either, `assertDriverTierEligible` throws
283
+ // `AdvisoryLockBackendUnavailableError` on the driver path.
284
+ return null
285
+ }
286
+
287
+ return backend
288
+ }
289
+
290
+ /**
291
+ * Fail-closed check on the driver path (design §9): an undeclared lock name
292
+ * keeps today's behavior (any driver serves it — full backward
293
+ * compatibility), but a name with an explicit policy entry is refused with
294
+ * `AdvisoryLockBackendUnavailableError` when the configured driver cannot
295
+ * satisfy its minimum tier (e.g. `minTier: "driver-native"` on a SQLite
296
+ * driver, which has no native advisory-lock primitive).
297
+ * @param {string} name - Lock name.
298
+ * @param {AdvisoryLockBackendProvenance} lockBackend - Tier the driver path would serve with.
299
+ * @returns {void}
300
+ */
301
+ assertDriverTierEligible(name, lockBackend) {
302
+ const policy = resolveAdvisoryLockBackendPolicy(name, this.configuration.getAdvisoryLockPolicy())
303
+
304
+ if (!policy.entry) return
305
+
306
+ const tier = lockBackend.backend
307
+
308
+ if (tier === "process-local" && !policy.allowedTiers.includes("process-local")) {
309
+ throw new AdvisoryLockBackendUnavailableError(
310
+ `Advisory lock ${JSON.stringify(name)} requires minimum tier ${JSON.stringify(policy.minTier)} but the configured driver (${String(lockBackend.driverType)}) only offers the process-local tier`,
311
+ {lockName: name, minTier: policy.minTier, reasons: [`driver tier ${tier} below minTier ${policy.minTier}`]}
312
+ )
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Resolves the wait bound for a file-tier acquisition: an explicit caller
318
+ * `timeoutMs` (including an explicit `null` opt-out) wins, otherwise the
319
+ * same framework default that bounds driver acquisitions applies.
320
+ * @param {{timeoutMs?: number | null}} args - Lock options.
321
+ * @returns {number | null} - Wait bound in milliseconds, or `null` for an indefinite wait.
322
+ */
323
+ fileAcquireTimeoutMs(args) {
324
+ if (args.timeoutMs !== undefined) return args.timeoutMs
325
+
326
+ return DEFAULT_ADVISORY_LOCK_ACQUIRE_TIMEOUT_MS
327
+ }
328
+
329
+ /**
330
+ * Builds the acquisition provenance for a file-tier hold. The file tier
331
+ * cannot carry fencing tokens, so the limitation is reported on every
332
+ * acquisition rather than silently pretended away (design §8).
333
+ * @param {import("./advisory-lock-file-backend.js").default} backend - Backend that served the acquisition.
334
+ * @param {string} name - Lock name.
335
+ * @returns {AdvisoryLockBackendProvenance} - Acquisition provenance.
336
+ */
337
+ fileProvenance(backend, name) {
338
+ return {backend: "file", fencingSupported: false, holderId: backend.holderId, lockName: name}
339
+ }
340
+
341
+ /**
342
+ * Builds the acquisition provenance for a driver-served hold. SQLite has no
343
+ * native advisory-lock primitive, so its emulation tier is reported as
344
+ * `process-local` rather than pretending to be database-native.
345
+ * @param {import("./drivers/base.js").default} connection - Connection that served the acquisition.
346
+ * @returns {AdvisoryLockBackendProvenance} - Acquisition provenance.
347
+ */
348
+ driverProvenance(connection) {
349
+ let driverType = null
350
+
351
+ try {
352
+ driverType = connection.getType()
353
+ } catch {
354
+ // Test stub drivers may not implement getType(); the tier alone identifies the backend.
355
+ }
356
+
357
+ const tier = typeof connection.advisoryLockBackendTier === "function"
358
+ ? connection.advisoryLockBackendTier()
359
+ : (driverType === "sqlite" ? "process-local" : "driver-native")
360
+
361
+ return {
362
+ backend: /** @type {import("./advisory-lock-backend-policy.js").AdvisoryLockTier} */ (tier),
363
+ driverType: driverType ?? undefined,
364
+ fencingSupported: tier === "driver-native"
163
365
  }
164
366
  }
165
367
 
@@ -218,16 +420,18 @@ export default class AdvisoryLockRunner {
218
420
  *
219
421
  * The callback receives a `TimeoutControl` from awaitery, enabling cooperative
220
422
  * cancellation via `control.check()`, `control.signal`, `control.timedOut`,
221
- * and `control.remaining()`.
423
+ * and `control.remaining()`, plus `lockBackend` provenance for the tier that
424
+ * served the acquisition.
222
425
  * @template T
223
426
  * @param {string} name - Lock name (for the error message).
224
- * @param {(args?: {control: import("awaitery/build/timeout.js").TimeoutControl}) => Promise<T>} callback - Callback holding the lock.
427
+ * @param {(args?: {control?: import("awaitery/build/timeout.js").TimeoutControl, lockBackend: AdvisoryLockBackendProvenance}) => Promise<T>} callback - Callback holding the lock.
225
428
  * @param {number | null} [holdTimeoutMs] - Max hold time; falsy disables the timeout.
429
+ * @param {AdvisoryLockBackendProvenance} [lockBackend] - Provenance of the acquisition serving this hold.
226
430
  * @returns {Promise<T>} - Resolves with the callback result.
227
431
  */
228
- static async runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs) {
432
+ static async runWithAdvisoryLockHoldTimeout(name, callback, holdTimeoutMs, lockBackend) {
229
433
  if (!holdTimeoutMs || holdTimeoutMs <= 0) {
230
- return await callback()
434
+ return await callback({lockBackend: lockBackend ?? AdvisoryLockRunner.unknownBackendProvenance()})
231
435
  }
232
436
 
233
437
  let callbackSettled = false
@@ -235,7 +439,7 @@ export default class AdvisoryLockRunner {
235
439
  try {
236
440
  return await timeout({timeout: holdTimeoutMs}, async ({control}) => {
237
441
  try {
238
- return await callback({control})
442
+ return await callback({control, lockBackend: lockBackend ?? AdvisoryLockRunner.unknownBackendProvenance()})
239
443
  } finally {
240
444
  callbackSettled = true
241
445
  }
@@ -248,6 +452,25 @@ export default class AdvisoryLockRunner {
248
452
  throw error
249
453
  }
250
454
  }
455
+
456
+ /**
457
+ * Neutral provenance for direct `runWithAdvisoryLockHoldTimeout` callers that
458
+ * hold a lock acquired outside this runner.
459
+ * @returns {AdvisoryLockBackendProvenance} - Provenance with an unknown tier.
460
+ */
461
+ static unknownBackendProvenance() {
462
+ return {backend: "process-local", fencingSupported: false}
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Runs advisory lock policy allows file tier.
468
+ * @param {import("./advisory-lock-backend-policy.js").ResolvedAdvisoryLockBackendPolicy} policy - Resolved policy.
469
+ * @returns {boolean} - Whether the file tier may serve this name.
470
+ */
471
+ function advisoryLockPolicyAllowsFileTier(policy) {
472
+ return policy.allowedTiers.includes("file")
251
473
  }
252
474
 
253
475
  export {AdvisoryLockBusyError, AdvisoryLockHoldTimeoutError, AdvisoryLockTimeoutError}
476
+ export {AdvisoryLockBackendUnavailableError, AdvisoryLockMinTierRefusedError} from "./advisory-lock-backend-policy.js"
@@ -36,6 +36,19 @@ export default class VelociousEnvironmentHandlerBase {
36
36
  */
37
37
  resolveTestSharedTransactionPoolType({configuredPoolType}) { return configuredPoolType }
38
38
 
39
+ /**
40
+ * Creates the file-tier advisory-lock backend (STC-213). Only filesystem
41
+ * capable environments (the Node handler) can provide this tier; browser
42
+ * and Expo configurations have no `fs`, so the default refuses loudly
43
+ * instead of half-working when a deployment explicitly enables
44
+ * `advisoryLocks.backends.file.enabled`.
45
+ * @param {{directory: string, leaseMs: number, renewIntervalMs: number}} _args - Backend options.
46
+ * @returns {Promise<import("../database/advisory-lock-file-backend.js").default>} - Resolves with the created backend in filesystem-capable handlers.
47
+ */
48
+ async createAdvisoryLockFileBackend(_args) {
49
+ throw new Error("The advisory-lock file tier requires a filesystem-capable environment handler (Node)")
50
+ }
51
+
39
52
  /**
40
53
  * Node test runtimes may replace a physical child connection with a broker
41
54
  * proxy. Other environments never participate in this test-only protocol.
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import "../database/annotations-async-hooks.js"
4
+ import AdvisoryLockFileBackend from "../database/advisory-lock-file-backend.js"
4
5
  import Base from "./base.js"
5
6
  import BackgroundJobsClient from "../background-jobs/client.js"
6
7
  import SqlBackgroundJobsAdapter from "../background-jobs/sql-adapter.js"
@@ -106,6 +107,16 @@ export default class VelociousEnvironmentHandlerNode extends Base{
106
107
  return AsyncTrackedMultiConnectionPool
107
108
  }
108
109
 
110
+ /**
111
+ * Creates the filesystem-backed advisory-lock backend for the file tier
112
+ * (STC-213). The Node handler is the only filesystem-capable environment.
113
+ * @param {{directory: string, leaseMs: number, renewIntervalMs: number}} args - Backend options.
114
+ * @returns {Promise<import("../database/advisory-lock-file-backend.js").default>} - Resolves with the created backend.
115
+ */
116
+ async createAdvisoryLockFileBackend(args) {
117
+ return new AdvisoryLockFileBackend(args)
118
+ }
119
+
109
120
  /**
110
121
  * Creates a test-only child proxy when TestRunner supplied an active broker.
111
122
  * @param {{DriverClass: typeof import("../database/drivers/base.js").default, config: import("../configuration-types.js").DatabaseConfigurationType, configuration: import("../configuration.js").default, databaseIdentifier: string, reuseKey?: string}} args - Connection details.
@@ -1,11 +1,15 @@
1
1
  // @ts-check
2
2
 
3
- import fs from "node:fs/promises"
4
- import os from "node:os"
3
+ import AdvisoryLockFileBackend from "../database/advisory-lock-file-backend.js"
5
4
  import path from "node:path"
6
5
 
7
6
  /**
8
- * Directory-based lock that allows only one HTTP server process per app directory.
7
+ * Directory-based lock that allows only one HTTP server process per app
8
+ * directory. Thin wrapper over the shared file-lock backend
9
+ * (`src/database/advisory-lock-file-backend.js` — the STC-210 `file` tier
10
+ * primitive generalized from this lock's original hand-rolled directory
11
+ * protocol), so the server lock and the advisory-lock file tier share one
12
+ * acquisition, owner-metadata, lease-renewal, and staleness implementation.
9
13
  */
10
14
  export default class VelociousHttpServerLock {
11
15
  /**
@@ -20,53 +24,26 @@ export default class VelociousHttpServerLock {
20
24
  this.host = host
21
25
  this.port = port
22
26
  this.lockPath = path.join(configuration.getDirectory(), "tmp", "server.lock")
27
+ this.backend = new AdvisoryLockFileBackend({
28
+ directory: path.join(configuration.getDirectory(), "tmp"),
29
+ extraOwnerFields: {host: this.host, port: this.port},
30
+ pathStyle: "plain"
31
+ })
23
32
  this.acquired = false
24
33
  }
25
34
 
26
35
  /**
27
- * Acquires the app-directory HTTP server lock before startup side effects run.
36
+ * Acquires the app-directory HTTP server lock before startup side effects
37
+ * run. A stale lock (dead local owner or expired lease) is reclaimed by the
38
+ * backend; a live foreign owner fails loudly with the owner details.
28
39
  * @returns {Promise<void>} - Resolves after the lock has been acquired.
29
40
  */
30
41
  async acquire() {
31
- await fs.mkdir(path.dirname(this.lockPath), {recursive: true})
42
+ const acquired = await this.backend.acquire("server", {timeoutMs: 0})
32
43
 
33
- while (true) {
34
- const acquired = await this.tryAcquire()
44
+ if (!acquired) throw new Error(await this.lockHeldMessage())
35
45
 
36
- if (acquired) {
37
- return
38
- }
39
-
40
- if (await this.isStale()) {
41
- await fs.rm(this.lockPath, {recursive: true, force: true})
42
- continue
43
- }
44
-
45
- throw new Error(await this.lockHeldMessage())
46
- }
47
- }
48
-
49
- /**
50
- * Tries to create the lock directory and write owner metadata.
51
- * @returns {Promise<boolean>} - Whether the lock was acquired.
52
- */
53
- async tryAcquire() {
54
- try {
55
- await fs.mkdir(this.lockPath)
56
- } catch (error) {
57
- if (/** @type {{code?: string}} */ (error).code === "EEXIST") return false
58
- throw error
59
- }
60
-
61
- try {
62
- await this.writeOwnerMetadata()
63
- this.acquired = true
64
-
65
- return true
66
- } catch (error) {
67
- await fs.rm(this.lockPath, {recursive: true, force: true})
68
- throw error
69
- }
46
+ this.acquired = true
70
47
  }
71
48
 
72
49
  /**
@@ -77,86 +54,15 @@ export default class VelociousHttpServerLock {
77
54
  if (!this.acquired) return
78
55
 
79
56
  this.acquired = false
80
- await fs.rm(this.lockPath, {recursive: true, force: true})
81
- }
82
-
83
- /**
84
- * Writes metadata used to explain or reclaim an existing server lock.
85
- * @returns {Promise<void>} - Resolves after owner metadata has been written.
86
- */
87
- async writeOwnerMetadata() {
88
- await fs.writeFile(path.join(this.lockPath, "owner.json"), JSON.stringify({
89
- acquiredAt: new Date().toISOString(),
90
- host: this.host,
91
- hostname: os.hostname(),
92
- pid: process.pid,
93
- port: this.port
94
- }))
95
- }
96
-
97
- /**
98
- * Checks whether the current lock owner is a dead process on this host.
99
- * @returns {Promise<boolean>} - Whether the existing lock belongs to a dead process.
100
- */
101
- async isStale() {
102
- const owner = await this.readOwnerMetadata()
103
-
104
- if (!this.isLocalProcessOwner(owner)) return false
105
-
106
- return this.processIsDead(/** @type {{pid: number}} */ (owner).pid)
107
- }
108
-
109
- /**
110
- * Runs is local process owner.
111
- * @param {Record<string, ReturnType<typeof JSON.parse>> | null} owner - Existing lock owner metadata.
112
- * @returns {boolean} - Whether owner metadata names a local process.
113
- */
114
- isLocalProcessOwner(owner) {
115
- if (!owner) return false
116
- if (typeof owner.pid !== "number") return false
117
-
118
- return this.ownerHostnameMatches(owner)
119
- }
120
-
121
- /**
122
- * Runs owner hostname matches.
123
- * @param {Record<string, ReturnType<typeof JSON.parse>>} owner - Existing lock owner metadata.
124
- * @returns {boolean} - Whether the owner hostname is local or absent.
125
- */
126
- ownerHostnameMatches(owner) {
127
- if (!owner.hostname) return true
128
- if (owner.hostname === os.hostname()) return true
129
-
130
- return false
131
- }
132
-
133
- /**
134
- * Runs process is dead.
135
- * @param {number} pid - Process id.
136
- * @returns {boolean} - Whether the process no longer exists.
137
- */
138
- processIsDead(pid) {
139
- try {
140
- process.kill(pid, 0)
141
-
142
- return false
143
- } catch (error) {
144
- return /** @type {{code?: string}} */ (error).code === "ESRCH"
145
- }
57
+ await this.backend.release("server")
146
58
  }
147
59
 
148
60
  /**
149
61
  * Reads owner metadata from an existing server lock directory.
150
- * @returns {Promise<Record<string, ReturnType<typeof JSON.parse>> | null>} - Parsed owner metadata, when readable.
62
+ * @returns {Promise<import("../database/advisory-lock-file-backend.js").AdvisoryLockFileOwner | null>} - Parsed owner metadata, when readable.
151
63
  */
152
64
  async readOwnerMetadata() {
153
- try {
154
- const rawOwner = await fs.readFile(path.join(this.lockPath, "owner.json"), "utf8")
155
-
156
- return /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (JSON.parse(rawOwner))
157
- } catch {
158
- return null
159
- }
65
+ return await this.backend.holderInfo("server")
160
66
  }
161
67
 
162
68
  /**
@@ -678,7 +678,7 @@ export type HttpServerConfiguration = {
678
678
  */
679
679
  maxBufferedResponseBodyBytes?: number;
680
680
  /**
681
- * - Maximum decoded request-body bytes accepted before routing. Unbounded when absent.
681
+ * - Maximum decoded request-body bytes accepted before routing. Unbounded when absent, except the built-in MCP endpoint, which defaults to a 1 MiB cap that this option overrides.
682
682
  */
683
683
  maxRequestBodyBytes?: number;
684
684
  /**
@@ -1635,6 +1635,23 @@ export type TenantDatabaseProviderType = {
1635
1635
  tenant: ReturnType<typeof JSON.parse>;
1636
1636
  }) => void | Promise<void>;
1637
1637
  };
1638
+ export type AdvisoryLocksConfiguration = {
1639
+ /**
1640
+ * - Backend enablement. `file.enabled` defaults to false; enabling it is the deployment's explicit single-host declaration.
1641
+ */
1642
+ backends?: {
1643
+ file?: {
1644
+ directory?: string;
1645
+ enabled?: boolean;
1646
+ leaseMs?: number;
1647
+ renewIntervalMs?: number;
1648
+ };
1649
+ };
1650
+ /**
1651
+ * - Per-lock-name minimum/allowed/preferred tier rules (design §5).
1652
+ */
1653
+ policy?: import("./database/advisory-lock-backend-policy.js").AdvisoryLockBackendPolicyEntry[];
1654
+ };
1638
1655
  export type ConfigurationArgsType = {
1639
1656
  /**
1640
1657
  * - Globally enable auto-batch-preload of relationships on lazy access. Default true.
@@ -1656,6 +1673,10 @@ export type ConfigurationArgsType = {
1656
1673
  * - Resolver for creating request-scoped ability instances.
1657
1674
  */
1658
1675
  abilityResolver?: AbilityResolverType;
1676
+ /**
1677
+ * - Advisory-lock backend chain configuration (backends + per-lock-name tier policy). Defaults to today's driver-native-only behavior.
1678
+ */
1679
+ advisoryLocks?: AdvisoryLocksConfiguration;
1659
1680
  /**
1660
1681
  * - Attachment storage configuration.
1661
1682
  */
@@ -2127,7 +2148,7 @@ export type ConfigurationArgsType = {
2127
2148
  * @property {string} [host] - Hostname to bind the HTTP server to.
2128
2149
  * @property {boolean} [inProcess] - Run HTTP handlers in the main thread instead of worker threads.
2129
2150
  * @property {number} [maxBufferedResponseBodyBytes] - Maximum UTF-8/binary byte length accepted by buffered response bodies. Unbounded when absent; streamed file responses are unaffected.
2130
- * @property {number} [maxRequestBodyBytes] - Maximum decoded request-body bytes accepted before routing. Unbounded when absent.
2151
+ * @property {number} [maxRequestBodyBytes] - Maximum decoded request-body bytes accepted before routing. Unbounded when absent, except the built-in MCP endpoint, which defaults to a 1 MiB cap that this option overrides.
2131
2152
  * @property {HttpRequestBodyPolicyResolver} [requestBodyPolicyResolver] - Optional synchronous header-stage resolver for per-request limits and raw-body handling.
2132
2153
  * @property {number} [maxWorkers] - Backward-compatible alias for workers.
2133
2154
  * @property {number} [port] - Port to bind the HTTP server to.
@@ -2519,6 +2540,15 @@ export type ConfigurationArgsType = {
2519
2540
  * @property {(args: {configuration: import("./configuration.js").default, databaseConfiguration: DatabaseConfigurationType, identifier: string, tenant: ReturnType<typeof JSON.parse>}) => void | Promise<void>} [checkTenant] - Checks one tenant database before generic connection validation.
2520
2541
  * @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.
2521
2542
  */
2543
+ /**
2544
+ * Advisory-lock backend chain configuration (STC-210 design §10, file tier
2545
+ * implemented by STC-213). Everything defaults to today's driver-native-only
2546
+ * behavior; the file backend is disabled by default and only selectable by
2547
+ * explicit configuration.
2548
+ * @typedef {object} AdvisoryLocksConfiguration
2549
+ * @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.
2550
+ * @property {import("./database/advisory-lock-backend-policy.js").AdvisoryLockBackendPolicyEntry[]} [policy] - Per-lock-name minimum/allowed/preferred tier rules (design §5).
2551
+ */
2522
2552
  /**
2523
2553
  * @typedef {object} ConfigurationArgsType
2524
2554
  * @property {boolean} [autoload] - Globally enable auto-batch-preload of relationships on lazy access. Default true.
@@ -2526,6 +2556,7 @@ export type ConfigurationArgsType = {
2526
2556
  * @property {string} [cookieSecret] - Secret for encrypting cookies.
2527
2557
  * @property {AbilityResourceClassType[]} [abilityResources] - Resource classes used to define abilities per model.
2528
2558
  * @property {AbilityResolverType} [abilityResolver] - Resolver for creating request-scoped ability instances.
2559
+ * @property {AdvisoryLocksConfiguration} [advisoryLocks] - Advisory-lock backend chain configuration (backends + per-lock-name tier policy). Defaults to today's driver-native-only behavior.
2529
2560
  * @property {AttachmentsConfiguration} [attachments] - Attachment storage configuration.
2530
2561
  * @property {BackendProjectConfiguration[]} [backendProjects] - Backend project definitions used for frontend model generation.
2531
2562
  * @property {VelociousPackageConfiguration[]} [packages] - External Velocious packages that contribute models, frontend-model resources and migrations.