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
@@ -110,42 +110,34 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
110
110
  }
111
111
 
112
112
  /**
113
- * Layers a filesystem lock directory on top of the in-process waiter
114
- * queue so SQLite deployments with multiple Node processes writing to
115
- * the same database file see consistent advisory-lock mutual exclusion
116
- * across processes, not just within a single process.
113
+ * The filesystem lock directory is this driver's driver-native tier on
114
+ * top of the shared process-local pre-filter tier from the base driver,
115
+ * so SQLite deployments with multiple Node processes writing to the same
116
+ * database file see consistent advisory-lock mutual exclusion across
117
+ * processes, not just within a single process.
118
+ * @returns {boolean} - Always true for the Node SQLite driver.
119
+ */
120
+ supportsDriverNativeAdvisoryLock() {
121
+ return true
122
+ }
123
+
124
+ /**
125
+ * Filesystem lock directory as the driver-native tier on top of the
126
+ * shared process-local pre-filter tier from the base driver, so SQLite
127
+ * deployments with multiple Node processes writing to the same database
128
+ * file see consistent advisory-lock mutual exclusion across processes,
129
+ * not just within a single process.
117
130
  *
118
- * The in-process queue from the shared SQLite base class is still used
119
- * for the fast intra-process path (no polling, waiters wake each other
120
- * through the `Set<string>` + waiter queue); the filesystem lock is
121
- * only checked once the in-process queue has granted the caller, so
122
- * typical single-process traffic pays at most two `fs.mkdir` calls
123
- * (create and remove) per critical section.
131
+ * The base driver's process-local tier serializes same-process callers
132
+ * first (no filesystem round trip for uncontended traffic); this hook
133
+ * is only reached once the tier has granted the caller its turn, and its
134
+ * result is what confirms cross-process exclusion for SQLite.
124
135
  * @param {string} name - Lock name.
125
136
  * @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null`, `undefined`, or negative blocks forever.
126
137
  * @returns {Promise<boolean>} - Whether the advisory lock was acquired.
127
138
  */
128
139
  async _acquireAdvisoryLock(name, {timeoutMs} = {}) {
129
- const deadline = typeof timeoutMs === "number" && timeoutMs >= 0 ? Date.now() + timeoutMs : null
130
- const remainingForInProcess = deadline !== null ? Math.max(0, deadline - Date.now()) : null
131
- const inProcessAcquired = await super._acquireAdvisoryLock(name, {timeoutMs: remainingForInProcess})
132
-
133
- if (!inProcessAcquired) return false
134
-
135
- try {
136
- const remainingForFile = deadline !== null ? Math.max(0, deadline - Date.now()) : null
137
- const fileAcquired = await this._acquireAdvisoryLockFile(name, {timeoutMs: remainingForFile})
138
-
139
- if (!fileAcquired) {
140
- await super._releaseAdvisoryLock(name)
141
- return false
142
- }
143
- } catch (error) {
144
- await super._releaseAdvisoryLock(name)
145
- throw error
146
- }
147
-
148
- return true
140
+ return await this._acquireAdvisoryLockFile(name, {timeoutMs})
149
141
  }
150
142
 
151
143
  /**
@@ -154,53 +146,40 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
154
146
  * @returns {Promise<boolean>} - Whether the advisory lock was acquired immediately.
155
147
  */
156
148
  async _tryAcquireAdvisoryLock(name) {
157
- const inProcessAcquired = await super._tryAcquireAdvisoryLock(name)
158
-
159
- if (!inProcessAcquired) return false
160
-
161
- try {
162
- const fileAcquired = await this._tryAcquireAdvisoryLockFile(name)
163
-
164
- if (!fileAcquired) {
165
- await super._releaseAdvisoryLock(name)
166
- return false
167
- }
168
- } catch (error) {
169
- await super._releaseAdvisoryLock(name)
170
- throw error
171
- }
172
-
173
- return true
149
+ return await this._tryAcquireAdvisoryLockFile(name)
174
150
  }
175
151
 
