velocious 1.0.606 → 1.0.608

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 (73) hide show
  1. package/README.md +45 -2
  2. package/build/background-jobs/job-semantics.js +145 -0
  3. package/build/background-jobs/local-adapter.js +161 -0
  4. package/build/background-jobs/local-dispatcher.js +418 -0
  5. package/build/background-jobs/local-job-registry.js +59 -0
  6. package/build/background-jobs/local-store.js +1220 -0
  7. package/build/background-jobs/main.js +2 -1
  8. package/build/background-jobs/store.js +97 -100
  9. package/build/background-jobs/types.js +28 -1
  10. package/build/background-jobs/worker.js +19 -14
  11. package/build/configuration-types.js +3 -1
  12. package/build/configuration.js +16 -1
  13. package/build/database/drivers/sqlite/index.js +46 -41
  14. package/build/environment-handlers/browser.js +10 -0
  15. package/build/src/background-jobs/job-semantics.d.ts +65 -0
  16. package/build/src/background-jobs/job-semantics.d.ts.map +1 -0
  17. package/build/src/background-jobs/job-semantics.js +125 -0
  18. package/build/src/background-jobs/local-adapter.d.ts +161 -0
  19. package/build/src/background-jobs/local-adapter.d.ts.map +1 -0
  20. package/build/src/background-jobs/local-adapter.js +141 -0
  21. package/build/src/background-jobs/local-dispatcher.d.ts +185 -0
  22. package/build/src/background-jobs/local-dispatcher.d.ts.map +1 -0
  23. package/build/src/background-jobs/local-dispatcher.js +383 -0
  24. package/build/src/background-jobs/local-job-registry.d.ts +26 -0
  25. package/build/src/background-jobs/local-job-registry.d.ts.map +1 -0
  26. package/build/src/background-jobs/local-job-registry.js +51 -0
  27. package/build/src/background-jobs/local-store.d.ts +364 -0
  28. package/build/src/background-jobs/local-store.d.ts.map +1 -0
  29. package/build/src/background-jobs/local-store.js +1079 -0
  30. package/build/src/background-jobs/main.d.ts.map +1 -1
  31. package/build/src/background-jobs/main.js +3 -2
  32. package/build/src/background-jobs/store.d.ts +17 -9
  33. package/build/src/background-jobs/store.d.ts.map +1 -1
  34. package/build/src/background-jobs/store.js +75 -91
  35. package/build/src/background-jobs/types.d.ts +107 -2
  36. package/build/src/background-jobs/types.d.ts.map +1 -1
  37. package/build/src/background-jobs/types.js +29 -2
  38. package/build/src/background-jobs/worker.d.ts +15 -7
  39. package/build/src/background-jobs/worker.d.ts.map +1 -1
  40. package/build/src/background-jobs/worker.js +20 -15
  41. package/build/src/configuration-types.d.ts +9 -2
  42. package/build/src/configuration-types.d.ts.map +1 -1
  43. package/build/src/configuration-types.js +4 -2
  44. package/build/src/configuration.d.ts +5 -0
  45. package/build/src/configuration.d.ts.map +1 -1
  46. package/build/src/configuration.js +15 -2
  47. package/build/src/database/drivers/sqlite/index.d.ts +8 -0
  48. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  49. package/build/src/database/drivers/sqlite/index.js +47 -41
  50. package/build/src/environment-handlers/browser.d.ts +9 -0
  51. package/build/src/environment-handlers/browser.d.ts.map +1 -1
  52. package/build/src/environment-handlers/browser.js +10 -1
  53. package/build/src/testing/test-runner.d.ts +42 -10
  54. package/build/src/testing/test-runner.d.ts.map +1 -1
  55. package/build/src/testing/test-runner.js +135 -35
  56. package/build/testing/test-runner.js +141 -33
  57. package/build/tsconfig.tsbuildinfo +1 -1
  58. package/package.json +1 -1
  59. package/scripts/test-browser.js +5 -1
  60. package/src/background-jobs/job-semantics.js +145 -0
  61. package/src/background-jobs/local-adapter.js +161 -0
  62. package/src/background-jobs/local-dispatcher.js +418 -0
  63. package/src/background-jobs/local-job-registry.js +59 -0
  64. package/src/background-jobs/local-store.js +1220 -0
  65. package/src/background-jobs/main.js +2 -1
  66. package/src/background-jobs/store.js +97 -100
  67. package/src/background-jobs/types.js +28 -1
  68. package/src/background-jobs/worker.js +19 -14
  69. package/src/configuration-types.js +3 -1
  70. package/src/configuration.js +16 -1
  71. package/src/database/drivers/sqlite/index.js +46 -41
  72. package/src/environment-handlers/browser.js +10 -0
  73. package/src/testing/test-runner.js +141 -33
@@ -1354,7 +1354,8 @@ export default class BackgroundJobsMain {
1354
1354
  workerId: worker.workerId,
1355
1355
  handedOffAtMs: handoff.handedOffAtMs,
1356
1356
  options: {
1357
- executionMode: job.executionMode
1357
+ executionMode: job.executionMode,
1358
+ ...(job.timeoutMs === null ? {} : {timeoutMs: job.timeoutMs})
1358
1359
  }
1359
1360
  }
1360
1361
  })
