velocious 1.0.666 → 1.0.668

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 (174) hide show
  1. package/README.md +40 -11
  2. package/build/background-jobs/adapter-client.js +22 -0
  3. package/build/background-jobs/adapter.js +15 -0
  4. package/build/background-jobs/client.js +147 -1
  5. package/build/background-jobs/job-semantics.js +30 -0
  6. package/build/background-jobs/job.js +26 -1
  7. package/build/background-jobs/local-adapter.js +36 -8
  8. package/build/background-jobs/local-store.js +410 -39
  9. package/build/background-jobs/main.js +69 -2
  10. package/build/background-jobs/platform-job.js +26 -1
  11. package/build/background-jobs/runtime.js +58 -0
  12. package/build/background-jobs/store.js +374 -6
  13. package/build/background-jobs/types.js +27 -2
  14. package/build/background-jobs/web/controller.js +1 -0
  15. package/build/configuration.js +30 -6
  16. package/build/database/drivers/mssql/column.js +2 -2
  17. package/build/database/drivers/mssql/table.js +19 -5
  18. package/build/database/pool/async-tracked-multi-connection.js +2 -1
  19. package/build/database/query/alter-table-base.js +8 -1
  20. package/build/database/record/attachments/store.js +2 -2
  21. package/build/environment-handlers/node/cli/commands/test/timing-manifest/merge.js +2 -48
  22. package/build/environment-handlers/node/cli/commands/test.js +17 -69
  23. package/build/frontend-model-controller.js +22 -2
  24. package/build/frontend-models/websocket-channel.js +18 -0
  25. package/build/http-server/client/websocket-session.js +41 -4
  26. package/build/http-server/websocket-channel.js +14 -0
  27. package/build/http-server/websocket-event-log-store.js +85 -12
  28. package/build/http-server/websocket-events-host.js +17 -7
  29. package/build/src/background-jobs/adapter-client.d.ts +17 -0
  30. package/build/src/background-jobs/adapter-client.d.ts.map +1 -1
  31. package/build/src/background-jobs/adapter-client.js +19 -1
  32. package/build/src/background-jobs/adapter.d.ts +15 -0
  33. package/build/src/background-jobs/adapter.d.ts.map +1 -1
  34. package/build/src/background-jobs/adapter.js +14 -1
  35. package/build/src/background-jobs/client.d.ts +17 -0
  36. package/build/src/background-jobs/client.d.ts.map +1 -1
  37. package/build/src/background-jobs/client.js +129 -1
  38. package/build/src/background-jobs/job-semantics.d.ts +18 -0
  39. package/build/src/background-jobs/job-semantics.d.ts.map +1 -1
  40. package/build/src/background-jobs/job-semantics.js +28 -1
  41. package/build/src/background-jobs/job.d.ts +15 -0
  42. package/build/src/background-jobs/job.d.ts.map +1 -1
  43. package/build/src/background-jobs/job.js +21 -2
  44. package/build/src/background-jobs/local-adapter.d.ts +23 -8
  45. package/build/src/background-jobs/local-adapter.d.ts.map +1 -1
  46. package/build/src/background-jobs/local-adapter.js +35 -9
  47. package/build/src/background-jobs/local-store.d.ts +120 -5
  48. package/build/src/background-jobs/local-store.d.ts.map +1 -1
  49. package/build/src/background-jobs/local-store.js +366 -40
  50. package/build/src/background-jobs/main.d.ts +24 -2
  51. package/build/src/background-jobs/main.d.ts.map +1 -1
  52. package/build/src/background-jobs/main.js +68 -3
  53. package/build/src/background-jobs/platform-job.d.ts +15 -0
  54. package/build/src/background-jobs/platform-job.d.ts.map +1 -1
  55. package/build/src/background-jobs/platform-job.js +19 -2
  56. package/build/src/background-jobs/runtime.d.ts +39 -0
  57. package/build/src/background-jobs/runtime.d.ts.map +1 -1
  58. package/build/src/background-jobs/runtime.js +49 -1
  59. package/build/src/background-jobs/store.d.ts +100 -1
  60. package/build/src/background-jobs/store.d.ts.map +1 -1
  61. package/build/src/background-jobs/store.js +334 -8
  62. package/build/src/background-jobs/types.d.ts +97 -4
  63. package/build/src/background-jobs/types.d.ts.map +1 -1
  64. package/build/src/background-jobs/types.js +28 -3
  65. package/build/src/background-jobs/web/controller.d.ts.map +1 -1
  66. package/build/src/background-jobs/web/controller.js +2 -1
  67. package/build/src/configuration.d.ts +18 -1
  68. package/build/src/configuration.d.ts.map +1 -1
  69. package/build/src/configuration.js +28 -6
  70. package/build/src/database/drivers/mssql/column.d.ts.map +1 -1
  71. package/build/src/database/drivers/mssql/column.js +2 -2
  72. package/build/src/database/drivers/mssql/table.d.ts.map +1 -1
  73. package/build/src/database/drivers/mssql/table.js +19 -5
  74. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  75. package/build/src/database/pool/async-tracked-multi-connection.js +3 -2
  76. package/build/src/database/query/alter-table-base.d.ts.map +1 -1
  77. package/build/src/database/query/alter-table-base.js +9 -2
  78. package/build/src/database/record/attachments/store.js +3 -3
  79. package/build/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.d.ts.map +1 -1
  80. package/build/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.js +3 -47
  81. package/build/src/environment-handlers/node/cli/commands/test.d.ts +2 -28
  82. package/build/src/environment-handlers/node/cli/commands/test.d.ts.map +1 -1
  83. package/build/src/environment-handlers/node/cli/commands/test.js +10 -61
  84. package/build/src/frontend-model-controller.d.ts.map +1 -1
  85. package/build/src/frontend-model-controller.js +18 -3
  86. package/build/src/frontend-models/websocket-channel.d.ts +9 -0
  87. package/build/src/frontend-models/websocket-channel.d.ts.map +1 -1
  88. package/build/src/frontend-models/websocket-channel.js +16 -1
  89. package/build/src/http-server/client/websocket-session.d.ts +25 -2
  90. package/build/src/http-server/client/websocket-session.d.ts.map +1 -1
  91. package/build/src/http-server/client/websocket-session.js +40 -5
  92. package/build/src/http-server/websocket-channel.d.ts +11 -0
  93. package/build/src/http-server/websocket-channel.d.ts.map +1 -1
  94. package/build/src/http-server/websocket-channel.js +14 -1
  95. package/build/src/http-server/websocket-event-log-store.d.ts +60 -41
  96. package/build/src/http-server/websocket-event-log-store.d.ts.map +1 -1
  97. package/build/src/http-server/websocket-event-log-store.js +79 -12
  98. package/build/src/http-server/websocket-events-host.d.ts +10 -3
  99. package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
  100. package/build/src/http-server/websocket-events-host.js +17 -8
  101. package/build/src/testing/test-files-finder.d.ts +17 -117
  102. package/build/src/testing/test-files-finder.d.ts.map +1 -1
  103. package/build/src/testing/test-files-finder.js +29 -294
  104. package/build/src/testing/test-filter-parser.d.ts +32 -22
  105. package/build/src/testing/test-filter-parser.d.ts.map +1 -1
  106. package/build/src/testing/test-filter-parser.js +25 -217
  107. package/build/src/testing/test-profile-activity.d.ts +1 -6
  108. package/build/src/testing/test-profile-activity.d.ts.map +1 -1
  109. package/build/src/testing/test-profile-activity.js +2 -12
  110. package/build/src/testing/test-profile-output.d.ts +1 -40
  111. package/build/src/testing/test-profile-output.d.ts.map +1 -1
  112. package/build/src/testing/test-profile-output.js +2 -217
  113. package/build/src/testing/test-profiler.d.ts +15 -659
  114. package/build/src/testing/test-profiler.d.ts.map +1 -1
  115. package/build/src/testing/test-profiler.js +35 -833
  116. package/build/src/testing/test-runner.d.ts +10 -1
  117. package/build/src/testing/test-runner.d.ts.map +1 -1
  118. package/build/src/testing/test-runner.js +26 -4
  119. package/build/src/testing/test-suite-splitter.d.ts +1 -128
  120. package/build/src/testing/test-suite-splitter.d.ts.map +1 -1
  121. package/build/src/testing/test-suite-splitter.js +2 -259
  122. package/build/src/testing/timing-manifest.d.ts +5 -93
  123. package/build/src/testing/timing-manifest.d.ts.map +1 -1
  124. package/build/src/testing/timing-manifest.js +4 -283
  125. package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -1
  126. package/build/src/testing/velocious-attempt-executor.js +8 -2
  127. package/build/testing/test-files-finder.js +32 -343
  128. package/build/testing/test-filter-parser.js +29 -248
  129. package/build/testing/test-profile-activity.js +1 -12
  130. package/build/testing/test-profile-output.js +8 -253
  131. package/build/testing/test-profiler.js +38 -895
  132. package/build/testing/test-runner.js +33 -9
  133. package/build/testing/test-suite-splitter.js +1 -301
  134. package/build/testing/timing-manifest.js +10 -344
  135. package/build/testing/velocious-attempt-executor.js +7 -1
  136. package/package.json +3 -3
  137. package/scripts/test-browser.js +30 -6
  138. package/scripts/verify-docker-dev-environment.js +13 -1
  139. package/src/background-jobs/adapter-client.js +22 -0
  140. package/src/background-jobs/adapter.js +15 -0
  141. package/src/background-jobs/client.js +147 -1
  142. package/src/background-jobs/job-semantics.js +30 -0
  143. package/src/background-jobs/job.js +26 -1
  144. package/src/background-jobs/local-adapter.js +36 -8
  145. package/src/background-jobs/local-store.js +410 -39
  146. package/src/background-jobs/main.js +69 -2
  147. package/src/background-jobs/platform-job.js +26 -1
  148. package/src/background-jobs/runtime.js +58 -0
  149. package/src/background-jobs/store.js +374 -6
  150. package/src/background-jobs/types.js +27 -2
  151. package/src/background-jobs/web/controller.js +1 -0
  152. package/src/configuration.js +30 -6
  153. package/src/database/drivers/mssql/column.js +2 -2
  154. package/src/database/drivers/mssql/table.js +19 -5
  155. package/src/database/pool/async-tracked-multi-connection.js +2 -1
  156. package/src/database/query/alter-table-base.js +8 -1
  157. package/src/database/record/attachments/store.js +2 -2
  158. package/src/environment-handlers/node/cli/commands/test/timing-manifest/merge.js +2 -48
  159. package/src/environment-handlers/node/cli/commands/test.js +17 -69
  160. package/src/frontend-model-controller.js +22 -2
  161. package/src/frontend-models/websocket-channel.js +18 -0
  162. package/src/http-server/client/websocket-session.js +41 -4
  163. package/src/http-server/websocket-channel.js +14 -0
  164. package/src/http-server/websocket-event-log-store.js +85 -12
  165. package/src/http-server/websocket-events-host.js +17 -7
  166. package/src/testing/test-files-finder.js +32 -343
  167. package/src/testing/test-filter-parser.js +29 -248
  168. package/src/testing/test-profile-activity.js +1 -12
  169. package/src/testing/test-profile-output.js +8 -253
  170. package/src/testing/test-profiler.js +38 -895
  171. package/src/testing/test-runner.js +33 -9
  172. package/src/testing/test-suite-splitter.js +1 -301
  173. package/src/testing/timing-manifest.js +10 -344
  174. package/src/testing/velocious-attempt-executor.js +7 -1