176
152
  /**
177
- * Releases the lock only if **this** driver instance acquired it, both
178
- * in the shared in-process owner table and in the on-disk lock
179
- * directory. A caller that tries to release a lock it never acquired
180
- * (or that was already released by another driver) gets `false` back
181
- * and the filesystem state is left alone so we never delete somebody
182
- * else's lock directory.
153
+ * Releases the on-disk lock directory for a lock this driver instance
154
+ * acquired through the base driver's two-tier chain. A caller that tries
155
+ * to release a lock it never acquired (or that was already released by
156
+ * another driver) gets `false` back and the filesystem state is left
157
+ * alone so we never delete somebody else's lock directory.
183
158
  * @param {string} name - Lock name.
184
159
  * @returns {Promise<boolean>} - Whether the advisory lock was released.
185
160
  */
186
161
  async _releaseAdvisoryLock(name) {
187
- const inProcessReleased = await super._releaseAdvisoryLock(name)
162
+ const lockPath = this._advisoryLockPath(name)
188
163
 
189
- if (inProcessReleased) {
190
- await this._releaseAdvisoryLockFile(name)
164
+ try {
165
+ await fs.stat(lockPath)
166
+ } catch {
167
+ return false
191
168
  }
192
169
 
193
- return inProcessReleased
170
+ await this._releaseAdvisoryLockFile(name)
171
+
172
+ return true
194
173
  }
195
174
 
196
175
  /**
197
- * Runs is advisory lock held.
176
+ * Filesystem-tier view for the base driver's `isAdvisoryLockHeld`, which
177
+ * answers from the process-local tier first and only asks this hook when
178
+ * the tier has no owner for the name.
198
179
  * @param {string} name - Lock name.
199
- * @returns {Promise<boolean>} - Whether the advisory lock is held.
180
+ * @returns {Promise<boolean>} - Whether the advisory lock file is held.
200
181
  */
201
- async isAdvisoryLockHeld(name) {
202
- if (await super.isAdvisoryLockHeld(name)) return true
203
-
182
+ async _isAdvisoryLockHeldNative(name) {
204
183
  return await this._isAdvisoryLockFileHeld(name)
205
184
  }
206
185
 
@@ -4,10 +4,24 @@ import {digg} from "diggerize"
4
4
  import * as inflection from "inflection"
5
5
  import Logger from "../logger.js"
6
6
  import MigrationsLedger from "./migrations-ledger.js"
7
+ import AdvisoryLockRunner from "./advisory-lock-runner.js"
7
8
  import {NotImplementedError} from "./migration/index.js"
8
9
  import { migrationExecutionPhase, migrationRunsInExecutionPhase } from "./migration-execution-phase.js"
9
10
  import restArgsError from "../utils/rest-args-error.js"
10
11
 
12
+ /**
13
+ * Builds the advisory-lock name that serialises migrations for one database identifier.
14
+ *
15
+ * The name is derived from the database identifier only (not the migration) so that
16
+ * concurrent processes queue on one lock while applying the pending set, instead of
17
+ * racing on individual DDL statements.
18
+ * @param {string} dbIdentifier - Database identifier being migrated.
19
+ * @returns {string} - Advisory lock name.
20
+ */
21
+ function migrationsAdvisoryLockName(dbIdentifier) {
22
+ return `velocious:migrations:${dbIdentifier}`
23
+ }
24
+
11
25
  export default class VelociousDatabaseMigrator {
12
26
  /**
13
27
  * Migrations versions.
@@ -49,8 +63,11 @@ export default class VelociousDatabaseMigrator {
49
63
  * @returns {Promise<void>} - Resolves when complete.
50
64
  */
51
65
  async prepare() {
66
+ console.error("[MIG-DEBUG] prepare() start")
52
67
  await this.createMigrationsTable()
68
+ console.error("[MIG-DEBUG] createMigrationsTable done")
53
69
  await this.loadMigrationsVersions()
70
+ console.error("[MIG-DEBUG] prepare() done")
54
71
  }
55
72
 
56
73
  /**
@@ -115,6 +132,7 @@ export default class VelociousDatabaseMigrator {
115
132
  */
116
133
  async migrateFiles(files, importCallback) {
117
134
  let appliedCount = 0
135
+ console.error("[MIG-DEBUG] migrateFiles count=" + files.length)
118
136
 
119
137
  await this.configuration.ensureConnections({databaseIdentifiers: this.databaseIdentifiers, name: "Database migrator: migrate files"}, async () => {
120
138
  for (const migration of files) {
@@ -278,10 +296,29 @@ export default class VelociousDatabaseMigrator {
278
296
 
279
297
  if (!environmentHandler || Object.keys(filteredDbs).length == 0) return
280
298
 
299
+ // Serialise the framework-schema bootstrap across processes. This creates the attachments
300
+ // and background-jobs tables, and it runs once per process outside the per-migration loop:
301
+ // without this lock, two processes bootstrapping one database race on CREATE TABLE /
302
+ // CREATE INDEX and one dies mid-boot (duplicate object/index).
303
+ for (const [dbIdentifier, db] of Object.entries(filteredDbs)) {
304
+ const runner = new AdvisoryLockRunner({
305
+ configuration: this.configuration,
306
+ connectionProvider: () => db,
307
+ databaseIdentifier: dbIdentifier
308
+ })
309
+
310
+ await runner.withAdvisoryLock(
311
+ migrationsAdvisoryLockName(dbIdentifier),
312
+ async () => {
313
+ await environmentHandler.ensureFrameworkSchema({dbs: {[dbIdentifier]: db}})
314
+ },
315
+ {dedicatedConnection: true}
316
+ )
317
+ }
318
+
281
319
  // Ensure Velocious' own framework schema before the structure dump. The dump is
282
320
  // gated to enabled environments, but migration-enabled databases must include
283
321
  // framework tables so `db:migrate` and schema:load produce a complete database.
284
- await environmentHandler.ensureFrameworkSchema({dbs: filteredDbs})
285
322
  await environmentHandler.afterMigrations({dbs: filteredDbs})
286
323
  }
287
324
 
@@ -487,6 +524,7 @@ export default class VelociousDatabaseMigrator {
487
524
  */
488
525
  async runMigrationFile({migration, requireMigration, direction = "up"}) {
489
526
  if (!this.configuration) throw new Error("No configuration set")
527
+ console.error("[MIG-DEBUG] runMigrationFile file=" + (migration && migration.file))
490
528
  if (!this.configuration.isDatabasePoolInitialized()) await this.configuration.initializeDatabasePool()
491
529
  if (!this.migrationsVersions) await this.loadMigrationsVersions()
492
530
 
@@ -542,67 +580,99 @@ export default class VelociousDatabaseMigrator {
542
580
  continue
543
581
  }
544
582
 
545
- if (direction == "up") {
546
- if (this.hasRunMigrationVersion(dbIdentifier, migration.date)) {
547
- this.logger.debug(`${dbIdentifier} has already run migration ${migration.file}`)
548
- continue
549
- }
550
- } else if (direction == "down") {
551
- if (!this.hasRunMigrationVersion(dbIdentifier, migration.date)) {
552
- this.logger.debug(`${dbIdentifier} hasn't run migration ${migration.file}`)
553
- continue
554
- }
555
- } else {
556
- throw new Error(`Unknown direction: ${direction}`)
557
- }
558
-
559
- this.logger.debug(`Running migration on ${dbIdentifier}: ${migration.file}`, {migrationDatabaseIdentifiers})
560
-
561
- applied = true
562
583
  const db = dbs[dbIdentifier]
563
- const MigrationClass = migrationClass
564
- const migrationInstance = new MigrationClass({
584
+ const dateString = `${digg(migration, "date")}`
585
+
586
+ // Serialise the check-then-apply across processes. Without this, two shards can both
587
+ // observe the migration as unapplied and both execute its DDL; the loser dies with the
588
+ // driver's duplicate-object error mid-boot.
589
+ //
590
+ // The version is re-read *inside* the lock: while this process waited, another process
591
+ // may already have applied the migration, in which case this one must become a no-op.
592
+ console.error("[MIG-DEBUG] acquiring lock db=" + dbIdentifier + " file=" + migration.file)
593
+ const runner = new AdvisoryLockRunner({
565
594
  configuration: this.configuration,
566
- databaseIdentifier: dbIdentifier,
567
- db
595
+ connectionProvider: () => db,
596
+ databaseIdentifier: dbIdentifier
568
597
  })
569
- const dateString = `${digg(migration, "date")}`
570
598
 
571
- if (direction == "up") {
572
- try {
573
- await migrationInstance.change()
574
- } catch (changeError) {
575
- if (changeError instanceof NotImplementedError) {
599
+ const appliedInLock = await runner.withAdvisoryLock(
600
+ migrationsAdvisoryLockName(dbIdentifier),
601
+ async () => {
602
+ // Re-read the applied set from the database while holding the lock. The version
603
+ // list was loaded during prepare(), before any other process may have finished
604
+ // applying migrations, so trusting that cached snapshot here would let this
605
+ // process re-run DDL that another process has already committed.
606
+ await this.loadMigrationsVersionsForDatabase({dbIdentifier, db})
607
+
608
+ if (direction == "up") {
609
+ if (this.hasRunMigrationVersion(dbIdentifier, migration.date)) {
610
+ this.logger.debug(`${dbIdentifier} has already run migration ${migration.file}`)
611
+ return false
612
+ }
613
+ } else if (direction == "down") {
614
+ if (!this.hasRunMigrationVersion(dbIdentifier, migration.date)) {
615
+ this.logger.debug(`${dbIdentifier} hasn't run migration ${migration.file}`)
616
+ return false
617
+ }
618
+ } else {
619
+ throw new Error(`Unknown direction: ${direction}`)
620
+ }
621
+
622
+ this.logger.debug(`Running migration on ${dbIdentifier}: ${migration.file}`, {migrationDatabaseIdentifiers})
623
+
624
+ const MigrationClass = migrationClass
625
+ const migrationInstance = new MigrationClass({
626
+ configuration: this.configuration,
627
+ databaseIdentifier: dbIdentifier,
628
+ db
629
+ })
630
+
631
+ if (direction == "up") {
576
632
  try {
577
- await migrationInstance.up()
578
- } catch (upError) {
579
- if (upError instanceof NotImplementedError) {
580
- throw new Error(`'change' or 'up' didn't exist on migration: ${migration.file}`, {cause: upError})
633
+ await migrationInstance.change()
634
+ } catch (changeError) {
635
+ if (changeError instanceof NotImplementedError) {
636
+ try {
637
+ await migrationInstance.up()
638
+ } catch (upError) {
639
+ if (upError instanceof NotImplementedError) {
640
+ throw new Error(`'change' or 'up' didn't exist on migration: ${migration.file}`, {cause: upError})
641
+ } else {
642
+ throw upError
643
+ }
644
+ }
581
645
  } else {
582
- throw upError
646
+ throw changeError
583
647
  }
584
648
  }
585
- } else {
586
- throw changeError
587
- }
588
- }
589
649
 
590
- await MigrationsLedger.recordVersion(db, dateString)
591
- } else if (direction == "down") {
592
- try {
593
- await migrationInstance.down()
594
- } catch (downError) {
595
- if (downError instanceof NotImplementedError) {
596
- throw new Error(`'down' didn't exist on migration: ${migration.file} or migrating down with a change method isn't currently supported`, {cause: downError})
650
+ await MigrationsLedger.recordVersion(db, dateString)
597
651
  } else {
598
- throw downError
652
+ try {
653
+ await migrationInstance.down()
654
+ } catch (downError) {
655
+ if (downError instanceof NotImplementedError) {
656
+ throw new Error(`'down' didn't exist on migration: ${migration.file} or migrating down with a change method isn't currently supported`, {cause: downError})
657
+ } else {
658
+ throw downError
659
+ }
660
+ }
661
+
662
+ await MigrationsLedger.removeVersion(db, dateString)
599
663
  }
600
- }
601
664
 
602
- await MigrationsLedger.removeVersion(db, dateString)
603
- } else {
604
- throw new Error(`Unknown direction: ${direction}`)
605
- }
665
+ return true
666
+ },
667
+ // Pin the lock to one physical session: MSSQL/explicit advisory locks are
668
+ // session-scoped, and a pooled connection can route acquire and release to
669
+ // different sessions, which would let two processes both "hold" the lock.
670
+ {dedicatedConnection: true}
671
+ )
672
+
673
+ if (!appliedInLock) continue
674
+
675
+ applied = true
606
676
  }
607
677
 
608
678
  return applied
@@ -15,6 +15,10 @@ const ATTACHMENT_OWNER_INDEX_NAME = "index_velocious_attachments_on_record_type_
15
15
  const ATTACHMENT_RECORD_ID_DIGEST_LENGTH = 64
16
16
  const ATTACHMENT_RECORD_ID_DIGEST_MIGRATION_BATCH_SIZE = 100
17
17
  const ATTACHMENT_SCHEMA_LOCK_NAME = "velocious-attachments-schema"
18
+ /** SQL Server deadlock victim — raised when concurrent sessions deadlock on the same catalog reads. */
19
+ const MSSQL_DEADLOCK_VICTIM_ERROR_NUMBER = 1205
20
+ /** Retry budget for transient catalog deadlocks while bootstrapping attachment schema. */
21
+ const ATTACHMENT_SCHEMA_MAX_ATTEMPTS = 3
18
22
 
19
23
  /**
20
24
  * Stores by configuration.
@@ -158,6 +162,52 @@ export function recordAttachmentsStoreForModel(model) {
158
162
  /**
159
163
  * Attachment persistence store.
160
164
  */
165
+ /**
166
+ * Returns whether an error means a concurrent process created the same schema object.
167
+ *
168
+ * Drivers report this differently: SQLite raises "index ... already exists", MSSQL raises
169
+ * "There is already an object named ..." (2714), PostgreSQL raises DuplicateTable (42P07) or
170
+ * DuplicateObject (42710). Matching on the message keeps the check driver-agnostic without
171
+ * hiding unrelated failures.
172
+ * @param {unknown} error - Thrown error.
173
+ * @returns {boolean} - Whether this is a concurrent-creation collision.
174
+ */
175
+ function isConcurrentSchemaCreationError(error) {
176
+ if (!error) return false
177
+
178
+ const code = typeof error === "object" && "number" in error ? Number(error.number) : NaN
179
+
180
+ // MSSQL 2714 (duplicate object), 1913 (duplicate index); PostgreSQL 42P07 / 42710.
181
+ if (code === 2714 || code === 1913 || code === 42) return true
182
+
183
+ const message = error instanceof Error ? error.message : String(error)
184
+
185
+ return /already exists/i.test(message) || /already an object named/i.test(message) || /duplicate (table|object|key)/i.test(message)
186
+ }
187
+
188
+ /**
189
+ * Whether an error is a retryable database deadlock victim.
190
+ *
191
+ * Matched by numeric error number, never by message text: SQL Server raises 1205 when a
192
+ * concurrent session wins the lock and this one is chosen as the victim. The victim's
193
+ * transaction has already rolled back, so the operation is safe to retry.
194
+ * @param {unknown} error - Thrown error.
195
+ * @returns {boolean} - Whether this is a retryable deadlock victim.
196
+ */
197
+ function isDeadlockVictimError(error) {
198
+ let current = error
199
+
200
+ while (current instanceof Error) {
201
+ const errorNumber = /** @type {{number?: unknown}} */ (current).number
202
+
203
+ if (typeof errorNumber === "number" && errorNumber === MSSQL_DEADLOCK_VICTIM_ERROR_NUMBER) return true
204
+
205
+ current = current.cause
206
+ }
207
+
208
+ return false
209
+ }
210
+
161
211
  export default class RecordAttachmentsStore {
162
212
  /**
163
213
  * Runs constructor.
@@ -237,7 +287,40 @@ export default class RecordAttachmentsStore {
237
287
  table.bigint("updated_at_ms", {null: false})
238
288
  table.addIndex(new TableIndex(["record_type", "record_id_digest"], {name: ATTACHMENT_OWNER_INDEX_NAME}))
239
289
 
240
- await db.createTable(table)
290
+ let attempt = 0
291
+
292
+ while (true) {
293
+ attempt++
294
+
295
+ try {
296
+ await db.createTable(table)
297
+
298
+ break
299
+ } catch (error) {
300
+ // A concurrent process may win the create between our tableExists() check and this
301
+ // call. createTable guards the table with ifNotExists but NOT its indexes, so the
302
+ // loser fails with a duplicate-object error. Treat that as "someone else bootstrapped
303
+ // it": reconcile through the upgrade path instead of failing the caller.
304
+ if (isConcurrentSchemaCreationError(error)) {
305
+ await this.ensureAttachmentStoreSchema({db})
306
+
307
+ return
308
+ }
309
+
310
+ // Concurrent boots also deadlock on the INFORMATION_SCHEMA metadata scan that
311
+ // getTableByName performs on SQL Server. The victim transaction rolled back cleanly,
312
+ // so retrying it is safe. Without this the loser takes down its caller even though
313
+ // another process is building the very table it needs.
314
+ if (isDeadlockVictimError(error) && attempt < ATTACHMENT_SCHEMA_MAX_ATTEMPTS) {
315
+ db.clearSchemaCache()
316
+
317
+ continue
318
+ }
319
+
320
+ throw error
321
+ }
322
+ }
323
+
241
324
  this._driverColumnsAvailable = true
242
325
  this._contentBase64Nullable = true
243
326
  this._schemaReadyGeneration = this._schemaCacheGeneration(db)
@@ -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.