velocious 1.0.658 → 1.0.660

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 (63) hide show
  1. package/README.md +11 -1
  2. package/build/background-jobs/adapter.js +15 -0
  3. package/build/background-jobs/client.js +6 -2
  4. package/build/background-jobs/execution-context.js +47 -0
  5. package/build/background-jobs/job-runner.js +5 -2
  6. package/build/background-jobs/job.js +21 -2
  7. package/build/background-jobs/main.js +24 -10
  8. package/build/background-jobs/runtime.js +6 -2
  9. package/build/background-jobs/sql-adapter.js +6 -0
  10. package/build/background-jobs/store.js +289 -48
  11. package/build/background-jobs/types.js +11 -2
  12. package/build/background-jobs/worker.js +10 -7
  13. package/build/frontend-model-controller.js +15 -2
  14. package/build/frontend-models/query.js +43 -0
  15. package/build/src/background-jobs/adapter.d.ts +19 -0
  16. package/build/src/background-jobs/adapter.d.ts.map +1 -1
  17. package/build/src/background-jobs/adapter.js +14 -1
  18. package/build/src/background-jobs/client.d.ts +5 -1
  19. package/build/src/background-jobs/client.d.ts.map +1 -1
  20. package/build/src/background-jobs/client.js +7 -3
  21. package/build/src/background-jobs/execution-context.d.ts +23 -0
  22. package/build/src/background-jobs/execution-context.d.ts.map +1 -0
  23. package/build/src/background-jobs/execution-context.js +42 -0
  24. package/build/src/background-jobs/job-runner.d.ts.map +1 -1
  25. package/build/src/background-jobs/job-runner.js +6 -3
  26. package/build/src/background-jobs/job.d.ts.map +1 -1
  27. package/build/src/background-jobs/job.js +21 -3
  28. package/build/src/background-jobs/main.d.ts +2 -2
  29. package/build/src/background-jobs/main.d.ts.map +1 -1
  30. package/build/src/background-jobs/main.js +26 -11
  31. package/build/src/background-jobs/runtime.d.ts +5 -1
  32. package/build/src/background-jobs/runtime.d.ts.map +1 -1
  33. package/build/src/background-jobs/runtime.js +7 -3
  34. package/build/src/background-jobs/sql-adapter.d.ts +5 -0
  35. package/build/src/background-jobs/sql-adapter.d.ts.map +1 -1
  36. package/build/src/background-jobs/sql-adapter.js +6 -1
  37. package/build/src/background-jobs/store.d.ts +119 -1
  38. package/build/src/background-jobs/store.d.ts.map +1 -1
  39. package/build/src/background-jobs/store.js +268 -47
  40. package/build/src/background-jobs/types.d.ts +33 -2
  41. package/build/src/background-jobs/types.d.ts.map +1 -1
  42. package/build/src/background-jobs/types.js +12 -3
  43. package/build/src/background-jobs/worker.d.ts.map +1 -1
  44. package/build/src/background-jobs/worker.js +11 -8
  45. package/build/src/frontend-model-controller.d.ts.map +1 -1
  46. package/build/src/frontend-model-controller.js +15 -3
  47. package/build/src/frontend-models/query.d.ts +6 -0
  48. package/build/src/frontend-models/query.d.ts.map +1 -1
  49. package/build/src/frontend-models/query.js +40 -1
  50. package/package.json +1 -1
  51. package/src/background-jobs/adapter.js +15 -0
  52. package/src/background-jobs/client.js +6 -2
  53. package/src/background-jobs/execution-context.js +47 -0
  54. package/src/background-jobs/job-runner.js +5 -2
  55. package/src/background-jobs/job.js +21 -2
  56. package/src/background-jobs/main.js +24 -10
  57. package/src/background-jobs/runtime.js +6 -2
  58. package/src/background-jobs/sql-adapter.js +6 -0
  59. package/src/background-jobs/store.js +289 -48
  60. package/src/background-jobs/types.js +11 -2
  61. package/src/background-jobs/worker.js +10 -7
  62. package/src/frontend-model-controller.js +15 -2
  63. package/src/frontend-models/query.js +43 -0