@@ -5,31 +5,39 @@ import UUID from "pure-uuid"
5
5
  import TableData from "../database/table-data/index.js"
6
6
  import TableIndex from "../database/table-data/table-index.js"
7
7
  import sha256Hex from "../utils/sha256-hex.js"
8
+ import VelociousError from "../velocious-error.js"
8
9
  import normalizeBackgroundJobError from "./normalize-error.js"
9
10
  import {
11
+ BACKGROUND_JOB_TERMINAL_STATUSES,
10
12
  DEFAULT_BACKGROUND_JOB_QUEUE,
11
13
  QUEUE_CONCURRENCY_KEY_PREFIX,
12
14
  normalizeBackgroundJobConcurrency,
13
15
  normalizeBackgroundJobExecutionMode,
14
16
  normalizeBackgroundJobMaxRetries,
15
17
  normalizeBackgroundJobQueue,
18
+ normalizeBackgroundJobScheduleKey,
16
19
  normalizeBackgroundJobScheduledAtMs,
20
+ normalizeBackgroundJobStatus,
17
21
  rescheduledBackgroundJobAtMs,
18
22
  retryDelayMs
19
23
  } from "./job-semantics.js"
20
24
 
21
25
  export const LOCAL_BACKGROUND_JOBS_TABLE = "velocious_local_background_jobs"