@@ -9,6 +9,19 @@ import BackgroundJobRecord from "./job-record.js"
9
9
  import normalizeBackgroundJobError from "./normalize-error.js"
10
10
  import {coordinateSharedTransactionConnection} from "../testing/shared-transaction-connection-coordinator.js"
11
11
  import stableJsonStringify from "../utils/stable-json.js"
12
+ import {
13
+ BACKGROUND_JOB_EXECUTION_MODES,
14
+ DEFAULT_BACKGROUND_JOB_EXECUTION_MODE,
15
+ DEFAULT_BACKGROUND_JOB_QUEUE,
16
+ QUEUE_CONCURRENCY_KEY_PREFIX,
17
+ normalizeBackgroundJobConcurrency,
18
+ normalizeBackgroundJobExecutionMode,
19
+ normalizeBackgroundJobMaxRetries,
20
+ normalizeBackgroundJobQueue,
21
+ normalizeBackgroundJobScheduledAtMs,
22
+ rescheduledBackgroundJobAtMs,
23
+ retryDelayMs
24
+ } from "./job-semantics.js"
12
25
  import {
13
26
  MAIL_DELIVERY_OPERATIONS_TABLE,
14
27
  mailDeliveryOperationForJob,
@@ -27,6 +40,7 @@ import {
27
40
  * @property {number} maxRetries - Retry cap.
28
41
  * @property {string} queue - Queue name.
29
42
  * @property {number} scheduledAtMs - Eligibility timestamp.
43
+ * @property {number | null} timeoutMs - Per-job timeout override, or null when omitted.
30
44
  */
31
45
 
32
46
  const MIGRATIONS_TABLE = "velocious_internal_migrations"
@@ -52,22 +66,9 @@ const COUNTS_REVISION_KEY = "counts"
52
66
  export const BACKGROUND_JOB_COUNTS_CHANNEL = "velocious-background-job-counts"
53
67
  export const BACKGROUND_JOB_COUNT_BUCKETS = ["all", "queued", "handed_off", "completed", "failed", "orphaned"]
54
68
  const COUNTED_JOB_STATUSES = BACKGROUND_JOB_COUNT_BUCKETS.slice(1)
55
- const DEFAULT_MAX_RETRIES = 10
69
+ const MAX_JOB_TIMEOUT_MS = 2_147_483_647
70
+ const JOB_TIMEOUT_VALIDATION_MESSAGE = `background job timeoutMs must be a finite non-positive number or an integer between 1 and ${MAX_JOB_TIMEOUT_MS}`
56
71
  const ORPHANED_AFTER_MS = 2 * 60 * 60 * 1000
57
- /**
58
- * Execution modes.
59
- * @type {import("./types.js").BackgroundJobExecutionMode[]} */
60
- const EXECUTION_MODES = ["inline", "forked", "pooled", "spawned"]
61
- /**
62
- * Execution mode for a new enqueue that names neither `executionMode` nor the
63
- * legacy `forked` flag. Pooled routes the job to a warm, reused local runner
64
- * process — the same isolation as forked without paying a fresh process per job.
65
- * @type {import("./types.js").BackgroundJobExecutionMode} */
66
- const DEFAULT_EXECUTION_MODE = "pooled"
67
- const DEFAULT_QUEUE = "default"
68
- // Queue-derived durable concurrency keys are namespaced so they can't collide
69
- // with explicit caller-supplied concurrencyKeys.
70
- const QUEUE_CONCURRENCY_KEY_PREFIX = "queue:"
71
72
 
72
73
  /**
73
74
  * Columns the dashboard is allowed to sort job listings by, mapped to their
@@ -498,7 +499,8 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
498
499
  maxRetries: preparedJob.maxRetries,
499
500
  queue: preparedJob.queue,
500
501
  scheduledAtMs: options.scheduledAtMs === undefined ? null : preparedJob.scheduledAtMs,
501
- scheduling: options.scheduledAtMs === undefined ? "immediate" : "scheduled"
502
+ scheduling: options.scheduledAtMs === undefined ? "immediate" : "scheduled",
503
+ ...(preparedJob.timeoutMs === null ? {} : {timeoutMs: preparedJob.timeoutMs})
502
504
  })
503
505
 
504
506
  return createHash("sha256").update(serialized).digest("hex")
@@ -783,7 +785,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
783
785
  .map(([queue, priority]) => `WHEN ${db.quote(queue)} THEN ${priority}`)
784
786
  .join(" ")
785
787
 
786
- return `CASE COALESCE(${queueColumn}, ${db.quote(DEFAULT_QUEUE)}) ${whens} ELSE 0 END`
788
+ return `CASE COALESCE(${queueColumn}, ${db.quote(DEFAULT_BACKGROUND_JOB_QUEUE)}) ${whens} ELSE 0 END`
787
789
  }
788
790
 
789
791
  /**
@@ -1302,13 +1304,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1302
1304
  * @returns {number} - Delay in milliseconds.
1303
1305
  */
1304
1306
  getRetryDelayMs(retryCount) {
1305
- const scheduleSeconds = [10, 60, 600, 3600]
1306
-
1307
- if (retryCount <= scheduleSeconds.length) {
1308
- return scheduleSeconds[retryCount - 1] * 1000
1309
- }
1310
-
1311
- return (retryCount - 3) * 60 * 60 * 1000
1307
+ return retryDelayMs(retryCount)
1312
1308
  }
1313
1309
 
1314
1310
  /**
@@ -1332,8 +1328,33 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1332
1328
  jobName,
1333
1329
  maxRetries: this._normalizeMaxRetries(options?.maxRetries),
1334
1330
  queue,
1335
- scheduledAtMs: this._normalizeScheduledAtMs(options?.scheduledAtMs, createdAtMs)
1331
+ scheduledAtMs: this._normalizeScheduledAtMs(options?.scheduledAtMs, createdAtMs),
1332
+ timeoutMs: this._normalizeJobTimeoutMs(options)
1333
+ }
1334
+ }
1335
+
1336
+ /**
1337
+ * Normalizes a per-job timeout while preserving omitted (worker fallback)
1338
+ * separately from explicitly disabled.
1339
+ * @param {import("./types.js").BackgroundJobOptions | undefined} options - Job options.
1340
+ * @returns {number | null} - Positive timeout, zero for disabled, or null when omitted.
1341
+ */
1342
+ _normalizeJobTimeoutMs(options) {
1343
+ if (options?.timeoutMs === undefined) return null
1344
+
1345
+ const timeoutMs = options.timeoutMs
1346
+
1347
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
1348
+ throw VelociousError.safe(JOB_TIMEOUT_VALIDATION_MESSAGE)
1336
1349
  }
1350
+
1351
+ if (timeoutMs <= 0) return 0
1352
+
1353
+ if (!Number.isInteger(timeoutMs) || timeoutMs > MAX_JOB_TIMEOUT_MS) {
1354
+ throw VelociousError.safe(JOB_TIMEOUT_VALIDATION_MESSAGE)
1355
+ }
1356
+
1357
+ return timeoutMs
1337
1358
  }
1338
1359
 
1339
1360
  /**
@@ -1371,6 +1392,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1371
1392
  schedule_key: scheduleKey,
1372
1393
  concurrency_key: concurrency?.concurrencyKey || null,
1373
1394
  max_concurrency: concurrency?.maxConcurrency || null,
1395
+ timeout_ms: preparedJob.timeoutMs,
1374
1396
  handoff_id: null
1375
1397
  }
1376
1398
  })
@@ -1382,11 +1404,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1382
1404
  * @returns {number} - Normalized max retries.
1383
1405
  */
1384
1406
  _normalizeMaxRetries(maxRetries) {
1385
- if (typeof maxRetries === "number" && Number.isFinite(maxRetries) && maxRetries >= 0) {
1386
- return Math.floor(maxRetries)
1387
- }
1388
-
1389
- return DEFAULT_MAX_RETRIES
1407
+ return normalizeBackgroundJobMaxRetries(maxRetries)
1390
1408
  }
1391
1409
 
1392
1410
  /**
@@ -1396,10 +1414,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1396
1414
  * @returns {number} - Dispatch timestamp.
1397
1415
  */
1398
1416
  _normalizeScheduledAtMs(scheduledAtMs, defaultScheduledAtMs) {
1399
- if (scheduledAtMs === undefined) return defaultScheduledAtMs
1400
- if (Number.isSafeInteger(scheduledAtMs) && scheduledAtMs >= 0) return scheduledAtMs
1401
-
1402
- throw VelociousError.safe("background job scheduledAtMs must be a non-negative safe integer")
1417
+ return normalizeBackgroundJobScheduledAtMs(scheduledAtMs, defaultScheduledAtMs)
1403
1418
  }
1404
1419
 
1405
1420
  /**
@@ -1408,14 +1423,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1408
1423
  * @returns {number} - Future eligibility timestamp.
1409
1424
  */
1410
1425
  _rescheduledAtMs(delayMs) {
1411
- this._validateRescheduleDelayMs(delayMs)
1412
-
1413
- const scheduledAtMs = Date.now() + delayMs
1414
- if (!Number.isSafeInteger(scheduledAtMs)) {
1415
- throw VelociousError.safe("background job reschedule scheduledAtMs must be a safe integer")
1416
- }
1417
-
1418
- return scheduledAtMs
1426
+ return rescheduledBackgroundJobAtMs(delayMs, Date.now())
1419
1427
  }
1420
1428
 
1421
1429
  /**
@@ -1424,9 +1432,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1424
1432
  * @returns {void}
1425
1433
  */
1426
1434
  _validateRescheduleDelayMs(delayMs) {
1427
- if (!Number.isSafeInteger(delayMs) || delayMs < 0) {
1428
- throw VelociousError.safe("background job reschedule delayMs must be a non-negative safe integer")
1429
- }
1435
+ rescheduledBackgroundJobAtMs(delayMs, 0)
1430
1436
  }
1431
1437
 
1432
1438
  /**
@@ -1612,6 +1618,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1612
1618
  table.text("last_error", {null: true})
1613
1619
  table.string("concurrency_key", {null: true, index: true})
1614
1620
  table.integer("max_concurrency", {null: true})
1621
+ table.bigint("timeout_ms", {null: true})
1615
1622
 
1616
1623
  await db.createTable(table)
1617
1624
  }
@@ -1704,6 +1711,35 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1704
1711
 
1705
1712
  await this._ensureQueueColumn(db)
1706
1713
  await this._ensureScheduleKeyColumn(db)
1714
+ await this._ensureJobTimeoutColumn(db)
1715
+ }
1716
+
1717
+ /**
1718
+ * Idempotently adds the per-job wall-clock timeout to existing job tables.
1719
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1720
+ * @returns {Promise<void>} - Resolves when ensured.
1721
+ */
1722
+ async _ensureJobTimeoutColumn(db) {
1723
+ const lockName = `${MIGRATION_SCOPE}:timeout_ms_column`
1724
+ const acquired = await db.acquireAdvisoryLock(lockName)
1725
+
1726
+ if (!acquired) throw new Error("Failed to acquire background jobs timeout schema lock")
1727
+
1728
+ try {
1729
+ db.clearSchemaCache()
1730
+ const table = await db.getTableByNameOrFail(JOBS_TABLE)
1731
+
1732
+ if (!(await table.getColumnByName("timeout_ms"))) {
1733
+ const tableData = new TableData(JOBS_TABLE)
1734
+ tableData.bigint("timeout_ms", {null: true})
1735
+
1736
+ for (const sql of await db.alterTableSQLs(tableData)) await db.query(sql)
1737
+
1738
+ db.clearSchemaCache()
1739
+ }
1740
+ } finally {
1741
+ await db.releaseAdvisoryLock(lockName)
1742
+ }
1707
1743
  }
1708
1744
 
1709
1745
  /**
@@ -2083,14 +2119,14 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2083
2119
  // `execution_mode` is the single source of truth for a job's runtime and is
2084
2120
  // written on every enqueue; the drop-forked migration backfills any pre-existing
2085
2121
  // rows before the legacy `forked` column is removed.
2086
- const executionMode = row.execution_mode ? this._normalizeExecutionModeName(String(row.execution_mode)) : DEFAULT_EXECUTION_MODE
2122
+ const executionMode = row.execution_mode ? this._normalizeExecutionModeName(String(row.execution_mode)) : DEFAULT_BACKGROUND_JOB_EXECUTION_MODE
2087
2123
 
2088
2124
  return {
2089
2125
  id: String(row.id),
2090
2126
  jobName: String(row.job_name),
2091
2127
  args: this._parseArgs(row.args_json),
2092
2128
  executionMode,
2093
- queue: row.queue ? String(row.queue) : DEFAULT_QUEUE,
2129
+ queue: row.queue ? String(row.queue) : DEFAULT_BACKGROUND_JOB_QUEUE,
2094
2130
  scheduleKey: row.schedule_key ? String(row.schedule_key) : null,
2095
2131
  status: row.status ? String(row.status) : "queued",
2096
2132
  attempts: this._normalizeNumber(row.attempts),
@@ -2105,26 +2141,9 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2105
2141
  workerId: row.worker_id ? String(row.worker_id) : null,
2106
2142
  lastError: row.last_error ? String(row.last_error) : null,
2107
2143
  concurrencyKey: row.concurrency_key ? String(row.concurrency_key) : null,
2108
- maxConcurrency: this._normalizeNumber(row.max_concurrency)
2109
- }
2110
- }
2111
-
2112
- /**
2113
- * Validates concurrency options.
2114
- * @param {import("./types.js").BackgroundJobOptions | undefined} options - Job options.
2115
- * @returns {{concurrencyKey: string, maxConcurrency: number} | null} - Normalized configuration.
2116
- */
2117
- _normalizeConcurrencyOptions(options) {
2118
- const key = options?.concurrencyKey
2119
- const cap = options?.maxConcurrency
2120
- if (key === undefined && cap === undefined) return null
2121
- if (typeof key !== "string" || key.length === 0 || !Number.isInteger(cap) || Number(cap) <= 0) {
2122
- throw new Error("background job concurrencyKey and maxConcurrency must be paired; concurrencyKey must be non-empty and maxConcurrency must be a positive integer")
2144
+ maxConcurrency: this._normalizeNumber(row.max_concurrency),
2145
+ timeoutMs: this._normalizeNumber(row.timeout_ms)
2123
2146
  }
2124
- if (key.startsWith(QUEUE_CONCURRENCY_KEY_PREFIX)) {
2125
- throw new Error(`background job concurrencyKey must not start with the reserved "${QUEUE_CONCURRENCY_KEY_PREFIX}" prefix, which is reserved for queue-derived concurrency caps`)
2126
- }
2127
- return {concurrencyKey: key, maxConcurrency: Number(cap)}
2128
2147
  }
2129
2148
 
2130
2149
  /**
@@ -2133,11 +2152,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2133
2152
  * @returns {string} - Queue name.
2134
2153
  */
2135
2154
  _normalizeQueue(options) {
2136
- const queue = options?.queue
2137
-
2138
- if (typeof queue === "string" && queue.trim().length > 0) return queue.trim()
2139
-
2140
- return DEFAULT_QUEUE
2155
+ return normalizeBackgroundJobQueue(options)
2141
2156
  }
2142
2157
 
2143
2158
  /**
@@ -2151,15 +2166,11 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2151
2166
  * @returns {{concurrencyKey: string, maxConcurrency: number, queueDerived: boolean} | null} - Resolved concurrency.
2152
2167
  */
2153
2168
  _resolveConcurrency(options, queue) {
2154
- const explicit = this._normalizeConcurrencyOptions(options)
2155
-
2156
- if (explicit) return {...explicit, queueDerived: false}
2157
-
2158
- const cap = this._queueMaxConcurrency(queue)
2159
-
2160
- if (cap === null) return null
2161
-
2162
- return {concurrencyKey: `${QUEUE_CONCURRENCY_KEY_PREFIX}${queue}`, maxConcurrency: cap, queueDerived: true}
2169
+ return normalizeBackgroundJobConcurrency({
2170
+ options: options || {},
2171
+ queue,
2172
+ queues: this.configuration.getBackgroundJobsConfig().queues
2173
+ })
2163
2174
  }
2164
2175
 
2165
2176
  /**
@@ -2667,21 +2678,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2667
2678
  * @returns {import("./types.js").BackgroundJobExecutionMode} - Normalized execution mode.
2668
2679
  */
2669
2680
  _normalizeExecutionMode(options) {
2670
- const executionMode = options?.executionMode
2671
-
2672
- if (executionMode) {
2673
- return this._normalizeExecutionModeName(executionMode)
2674
- }
2675
-
2676
- // The `forked` option alias was removed. Reject it loudly instead of silently
2677
- // defaulting to pooled, which would turn an explicitly inline (`forked: false`)
2678
- // or one-shot forked (`forked: true`) job into a pooled child-runner job — a
2679
- // silent semantic change for any not-yet-migrated caller.
2680
- if (options && "forked" in options) {
2681
- throw new Error("The background job `forked` option was removed; pass `executionMode` (\"inline\", \"forked\", \"pooled\", or \"spawned\") instead")
2682
- }
2683
-
2684
- return DEFAULT_EXECUTION_MODE
2681
+ return normalizeBackgroundJobExecutionMode(options || {}, DEFAULT_BACKGROUND_JOB_EXECUTION_MODE)
2685
2682
  }
2686
2683
 
2687
2684
  /**
@@ -2690,11 +2687,11 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2690
2687
  * @returns {import("./types.js").BackgroundJobExecutionMode} - Normalized execution mode.
2691
2688
  */
2692
2689
  _normalizeExecutionModeName(executionMode) {
2693
- for (const mode of EXECUTION_MODES) {
2694
- if (mode === executionMode) return mode
2695
- }
2696
-
2697
- throw new Error(`Invalid background job executionMode: ${executionMode}`)
2690
+ return normalizeBackgroundJobExecutionMode(
2691
+ {executionMode: /** @type {import("./types.js").BackgroundJobExecutionMode} */ (executionMode)},
2692
+ DEFAULT_BACKGROUND_JOB_EXECUTION_MODE,
2693
+ BACKGROUND_JOB_EXECUTION_MODES
2694
+ )
2698
2695
  }
2699
2696
 
2700
2697
  /**
@@ -3,6 +3,31 @@
3
3
  /**
4
4
  * @typedef {"inline" | "forked" | "pooled" | "spawned"} BackgroundJobExecutionMode
5
5
  */
6
+ /**
7
+ * @typedef {object} LocalBackgroundJobsClock
8
+ * @property {() => number} now - Current epoch milliseconds.
9
+ * @property {(callback: () => void, delayMs: number) => ReturnType<typeof setTimeout> | number} setTimeout - Arms a timer.
10
+ * @property {(timerId: ReturnType<typeof setTimeout> | number) => void} clearTimeout - Clears a timer.
11
+ */
12
+ /**
13
+ * @typedef {object} ResolvedBackgroundJobConcurrency
14
+ * @property {string} concurrencyKey - Durable cap identity.
15
+ * @property {number} maxConcurrency - Positive cap.
16
+ * @property {boolean} queueDerived - Whether queue configuration owns the cap.
17
+ */
18
+ /**
19
+ * @typedef {object} PreparedLocalBackgroundJob
20
+ * @property {string} argsDigest - Fixed-width digest of the serialized arguments.
21
+ * @property {string} argsJson - Serialized arguments.
22
+ * @property {ResolvedBackgroundJobConcurrency | null} concurrency - Resolved concurrency.
23
+ * @property {number} createdAtMs - Creation timestamp.
24
+ * @property {"inline"} executionMode - Local in-process execution mode.
25
+ * @property {string} jobId - Durable id.
26
+ * @property {string} jobName - Registered name.
27
+ * @property {number} maxRetries - Retry cap.
28
+ * @property {string} queue - Queue name.
29
+ * @property {number} scheduledAtMs - Eligibility timestamp.
30
+ */
6
31
  /**
7
32
  * @typedef {object} BackgroundJobsHealth
8
33
  * @property {boolean} ready - Whether the adapter can accept and process work.
@@ -20,7 +45,7 @@
20
45
  */
21
46
  /**
22
47
  * @typedef {object} BackgroundJobOptions
23
- * @property {BackgroundJobExecutionMode} [executionMode] - How the job should run. Defaults to `"pooled"` (a warm, reused local runner process). `"forked"` runs the job in a fresh `child_process.fork()` child, `"spawned"` in a detached CLI runner, and `"inline"` inside the worker process. Omit to use the default `"pooled"` mode.
48
+ * @property {BackgroundJobExecutionMode} [executionMode] - How the job should run. Node defaults to `"pooled"` (a warm, reused local runner process). Browser/Expo local dispatch defaults to and only accepts `"inline"`. `"forked"` runs a Node job in a fresh `child_process.fork()` child, and `"spawned"` in a detached CLI runner.
24
49
  * @property {number} [maxRetries] - Max retries for a failed job before it is marked failed.
25
50
  * @property {string} [queue] - Queue name. Defaults to `"default"`. When the queue has a configured cap in `backgroundJobs.queues`, that cap is enforced cluster-wide.
26
51
  * @property {string} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap. Overrides any queue-derived cap.
@@ -28,6 +53,7 @@
28
53
  * @property {boolean} [deduplicateWhileQueued] - When true, skip the enqueue if an identical still-queued job (same job name, args and queue) is scheduled no later than this enqueue, returning the earliest matching job's id. A future retry does not suppress earlier work. Deduplication is independent of `concurrencyKey`, so the job keeps its normal (e.g. queue-derived) concurrency cap. Keeps an interval-scheduled recurring job (e.g. retention pruning) from piling up redundant queued rows when it runs slower than its interval or no worker is free.
29
54
  * @property {string} [idempotencyKey] - Durable enqueue identity scoped to the resolved job class name and queue. Exact replay returns the original job id across every state and after job pruning; reuse with different canonical arguments or behavior-affecting options fails. Ownership is independent of `deduplicateWhileQueued` and is retained until an explicit future retention policy removes it.
30
55
  * @property {number} [scheduledAtMs] - Epoch timestamp in milliseconds when the job becomes eligible for dispatch. Defaults to enqueue time.
56
+ * @property {number} [timeoutMs] - Per-job wall-clock timeout for forked and pooled execution. A positive integer up to 2,147,483,647 overrides the worker-level `jobTimeoutMs`; a non-positive finite value disables the timeout for this job.
31
57
  */
32
58
  /**
33
59
  * @typedef {object} BackgroundJobPayload
@@ -61,6 +87,7 @@
61
87
  * @property {string | null} lastError - Last failure message.
62
88
  * @property {string | null} concurrencyKey - Durable concurrency key.
63
89
  * @property {number | null} maxConcurrency - Durable per-key cap.
90
+ * @property {number | null} timeoutMs - Per-job wall-clock timeout override, or null when omitted.
64
91
  */
65
92
  /**
66
93
  * @typedef {"queued" | "handed_off" | null} BackgroundJobReplacementPreviousStatus
@@ -690,7 +690,7 @@ export default class BackgroundJobsWorker {
690
690
  state.lastDispatchSeq = ++this._pooledDispatchSeq
691
691
 
692
692
  return new Promise((resolve) => {
693
- const timeoutTimer = this._armPooledJobTimeout({child, jobId: payload.id})
693
+ const timeoutTimer = this._armPooledJobTimeout({child, payload})
694
694
 
695
695
  state.inflight.set(payload.id, {payload, resolve, timeoutTimer})
696
696
  try {
@@ -753,15 +753,15 @@ export default class BackgroundJobsWorker {
753
753
  * the timer, or null when no timeout is configured.
754
754
  * @param {object} args - Options.
755
755
  * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
756
- * @param {string} args.jobId - Job id whose overrun is guarded.
756
+ * @param {import("./types.js").BackgroundJobPayload & {id: string}} args.payload - Job payload whose overrun is guarded.
757
757
  * @returns {ReturnType<typeof setTimeout> | null} - The armed timer, or null.
758
758
  */
759
- _armPooledJobTimeout({child, jobId}) {
760
- const timeoutMs = this._resolveJobTimeoutMs()
759
+ _armPooledJobTimeout({child, payload}) {
760
+ const timeoutMs = this._resolveJobTimeoutMs(payload.options)
761
761
 
762
762
  if (!(typeof timeoutMs === "number" && timeoutMs > 0)) return null
763
763
 
764
- return setTimeout(() => this._onPooledJobTimeout({child, jobId}), timeoutMs)
764
+ return setTimeout(() => this._onPooledJobTimeout({child, jobId: payload.id}), timeoutMs)
765
765
  }
766
766
 
767
767
  /**
@@ -1016,7 +1016,7 @@ export default class BackgroundJobsWorker {
1016
1016
  * @returns {Promise<void>} - Resolves when the child exits.
1017
1017
  */
1018
1018
  _waitForForkedChild({child, payload}) {
1019
- const timeoutState = this._armForkedJobTimeout({child})
1019
+ const timeoutState = this._armForkedJobTimeout({child, payload})
1020
1020
 
1021
1021
  return new Promise((resolve) => {
1022
1022
  child.once("exit", (code, signal) => {
@@ -1040,10 +1040,11 @@ export default class BackgroundJobsWorker {
1040
1040
  * and behavior is unchanged.
1041
1041
  * @param {object} args - Options.
1042
1042
  * @param {import("node:child_process").ChildProcess} args.child - Forked child process.
1043
+ * @param {import("./types.js").BackgroundJobPayload & {id: string}} args.payload - Job payload.
1043
1044
  * @returns {ForkedJobTimeoutState} - Timeout state.
1044
1045
  */
1045
- _armForkedJobTimeout({child}) {
1046
- const timeoutMs = this._resolveJobTimeoutMs()
1046
+ _armForkedJobTimeout({child, payload}) {
1047
+ const timeoutMs = this._resolveJobTimeoutMs(payload.options)
1047
1048
  /** @type {ForkedJobTimeoutState} */
1048
1049
  const state = {timedOut: false, timeoutMs, timer: null, sigkillTimer: null}
1049
1050
 
@@ -1056,14 +1057,18 @@ export default class BackgroundJobsWorker {
1056
1057
 
1057
1058
  /**
1058
1059
  * Resolves the effective wall-clock job timeout in ms (shared by forked and pooled jobs), or null when disabled. The
1059
- * constructor override wins; otherwise the value comes from the background-jobs
1060
- * configuration. A non-positive value disables the backstop.
1060
+ * per-job override wins, followed by the constructor override, then the value
1061
+ * from the background-jobs configuration. A non-positive value disables the
1062
+ * backstop at whichever level supplied it.
1063
+ * @param {import("./types.js").BackgroundJobOptions} [jobOptions] - Per-job options.
1061
1064
  * @returns {number | null} - Timeout in ms, or null when disabled.
1062
1065
  */
1063
- _resolveJobTimeoutMs() {
1064
- const raw = typeof this.jobTimeoutMsOverride === "number"
1065
- ? this.jobTimeoutMsOverride
1066
- : (this.configuration ? this.configuration.getBackgroundJobsConfig().jobTimeoutMs : null)
1066
+ _resolveJobTimeoutMs(jobOptions) {
1067
+ const raw = typeof jobOptions?.timeoutMs === "number"
1068
+ ? jobOptions.timeoutMs
1069
+ : (typeof this.jobTimeoutMsOverride === "number"
1070
+ ? this.jobTimeoutMsOverride
1071
+ : (this.configuration ? this.configuration.getBackgroundJobsConfig().jobTimeoutMs : null))
1067
1072
 
1068
1073
  // A non-finite (e.g. Infinity) or non-positive value disables the backstop;
1069
1074
  // a finite value beyond Node's timer range is clamped to the max rather than
@@ -158,14 +158,16 @@
158
158
 
159
159
  /** @typedef {"background" | "inline"} BackgroundJobsMode */
160
160
  /** @typedef {(args: {configuration: import("./configuration.js").default}) => import("./background-jobs/adapter.js").default} BackgroundJobsAdapterFactory */
161
+ /** @typedef {typeof import("./background-jobs/platform-job.js").default} BackgroundJobClass */
161
162
 
162
163
  /**
163
164
  * @typedef {object} BackgroundJobsConfiguration
164
165
  * @property {import("./background-jobs/adapter.js").default | BackgroundJobsAdapterFactory} [adapter] - Adapter instance or synchronous factory. A factory creates one adapter per configuration lifecycle; the framework closes adapters it resolves.
166
+ * @property {BackgroundJobClass[]} [jobClasses] - Static portable job classes available to Browser/Expo local dispatch. Defaults to `[]`; Node keeps its filesystem registry.
165
167
  * @property {BackgroundJobsMode} [mode] - `"background"` uses the configured adapter/transport and durable queue semantics; `"inline"` performs immediately without durable queue state. Defaults to `"background"`.
166
168
  * @property {string} [host] - Hostname for the background jobs main process.
167
169
  * @property {number} [port] - Port for the background jobs main process.
168
- * @property {string} [databaseIdentifier] - Database identifier used to store background jobs.
170
+ * @property {string} [databaseIdentifier] - Database identifier used to store background jobs. Browser/Expo local dispatch uses this existing SQLite database and defaults to `"default"`.
169
171
  * @property {number} [maxConcurrentInlineJobs] - How many `forked: false` jobs a single
170
172
  * `background-jobs-worker` process is allowed to run in parallel. Concurrency
171
173
  * is at the JS event-loop level: every concurrent job shares the worker's
@@ -1543,7 +1543,22 @@ export default class VelociousConfiguration {
1543
1543
  : 60 * 60 * 1000
1544
1544
  }
1545
1545
 
1546
- return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, dispatchStrategy, pollIntervalMs, queues, jobTimeoutMs, retention}
1546
+ const jobClasses = this.getBackgroundJobClasses()
1547
+
1548
+ return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, mode, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, dispatchStrategy, pollIntervalMs, queues, jobClasses, jobTimeoutMs, retention}
1549
+ }
1550
+
1551
+ /**
1552
+ * Returns statically registered portable background jobs.
1553
+ * @returns {import("./configuration-types.js").BackgroundJobClass[]} - Configured job classes.
1554
+ */
1555
+ getBackgroundJobClasses() {
1556
+ const jobClasses = this._backgroundJobs?.jobClasses
1557
+
1558
+ if (jobClasses === undefined) return []
1559
+ if (!Array.isArray(jobClasses)) throw new TypeError("backgroundJobs.jobClasses must be an array")
1560
+
1561
+ return [...jobClasses]
1547
1562
  }
1548
1563
 
1549
1564
  /**
@@ -255,6 +255,35 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
255
255
  await fs.writeFile(ownerPath, payload)
256
256
  }
257
257
 
258
+ /**
259
+ * Publishes a fully initialized lock directory with one atomic rename.
260
+ * A losing candidate is removed in the same call, so concurrent acquisition
261
+ * cannot observe or delete another process's half-written owner metadata.
262
+ * @param {string} lockPath - Stable advisory-lock path.
263
+ * @returns {Promise<boolean>} - Whether this candidate became the lock owner.
264
+ */
265
+ async _publishAdvisoryLockDirectory(lockPath) {
266
+ const candidatePath = await fs.mkdtemp(`${lockPath}.pending-`)
267
+ let published = false
268
+
269
+ try {
270
+ await this._writeAdvisoryLockMetadata(candidatePath)
271
+
272
+ try {
273
+ await fs.rename(candidatePath, lockPath)
274
+ published = true
275
+ return true
276
+ } catch (error) {
277
+ const code = /** @type {Error & {code?: string}} */ (error)?.code
278
+
279
+ if (code === "EEXIST" || code === "ENOTEMPTY") return false
280
+ throw error
281
+ }
282
+ } finally {
283
+ if (!published) await fs.rm(candidatePath, {force: true, recursive: true})
284
+ }
285
+ }
286
+
258
287
  /**
259
288
  * Runs acquire advisory lock file.
260
289
  * @param {string} name - Lock name.
@@ -272,28 +301,21 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
272
301
  // mkdir succeeds, the deadline elapses, or an unexpected error is
273
302
  // re-thrown.
274
303
  while (true) {
275
- try {
276
- await fs.mkdir(lockPath)
277
- await this._writeAdvisoryLockMetadata(lockPath)
304
+ if (await this._publishAdvisoryLockDirectory(lockPath)) return true
278
305
 
279
- return true
280
- } catch (error) {
281
- if (/** @type {Error & {code?: string}} */ (error)?.code !== "EEXIST") throw error
282
-
283
- if (await this._isAdvisoryLockStale(lockPath)) {
284
- await fs.rm(lockPath, {recursive: true, force: true})
285
- continue
286
- }
306
+ if (await this._isAdvisoryLockStale(lockPath)) {
307
+ await fs.rm(lockPath, {recursive: true, force: true})
308
+ continue
309
+ }
287
310
 
288
- if (deadline !== null) {
289
- const remaining = deadline - Date.now()
311
+ if (deadline !== null) {
312
+ const remaining = deadline - Date.now()
290
313
 
291
- if (remaining <= 0) return false
314
+ if (remaining <= 0) return false
292
315
 
293
- await wait(Math.min(pollIntervalMs, remaining))
294
- } else {
295
- await wait(pollIntervalMs)
296
- }
316
+ await wait(Math.min(pollIntervalMs, remaining))
317
+ } else {
318
+ await wait(pollIntervalMs)
297
319
  }
298
320
  }
299
321
  }
@@ -308,31 +330,14 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
308
330
 
309
331
  const lockPath = this._advisoryLockPath(name)
310
332
 
311
- try {
312
- await fs.mkdir(lockPath)
313
- await this._writeAdvisoryLockMetadata(lockPath)
333
+ if (await this._publishAdvisoryLockDirectory(lockPath)) return true
314
334
 
315
- return true
316
- } catch (error) {
317
- if (/** @type {Error & {code?: string}} */ (error)?.code !== "EEXIST") throw error
318
-
319
- if (await this._isAdvisoryLockStale(lockPath)) {
320
- await fs.rm(lockPath, {recursive: true, force: true})
321
-
322
- try {
323
- await fs.mkdir(lockPath)
324
- await this._writeAdvisoryLockMetadata(lockPath)
325
-
326
- return true
327
- } catch (retryError) {
328
- if (/** @type {Error & {code?: string}} */ (retryError)?.code === "EEXIST") return false
329
-
330
- throw retryError
331
- }
332
- }
333
-
334
- return false
335
+ if (await this._isAdvisoryLockStale(lockPath)) {
336
+ await fs.rm(lockPath, {recursive: true, force: true})
337
+ return await this._publishAdvisoryLockDirectory(lockPath)
335
338
  }
339
+
340
+ return false
336
341
  }
337
342
 
338
343
  /**
@@ -2,6 +2,7 @@ import Base from "./base.js"
2
2
  import * as inflection from "inflection"
3
3
  import restArgsError from "../utils/rest-args-error.js"
4
4
  import Logger from "../logger.js"
5
+ import LocalBackgroundJobsAdapter from "../background-jobs/local-adapter.js"
5
6
 
6
7
  /**
7
8
  * Defines this typedef.
@@ -40,6 +41,15 @@ function isMigrationObject(migration) {
40
41
  }
41
42
 
42
43
  export default class VelociousEnvironmentsHandlerBrowser extends Base {
44
+ /**
45
+ * Creates the Browser/Expo local SQLite adapter and in-process dispatcher.
46
+ * @param {{configuration: import("../configuration.js").default}} args - Adapter options.
47
+ * @returns {LocalBackgroundJobsAdapter} - Local background-jobs adapter.
48
+ */
49
+ createBackgroundJobsAdapter({configuration}) {
50
+ return new LocalBackgroundJobsAdapter({configuration})
51
+ }
52
+
43
53
  /**
44
54
  * Find commands require context result.
45
55
  * @type {CommandsRequireContextType | undefined} */