package/README.md CHANGED
@@ -879,7 +879,7 @@ Use `await FrontendModelBase.waitForIdle()` when a test harness or app lifecycle
879
879
  Frontend-model HTTP requests always use `credentials: "include"` so shared custom commands can set session cookies without app-level transport overrides.
880
880
 
881
881
  Unexpected frontend-model endpoint failures return their original message and full stack trace by default in every environment, including production. Responses use `errorType: "internal_error"`, a server-generated `correlationId` shared with the matching framework-error report, and the established `debugErrorClass`, `debugErrorMessage`, and `debugBacktrace` fields. Expected application failures can use `VelociousError.safe(message, {errorType, details, code})`; generated frontend-model callers preserve the server's safe error fields without adding irrelevant debug fields. See [docs/frontend-models.md](docs/frontend-models.md#error-payloads).
882
- Invalid client query descriptors, such as unknown `select`, `where`, `search`, `joins`, `preload`, `group`, `sort`, `pluck`, or Ransack attributes, return the specific frontend-model query error message with `velocious.code: "frontend-model-query-error"` and are not emitted as framework errors.
882
+ Invalid client query descriptors, such as unknown `select`, `where`, `search`, `joins`, `preload`, `group`, `sort`, `pluck`, or Ransack attributes, return the specific frontend-model query error message with `velocious.code: "frontend-model-query-error"` and are not emitted as framework errors. Shared index payloads reject unknown top-level keys per request, including wrapper nesting such as `payload.query.where`; use `payload.where` directly.
883
883
  Invalid frontend-model write attributes and attachment names, including attributes rejected by `permittedParams()`, return the specific safe error message with `velocious.code: "frontend-model-attribute-error"` and are not emitted as framework errors.
884
884
  To mask unexpected internal details, explicitly opt out for the application configuration:
885
885
 
