velocious 1.0.516 → 1.0.517

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 (53) hide show
  1. package/README.md +2 -0
  2. package/build/background-jobs/store.js +222 -39
  3. package/build/background-jobs/types.js +4 -0
  4. package/build/database/drivers/base.js +26 -0
  5. package/build/database/drivers/mssql/index.js +13 -0
  6. package/build/database/drivers/mysql/index.js +18 -0
  7. package/build/database/drivers/pgsql/index.js +12 -0
  8. package/build/database/drivers/sqlite/connection-sql-js.js +11 -0
  9. package/build/database/drivers/sqlite/index.js +14 -4
  10. package/build/database/drivers/sqlite/index.native.js +13 -0
  11. package/build/database/drivers/sqlite/index.web.js +11 -1
  12. package/build/database/drivers/sqlite/query.js +1 -2
  13. package/build/src/background-jobs/store.d.ts +64 -9
  14. package/build/src/background-jobs/store.d.ts.map +1 -1
  15. package/build/src/background-jobs/store.js +224 -40
  16. package/build/src/background-jobs/types.d.ts +20 -0
  17. package/build/src/background-jobs/types.d.ts.map +1 -1
  18. package/build/src/background-jobs/types.js +5 -1
  19. package/build/src/database/drivers/base.d.ts +13 -0
  20. package/build/src/database/drivers/base.d.ts.map +1 -1
  21. package/build/src/database/drivers/base.js +25 -1
  22. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  23. package/build/src/database/drivers/mssql/index.js +13 -1
  24. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  25. package/build/src/database/drivers/mysql/index.js +21 -1
  26. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  27. package/build/src/database/drivers/pgsql/index.js +14 -1
  28. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +6 -0
  29. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
  30. package/build/src/database/drivers/sqlite/connection-sql-js.js +11 -1
  31. package/build/src/database/drivers/sqlite/index.d.ts +2 -2
  32. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  33. package/build/src/database/drivers/sqlite/index.js +15 -5
  34. package/build/src/database/drivers/sqlite/index.native.d.ts.map +1 -1
  35. package/build/src/database/drivers/sqlite/index.native.js +14 -1
  36. package/build/src/database/drivers/sqlite/index.web.d.ts +2 -1
  37. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  38. package/build/src/database/drivers/sqlite/index.web.js +11 -2
  39. package/build/src/database/drivers/sqlite/query.d.ts +2 -2
  40. package/build/src/database/drivers/sqlite/query.d.ts.map +1 -1
  41. package/build/src/database/drivers/sqlite/query.js +2 -3
  42. package/package.json +1 -1
  43. package/src/background-jobs/store.js +222 -39
  44. package/src/background-jobs/types.js +4 -0
  45. package/src/database/drivers/base.js +26 -0
  46. package/src/database/drivers/mssql/index.js +13 -0
  47. package/src/database/drivers/mysql/index.js +18 -0
  48. package/src/database/drivers/pgsql/index.js +12 -0
  49. package/src/database/drivers/sqlite/connection-sql-js.js +11 -0
  50. package/src/database/drivers/sqlite/index.js +14 -4
  51. package/src/database/drivers/sqlite/index.native.js +13 -0
  52. package/src/database/drivers/sqlite/index.web.js +11 -1
  53. package/src/database/drivers/sqlite/query.js +1 -2
package/README.md CHANGED
@@ -1930,6 +1930,8 @@ Create the file `src/routes/testing/another-action.ejs` and so something like th
1930
1930
 
1931
1931
  Velocious includes a simple background jobs system inspired by Sidekiq.
1932
1932
 