22
26
  export const LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE = "velocious_local_background_job_concurrency"
27
+ export const LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE = "velocious_local_background_job_schedule_keys"
23
28
  const MIGRATIONS_TABLE = "velocious_internal_migrations"
24
29
  const MIGRATION_SCOPE = "local_background_jobs"
25
- const MIGRATION_VERSION = "1"
30
+ const MIGRATION_VERSIONS = ["1", "2", "3"]
26
31
  const LOCAL_EXECUTION_MODES = [/** @type {const} */ ("inline")]
27
32
  export const LOCAL_BACKGROUND_JOBS_INDEX_NAMES = [
28
33
  "index_velocious_local_background_jobs_due",
29
34
  "index_velocious_local_background_jobs_queue_status",
30
35
  "index_velocious_local_background_jobs_deduplication",
31
- "index_velocious_local_background_jobs_concurrency"
36
+ "index_velocious_local_background_jobs_concurrency",
37
+ "index_velocious_local_background_jobs_schedule_history",
38
+ "index_velocious_local_background_jobs_schedule_order"
32
39
  ]
40
+ export const LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_INDEX_NAMES = ["index_velocious_local_background_job_schedule_keys_job"]
33
41
  const EXPECTED_JOB_COLUMNS = [
34
42
  "id",
35
43
  "job_name",
@@ -37,6 +45,8 @@ const EXPECTED_JOB_COLUMNS = [
37
45
  "args_digest",
38
46
  "execution_mode",
39
47
  "queue",
48
+ "schedule_key",
49
+ "schedule_order",
40
50
  "max_retries",
41
51
  "attempts",
42
52
  "status",
@@ -56,6 +66,7 @@ const EXPECTED_JOB_COLUMNS = [
56
66
  "child_pid"
57
67
  ]
58
68
  const EXPECTED_CONCURRENCY_COLUMNS = ["concurrency_key", "max_concurrency", "active_count"]
69
+ const EXPECTED_SCHEDULE_KEY_COLUMNS = ["schedule_key", "job_id"]
59
70
  /** @type {WeakMap<import("../configuration.js").default, Map<string, Promise<void>>>} */
60
71
  const deduplicatedEnqueueChains = new WeakMap()
61
72
 
@@ -190,7 +201,8 @@ export default class LocalBackgroundJobsStore {
190
201
  }
191
202
 
192
203
  /**
193
- * Creates or repairs version-one tables and indexes.
204
+ * Creates or upgrades the versioned local tables and indexes without
205
+ * rebuilding persisted queue data.
194
206
  * @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
195
207
  * @returns {Promise<boolean>} - Whether schema state changed.
196
208
  */
@@ -217,20 +229,19 @@ export default class LocalBackgroundJobsStore {
217
229
  await this._assertColumns(db, LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE, EXPECTED_CONCURRENCY_COLUMNS)
218
230
  }
219
231
 
232
+ if (!(await db.tableExists(LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE))) {
233
+ await db.createTable(this._scheduleKeysTableData())
234
+ changed = true
235
+ } else {
236
+ await this._assertColumns(db, LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE, EXPECTED_SCHEDULE_KEY_COLUMNS)
237
+ }
238
+
220
239
  if (await this._ensureIndexes(db)) changed = true
221
240
 
222
- if (!(await this._hasMigration(db))) {
223
- await db.upsert({
224
- tableName: MIGRATIONS_TABLE,
225
- data: {
226
- applied_at_ms: this.clock.now(),
227
- key: this._migrationKey(),
228
- scope: MIGRATION_SCOPE,
229
- version: MIGRATION_VERSION
230
- },
231
- conflictColumns: ["key"],
232
- updateColumns: ["scope", "version", "applied_at_ms"]
233
- })
241
+ for (const version of MIGRATION_VERSIONS) {
242
+ if (await this._hasMigration(db, version)) continue
243
+
244
+ await this._recordMigration(db, version)
234
245
  changed = true
235
246
  }
236
247
 
@@ -303,6 +314,8 @@ export default class LocalBackgroundJobsStore {
303
314
  table.string("args_digest", {maxLength: 64, null: false})
304
315
  table.string("execution_mode", {null: false})
305
316
  table.string("queue", {null: false})
317
+ table.string("schedule_key", {null: true})
318
+ table.bigint("schedule_order", {null: true})
306
319
  table.integer("max_retries", {null: false})
307
320
  table.integer("attempts", {null: false})
308
321
  table.string("status", {null: false})
@@ -324,6 +337,8 @@ export default class LocalBackgroundJobsStore {
324
337
  table.addIndex(new TableIndex(["queue", "status", "created_at_ms"], {name: LOCAL_BACKGROUND_JOBS_INDEX_NAMES[1]}))
325
338
  table.addIndex(new TableIndex(["args_digest"], {name: LOCAL_BACKGROUND_JOBS_INDEX_NAMES[2]}))
326
339
  table.addIndex(new TableIndex(["status", "concurrency_key", "scheduled_at_ms"], {name: LOCAL_BACKGROUND_JOBS_INDEX_NAMES[3]}))
340
+ table.addIndex(new TableIndex(["schedule_key", "created_at_ms", "id"], {name: LOCAL_BACKGROUND_JOBS_INDEX_NAMES[4]}))
341
+ table.addIndex(new TableIndex(["schedule_key", "schedule_order", "created_at_ms", "id"], {name: LOCAL_BACKGROUND_JOBS_INDEX_NAMES[5]}))
327
342
  return table
328
343
  }
329
344
 
@@ -340,6 +355,19 @@ export default class LocalBackgroundJobsStore {
340
355
  return table
341
356
  }
342
357
 
358
+ /**
359
+ * Builds the stable schedule-owner table definition.
360
+ * @returns {TableData} - Stable owner table definition.
361
+ */
362
+ _scheduleKeysTableData() {
363
+ const table = new TableData(LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE, {ifNotExists: true})
364
+
365
+ table.string("schedule_key", {null: false, primaryKey: true})
366
+ table.string("job_id", {null: false})
367
+ table.addIndex(new TableIndex(["job_id"], {name: LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_INDEX_NAMES[0]}))
368
+ return table
369
+ }
370
+
343
371
  /**
344
372
  * Rejects an incompatible current-version table rather than rebuilding data.
345
373
  * @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
@@ -368,25 +396,33 @@ export default class LocalBackgroundJobsStore {
368
396
  */
369
397
  async _ensureIndexes(db) {
370
398
  db.clearSchemaCache()
371
- const jobsTable = await db.getTableByNameOrFail(LOCAL_BACKGROUND_JOBS_TABLE)
372
- const existingNames = new Set((await jobsTable.getIndexes()).map((index) => index.getName()))
373
399
  let changed = false
400
+ /** @type {Array<[string, TableData]>} */
401
+ const definitions = [
402
+ [LOCAL_BACKGROUND_JOBS_TABLE, this._jobsTableData()],
403
+ [LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE, this._scheduleKeysTableData()]
404
+ ]
405
+
406
+ for (const [tableName, tableData] of definitions) {
407
+ const table = await db.getTableByNameOrFail(tableName)
408
+ const existingNames = new Set((await table.getIndexes()).map((index) => index.getName()))
409
+
410
+ for (const index of tableData.getIndexes()) {
411
+ const indexName = index.getName()
412
+
413
+ if (!indexName || existingNames.has(indexName)) continue
414
+
415
+ const sqls = await db.createIndexSQLs({
416
+ columns: index.getColumns(),
417
+ ifNotExists: true,
418
+ name: indexName,
419
+ tableName,
420
+ unique: index.getUnique()
421
+ })
374
422
 
375
- for (const index of this._jobsTableData().getIndexes()) {
376
- const indexName = index.getName()
377
-
378
- if (!indexName || existingNames.has(indexName)) continue
379
-
380
- const sqls = await db.createIndexSQLs({
381
- columns: index.getColumns(),
382
- ifNotExists: true,
383
- name: indexName,
384
- tableName: LOCAL_BACKGROUND_JOBS_TABLE,
385
- unique: index.getUnique()
386
- })
387
-
388
- for (const sql of sqls) await db.query(sql)
389
- changed = true
423
+ for (const sql of sqls) await db.query(sql)
424
+ changed = true
425
+ }
390
426
  }
391
427
 
392
428
  if (changed) db.clearSchemaCache()
@@ -396,24 +432,46 @@ export default class LocalBackgroundJobsStore {
396
432
  /**
397
433
  * Checks whether the current local schema version is recorded.
398
434
  * @param {import("../database/drivers/base.js").default} db - Connection.
399
- * @returns {Promise<boolean>} - Whether version one is recorded.
435
+ * @param {string} version - Local schema version.
436
+ * @returns {Promise<boolean>} - Whether the version is recorded.
400
437
  */
401
- async _hasMigration(db) {
438
+ async _hasMigration(db, version) {
402
439
  const rows = await db
403
440
  .newQuery()
404
441
  .from(MIGRATIONS_TABLE)
405
- .where({key: this._migrationKey()})
442
+ .where({key: this._migrationKey(version)})
406
443
  .limit(1)
407
444
  .results()
408
445
 
409
446
  return rows.length > 0
410
447
  }
411
448
 
449
+ /**
450
+ * Records one local schema version after its additive changes are present.
451
+ * @param {import("../database/drivers/base.js").default} db - Connection.
452
+ * @param {string} version - Local schema version.
453
+ * @returns {Promise<void>} - Resolves after recording.
454
+ */
455
+ async _recordMigration(db, version) {
456
+ await db.upsert({
457
+ tableName: MIGRATIONS_TABLE,
458
+ data: {
459
+ applied_at_ms: this.clock.now(),
460
+ key: this._migrationKey(version),
461
+ scope: MIGRATION_SCOPE,
462
+ version
463
+ },
464
+ conflictColumns: ["key"],
465
+ updateColumns: ["scope", "version", "applied_at_ms"]
466
+ })
467
+ }
468
+
412
469
  /**
413
470
  * Builds the scoped migration key.
471
+ * @param {string} version - Local schema version.
414
472
  * @returns {string} - Scoped migration key.
415
473
  */
416
- _migrationKey() { return `${MIGRATION_SCOPE}:${MIGRATION_VERSION}` }
474
+ _migrationKey(version) { return `${MIGRATION_SCOPE}:${version}` }
417
475
 
418
476
  /**
419
477
  * Enqueues a local job in the caller's active transaction when present.
@@ -469,6 +527,180 @@ export default class LocalBackgroundJobsStore {
469
527
  return await mutate()
470
528
  }
471
529
 
530
+ /**
531
+ * Replaces the queued owner of a stable schedule key with a new local job.
532
+ * A handed-off owner remains runnable but is detached from future ownership.
533
+ * @param {object} args - Replacement request.
534
+ * @param {string} args.scheduleKey - Stable logical schedule key.
535
+ * @param {string} args.jobName - Registered job name.
536
+ * @param {Array<ReturnType<typeof JSON.parse>>} args.args - Serialized job arguments.
537
+ * @param {import("./types.js").BackgroundJobOptions} [args.options] - Job options.
538
+ * @returns {Promise<import("./types.js").BackgroundJobReplacementResult>} - Replacement result.
539
+ */
540
+ async replaceScheduled({scheduleKey, jobName, args, options = {}}) {
541
+ await this.ensureReady()
542
+
543
+ const normalizedScheduleKey = normalizeBackgroundJobScheduleKey(scheduleKey)
544
+ const preparedJob = this._prepareJob({args, jobName, options})
545
+
546
+ return await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
547
+ await this._lockScheduleKey(db, normalizedScheduleKey)
548
+ const ownerJob = await this._scheduledOwnerJob(db, normalizedScheduleKey)
549
+ /** @type {import("./types.js").BackgroundJobReplacementPreviousStatus} */
550
+ let previousStatus = null
551
+ let previousJobId = null
552
+
553
+ if (ownerJob?.status === "queued") {
554
+ const affectedRows = await this._updateAffectedRows(db, {
555
+ conditions: {id: ownerJob.id, status: "queued"},
556
+ data: {status: "cancelled"},
557
+ tableName: LOCAL_BACKGROUND_JOBS_TABLE
558
+ })
559
+
560
+ if (affectedRows === 1) {
561
+ previousJobId = ownerJob.id
562
+ previousStatus = "queued"
563
+ } else {
564
+ const currentOwnerJob = await this._getJob(db, ownerJob.id)
565
+
566
+ if (currentOwnerJob?.status === "handed_off") {
567
+ previousJobId = currentOwnerJob.id
568
+ previousStatus = "handed_off"
569
+ }
570
+ }
571
+ } else if (ownerJob?.status === "handed_off") {
572
+ previousJobId = ownerJob.id
573
+ previousStatus = "handed_off"
574
+ }
575
+
576
+ const scheduleOrder = await this._nextScheduleOrder(db, normalizedScheduleKey)
577
+
578
+ if (preparedJob.concurrency) await this._ensureConcurrency(db, preparedJob.concurrency)
579
+ await this._insertPreparedJob(db, preparedJob, normalizedScheduleKey, scheduleOrder)
580
+ await db.upsert({
581
+ conflictColumns: ["schedule_key"],
582
+ data: {job_id: preparedJob.jobId, schedule_key: normalizedScheduleKey},
583
+ tableName: LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE,
584
+ updateColumns: ["job_id"]
585
+ })
586
+ await this._wakeDispatcherAfterCommit(db)
587
+
588
+ return {jobId: preparedJob.jobId, previousJobId, previousStatus}
589
+ }))
590
+ }
591
+
592
+ /**
593
+ * Cancels a queued stable owner or detaches an active handoff truthfully.
594
+ * @param {string} scheduleKey - Stable logical schedule key.
595
+ * @returns {Promise<import("./types.js").BackgroundJobCancellationResult>} - Cancellation result.
596
+ */
597
+ async cancelScheduled(scheduleKey) {
598
+ await this.ensureReady()
599
+
600
+ const normalizedScheduleKey = normalizeBackgroundJobScheduleKey(scheduleKey)
601
+
602
+ return await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
603
+ await this._lockScheduleKey(db, normalizedScheduleKey)
604
+ const ownerJob = await this._scheduledOwnerJob(db, normalizedScheduleKey)
605
+
606
+ if (!ownerJob) {
607
+ await this._releaseScheduleOwnership(db, {jobId: null, scheduleKey: normalizedScheduleKey})
608
+ return {jobId: null, outcome: "not_found"}
609
+ }
610
+
611
+ if (ownerJob.status === "queued") {
612
+ const affectedRows = await this._updateAffectedRows(db, {
613
+ conditions: {id: ownerJob.id, status: "queued"},
614
+ data: {status: "cancelled"},
615
+ tableName: LOCAL_BACKGROUND_JOBS_TABLE
616
+ })
617
+
618
+ if (affectedRows === 1) {
619
+ await this._releaseScheduleOwnership(db, {jobId: ownerJob.id, scheduleKey: normalizedScheduleKey})
620
+ await this._wakeDispatcherAfterCommit(db)
621
+ return {jobId: ownerJob.id, outcome: "cancelled"}
622
+ }
623
+ }
624
+
625
+ const currentJob = await this._scheduledOwnerJob(db, normalizedScheduleKey)
626
+
627
+ await this._releaseScheduleOwnership(db, {jobId: ownerJob.id, scheduleKey: normalizedScheduleKey})
628
+ await this._wakeDispatcherAfterCommit(db)
629
+ if (currentJob?.status === "handed_off") return {jobId: currentJob.id, outcome: "handed_off"}
630
+ return {jobId: null, outcome: "not_found"}
631
+ }))
632
+ }
633
+
634
+ /**
635
+ * Reads stable ownership and optional latest terminal history in one transaction.
636
+ * @param {string} scheduleKey - Stable logical schedule key.
637
+ * @param {{includeLatestTerminal?: boolean}} [options] - Lookup options.
638
+ * @returns {Promise<import("./types.js").BackgroundJobScheduledLookupResult>} - Normalized local jobs.
639
+ */
640
+ async getScheduledJob(scheduleKey, {includeLatestTerminal = false} = {}) {
641
+ await this.ensureReady()
642
+
643
+ const normalizedScheduleKey = normalizeBackgroundJobScheduleKey(scheduleKey)
644
+
645
+ if (typeof includeLatestTerminal !== "boolean") {
646
+ throw VelociousError.safe("background job includeLatestTerminal must be a boolean")
647
+ }
648
+
649
+ return await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
650
+ return await this._scheduledJobLookup(db, {
651
+ includeLatestTerminal,
652
+ scheduleKey: normalizedScheduleKey
653
+ })
654
+ }))
655
+ }
656
+
657
+ /**
658
+ * Moves only a future queued stable owner to the current time.
659
+ * @param {string} scheduleKey - Stable logical schedule key.
660
+ * @returns {Promise<import("./types.js").BackgroundJobWakeResult>} - Exact wake outcome.
661
+ */
662
+ async wakeScheduled(scheduleKey) {
663
+ await this.ensureReady()
664
+
665
+ const normalizedScheduleKey = normalizeBackgroundJobScheduleKey(scheduleKey)
666
+
667
+ return await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
668
+ await this._lockScheduleKey(db, normalizedScheduleKey)
669
+ const job = await this._scheduledOwnerJob(db, normalizedScheduleKey)
670
+
671
+ if (!job || (job.status !== "queued" && job.status !== "handed_off")) return {jobId: null, outcome: "not_found"}
672
+ if (job.status === "handed_off") return {jobId: job.id, outcome: "handed_off"}
673
+
674
+ const nowMs = this.clock.now()
675
+
676
+ if (Number(job.scheduledAtMs) <= nowMs) {
677
+ await this._wakeDispatcherAfterCommit(db)
678
+ return {jobId: job.id, outcome: "already_due"}
679
+ }
680
+
681
+ const affectedRows = await this._updateAffectedRows(db, {
682
+ conditions: {id: job.id, scheduled_at_ms: job.scheduledAtMs, status: "queued"},
683
+ data: {scheduled_at_ms: nowMs},
684
+ tableName: LOCAL_BACKGROUND_JOBS_TABLE
685
+ })
686
+
687
+ if (affectedRows === 1) {
688
+ await this._wakeDispatcherAfterCommit(db)
689
+ return {jobId: job.id, outcome: "woken"}
690
+ }
691
+
692
+ const currentJob = await this._scheduledOwnerJob(db, normalizedScheduleKey)
693
+
694
+ if (currentJob?.status === "handed_off") return {jobId: currentJob.id, outcome: "handed_off"}
695
+ if (currentJob?.status === "queued" && Number(currentJob.scheduledAtMs) <= nowMs) {
696
+ await this._wakeDispatcherAfterCommit(db)
697
+ return {jobId: currentJob.id, outcome: "already_due"}
698
+ }
699
+
700
+ return {jobId: null, outcome: "not_found"}
701
+ }))
702
+ }
703
+
472
704
  /**
473
705
  * Serializes matching in-process deduplication checks through commit while
474
706
  * leaving unrelated job identities independent.
@@ -559,9 +791,11 @@ export default class LocalBackgroundJobsStore {
559
791
  * Inserts one prepared local job row and its concurrency metadata.
560
792
  * @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
561
793
  * @param {import("./types.js").PreparedLocalBackgroundJob} preparedJob - Prepared row data.
794
+ * @param {string | null} [scheduleKey] - Stable schedule history key.
795
+ * @param {number | null} [scheduleOrder] - Monotonic stable ownership order.
562
796
  * @returns {Promise<void>} - Resolves after insertion.
563
797
  */