@@ -2442,6 +2442,16 @@ the new main. Deploy and HTTP/WebSocket drain completion are independent of this
2442
2442
  potentially hours-long lifecycle. See [release-generation
2443
2443
  draining](docs/background-jobs.md#release-generation-draining).
2444
2444
 
2445
+ Jobs that remain owned by a retired generation may still use the unchanged
2446
+ `performLater()` / `performLaterWithOptions()` APIs to create follow-up work.
2447
+ Velocious carries the producer's exact handoff through its asynchronous
2448
+ execution context and atomically validates that lease with insertion or queued
2449
+ deduplication. Each call has its own internal replay identity, so two identical
2450
+ calls create distinct jobs unless the caller explicitly requests queued
2451
+ deduplication or durable idempotency. The retired main commits and wakes the
2452
+ queue but never dispatches the follow-up; the active generation owns that work.
2453
+ Ordinary retired enqueue, replace, and cancel requests remain rejected.
2454
+
2445
2455
  Velocious provides the opt-in generation protocol; production still requires a
2446
2456
  supervisor that preserves old generation units and release pins, and a deploy
2447
2457
  coordinator that retires the old generation before activating the healthy
@@ -14,6 +14,14 @@ export default class BackgroundJobsAdapter {
14
14
  */
15
15
  supportsReleaseScopedGenerations() { return false }
16
16
 
17
+ /**
18
+ * Declares atomic producer-handoff validation plus enqueue support. A
19
+ * generation-capable adapter must override this together with
20
+ * `enqueueFromOwnedHandoff`.
21
+ * @returns {boolean} - Whether atomic owned enqueue is supported.
22
+ */
23
+ supportsOwnedEnqueueFromHandoff() { return false }
24
+
17
25
  /**
18
26
  * Ensures the adapter can accept work.
19
27
  * @returns {Promise<void>} - Resolves when ready.
@@ -64,6 +72,13 @@ export default class BackgroundJobsAdapter {
64
72
  */
65
73
  async enqueue(_args) { throw new Error("BackgroundJobsAdapter#enqueue is not implemented") }
66
74
 
75
+ /**
76
+ * Atomically validates an exact producing handoff and enqueues its follow-up.
77
+ * @param {{jobName: string, args: Array<ReturnType<typeof JSON.parse>>, options?: import("./types.js").BackgroundJobOptions, producerInvocationId?: string, producerProof: import("./types.js").BackgroundJobProducerProof}} _args - Owned enqueue request.
78
+ * @returns {Promise<string>} - Job id.
79
+ */
80
+ async enqueueFromOwnedHandoff(_args) { throw new Error("BackgroundJobsAdapter#enqueueFromOwnedHandoff is not implemented") }
81
+
67
82
  /**
68
83
  * Replaces the owner of a stable schedule key.
69
84
  * @param {{scheduleKey: string, jobName: string, args: Array<ReturnType<typeof JSON.parse>>, options?: import("./types.js").BackgroundJobOptions}} _args - Replacement request.
@@ -44,9 +44,11 @@ export default class BackgroundJobsClient {
44
44
  * @param {string} args.jobName - Job name.
45
45
  * @param {Array<ReturnType<typeof JSON.parse>>} args.args - Job args.
46
46
  * @param {import("./types.js").BackgroundJobOptions} [args.options] - Job options.
47
+ * @param {string} [args.producerInvocationId] - Stable identity for one owned enqueue invocation.
48
+ * @param {import("./types.js").BackgroundJobProducerProof} [args.producerProof] - Exact internal producer handoff.
47
49
  * @returns {Promise<string>} - Job id.
48
50
  */
49
- async enqueue({jobName, args, options}) {
51
+ async enqueue({jobName, args, options, producerInvocationId, producerProof}) {
50
52
  const request = await this._request()
51
53
 
52
54
  return await timeout({
@@ -59,7 +61,9 @@ export default class BackgroundJobsClient {
59
61
  type: "enqueue",
60
62
  jobName,
61
63
  args,
62
- options
64
+ options,
65
+ ...(producerInvocationId ? {producerInvocationId} : {}),
66
+ ...(producerProof ? {producerProof} : {})
63
67
  })
64
68
  },
65
69
  onMessage: ({message, resolve, reject}) => {
@@ -0,0 +1,47 @@
1
+ // @ts-check
2
+
3
+ import { AsyncLocalStorage } from "node:async_hooks"
4
+
5
+ /** @type {AsyncLocalStorage<import("./types.js").BackgroundJobProducerProof>} */
6
+ const producerProofStorage = new AsyncLocalStorage()
7
+
8
+ /**
9
+ * Returns the exact producer handoff for the current asynchronous job chain.
10
+ * @returns {import("./types.js").BackgroundJobProducerProof | undefined} - Current producer proof.
11
+ */
12
+ export function currentBackgroundJobProducerProof() {
13
+ return producerProofStorage.getStore()
14
+ }
15
+
16
+ /**
17
+ * Runs one job-owned asynchronous chain with an immutable producer proof.
18
+ * @template T
19
+ * @param {import("./types.js").BackgroundJobProducerProof} producerProof - Exact producer handoff.
20
+ * @param {() => T} callback - Job-owned work.
21
+ * @returns {T} - Callback result.
22
+ */
23
+ export function runWithBackgroundJobProducerProof(producerProof, callback) {
24
+ return producerProofStorage.run(Object.freeze({...producerProof}), callback)
25
+ }
26
+
27
+ /**
28
+ * Runs under a payload's exact lease when all fencing fields are present.
29
+ * Legacy payloads without a complete lease retain their existing behavior.
30
+ * @template T
31
+ * @param {import("./types.js").BackgroundJobPayload} payload - Persisted runner payload.
32
+ * @param {() => T} callback - Job-owned work.
33
+ * @returns {T} - Callback result.
34
+ */
35
+ export function runWithBackgroundJobPayload(payload, callback) {
36
+ const handedOffAtMs = payload.handedOffAtMs
37
+ if (!payload.id || !payload.handoffId || !payload.workerId || handedOffAtMs === undefined || !Number.isSafeInteger(handedOffAtMs)) {
38
+ return callback()
39
+ }
40
+
41
+ return runWithBackgroundJobProducerProof({
42
+ handedOffAtMs,
43
+ handoffId: payload.handoffId,
44
+ jobId: payload.id,
45
+ workerId: payload.workerId
46
+ }, callback)
47
+ }
@@ -4,6 +4,7 @@ import configurationResolver from "../configuration-resolver.js"
4
4
  import BackgroundJobRegistry from "./job-registry.js"
5
5
  import BackgroundJobsStatusReporter from "./status-reporter.js"
6
6
  import BackgroundJobRescheduleSignal from "./reschedule-signal.js"
7
+ import { runWithBackgroundJobPayload } from "./execution-context.js"
7
8
  import { closeRunnerConnections } from "./runner-graceful-shutdown.js"
8
9
 
9
10
  const BEACON_READY_TIMEOUT_MS = 5000
@@ -116,8 +117,10 @@ export default async function runJobPayload(payload, {closeConnections = true, m
116
117
 
117
118
  try {
118
119
  try {
119
- await configuration.withConnections({databaseIdentifiers: JobClass.databaseIdentifiers, name: `Background job runner: ${payload.jobName}`}, async () => {
120
- await perform.apply(jobInstance, jobArgs)
120
+ await runWithBackgroundJobPayload(payload, async () => {
121
+ await configuration.withConnections({databaseIdentifiers: JobClass.databaseIdentifiers, name: `Background job runner: ${payload.jobName}`}, async () => {
122
+ await perform.apply(jobInstance, jobArgs)
123
+ })
121
124
  })
122
125
  } catch (error) {
123
126
  if (error instanceof BackgroundJobRescheduleSignal) {
@@ -1,6 +1,9 @@
1
1
  // @ts-check
2
2
 
3
+ import { randomUUID } from "node:crypto"
4
+
3
5
  import configurationResolver from "../configuration-resolver.js"
6
+ import { currentBackgroundJobProducerProof } from "./execution-context.js"
4
7
  import PlatformVelociousJob from "./platform-job.js"
5
8
  import {
6
9
  cancelScheduledBackgroundJobForConfiguration,
@@ -24,8 +27,16 @@ export default class VelociousJob extends PlatformVelociousJob {
24
27
  static async performLater(...args) {
25
28
  const configuration = await configurationResolver()
26
29
  const {jobArgs, jobOptions} = this._splitArgsAndOptions(args)
30
+ const producerProof = currentBackgroundJobProducerProof()
27
31
 
28
- return await enqueueBackgroundJobForConfiguration({configuration, JobClass: this, jobArgs, jobOptions})
32
+ return await enqueueBackgroundJobForConfiguration({
33
+ configuration,
34
+ JobClass: this,
35
+ jobArgs,
36
+ jobOptions,
37
+ producerProof,
38
+ producerInvocationId: producerProof ? randomUUID() : undefined
39
+ })
29
40
  }
30
41
 
31
42
  /**
@@ -37,8 +48,16 @@ export default class VelociousJob extends PlatformVelociousJob {
37
48
  */
38
49
  static async performLaterWithOptions({args, options}) {
39
50
  const configuration = await configurationResolver()
51
+ const producerProof = currentBackgroundJobProducerProof()
40
52
 
41
- return await enqueueBackgroundJobForConfiguration({configuration, JobClass: this, jobArgs: args, jobOptions: options})
53
+ return await enqueueBackgroundJobForConfiguration({
54
+ configuration,
55
+ JobClass: this,
56
+ jobArgs: args,
57
+ jobOptions: options,
58
+ producerProof,
59
+ producerInvocationId: producerProof ? randomUUID() : undefined
60
+ })
42
61
  }
43
62
 
44
63
  /**
@@ -201,7 +201,7 @@ export default class BackgroundJobsMain {
201
201
  this._pollTimer = undefined
202
202
  /**
203
203
  * Narrows the runtime value to the documented type.
204
- * @type {ReturnType<typeof setTimeout> | undefined} */
204
+ * @type {ReturnType<typeof setTimeout> | number | undefined} */
205
205
  this._scheduledTimer = undefined
206
206
  /**
207
207
  * Narrows the runtime value to the documented type.
@@ -291,6 +291,9 @@ export default class BackgroundJobsMain {
291
291
  if (this.generationId && !this.adapter.supportsReleaseScopedGenerations()) {
292
292
  throw new Error("The configured background jobs adapter does not support release-scoped generations")
293
293
  }
294
+ if (this.generationId && !this.adapter.supportsOwnedEnqueueFromHandoff()) {
295
+ throw new Error("The configured background jobs adapter does not support atomic owned-handoff enqueue")
296
+ }
294
297
 
295
298
  if (!this.generationId || this.initialGenerationState !== "candidate") {
296
299
  this.startupHandoffSnapshot = await this._generationOwnedHandoffSnapshot()
@@ -411,7 +414,7 @@ export default class BackgroundJobsMain {
411
414
  * @returns {void} */
412
415
  _clearTimers() {
413
416
  if (this._pollTimer) clearInterval(this._pollTimer)
414
- if (this._scheduledTimer) clearTimeout(this._scheduledTimer)
417
+ if (this._scheduledTimer) this.clock.clearTimeout(this._scheduledTimer)
415
418
  if (this._errorRetryTimer) clearTimeout(this._errorRetryTimer)
416
419
  if (this._orphanTimer) clearInterval(this._orphanTimer)
417
420
  if (this._workerStaleTimer) clearInterval(this._workerStaleTimer)
@@ -670,7 +673,7 @@ export default class BackgroundJobsMain {
670
673
  /** Clears timers that can initiate new global dispatch or schedule work. */
671
674
  _clearDispatchTimers() {
672
675
  if (this._pollTimer) clearInterval(this._pollTimer)
673
- if (this._scheduledTimer) clearTimeout(this._scheduledTimer)
676
+ if (this._scheduledTimer) this.clock.clearTimeout(this._scheduledTimer)
674
677
  if (this._errorRetryTimer) clearTimeout(this._errorRetryTimer)
675
678
  this._pollTimer = undefined
676
679
  this._scheduledTimer = undefined
@@ -1151,10 +1154,10 @@ export default class BackgroundJobsMain {
1151
1154
  */
1152
1155
  async _handleClientSocketMessage({jsonSocket, message}) {
1153
1156
  if (this.generationId && (this.lifecycleState === "retiring" || this.lifecycleState === "retired")) {
1154
- if (message?.type === "enqueue") jsonSocket.send({type: "enqueue-error", error: "Background jobs generation is retired"})
1157
+ if (message?.type === "enqueue" && !message.producerProof) jsonSocket.send({type: "enqueue-error", error: "Background jobs generation is retired"})
1155
1158
  if (message?.type === "replace-scheduled") jsonSocket.send({type: "replace-scheduled-error", error: "Background jobs generation is retired"})
1156
1159
  if (message?.type === "cancel-scheduled") jsonSocket.send({type: "cancel-scheduled-error", error: "Background jobs generation is retired"})
1157
- return
1160
+ if (message?.type !== "enqueue" || !message.producerProof) return
1158
1161
  }
1159
1162
 
1160
1163
  if (message?.type === "enqueue") {
@@ -1473,15 +1476,26 @@ export default class BackgroundJobsMain {
1473
1476
  */
1474
1477
  async _handleEnqueue({jsonSocket, message}) {
1475
1478
  try {
1476
- const jobId = await this.store.enqueue({
1479
+ if (this.generationId
1480
+ && typeof message.producerProof?.workerId === "string"
1481
+ && !workerIdBelongsToGeneration({generationId: this.generationId, workerId: message.producerProof.workerId})) {
1482
+ throw VelociousError.safe("Background job producer handoff belongs to another generation.", {
1483
+ code: "background-job-producer-generation-mismatch"
1484
+ })
1485
+ }
1486
+
1487
+ const request = {
1477
1488
  jobName: message.jobName,
1478
1489
  args: message.args || [],
1479
1490
  options: message.options || {}
1480
- })
1491
+ }
1492
+ const jobId = this.generationId && message.producerProof
1493
+ ? await this.store.enqueueFromOwnedHandoff({...request, producerInvocationId: message.producerInvocationId, producerProof: message.producerProof})
1494
+ : await this.store.enqueue(request)
1481
1495
 
1482
1496
  jsonSocket.send({type: "enqueued", jobId})
1483
1497
  this._notifyEnqueued()
1484
- await this._drain()
1498
+ if (this.lifecycleState === "active") await this._drain()
1485
1499
  } catch (error) {
1486
1500
  this._handleClientMutationError({
1487
1501
  context: {jobName: message.jobName, stage: "background-job-enqueue"},
@@ -2295,7 +2309,7 @@ export default class BackgroundJobsMain {
2295
2309
  */
2296
2310
  async _armScheduledTimer() {
2297
2311
  if (this._scheduledTimer) {
2298
- clearTimeout(this._scheduledTimer)
2312
+ this.clock.clearTimeout(this._scheduledTimer)
2299
2313
  this._scheduledTimer = undefined
2300
2314
  }
2301
2315
 
@@ -2318,7 +2332,7 @@ export default class BackgroundJobsMain {
2318
2332
 
2319
2333
  if (typeof delay !== "number") return
2320
2334
 
2321
- this._scheduledTimer = setTimeout(() => {
2335
+ this._scheduledTimer = this.clock.setTimeout(() => {
2322
2336
  this._scheduledTimer = undefined
2323
2337
  void this._drain()
2324
2338
  }, delay)
@@ -49,9 +49,11 @@ export async function enqueueBackgroundJob({JobClass, jobArgs, jobOptions}) {
49
49
  * @param {typeof import("./platform-job.js").default} args.JobClass - Job class.
50
50
  * @param {Array<ReturnType<typeof JSON.parse>>} args.jobArgs - Job arguments.
51
51
  * @param {import("./types.js").BackgroundJobOptions | undefined} args.jobOptions - Job options.
52
+ * @param {string} [args.producerInvocationId] - Stable identity for one owned enqueue invocation.
53
+ * @param {import("./types.js").BackgroundJobProducerProof} [args.producerProof] - Exact internal producer handoff.
52
54
  * @returns {Promise<string>} - Durable job id or ephemeral inline performance id.
53
55
  */
54
- export async function enqueueBackgroundJobForConfiguration({configuration, JobClass, jobArgs, jobOptions}) {
56
+ export async function enqueueBackgroundJobForConfiguration({configuration, JobClass, jobArgs, jobOptions, producerInvocationId, producerProof}) {
55
57
  const resolvedJobOptions = JobClass._withJobContext({jobArgs, jobOptions})
56
58
 
57
59
  if (configuration.getBackgroundJobsConfig().mode === "inline") {
@@ -83,7 +85,9 @@ export async function enqueueBackgroundJobForConfiguration({configuration, JobCl
83
85
  return await client.enqueue({
84
86
  jobName: JobClass.jobName(),
85
87
  args: jobArgs,
86
- options: resolvedJobOptions
88
+ options: resolvedJobOptions,
89
+ producerInvocationId,
90
+ producerProof
87
91
  })
88
92
  }
89
93
 
@@ -10,6 +10,12 @@ export default class SqlBackgroundJobsAdapter extends BackgroundJobsStore {
10
10
  */
11
11
  supportsReleaseScopedGenerations() { return true }
12
12
 
13
+ /**
14
+ * Declares atomic owned-handoff enqueue support.
15
+ * @returns {boolean} - The SQL transaction validates ownership and enqueues atomically.
16
+ */
17
+ supportsOwnedEnqueueFromHandoff() { return true }
18
+
13
19
  /**
14
20
  * Ensures the built-in SQL schema during migration.
15
21
  * @param {{dbs: Record<string, import("../database/drivers/base.js").default>}} args - Migrated databases.