1933
+ Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).
1934
+
1933
1935
  Production apps can listen for `background-job-failed` or its `all-error` mirror to report accepted failed attempts, including retry and terminal state metadata. See [docs/background-jobs.md](docs/background-jobs.md#failure-events).
1934
1936
 
1935
1937
  ## Setup
@@ -11,6 +11,7 @@ const MIGRATION_SCOPE = "background_jobs"
11
11
  const MIGRATION_VERSION = "20250215000000"
12
12
  const EXECUTION_MODE_BACKFILL_MIGRATION_VERSION = "20260607131010"
13
13
  const JOBS_TABLE = "background_jobs"
14
+ const CONCURRENCY_TABLE = "background_job_concurrency"
14
15
  const DEFAULT_MAX_RETRIES = 10
15
16
  const ORPHANED_AFTER_MS = 2 * 60 * 60 * 1000
16
17
  /**
@@ -94,8 +95,10 @@ export default class BackgroundJobsStore {
94
95
  const executionMode = this._normalizeExecutionMode(options)
95
96
  const maxRetries = this._normalizeMaxRetries(options?.maxRetries)
96
97
  const argsJson = JSON.stringify(args || [])
98
+ const concurrency = this._normalizeConcurrencyOptions(options)
97
99
 
98
100
  await this._withDb(async (db) => {
101
+ if (concurrency) await this._ensureConcurrencyKey(db, concurrency)
99
102
  await db.insert({
100
103
  tableName: JOBS_TABLE,
101
104
  data: {
@@ -108,7 +111,9 @@ export default class BackgroundJobsStore {
108
111
  attempts: 0,
109
112
  status: "queued",
110
113
  scheduled_at_ms: now,
111
- created_at_ms: now
114
+ created_at_ms: now,
115
+ concurrency_key: concurrency?.concurrencyKey || null,
116
+ max_concurrency: concurrency?.maxConcurrency || null
112
117
  }
113
118
  })
114
119
  })
@@ -169,6 +174,17 @@ export default class BackgroundJobsStore {
169
174
  .where({status: "queued"})
170
175
  .where(`scheduled_at_ms ${scheduledAtOperator} ${db.quote(now)}`)
171
176
 
177
+ if (scheduledAtOperator === "<=") {
178
+ const jobsTable = db.quoteTable(JOBS_TABLE)
179
+ const concurrencyTable = db.quoteTable(CONCURRENCY_TABLE)
180
+ query = query.where(
181
+ `(${jobsTable}.${db.quoteColumn("concurrency_key")} IS NULL OR EXISTS (` +
182
+ `SELECT 1 FROM ${concurrencyTable} WHERE ` +
183
+ `${concurrencyTable}.${db.quoteColumn("concurrency_key")} = ${jobsTable}.${db.quoteColumn("concurrency_key")} AND ` +
184
+ `${concurrencyTable}.${db.quoteColumn("active_count")} < ${concurrencyTable}.${db.quoteColumn("max_concurrency")}))`
185
+ )
186
+ }
187
+
172
188
  if (typeof forked === "boolean") {
173
189
  query = query.where({forked})
174
190
  }
@@ -314,8 +330,12 @@ export default class BackgroundJobsStore {
314
330
  const handedOffAtMs = Date.now()
315
331
  const handoffId = randomUUID()
316
332
 
317
- return await this._withDb(async (db) => {
318
- await db.update({
333
+ return await this._withDb(async (db) => await this._transactionResult(db, async () => {
334
+ const queuedJob = await this._getJobRowById(db, jobId)
335
+ if (!queuedJob || queuedJob.status !== "queued") return null
336
+ if (queuedJob.concurrencyKey && !(await this._reserveConcurrency(db, queuedJob.concurrencyKey))) return null
337
+
338
+ const affectedRows = await this._updateAffectedRows(db, {
319
339
  tableName: JOBS_TABLE,
320
340
  data: {
321
341
  status: "handed_off",
@@ -326,12 +346,13 @@ export default class BackgroundJobsStore {
326
346
  conditions: {id: jobId, status: "queued"}
327
347
  })
328
348
 
329
- const job = await this._getJobRowById(db, jobId)
330
-
331
- if (job?.status !== "handed_off" || job.handoffId !== handoffId) return null
349
+ if (affectedRows !== 1) {
350
+ await this._releaseConcurrency(db, queuedJob.concurrencyKey)
351
+ return null
352
+ }
332
353
 
333
354
  return {handedOffAtMs, handoffId}
334
- })
355
+ }))
335
356
  }
336
357
 
337
358
  /**
@@ -346,13 +367,13 @@ export default class BackgroundJobsStore {
346
367
  async markCompleted({jobId, handoffId, workerId, handedOffAtMs}) {
347
368
  await this.ensureReady()
348
369
 
349
- return await this._withDb(async (db) => await this._withHandoffLock({db, jobId}, async () => {
370
+ return await this._withDb(async (db) => await this._transactionResult(db, async () => {
350
371
  const job = await this._getJobRowById(db, jobId)
351
372
 
352
373
  if (!job) return false
353
374
  if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return false
354
375
 
355
- await db.update({
376
+ const affectedRows = await this._updateAffectedRows(db, {
356
377
  tableName: JOBS_TABLE,
357
378
  data: {
358
379
  status: "completed",
@@ -361,9 +382,9 @@ export default class BackgroundJobsStore {
361
382
  conditions: this._activeHandoffConditions(job)
362
383
  })
363
384
 
364
- const updatedJob = await this._getJobRowById(db, jobId)
365
-
366
- return updatedJob?.status === "completed" && updatedJob.handoffId === job.handoffId
385
+ if (affectedRows !== 1) return false
386
+ await this._releaseConcurrency(db, job.concurrencyKey)
387
+ return true
367
388
  }))
368
389
  }
369
390
 
@@ -377,8 +398,10 @@ export default class BackgroundJobsStore {
377
398
  async markReturnedToQueue({jobId, handoffId}) {
378
399
  await this.ensureReady()
379
400
 
380
- await this._withDb(async (db) => {
381
- await db.update({
401
+ await this._withDb(async (db) => await db.transaction(async () => {
402
+ const job = await this._getJobRowById(db, jobId)
403
+ if (!job || job.handoffId !== handoffId || job.status !== "handed_off") return
404
+ const affectedRows = await this._updateAffectedRows(db, {
382
405
  tableName: JOBS_TABLE,
383
406
  data: {
384
407
  status: "queued",
@@ -389,7 +412,8 @@ export default class BackgroundJobsStore {
389
412
  },
390
413
  conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"}
391
414
  })
392
- })
415
+ if (affectedRows === 1) await this._releaseConcurrency(db, job.concurrencyKey)
416
+ }))
393
417
  }
394
418
 
395
419
  /**
@@ -405,13 +429,14 @@ export default class BackgroundJobsStore {
405
429
  async markFailed({jobId, error, handoffId, workerId, handedOffAtMs}) {
406
430
  await this.ensureReady()
407
431
 
408
- return await this._withDb(async (db) => await this._withHandoffLock({db, jobId}, async () => {
432
+ return await this._withDb(async (db) => await this._transactionResult(db, async () => {
409
433
  const job = await this._getJobRowById(db, jobId)
410
434
 
411
435
  if (!job) return null
412
436
  if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return null
413
437
 
414
- return await this._applyFailure({db, job, error, markOrphaned: false})
438
+ const updatedJob = await this._applyFailure({db, job, error, markOrphaned: false})
439
+ return updatedJob
415
440
  }))
416
441
  }
417
442
 
@@ -462,9 +487,27 @@ export default class BackgroundJobsStore {
462
487
 
463
488
  await this._withDb(async (db) => {
464
489
  await db.query(`DELETE FROM ${db.quoteTable(JOBS_TABLE)}`)
490
+ if (await db.tableExists(CONCURRENCY_TABLE)) await db.query(`DELETE FROM ${db.quoteTable(CONCURRENCY_TABLE)}`)
465
491
  })
466
492
  }
467
493
 
494
+ /**
495
+ * Cancels a queued or handed-off job and releases any durable concurrency reservation.
496
+ * @param {string} jobId - Job id.
497
+ * @returns {Promise<boolean>} - Whether the job was cancelled.
498
+ */
499
+ async cancel(jobId) {
500
+ await this.ensureReady()
501
+ return await this._withDb(async (db) => await this._transactionResult(db, async () => {
502
+ const job = await this._getJobRowById(db, jobId)
503
+ if (!job || (job.status !== "queued" && job.status !== "handed_off")) return false
504
+ const affectedRows = await this._updateAffectedRows(db, {tableName: JOBS_TABLE, data: {status: "cancelled"}, conditions: {id: job.id, status: job.status}})
505
+ if (affectedRows !== 1) return false
506
+ if (job.status === "handed_off") await this._releaseConcurrency(db, job.concurrencyKey)
507
+ return true
508
+ }))
509
+ }
510
+
468
511
  /**
469
512
  * Runs get retry delay ms.
470
513
  * @param {number} retryCount - Retry attempt count (1-based).
@@ -506,11 +549,15 @@ export default class BackgroundJobsStore {
506
549
  // row alone, otherwise later callers fail with "no such table".
507
550
  if (alreadyApplied && await db.tableExists(JOBS_TABLE)) {
508
551
  await this._ensureJobsTableColumns(db)
552
+ await this._ensureConcurrencyTable(db)
553
+ await this._reconcileConcurrency(db)
509
554
  return
510
555
  }
511
556
 
512
557
  await this._applyMigrations(db)
513
558
  await this._ensureJobsTableColumns(db)
559
+ await this._ensureConcurrencyTable(db)
560
+ await this._reconcileConcurrency(db)
514
561
 
515
562
  if (alreadyApplied) return
516
563
 
@@ -586,6 +633,8 @@ export default class BackgroundJobsStore {
586
633
  table.bigint("orphaned_at_ms", {null: true, index: true})
587
634
  table.string("worker_id", {null: true})
588
635
  table.text("last_error", {null: true})
636
+ table.string("concurrency_key", {null: true, index: true})
637
+ table.integer("max_concurrency", {null: true})
589
638
 
590
639
  await db.createTable(table)
591
640
  }
@@ -643,6 +692,37 @@ export default class BackgroundJobsStore {
643
692
  }
644
693
 
645
694
  await this._backfillExecutionModesOnce(db)
695
+
696
+ const lockName = `${MIGRATION_SCOPE}:concurrency_columns`
697
+ const acquired = await db.acquireAdvisoryLock(lockName)
698
+
699
+ if (!acquired) throw new Error("Failed to acquire background jobs concurrency schema lock")
700
+
701
+ try {
702
+ // SQL Server schema reads can deadlock with a concurrent ALTER TABLE, so
703
+ // acquire the lock before inspecting either column rather than only
704
+ // protecting the mutation.
705
+ db.clearSchemaCache()
706
+ const lockedTable = await db.getTableByNameOrFail(JOBS_TABLE)
707
+ const concurrencyColumnNames = ["concurrency_key", "max_concurrency"]
708
+
709
+ for (const concurrencyColumnName of concurrencyColumnNames) {
710
+ if (await lockedTable.getColumnByName(concurrencyColumnName)) continue
711
+
712
+ const tableData = new TableData(JOBS_TABLE)
713
+ if (concurrencyColumnName == "concurrency_key") {
714
+ tableData.string("concurrency_key", {null: true, index: true})
715
+ } else {
716
+ tableData.integer("max_concurrency", {null: true})
717
+ }
718
+
719
+ for (const sql of await db.alterTableSQLs(tableData)) await db.query(sql)
720
+ }
721
+
722
+ db.clearSchemaCache()
723
+ } finally {
724
+ await db.releaseAdvisoryLock(lockName)
725
+ }
646
726
  }
647
727
 
648
728
  /**
@@ -757,19 +837,18 @@ export default class BackgroundJobsStore {
757
837
  shouldRetry
758
838
  })
759
839
 
760
- await db.update({
840
+ const affectedRows = await this._updateAffectedRows(db, {
761
841
  tableName: JOBS_TABLE,
762
842
  data: update,
763
843
  conditions: this._activeHandoffConditions(job)
764
844
  })
765
845
 
846
+ if (affectedRows !== 1) return null
847
+ await this._releaseConcurrency(db, job.concurrencyKey)
848
+
766
849
  const updatedJob = await this._getJobRowById(db, job.id)
767
850
 
768
851
  if (!updatedJob) return null
769
- if (updatedJob.handoffId !== job.handoffId) return null
770
- if (updatedJob.attempts !== nextAttempt) return null
771
- if (updatedJob.status !== update.status) return null
772
-
773
852
  return updatedJob
774
853
  }
775
854
 
@@ -866,8 +945,115 @@ export default class BackgroundJobsStore {
866
945
  failedAtMs: this._normalizeNumber(row.failed_at_ms),
867
946
  orphanedAtMs: this._normalizeNumber(row.orphaned_at_ms),
868
947
  workerId: row.worker_id ? String(row.worker_id) : null,
869
- lastError: row.last_error ? String(row.last_error) : null
948
+ lastError: row.last_error ? String(row.last_error) : null,
949
+ concurrencyKey: row.concurrency_key ? String(row.concurrency_key) : null,
950
+ maxConcurrency: this._normalizeNumber(row.max_concurrency)
951
+ }
952
+ }
953
+
954
+ /**
955
+ * Validates concurrency options.
956
+ * @param {import("./types.js").BackgroundJobOptions | undefined} options - Job options.
957
+ * @returns {{concurrencyKey: string, maxConcurrency: number} | null} - Normalized configuration.
958
+ */
959
+ _normalizeConcurrencyOptions(options) {
960
+ const key = options?.concurrencyKey
961
+ const cap = options?.maxConcurrency
962
+ if (key === undefined && cap === undefined) return null
963
+ if (typeof key !== "string" || key.length === 0 || !Number.isInteger(cap) || Number(cap) <= 0) {
964
+ throw new Error("background job concurrencyKey and maxConcurrency must be paired; concurrencyKey must be non-empty and maxConcurrency must be a positive integer")
965
+ }
966
+ return {concurrencyKey: key, maxConcurrency: Number(cap)}
967
+ }
968
+
969
+ /**
970
+ * Ensures the concurrency state table exists.
971
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
972
+ * @returns {Promise<void>} - Resolves when ready.
973
+ */
974
+ async _ensureConcurrencyTable(db) {
975
+ if (await db.tableExists(CONCURRENCY_TABLE)) return
976
+ const table = new TableData(CONCURRENCY_TABLE, {ifNotExists: true})
977
+ table.string("concurrency_key", {primaryKey: true})
978
+ table.integer("max_concurrency", {null: false})
979
+ table.integer("active_count", {null: false})
980
+ await db.createTable(table)
981
+ }
982
+
983
+ /**
984
+ * Registers or verifies a stable key configuration.
985
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
986
+ * @param {object} concurrency - Concurrency configuration.
987
+ * @param {string} concurrency.concurrencyKey - Concurrency key.
988
+ * @param {number} concurrency.maxConcurrency - Stable cap.
989
+ * @returns {Promise<void>} - Resolves when verified.
990
+ */
991
+ async _ensureConcurrencyKey(db, {concurrencyKey, maxConcurrency}) {
992
+ const rows = await db.newQuery().from(CONCURRENCY_TABLE).where({concurrency_key: concurrencyKey}).limit(1).results()
993
+ if (!rows[0]) {
994
+ try {
995
+ await db.insert({tableName: CONCURRENCY_TABLE, data: {active_count: 0, concurrency_key: concurrencyKey, max_concurrency: maxConcurrency}})
996
+ return
997
+ } catch (error) {
998
+ const racedRows = await db.newQuery().from(CONCURRENCY_TABLE).where({concurrency_key: concurrencyKey}).limit(1).results()
999
+ if (!racedRows[0]) throw error
1000
+ rows[0] = racedRows[0]
1001
+ }
870
1002
  }
1003
+ const configured = /** @type {{max_concurrency?: number | string}} */ (rows[0])
1004
+ if (this._normalizeNumber(configured.max_concurrency) !== maxConcurrency) throw new Error(`Conflicting maxConcurrency for background job concurrencyKey: ${concurrencyKey}`)
1005
+ }
1006
+
1007
+ /**
1008
+ * Atomically reserves capacity for a key.
1009
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1010
+ * @param {string} concurrencyKey - Concurrency key.
1011
+ * @returns {Promise<boolean>} - Whether capacity was reserved.
1012
+ */
1013
+ async _reserveConcurrency(db, concurrencyKey) {
1014
+ const table = db.quoteTable(CONCURRENCY_TABLE)
1015
+ const count = db.quoteColumn("active_count")
1016
+ const affectedRows = await db.affectedRows(`UPDATE ${table} SET ${count} = ${count} + 1 WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)} AND ${count} < ${db.quoteColumn("max_concurrency")}`)
1017
+ return affectedRows === 1
1018
+ }
1019
+
1020
+ /**
1021
+ * Runs a portable update and returns its affected-row count.
1022
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1023
+ * @param {import("../database/drivers/base.js").UpdateSqlArgsType} args - Update options.
1024
+ * @returns {Promise<number>} - Affected row count.
1025
+ */
1026
+ async _updateAffectedRows(db, args) {
1027
+ return await db.affectedRows(db.updateSql(args))
1028
+ }
1029
+
1030
+ /**
1031
+ * Releases capacity for a key.
1032
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1033
+ * @param {string | null} concurrencyKey - Concurrency key.
1034
+ * @returns {Promise<void>} - Resolves when released.
1035
+ */
1036
+ async _releaseConcurrency(db, concurrencyKey) {
1037
+ if (!concurrencyKey) return
1038
+ const table = db.quoteTable(CONCURRENCY_TABLE)
1039
+ const count = db.quoteColumn("active_count")
1040
+ await db.query(`UPDATE ${table} SET ${count} = ${count} - 1 WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)} AND ${count} > 0`)
1041
+ }
1042
+
1043
+ /**
1044
+ * Rebuilds durable counts from active handoffs after startup.
1045
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1046
+ * @returns {Promise<void>} - Resolves when reconciled.
1047
+ */
1048
+ async _reconcileConcurrency(db) {
1049
+ if (!(await db.tableExists(CONCURRENCY_TABLE))) return
1050
+ const concurrencyTable = db.quoteTable(CONCURRENCY_TABLE)
1051
+ const jobsTable = db.quoteTable(JOBS_TABLE)
1052
+ await db.query(
1053
+ `UPDATE ${concurrencyTable} SET ${db.quoteColumn("active_count")} = (` +
1054
+ `SELECT COUNT(*) FROM ${jobsTable} WHERE ${jobsTable}.${db.quoteColumn("status")} = ${db.quote("handed_off")} AND ` +
1055
+ `${jobsTable}.${db.quoteColumn("concurrency_key")} = ${concurrencyTable}.${db.quoteColumn("concurrency_key")})`
1056
+ )
871
1057
  }
872
1058
 
873
1059
  /**
@@ -973,25 +1159,22 @@ export default class BackgroundJobsStore {
973
1159
  }
974
1160
 
975
1161
  /**
976
- * Serializes reports for one job so duplicate reports cannot both appear accepted.
1162
+ * Runs a value-returning callback inside the driver's void-typed transaction API.
977
1163
  * @template T
978
- * @param {object} args - Options.
979
- * @param {import("../database/drivers/base.js").default} args.db - Database connection.
980
- * @param {string} args.jobId - Job id.
981
- * @param {() => Promise<T>} callback - Locked callback.
1164
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1165
+ * @param {() => Promise<T>} callback - Transaction callback.
982
1166
  * @returns {Promise<T>} - Callback result.
983
1167
  */
984
- async _withHandoffLock({db, jobId}, callback) {
985
- const lockName = `background-job:${jobId}`
986
- const acquired = await db.acquireAdvisoryLock(lockName)
987
-
988
- if (!acquired) throw new Error(`Failed to acquire background job handoff lock: ${jobId}`)
989
-
990
- try {
991
- return await callback()
992
- } finally {
993
- await db.releaseAdvisoryLock(lockName)
994
- }
1168
+ async _transactionResult(db, callback) {
1169
+ let completed = false
1170
+ /** @type {T | undefined} */
1171
+ let result
1172
+ await db.transaction(async () => {
1173
+ result = await callback()
1174
+ completed = true
1175
+ })
1176
+ if (!completed) throw new Error("Background jobs transaction callback was not invoked")
1177
+ return /** @type {T} */ (result)
995
1178
  }
996
1179
 
997
1180
  /**
@@ -13,6 +13,8 @@
13
13
  * @property {BackgroundJobExecutionMode} [executionMode] - How the job should run. Defaults to `"forked"`.
14
14
  * @property {boolean} [forked] - Compatibility alias: `false` maps to `"inline"` and `true` maps to `"forked"`.
15
15
  * @property {number} [maxRetries] - Max retries for a failed job before it is marked failed.
16
+ * @property {string} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap.
17
+ * @property {number} [maxConcurrency] - Positive integer cap; must be paired with `concurrencyKey`.
16
18
  */
17
19
  /**
18
20
  * @typedef {object} BackgroundJobPayload
@@ -43,6 +45,8 @@
43
45
  * @property {number | null} orphanedAtMs - Orphaned time in ms.
44
46
  * @property {string | null} workerId - Worker id handling the job.
45
47
  * @property {string | null} lastError - Last failure message.
48
+ * @property {string | null} concurrencyKey - Durable concurrency key.
49
+ * @property {number | null} maxConcurrency - Durable per-key cap.
46
50
  */
47
51
  /**
48
52
  * @typedef {object} BackgroundJobFailureEvent
@@ -1050,6 +1050,22 @@ export default class VelociousDatabaseDriversBase {
1050
1050
  throw new Error("'query' unexpected came here")
1051
1051
  }
1052
1052
 
1053
+ /**
1054
+ * Executes a mutation and returns the number of rows changed by that statement.
1055
+ * @param {string} sql - Mutation SQL string.
1056
+ * @returns {Promise<number>} - Affected row count.
1057
+ */
1058
+ async affectedRows(sql) {
1059
+ this._assertWritableQuery(sql)
1060
+ await this.beforeQuery(sql, {})
1061
+
1062
+ try {
1063
+ return await this._affectedRowsActual(sql)
1064
+ } finally {
1065
+ await this.afterQuery(sql, {})
1066
+ }
1067
+ }
1068
+
1053
1069
  /**
1054
1070
  * Runs query actual with logging.
1055
1071
  * @param {object} args - Options object.
@@ -1308,6 +1324,16 @@ export default class VelociousDatabaseDriversBase {
1308
1324
  throw new Error(`queryActual not implemented`)
1309
1325
  }
1310
1326
 
1327
+ /**
1328
+ * Executes a mutation and returns its affected row count.
1329
+ * @abstract
1330
+ * @param {string} sql - Mutation SQL string.
1331
+ * @returns {Promise<number>} - Affected row count.
1332
+ */
1333
+ _affectedRowsActual(sql) { // eslint-disable-line no-unused-vars
1334
+ throw new Error(`affectedRowsActual not implemented`)
1335
+ }
1336
+
1311
1337
  /**
1312
1338
  * Runs query to sql.
1313
1339
  * @abstract
@@ -328,6 +328,19 @@ export default class VelociousDatabaseDriversMssql extends Base{
328
328
  return Array.isArray(result.recordsets) ? result.recordsets[0] || [] : []
329
329
  }
330
330
 
331
+ /**
332
+ * Executes a mutation with affected-row metadata.
333
+ * @param {string} sql - Mutation SQL.
334
+ * @returns {Promise<number>} - Affected row count.
335
+ */
336
+ async _affectedRowsActual(sql) {
337
+ const request = this._currentTransaction
338
+ ? new mssql.Request(this._currentTransaction)
339
+ : new mssql.Request(this.connection)
340
+ const result = await request.query(sql)
341
+ return result.rowsAffected.reduce((total, count) => total + count, 0)
342
+ }
343
+
331
344
  /**
332
345
  * Runs query to sql.
333
346
  * @param {import("../../query/index.js").default} query - Query instance.
@@ -344,6 +344,24 @@ export default class VelociousDatabaseDriversMysql extends Base{
344
344
  }
345
345
  }
346
346
 
347
+ /**
348
+ * Executes a mutation with affected-row metadata.
349
+ * @param {string} sql - Mutation SQL.
350
+ * @returns {Promise<number>} - Affected row count.
351
+ */
352
+ async _affectedRowsActual(sql) {
353
+ if (!this.pool) await this.connect()
354
+ if (!this.pool) throw new Error("MySQL pool failed to initialize")
355
+ const pool = this.pool
356
+
357
+ return await new Promise((resolve, reject) => {
358
+ pool.query(sql, (error, result) => {
359
+ if (error) reject(error)
360
+ else resolve("affectedRows" in result ? result.affectedRows : 0)
361
+ })
362
+ })
363
+ }
364
+
347
365
  /**
348
366
  * Runs query to sql.
349
367
  * @param {import("../../query/index.js").default} query - Query instance.
@@ -227,6 +227,18 @@ export default class VelociousDatabaseDriversPgsql extends Base{
227
227
  return response.rows
228
228
  }
229
229
 
230
+ /**
231
+ * Executes a mutation with affected-row metadata.
232
+ * @param {string} sql - Mutation SQL.
233
+ * @returns {Promise<number>} - Affected row count.
234
+ */
235
+ async _affectedRowsActual(sql) {
236
+ if (!this.connection) await this.connect()
237
+ if (!this.connection) throw new Error("PostgreSQL connection failed to initialize")
238
+ const response = await this.connection.query(sql)
239
+ return response.rowCount || 0
240
+ }
241
+
230
242
  /**
231
243
  * Runs query to sql.
232
244
  * @param {import("../../query/index.js").default} query - Query instance.
@@ -47,6 +47,17 @@ export default class VelociousDatabaseDriversSqliteConnectionSqlJs {
47
47
  return result
48
48
  }
49
49
 
50
+ /**
51
+ * Executes a mutation with affected-row metadata.
52
+ * @param {string} sql - Mutation SQL.
53
+ * @returns {Promise<number>} - Affected row count.
54
+ */
55
+ async affectedRows(sql) {
56
+ await this.query(sql)
57
+ const connection = /** @type {import("sql.js").Database & {getRowsModified: () => number}} */ (this.connection)
58
+ return connection.getRowsModified()
59
+ }
60
+
50
61
  saveDatabase = async () => {
51
62
  const databaseContent = this.connection.export()
52
63
 
@@ -15,7 +15,7 @@ import fileExists from "../../../utils/file-exists.js"
15
15
  export default class VelociousDatabaseDriversSqliteNode extends Base {
16
16
  /**
17
17
  * Connection.
18
- * @type {import("sqlite3").Database | undefined} */
18
+ * @type {import("sqlite").Database | undefined} */
19
19
  connection = undefined
20
20
 
21
21
  /**
@@ -39,11 +39,10 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
39
39
  this._advisoryLockDirectory = path.join(databaseDir, `${this.localStorageName()}.velocious-advisory-locks`)
40
40
 
41
41
  try {
42
- // @ts-expect-error
43
- this.connection = /** @type {import("sqlite3").Database} */ (await open({
42
+ this.connection = await open({
44
43
  filename: databasePath,
45
44
  driver: sqlite3.Database
46
- }))
45
+ })
47
46
  } catch (error) {
48
47
  if (error instanceof Error) {
49
48
  throw new Error(`Couldn't open database ${databasePath} because of ${error.constructor.name}: ${error.message}`, {cause: error})
@@ -79,6 +78,17 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
79
78
  return await query(this.connection, sql)
80
79
  }
81
80
 
81
+ /**
82
+ * Executes a mutation with affected-row metadata.
83
+ * @param {string} sql - Mutation SQL.
84
+ * @returns {Promise<number>} - Affected row count.
85
+ */
86
+ async _affectedRowsActual(sql) {
87
+ if (!this.connection) throw new Error("No connection")
88
+ const result = await this.connection.run(sql)
89
+ return result.changes || 0
90
+ }
91
+
82
92
  /**
83
93
  * Layers a filesystem lock directory on top of the in-process waiter
84
94
  * queue so SQLite deployments with multiple Node processes writing to
@@ -81,4 +81,17 @@ export default class VelociousDatabaseDriversSqliteNative extends Base {
81
81
  return query(this.connection, sql)
82
82
  })
83
83
  }
84
+
85
+ /**
86
+ * Executes a mutation with affected-row metadata.
87
+ * @param {string} sql - Mutation SQL.
88
+ * @returns {Promise<number>} - Affected row count.
89
+ */
90
+ async _affectedRowsActual(sql) {
91
+ return await this._queryMutex.sync(async () => {
92
+ if (!this.connection) throw new Error("Not connected yet")
93
+ const result = await this.connection.runAsync(sql)
94
+ return result.changes
95
+ })
96
+ }
84
97
  }
@@ -8,7 +8,7 @@ import Base from "./base.js"
8
8
 
9
9
  /**
10
10
  * VelociousDatabaseDriversSqliteWeb class.
11
- * @typedef {{query: (sql: string) => Promise<Record<string, ?>[]>, close: () => Promise<void>}} SqliteWebConnection
11
+ * @typedef {{query: (sql: string) => Promise<Record<string, ?>[]>, affectedRows: (sql: string) => Promise<number>, close: () => Promise<void>}} SqliteWebConnection
12
12
  */
13
13
 
14
14
  export default class VelociousDatabaseDriversSqliteWeb extends Base {
@@ -110,4 +110,14 @@ export default class VelociousDatabaseDriversSqliteWeb extends Base {
110
110
 
111
111
  return result
112
112
  }
113
+
114
+ /**
115
+ * Executes a mutation with affected-row metadata.
116
+ * @param {string} sql - Mutation SQL.
117
+ * @returns {Promise<number>} - Affected row count.
118
+ */
119
+ async _affectedRowsActual(sql) {
120
+ const connection = this.getConnection()
121
+ return await connection.affectedRows(sql)
122
+ }
113
123
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * Runs query.
5
- * @param {import("sqlite3").Database} connection - Connection.
5
+ * @param {import("sqlite").Database} connection - Connection.
6
6
  * @param {string} sql - SQL string.
7
7
  * @returns {Promise<Record<string, ?>[]>} - Resolves with string value.
8
8
  */
@@ -13,7 +13,6 @@ export default async function query(connection, sql) {
13
13
  * @type {Record<string, ?>[]} */
14
14
  let result
15
15
 
16
- // @ts-expect-error
17
16
  result = await connection.all(sql)
18
17
 
19
18
  return result