564
- async _insertPreparedJob(db, preparedJob) {
798
+ async _insertPreparedJob(db, preparedJob, scheduleKey = null, scheduleOrder = null) {
565
799
  await db.insert({
566
800
  tableName: LOCAL_BACKGROUND_JOBS_TABLE,
567
801
  data: {
@@ -581,6 +815,8 @@ export default class LocalBackgroundJobsStore {
581
815
  max_concurrency: preparedJob.concurrency?.maxConcurrency || null,
582
816
  max_retries: preparedJob.maxRetries,
583
817
  queue: preparedJob.queue,
818
+ schedule_key: scheduleKey,
819
+ schedule_order: scheduleOrder,
584
820
  scheduled_at_ms: preparedJob.scheduledAtMs,
585
821
  status: "queued",
586
822
  worker_id: null
@@ -847,6 +1083,7 @@ export default class LocalBackgroundJobsStore {
847
1083
 
848
1084
  if (affectedRows !== 1) return false
849
1085
  await this._releaseConcurrency(db, job.concurrencyKey)
1086
+ await this._releaseScheduleOwnershipForJob(db, job)
850
1087
  return true
851
1088
  }))
852
1089
  }
@@ -976,6 +1213,7 @@ export default class LocalBackgroundJobsStore {
976
1213
  async clearAll() {
977
1214
  await this.ensureReady()
978
1215
  await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
1216
+ await db.query(`DELETE FROM ${db.quoteTable(LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE)}`)
979
1217
  await db.query(`DELETE FROM ${db.quoteTable(LOCAL_BACKGROUND_JOBS_TABLE)}`)
980
1218
  await db.query(`DELETE FROM ${db.quoteTable(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE)}`)
981
1219
  }))
@@ -1020,6 +1258,7 @@ export default class LocalBackgroundJobsStore {
1020
1258
 
1021
1259
  if (affectedRows !== 1) return null
1022
1260
  await this._releaseConcurrency(db, job.concurrencyKey)
1261
+ if (!willRetry) await this._releaseScheduleOwnershipForJob(db, job)
1023
1262
 
1024
1263
  return {
1025
1264
  ...job,
@@ -1197,6 +1436,137 @@ export default class LocalBackgroundJobsStore {
1197
1436
  return rows[0] ? this._normalizeRow(rows[0]) : null
1198
1437
  }
1199
1438
 
1439
+ /**
1440
+ * Reads the job currently named by one stable owner row.
1441
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1442
+ * @param {string} scheduleKey - Validated stable schedule key.
1443
+ * @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Normalized owner job.
1444
+ */
1445
+ async _scheduledOwnerJob(db, scheduleKey) {
1446
+ const ownerRows = await db
1447
+ .newQuery()
1448
+ .from(LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE)
1449
+ .where({schedule_key: scheduleKey})
1450
+ .limit(1)
1451
+ .results()
1452
+ const ownerRow = ownerRows[0]
1453
+
1454
+ if (!ownerRow) return null
1455
+
1456
+ return await this._getJob(db, String(ownerRow.job_id))
1457
+ }
1458
+
1459
+ /**
1460
+ * Assigns the next ownership order after SQLite write serialization is held.
1461
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1462
+ * @param {string} scheduleKey - Validated stable schedule key.
1463
+ * @returns {Promise<number>} - Next monotonic ownership order.
1464
+ */
1465
+ async _nextScheduleOrder(db, scheduleKey) {
1466
+ const rows = await db
1467
+ .newQuery()
1468
+ .from(LOCAL_BACKGROUND_JOBS_TABLE)
1469
+ .select("schedule_order")
1470
+ .where({schedule_key: scheduleKey})
1471
+ .where(`${db.quoteColumn("schedule_order")} IS NOT NULL`)
1472
+ .order("schedule_order DESC")
1473
+ .limit(1)
1474
+ .results()
1475
+ const currentOrder = this._numberOrNull(/** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (rows[0] || {}).schedule_order)
1476
+
1477
+ if (currentOrder === null) return 1
1478
+ if (!Number.isSafeInteger(currentOrder) || currentOrder < 1 || currentOrder >= Number.MAX_SAFE_INTEGER) {
1479
+ throw new Error(`Invalid local background job schedule ownership order: ${currentOrder}`)
1480
+ }
1481
+
1482
+ return currentOrder + 1
1483
+ }
1484
+
1485
+ /**
1486
+ * Builds a stable-schedule lookup exclusively from normalized local jobs.
1487
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1488
+ * @param {object} args - Lookup options.
1489
+ * @param {boolean} args.includeLatestTerminal - Whether terminal history is requested.
1490
+ * @param {string} args.scheduleKey - Validated stable schedule key.
1491
+ * @returns {Promise<import("./types.js").BackgroundJobScheduledLookupResult>} - Normalized local jobs.
1492
+ */
1493
+ async _scheduledJobLookup(db, {includeLatestTerminal, scheduleKey}) {
1494
+ const ownerJob = await this._scheduledOwnerJob(db, scheduleKey)
1495
+ const currentJob = ownerJob && (ownerJob.status === "queued" || ownerJob.status === "handed_off") ? ownerJob : null
1496
+
1497
+ if (!includeLatestTerminal) return {currentJob, latestTerminalJob: null}
1498
+
1499
+ const terminalStatuses = BACKGROUND_JOB_TERMINAL_STATUSES.map((status) => db.quote(status)).join(", ")
1500
+ const terminalRows = await db
1501
+ .newQuery()
1502
+ .from(LOCAL_BACKGROUND_JOBS_TABLE)
1503
+ .where({schedule_key: scheduleKey})
1504
+ .where(`${db.quoteColumn("status")} IN (${terminalStatuses})`)
1505
+ .order(`CASE WHEN ${db.quoteColumn("schedule_order")} IS NULL THEN 0 ELSE 1 END DESC`)
1506
+ .order("schedule_order DESC")
1507
+ .order("created_at_ms DESC")
1508
+ .order("id DESC")
1509
+ .limit(1)
1510
+ .results()
1511
+ const latestTerminalJob = terminalRows[0] ? this._normalizeRow(terminalRows[0]) : null
1512
+
1513
+ return {currentJob, latestTerminalJob}
1514
+ }
1515
+
1516
+ /**
1517
+ * Releases ownership only when the key still points at the expected job.
1518
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1519
+ * @param {object} args - Ownership identity.
1520
+ * @param {string | null} args.jobId - Expected owner job id, or null for a dangling owner.
1521
+ * @param {string} args.scheduleKey - Stable schedule key.
1522
+ * @returns {Promise<void>} - Resolves when deleted or already superseded.
1523
+ */
1524
+ async _releaseScheduleOwnership(db, {jobId, scheduleKey}) {
1525
+ const conditions = jobId === null
1526
+ ? {schedule_key: scheduleKey}
1527
+ : {job_id: jobId, schedule_key: scheduleKey}
1528
+
1529
+ await db.delete({conditions, tableName: LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE})
1530
+ }
1531
+
1532
+ /**
1533
+ * Releases a terminal job's stable ownership when still current.
1534
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1535
+ * @param {import("./types.js").BackgroundJobRow} job - Terminal job.
1536
+ * @returns {Promise<void>} - Resolves when deleted or not applicable.
1537
+ */
1538
+ async _releaseScheduleOwnershipForJob(db, job) {
1539
+ if (!job.scheduleKey) return
1540
+
1541
+ await this._releaseScheduleOwnership(db, {jobId: job.id, scheduleKey: job.scheduleKey})
1542
+ }
1543
+
1544
+ /**
1545
+ * Acquires SQLite's transaction write serialization before reading a stable
1546
+ * owner. A zero-row update still establishes the write boundary for a new key.
1547
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1548
+ * @param {string} scheduleKey - Validated stable schedule key.
1549
+ * @returns {Promise<void>} - Resolves after write serialization is acquired.
1550
+ */
1551
+ async _lockScheduleKey(db, scheduleKey) {
1552
+ const table = db.quoteTable(LOCAL_BACKGROUND_JOB_SCHEDULE_KEYS_TABLE)
1553
+ const jobId = db.quoteColumn("job_id")
1554
+
1555
+ await db.query(
1556
+ `UPDATE ${table} SET ${jobId} = ${jobId} ` +
1557
+ `WHERE ${db.quoteColumn("schedule_key")} = ${db.quote(scheduleKey)}`
1558
+ )
1559
+ }
1560
+
1561
+ /**
1562
+ * Registers the local dispatch poke on the surrounding transaction commit.
1563
+ * @param {import("../database/drivers/base.js").default} db - Transaction connection.
1564
+ * @returns {Promise<void>} - Resolves after registration.
1565
+ */
1566
+ async _wakeDispatcherAfterCommit(db) {
1567
+ if (this.onCommittedEnqueue) await db.afterCommit(this.onCommittedEnqueue)
1568
+ }
1569
+
1200
1570
  /**
1201
1571
  * Normalizes one raw local database row.
1202
1572
  * @param {Record<string, ReturnType<typeof JSON.parse>>} row - Raw row.
@@ -1230,9 +1600,10 @@ export default class LocalBackgroundJobsStore {
1230
1600
  maxRetries: this._numberOrNull(row.max_retries),
1231
1601
  orphanedAtMs: null,
1232
1602
  queue: row.queue ? String(row.queue) : DEFAULT_BACKGROUND_JOB_QUEUE,
1233
- scheduleKey: null,
1603
+ scheduleKey: row.schedule_key === null || row.schedule_key === undefined ? null : String(row.schedule_key),
1604
+ scheduleOrder: this._numberOrNull(row.schedule_order),
1234
1605
  scheduledAtMs: this._numberOrNull(row.scheduled_at_ms),
1235
- status: row.status ? String(row.status) : "queued",
1606
+ status: normalizeBackgroundJobStatus(row.status ? String(row.status) : "queued"),
1236
1607
  timeoutMs: null,
1237
1608
  workerId: row.worker_id === null || row.worker_id === undefined ? null : String(row.worker_id)
1238
1609
  }