velocious 1.0.665 → 1.0.667

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 (108) hide show
  1. package/README.md +14 -5
  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 +206 -14
  5. package/build/background-jobs/enqueue-acknowledgement-timeout-error.js +50 -0
  6. package/build/background-jobs/job-semantics.js +30 -0
  7. package/build/background-jobs/job.js +26 -1
  8. package/build/background-jobs/local-adapter.js +36 -8
  9. package/build/background-jobs/local-store.js +410 -39
  10. package/build/background-jobs/main.js +69 -2
  11. package/build/background-jobs/platform-job.js +26 -1
  12. package/build/background-jobs/runtime.js +58 -0
  13. package/build/background-jobs/store.js +374 -6
  14. package/build/background-jobs/types.js +27 -2
  15. package/build/background-jobs/web/controller.js +1 -0
  16. package/build/configuration.js +7 -5
  17. package/build/database/drivers/mssql/column.js +2 -2
  18. package/build/database/drivers/mssql/table.js +19 -5
  19. package/build/database/pool/async-tracked-multi-connection.js +2 -1
  20. package/build/database/query/alter-table-base.js +8 -1
  21. package/build/database/record/attachments/store.js +2 -2
  22. package/build/frontend-model-controller.js +22 -2
  23. package/build/http-server/websocket-events-host.js +3 -1
  24. package/build/src/background-jobs/adapter-client.d.ts +17 -0
  25. package/build/src/background-jobs/adapter-client.d.ts.map +1 -1
  26. package/build/src/background-jobs/adapter-client.js +19 -1
  27. package/build/src/background-jobs/adapter.d.ts +15 -0
  28. package/build/src/background-jobs/adapter.d.ts.map +1 -1
  29. package/build/src/background-jobs/adapter.js +14 -1
  30. package/build/src/background-jobs/client.d.ts +17 -0
  31. package/build/src/background-jobs/client.d.ts.map +1 -1
  32. package/build/src/background-jobs/client.js +186 -15
  33. package/build/src/background-jobs/enqueue-acknowledgement-timeout-error.d.ts +81 -0
  34. package/build/src/background-jobs/enqueue-acknowledgement-timeout-error.d.ts.map +1 -0
  35. package/build/src/background-jobs/enqueue-acknowledgement-timeout-error.js +47 -0
  36. package/build/src/background-jobs/job-semantics.d.ts +18 -0
  37. package/build/src/background-jobs/job-semantics.d.ts.map +1 -1
  38. package/build/src/background-jobs/job-semantics.js +28 -1
  39. package/build/src/background-jobs/job.d.ts +15 -0
  40. package/build/src/background-jobs/job.d.ts.map +1 -1
  41. package/build/src/background-jobs/job.js +21 -2
  42. package/build/src/background-jobs/local-adapter.d.ts +23 -8
  43. package/build/src/background-jobs/local-adapter.d.ts.map +1 -1
  44. package/build/src/background-jobs/local-adapter.js +35 -9
  45. package/build/src/background-jobs/local-store.d.ts +120 -5
  46. package/build/src/background-jobs/local-store.d.ts.map +1 -1
  47. package/build/src/background-jobs/local-store.js +366 -40
  48. package/build/src/background-jobs/main.d.ts +24 -2
  49. package/build/src/background-jobs/main.d.ts.map +1 -1
  50. package/build/src/background-jobs/main.js +68 -3
  51. package/build/src/background-jobs/platform-job.d.ts +15 -0
  52. package/build/src/background-jobs/platform-job.d.ts.map +1 -1
  53. package/build/src/background-jobs/platform-job.js +19 -2
  54. package/build/src/background-jobs/runtime.d.ts +39 -0
  55. package/build/src/background-jobs/runtime.d.ts.map +1 -1
  56. package/build/src/background-jobs/runtime.js +49 -1
  57. package/build/src/background-jobs/store.d.ts +100 -1
  58. package/build/src/background-jobs/store.d.ts.map +1 -1
  59. package/build/src/background-jobs/store.js +334 -8
  60. package/build/src/background-jobs/types.d.ts +97 -4
  61. package/build/src/background-jobs/types.d.ts.map +1 -1
  62. package/build/src/background-jobs/types.js +28 -3
  63. package/build/src/background-jobs/web/controller.d.ts.map +1 -1
  64. package/build/src/background-jobs/web/controller.js +2 -1
  65. package/build/src/configuration.d.ts.map +1 -1
  66. package/build/src/configuration.js +7 -5
  67. package/build/src/database/drivers/mssql/column.d.ts.map +1 -1
  68. package/build/src/database/drivers/mssql/column.js +2 -2
  69. package/build/src/database/drivers/mssql/table.d.ts.map +1 -1
  70. package/build/src/database/drivers/mssql/table.js +19 -5
  71. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  72. package/build/src/database/pool/async-tracked-multi-connection.js +3 -2
  73. package/build/src/database/query/alter-table-base.d.ts.map +1 -1
  74. package/build/src/database/query/alter-table-base.js +9 -2
  75. package/build/src/database/record/attachments/store.js +3 -3
  76. package/build/src/frontend-model-controller.d.ts.map +1 -1
  77. package/build/src/frontend-model-controller.js +18 -3
  78. package/build/src/http-server/websocket-events-host.d.ts.map +1 -1
  79. package/build/src/http-server/websocket-events-host.js +4 -2
  80. package/build/src/testing/test-files-finder.d.ts +4 -1
  81. package/build/src/testing/test-files-finder.d.ts.map +1 -1
  82. package/build/src/testing/test-files-finder.js +8 -3
  83. package/build/testing/test-files-finder.js +6 -1
  84. package/package.json +1 -1
  85. package/scripts/test-browser.js +1 -0
  86. package/src/background-jobs/adapter-client.js +22 -0
  87. package/src/background-jobs/adapter.js +15 -0
  88. package/src/background-jobs/client.js +206 -14
  89. package/src/background-jobs/enqueue-acknowledgement-timeout-error.js +50 -0
  90. package/src/background-jobs/job-semantics.js +30 -0
  91. package/src/background-jobs/job.js +26 -1
  92. package/src/background-jobs/local-adapter.js +36 -8
  93. package/src/background-jobs/local-store.js +410 -39
  94. package/src/background-jobs/main.js +69 -2
  95. package/src/background-jobs/platform-job.js +26 -1
  96. package/src/background-jobs/runtime.js +58 -0
  97. package/src/background-jobs/store.js +374 -6
  98. package/src/background-jobs/types.js +27 -2
  99. package/src/background-jobs/web/controller.js +1 -0
  100. package/src/configuration.js +7 -5
  101. package/src/database/drivers/mssql/column.js +2 -2
  102. package/src/database/drivers/mssql/table.js +19 -5
  103. package/src/database/pool/async-tracked-multi-connection.js +2 -1
  104. package/src/database/query/alter-table-base.js +8 -1
  105. package/src/database/record/attachments/store.js +2 -2
  106. package/src/frontend-model-controller.js +22 -2
  107. package/src/http-server/websocket-events-host.js +3 -1
  108. package/src/testing/test-files-finder.js +6 -1
package/README.md CHANGED
@@ -37,7 +37,7 @@
37
37
  * Explicit database-free in-process HTTP applications with optional request and buffered-response byte limits (see [docs/http-server.md](docs/http-server.md#database-free-applications))
38
38
  * Default-on buffered HTTP response compression with Brotli/gzip content negotiation, global and per-response opt-outs, and HEAD-correct representation headers (see [docs/http-server.md](docs/http-server.md#response-compression))
39
39
  * Background jobs with Node SQL/TCP workers plus a Browser/Expo local SQLite store and in-process dispatcher, including failure events, authorized database-scoped dashboard counts, and an opt-in release-scoped main/worker generation protocol with acknowledged activation, asynchronous retirement, and retired-main recovery. Production compliance additionally requires downstream supervisor retention/activation ordering and release pins (see [docs/background-jobs.md](docs/background-jobs.md), [docs/local-background-jobs.md](docs/local-background-jobs.md), and [docs/background-jobs-dashboard.md](docs/background-jobs-dashboard.md))
40
- * Durable one-off background-job scheduling with exact epoch timestamps (see [docs/scheduled-background-job-enqueue.md](docs/scheduled-background-job-enqueue.md))
40
+ * Durable one-off background-job scheduling with exact epoch timestamps plus stable replacement, cancellation, normalized readback, and duplicate-free wake across Node SQL/TCP and Browser/Expo local SQLite adapters (see [docs/scheduled-background-job-enqueue.md](docs/scheduled-background-job-enqueue.md))
41
41
  * Rails-style request and database query logging with structured credential redaction (see [docs/logging.md](docs/logging.md))
42
42
  * EJS-backed mailers with delivery, queueing, and payload rendering support (see [docs/mailers.md](docs/mailers.md))
43
43
  * Trusted reverse proxy handling for `request.remoteAddress()` (see [docs/trusted-proxies.md](docs/trusted-proxies.md))
@@ -880,7 +880,7 @@ Use `await FrontendModelBase.waitForIdle()` when a test harness or app lifecycle
880
880
  Frontend-model HTTP requests always use `credentials: "include"` so shared custom commands can set session cookies without app-level transport overrides.
881
881
 
882
882
  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).
883
- 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
+ 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. Legacy shared index callers may send `payload.authenticationToken`, which is extracted into request context before validation; it is not a query option, `requestContext.authenticationToken` wins when both are present, and unrelated unknown keys still fail. See [frontend-model transport](docs/frontend-models.md#core-transport).
884
884
  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.
885
885
  To mask unexpected internal details, explicitly opt out for the application configuration:
886
886
 
@@ -2723,13 +2723,22 @@ const result = await MyJob.replaceScheduled({
2723
2723
  await MyJob.cancelScheduled(`event:${eventId}:reminder:24h`)
2724
2724
  ```
2725
2725
 
2726
- A queued owner is atomically cancelled during replacement/cancellation. Its acknowledgement waits for the corresponding dispatch drain lifecycle; if another drain is already active, the request coalesces and waits for its re-drain and future-job timer re-arm instead of acknowledging early. A `previousStatus` or cancellation `outcome` of `"handed_off"` means the worker may already be running; Velocious removes or replaces key ownership but does not claim that JavaScript stopped. Store a generation/revision in application state, pass it to the job, and re-check it immediately before irreversible effects. Stable keys and full result shapes are documented in [Scheduling One-Off Background Jobs](docs/scheduled-background-job-enqueue.md#replacing-or-cancelling-a-logical-schedule).
2726
+ Read the durable owner/history or expedite the same queued row without creating another job:
2727
+
2728
+ ```js
2729
+ const scheduled = await MyJob.getScheduledJob(`event:${eventId}:reminder:24h`, {
2730
+ includeLatestTerminal: true
2731
+ })
2732
+ const wake = await MyJob.wakeScheduled(`event:${eventId}:reminder:24h`)
2733
+ ```
2734
+
2735
+ A queued owner is atomically cancelled during replacement/cancellation. On Node/TCP, its acknowledgement waits for the corresponding main-process dispatch drain lifecycle. The local adapter commits an adapter-owned transaction before its dispatcher wake runs; inside an ambient application transaction, the mutation and wake remain deferred to that outer commit. Readback returns normalized public jobs, including the transaction-assigned `scheduleOrder` used for causal history ordering; Node retains the per-key order high-water mark when terminal history is pruned. Wake moves only a future queued owner's eligibility to now while preserving its id, attempts, last error, and lineage; repeated calls never enqueue duplicates. A `previousStatus`, cancellation `outcome`, or wake `outcome` of `"handed_off"` means the worker may already be running; Velocious removes or replaces key ownership where appropriate but does not claim that JavaScript stopped. Store a generation/revision in application state, pass it to the job, and re-check it immediately before irreversible effects. Stable keys and full result shapes are documented in [Scheduling One-Off Background Jobs](docs/scheduled-background-job-enqueue.md#replacing-or-cancelling-a-logical-schedule).
2727
2736
 
2728
2737
  Set `deduplicateWhileQueued: true` to coalesce an enqueue onto the earliest identical queued job with the same job name, arguments, and queue when that existing job is scheduled no later than the new request. A retry backed off into the future does not suppress a new immediate enqueue, while repeated immediate triggers and equal or later schedules still coalesce.
2729
2738
 
2730
2739
  Use `options: {idempotencyKey}` when producer replay must converge on the original durable job across every state and even after terminal-job pruning. Ownership is scoped to the resolved job class name, resolved queue, and key; reusing that scope with changed canonical arguments or behavior-affecting options fails. This is distinct from queued-only deduplication, and ownership rows are intentionally retained until a future explicit reconciliation/deletion policy. See [durable idempotent enqueue](docs/background-jobs.md#durable-idempotent-enqueue).
2731
2740
 
2732
- The Node producer gives connection/handshake and post-send acknowledgement separate bounded phases, so connection or generation-handshake latency does not consume the 5-second `enqueued` acknowledgement budget. It rejects and destroys its one-shot socket when either phase stalls or when the main closes before acknowledging. Because the main may already have committed the job, a post-send failure is an ambiguous outcome. A call made by an executing generation-owned job automatically makes one recovery attempt with its exact internal producer proof and per-call invocation identity, but never after a pre-send generation failure or explicit enqueue rejection. Ordinary enqueues are not retried automatically: replay one with the same durable `idempotencyKey` to recover the original job id without creating a duplicate. Direct `BackgroundJobsClient` users can set a different bounded `enqueueTimeoutMs` constructor option for both phases. See [durable idempotent enqueue](docs/background-jobs.md#durable-idempotent-enqueue).
2741
+ The Node producer gives connection/handshake and post-send acknowledgement separate bounded phases, so connection or generation-handshake latency does not consume the 5-second `enqueued` acknowledgement budget. It rejects and destroys its one-shot socket when either phase stalls or when the main closes before acknowledging. Only a post-send acknowledgement deadline raises `BackgroundJobEnqueueAcknowledgementTimeoutError`, an Awaitery `TimeoutError` subtype with code `BACKGROUND_JOB_ENQUEUE_ACKNOWLEDGEMENT_TIMEOUT` and frozen, payload-free attempt history. The error proves the request was sent but cannot say whether the main committed it. A call made by an executing generation-owned job automatically makes one recovery attempt with its exact internal producer proof and per-call invocation identity, but never after a pre-send generation failure or explicit enqueue rejection. If that replay also times out, the final typed error describes both timed-out attempts. Ordinary enqueues are not retried automatically: replay one with the same durable `idempotencyKey` to recover the original job id without creating a duplicate. Direct `BackgroundJobsClient` users can set a different bounded `enqueueTimeoutMs` constructor option for both phases. See [durable idempotent enqueue](docs/background-jobs.md#durable-idempotent-enqueue).
2733
2742
 
2734
2743
  Select a non-default runtime explicitly with `options: {executionMode: "inline" | "forked" | "spawned"}`.
2735
2744
 
@@ -2817,7 +2826,7 @@ Each job must define exactly one of `every` or `cron`. Cron times are evaluated
2817
2826
 
2818
2827
  ## Persistence and retries
2819
2828
 
2820
- Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the main generates a unique lease id before asking the adapter to mark the job handed off, and the worker reports completion or failure back to the main process. If that persistence call has an ambiguous result, only the exact caller-generated lease is conditionally returned; failed recovery is retained for the dispatch error-retry path, so worker admission and concurrency do not remain stranded and a newer lease is never reclaimed. Custom adapters must persist a supplied `markHandedOff({handoffId})` exactly; built-in adapters continue generating one for legacy direct callers that omit it. A legacy worker disconnect returns only that socket's leases immediately. Generation mode instead preserves the exact leases through reconnect grace for the same qualified worker, then returns them to the global queue on expiry. Late reports are fenced by generation-qualified worker id, lease id, and handoff time so they cannot mutate a newer attempt. This recovery is at-least-once and may repeat application side effects if the disconnected attempt had already started them. A release-retiring worker revokes readiness but retains heartbeat, its unchanged old endpoint, exact-generation reconnect, accepted work, child execution, durable reports, and acknowledgements until its drain settles; retiring/retired mains reject new identities and never grant reconnecting workers readiness. Startup reconnection/adoption is an abnormal crash/legacy-recovery facility, not the normal deploy topology: during ordinary release retirement the old main remains alive and owns its old workers, and they must not reconnect to the new main. A production integration that restarts jobs-main on every deploy and depends on worker adoption is not compliant with the release-generation contract. See [release-generation draining](docs/background-jobs.md#release-generation-draining) and [worker disconnect recovery](docs/background-jobs.md#worker-disconnect-recovery).
2829
+ Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the main generates a unique lease id before asking the adapter to mark the job handed off, and the worker reports completion or failure back to the main process. If that persistence call has an ambiguous result, only the exact caller-generated lease is conditionally returned; failed recovery is retained for the dispatch error-retry path, so worker admission and concurrency do not remain stranded and a newer lease is never reclaimed. Custom adapters must persist a supplied `markHandedOff({handoffId})` exactly; built-in adapters continue generating one for legacy direct callers that omit it. A legacy worker disconnect returns only that socket's leases immediately. Generation mode instead preserves the exact leases through reconnect grace for the same qualified worker, starting that grace only after active ownership completes its initial startup drain, then returns absent workers' leases to the global queue on expiry. Late reports are fenced by generation-qualified worker id, lease id, and handoff time so they cannot mutate a newer attempt. This recovery is at-least-once and may repeat application side effects if the disconnected attempt had already started them. A release-retiring worker revokes readiness but retains heartbeat, its unchanged old endpoint, exact-generation reconnect, accepted work, child execution, durable reports, and acknowledgements until its drain settles; retiring/retired mains reject new identities and never grant reconnecting workers readiness. Startup reconnection/adoption is an abnormal crash/legacy-recovery facility, not the normal deploy topology: during ordinary release retirement the old main remains alive and owns its old workers, and they must not reconnect to the new main. A production integration that restarts jobs-main on every deploy and depends on worker adoption is not compliant with the release-generation contract. See [release-generation draining](docs/background-jobs.md#release-generation-draining) and [worker disconnect recovery](docs/background-jobs.md#worker-disconnect-recovery).
2821
2830
 
2822
2831
  Failed jobs are re-queued with backoff and retried up to 10 times by default (10s, 1m, 10m, 1h, then +1h per retry). You can override the retry limit per job:
2823
2832
 
@@ -42,4 +42,26 @@ export default class BackgroundJobsAdapterClient {
42
42
 
43
43
  return await adapter.cancelScheduled(scheduleKey)
44
44
  }
45
+
46
+ /**
47
+ * Reads a stable schedule through the configured adapter.
48
+ * @param {{scheduleKey: string, includeLatestTerminal?: boolean}} args - Lookup request.
49
+ * @returns {Promise<import("./types.js").BackgroundJobScheduledLookupResult>} - Normalized stable schedule jobs.
50
+ */
51
+ async getScheduledJob({scheduleKey, includeLatestTerminal}) {
52
+ const adapter = await this.configuration.acquireReadyBackgroundJobsAdapter()
53
+
54
+ return await adapter.getScheduledJob(scheduleKey, {includeLatestTerminal})
55
+ }
56
+
57
+ /**
58
+ * Wakes a stable schedule through the configured adapter.
59
+ * @param {{scheduleKey: string}} args - Wake request.
60
+ * @returns {Promise<import("./types.js").BackgroundJobWakeResult>} - Wake result.
61
+ */
62
+ async wakeScheduled({scheduleKey}) {
63
+ const adapter = await this.configuration.acquireReadyBackgroundJobsAdapter()
64
+
65
+ return await adapter.wakeScheduled(scheduleKey)
66
+ }
45
67
  }
@@ -93,6 +93,21 @@ export default class BackgroundJobsAdapter {
93
93
  */
94
94
  async cancelScheduled(_scheduleKey) { throw new Error("BackgroundJobsAdapter#cancelScheduled is not implemented") }
95
95
 
96
+ /**
97
+ * Reads current stable ownership and optional terminal history.
98
+ * @param {string} _scheduleKey - Stable schedule key.
99
+ * @param {{includeLatestTerminal?: boolean}} [_options] - Lookup options.
100
+ * @returns {Promise<import("./types.js").BackgroundJobScheduledLookupResult>} - Normalized public jobs.
101
+ */
102
+ async getScheduledJob(_scheduleKey, _options = {}) { throw new Error("BackgroundJobsAdapter#getScheduledJob is not implemented") }
103
+
104
+ /**
105
+ * Expedites a future queued stable owner without changing its identity.
106
+ * @param {string} _scheduleKey - Stable schedule key.
107
+ * @returns {Promise<import("./types.js").BackgroundJobWakeResult>} - Wake result.
108
+ */
109
+ async wakeScheduled(_scheduleKey) { throw new Error("BackgroundJobsAdapter#wakeScheduled is not implemented") }
110
+
96
111
  /**
97
112
  * Finds the next eligible job.
98
113
  * @param {{executionMode?: import("./types.js").BackgroundJobExecutionMode | import("./types.js").BackgroundJobExecutionMode[]}} [_args] - Dequeue filters.
@@ -1,11 +1,82 @@
1
1
  // @ts-check
2
2
 
3
- import timeout from "awaitery/build/timeout.js"
3
+ import timeout, { TimeoutError } from "awaitery/build/timeout.js"
4
4
  import configurationResolver from "../configuration-resolver.js"
5
+ import isPlainObject from "../utils/plain-object.js"
6
+ import BackgroundJobEnqueueAcknowledgementTimeoutError from "./enqueue-acknowledgement-timeout-error.js"
5
7
  import BackgroundJobsSocketRequest from "./socket-request.js"
6
8
  import { DEFAULT_GENERATION_HANDSHAKE_TIMEOUT_MS, validateGenerationHandshakeTimeoutMs } from "./generation-handshake-timeout-error.js"
9
+ import { BACKGROUND_JOB_ACTIVE_STATUSES, BACKGROUND_JOB_EXECUTION_MODES, BACKGROUND_JOB_STATUSES, BACKGROUND_JOB_TERMINAL_STATUSES } from "./job-semantics.js"
7
10
 
8
11
  const DEFAULT_ENQUEUE_TIMEOUT_MS = 5000
12
+ const BACKGROUND_JOB_WAKE_OUTCOMES = ["woken", "already_due", "handed_off", "not_found"]
13
+ const BACKGROUND_JOB_NULLABLE_NUMBER_FIELDS = [
14
+ "attempts",
15
+ "childPid",
16
+ "childReceivedAtMs",
17
+ "childStartedAtMs",
18
+ "completedAtMs",
19
+ "createdAtMs",
20
+ "failedAtMs",
21
+ "handedOffAtMs",
22
+ "maxConcurrency",
23
+ "maxRetries",
24
+ "orphanedAtMs",
25
+ "scheduleOrder",
26
+ "scheduledAtMs",
27
+ "timeoutMs"
28
+ ]
29
+ const BACKGROUND_JOB_NULLABLE_STRING_FIELDS = ["childInstanceId", "concurrencyKey", "handoffId", "lastError", "scheduleKey", "workerId"]
30
+
31
+ /**
32
+ * Checks a required nullable number from a normalized wire row.
33
+ * @param {ReturnType<typeof JSON.parse>} value - Field value.
34
+ * @returns {boolean} - Whether the field is null or a finite number.
35
+ */
36
+ function isNullableBackgroundJobNumber(value) {
37
+ return value === null || (typeof value === "number" && Number.isFinite(value))
38
+ }
39
+
40
+ /**
41
+ * Checks a required nullable string from a normalized wire row.
42
+ * @param {ReturnType<typeof JSON.parse>} value - Field value.
43
+ * @returns {boolean} - Whether the field is null or a string.
44
+ */
45
+ function isNullableBackgroundJobString(value) {
46
+ return value === null || typeof value === "string"
47
+ }
48
+
49
+ /**
50
+ * Checks that a transport job uses the normalized public camel-case shape.
51
+ * @param {ReturnType<typeof JSON.parse>} value - Transport value.
52
+ * @returns {value is import("./types.js").BackgroundJobRow} - Whether normalized.
53
+ */
54
+ function isNormalizedBackgroundJob(value) {
55
+ if (!isPlainObject(value)) return false
56
+
57
+ const job = value
58
+
59
+ return typeof job.id === "string"
60
+ && typeof job.jobName === "string"
61
+ && Array.isArray(job.args)
62
+ && BACKGROUND_JOB_EXECUTION_MODES.some((executionMode) => executionMode === job.executionMode)
63
+ && typeof job.queue === "string"
64
+ && BACKGROUND_JOB_STATUSES.some((status) => status === job.status)
65
+ && BACKGROUND_JOB_NULLABLE_NUMBER_FIELDS.every((field) => isNullableBackgroundJobNumber(job[field]))
66
+ && BACKGROUND_JOB_NULLABLE_STRING_FIELDS.every((field) => isNullableBackgroundJobString(job[field]))
67
+ }
68
+
69
+ /**
70
+ * Describes an unexpected protocol response without echoing its payload.
71
+ * @param {string} operation - Public operation name.
72
+ * @param {import("./types.js").BackgroundJobSocketMessage} message - Response.
73
+ * @returns {Error} - Protocol error.
74
+ */
75
+ function unexpectedResponseError(operation, message) {
76
+ const responseType = message && typeof message.type === "string" ? message.type : "missing type"
77
+
78
+ return new Error(`Unexpected ${operation} response: ${responseType}`)
79
+ }
9
80
 
10
81
  export default class BackgroundJobsClient {
11
82
  /**
@@ -57,17 +128,36 @@ export default class BackgroundJobsClient {
57
128
  ...(producerInvocationId ? {producerInvocationId} : {}),
58
129
  ...(producerProof ? {producerProof} : {})
59
130
  }
60
- const acknowledgement = {explicitlyRejected: false, generationFenced: false, requestSent: false}
131
+ /**
132
+ * Creates safe observations for one attempt without retaining request data.
133
+ * @param {object} args - Attempt identity.
134
+ * @param {"initial" | "owned_replay"} args.attemptKind - Initial attempt or owned replay.
135
+ * @param {number} args.attemptNumber - One-based attempt number.
136
+ * @returns {import("./enqueue-acknowledgement-timeout-error.js").BackgroundJobEnqueueAttempt} - Mutable attempt observations.
137
+ */
138
+ const newAttemptObservation = ({attemptKind, attemptNumber}) => ({
139
+ acknowledgementWaitElapsedMs: 0,
140
+ attemptElapsedMs: 0,
141
+ attemptKind,
142
+ attemptNumber,
143
+ explicitlyRejected: false,
144
+ generationFenced: false,
145
+ requestSent: false
146
+ })
61
147
  /**
62
148
  * Sends one enqueue attempt. An owned caller may replay this exact message
63
149
  * once when transport acknowledgement remains ambiguous after send.
64
- * @param {{explicitlyRejected: boolean, generationFenced: boolean, requestSent: boolean} | undefined} attemptAcknowledgement - First-attempt observations.
150
+ * @param {import("./enqueue-acknowledgement-timeout-error.js").BackgroundJobEnqueueAttempt} attemptObservation - Mutable attempt observations.
151
+ * @param {Readonly<Array<import("./enqueue-acknowledgement-timeout-error.js").BackgroundJobEnqueueAttempt>>} previousAttempts - Earlier timed-out attempts.
65
152
  * @returns {Promise<string>} - Job id.
66
153
  */
67
- const enqueueAttempt = async (attemptAcknowledgement) => {
154
+ const enqueueAttempt = async (attemptObservation, previousAttempts) => {
155
+ const attemptStartedAtMs = Date.now()
68
156
  const request = await this._request()
69
157
  const requestAbortController = new AbortController()
70
158
  const timeoutErrorMessage = `Background job enqueue acknowledgement timed out after ${this.enqueueTimeoutMs}ms`
159
+ /** @type {number | undefined} */
160
+ let requestSentAtMs
71
161
  /**
72
162
  * Resolves the pre-send phase when the mutation has entered the socket.
73
163
  * @type {() => void}
@@ -99,10 +189,9 @@ export default class BackgroundJobsClient {
99
189
  signal: requestAbortController.signal,
100
190
  onConnect: (jsonSocket) => {
101
191
  jsonSocket.send(message)
102
- if (attemptAcknowledgement) {
103
- attemptAcknowledgement.generationFenced = Boolean(request.generationId)
104
- attemptAcknowledgement.requestSent = true
105
- }
192
+ attemptObservation.generationFenced = Boolean(request.generationId)
193
+ attemptObservation.requestSent = true
194
+ requestSentAtMs = Date.now()
106
195
  markRequestSent()
107
196
  },
108
197
  onMessage: ({message, resolve, reject}) => {
@@ -112,7 +201,7 @@ export default class BackgroundJobsClient {
112
201
  }
113
202
 
114
203
  if (message?.type === "enqueue-error") {
115
- if (attemptAcknowledgement) attemptAcknowledgement.explicitlyRejected = true
204
+ attemptObservation.explicitlyRejected = true
116
205
  reject(new Error(message.error || "Failed to enqueue job"))
117
206
  }
118
207
  }
@@ -120,16 +209,43 @@ export default class BackgroundJobsClient {
120
209
 
121
210
  await withEnqueueTimeout(async () => await Promise.race([requestSent, requestPromise]))
122
211
 
123
- return await withEnqueueTimeout(async () => await requestPromise)
212
+ try {
213
+ return await withEnqueueTimeout(async () => await requestPromise)
214
+ } catch (error) {
215
+ if (!(error instanceof TimeoutError)) throw error
216
+ if (requestSentAtMs === undefined) throw new Error("Background job enqueue acknowledgement wait started before the request was sent", {cause: error})
217
+
218
+ const timedOutAtMs = Date.now()
219
+ // The fired timer proves its logical deadline even when the adjustable wall clock reports a shorter interval.
220
+ const acknowledgementWaitElapsedMs = Math.max(this.enqueueTimeoutMs, timedOutAtMs - requestSentAtMs)
221
+
222
+ attemptObservation.acknowledgementWaitElapsedMs = acknowledgementWaitElapsedMs
223
+ attemptObservation.attemptElapsedMs = Math.max(acknowledgementWaitElapsedMs, timedOutAtMs - attemptStartedAtMs)
224
+
225
+ throw new BackgroundJobEnqueueAcknowledgementTimeoutError({
226
+ acknowledgementTimeoutMs: this.enqueueTimeoutMs,
227
+ attemptHistory: [...previousAttempts, attemptObservation],
228
+ cause: error,
229
+ generationId: request.generationId,
230
+ jobName,
231
+ producerInvocationId,
232
+ producerProofPresent: Boolean(producerProof)
233
+ })
234
+ }
124
235
  }
125
236
 
237
+ const initialAttempt = newAttemptObservation({attemptKind: "initial", attemptNumber: 1})
238
+
126
239
  try {
127
- return await enqueueAttempt(acknowledgement)
240
+ return await enqueueAttempt(initialAttempt, [])
128
241
  } catch (error) {
129
- if (!producerInvocationId || !producerProof || !acknowledgement.generationFenced || !acknowledgement.requestSent || acknowledgement.explicitlyRejected) throw error
130
- }
242
+ if (!producerInvocationId || !producerProof || !initialAttempt.generationFenced || !initialAttempt.requestSent || initialAttempt.explicitlyRejected) throw error
243
+
244
+ const previousAttempts = error instanceof BackgroundJobEnqueueAcknowledgementTimeoutError ? error.attemptHistory : []
245
+ const replayAttempt = newAttemptObservation({attemptKind: "owned_replay", attemptNumber: 2})
131
246
 
132
- return await enqueueAttempt(undefined)
247
+ return await enqueueAttempt(replayAttempt, previousAttempts)
248
+ }
133
249
  }
134
250
 
135
251
  /**
@@ -190,4 +306,80 @@ export default class BackgroundJobsClient {
190
306
  }
191
307
  })
192
308
  }
309
+
310
+ /**
311
+ * Reads current stable ownership and optional terminal history.
312
+ * @param {{scheduleKey: string, includeLatestTerminal?: boolean}} args - Lookup request.
313
+ * @returns {Promise<import("./types.js").BackgroundJobScheduledLookupResult>} - Normalized stable schedule jobs.
314
+ */
315
+ async getScheduledJob({scheduleKey, includeLatestTerminal}) {
316
+ const request = await this._request()
317
+
318
+ return await request.run({
319
+ onConnect: (jsonSocket) => {
320
+ jsonSocket.send({type: "get-scheduled-job", scheduleKey, includeLatestTerminal})
321
+ },
322
+ onMessage: ({message, resolve, reject}) => {
323
+ if (message?.type === "scheduled-job") {
324
+ const {currentJob, latestTerminalJob} = message
325
+ const currentJobValid = currentJob === null
326
+ || (isNormalizedBackgroundJob(currentJob) && BACKGROUND_JOB_ACTIVE_STATUSES.some((status) => status === currentJob.status))
327
+ const latestTerminalJobValid = latestTerminalJob === null
328
+ || (isNormalizedBackgroundJob(latestTerminalJob) && BACKGROUND_JOB_TERMINAL_STATUSES.some((status) => status === latestTerminalJob.status))
329
+
330
+ if (!currentJobValid || !latestTerminalJobValid) {
331
+ reject(new Error("Invalid getScheduledJob response: expected normalized public job values"))
332
+ return
333
+ }
334
+
335
+ resolve({currentJob, latestTerminalJob})
336
+ return
337
+ }
338
+
339
+ if (message?.type === "get-scheduled-job-error") {
340
+ reject(new Error(message.error || "Failed to read scheduled job"))
341
+ return
342
+ }
343
+
344
+ reject(unexpectedResponseError("getScheduledJob", message))
345
+ }
346
+ })
347
+ }
348
+
349
+ /**
350
+ * Expedites a future queued stable owner without changing job identity.
351
+ * @param {{scheduleKey: string}} args - Wake request.
352
+ * @returns {Promise<import("./types.js").BackgroundJobWakeResult>} - Wake result.
353
+ */
354
+ async wakeScheduled({scheduleKey}) {
355
+ const request = await this._request()
356
+
357
+ return await request.run({
358
+ onConnect: (jsonSocket) => {
359
+ jsonSocket.send({type: "wake-scheduled", scheduleKey})
360
+ },
361
+ onMessage: ({message, resolve, reject}) => {
362
+ if (message?.type === "schedule-woken") {
363
+ const outcome = message.outcome
364
+ const knownOutcome = BACKGROUND_JOB_WAKE_OUTCOMES.includes(outcome)
365
+ const validJobId = outcome === "not_found" ? message.jobId === null : typeof message.jobId === "string" && message.jobId.length > 0
366
+
367
+ if (!knownOutcome || !validJobId) {
368
+ reject(new Error("Invalid wakeScheduled response"))
369
+ return
370
+ }
371
+
372
+ resolve({jobId: message.jobId, outcome})
373
+ return
374
+ }
375
+
376
+ if (message?.type === "wake-scheduled-error") {
377
+ reject(new Error(message.error || "Failed to wake scheduled job"))
378
+ return
379
+ }
380
+
381
+ reject(unexpectedResponseError("wakeScheduled", message))
382
+ }
383
+ })
384
+ }
193
385
  }
@@ -0,0 +1,50 @@
1
+ // @ts-check
2
+
3
+ import {TimeoutError} from "awaitery/build/timeout.js"
4
+
5
+ /**
6
+ * @typedef {object} BackgroundJobEnqueueAttempt
7
+ * @property {number} acknowledgementWaitElapsedMs - Time spent waiting for the acknowledgement after send.
8
+ * @property {number} attemptElapsedMs - Total attempt time including connection and generation fencing.
9
+ * @property {"initial" | "owned_replay"} attemptKind - Initial attempt or the one eligible owned replay.
10
+ * @property {number} attemptNumber - One-based attempt number.
11
+ * @property {boolean} explicitlyRejected - Whether the main explicitly rejected the enqueue.
12
+ * @property {boolean} generationFenced - Whether the configured generation accepted the connection before send.
13
+ * @property {boolean} requestSent - Whether the enqueue request entered the socket.
14
+ */
15
+
16
+ /** Safe typed failure for an ambiguous post-send enqueue acknowledgement timeout. */
17
+ export default class BackgroundJobEnqueueAcknowledgementTimeoutError extends TimeoutError {
18
+ /**
19
+ * Builds an enqueue acknowledgement timeout error without request payload or connection details.
20
+ * @param {object} args - Safe timeout context.
21
+ * @param {number} args.acknowledgementTimeoutMs - Configured post-send acknowledgement deadline.
22
+ * @param {Array<BackgroundJobEnqueueAttempt>} args.attemptHistory - Safe observations for timed-out attempts.
23
+ * @param {TimeoutError} [args.cause] - Original Awaitery timeout.
24
+ * @param {string} [args.generationId] - Accepted release generation identity.
25
+ * @param {string} args.jobName - Resolved job class name.
26
+ * @param {string} [args.producerInvocationId] - Owned enqueue invocation identity.
27
+ * @param {boolean} args.producerProofPresent - Whether the request carried an internal producer proof.
28
+ */
29
+ constructor({acknowledgementTimeoutMs, attemptHistory, cause, generationId, jobName, producerInvocationId, producerProofPresent}) {
30
+ super(`Background job enqueue acknowledgement timed out after ${acknowledgementTimeoutMs}ms`, cause ? {cause} : undefined)
31
+
32
+ this.name = "BackgroundJobEnqueueAcknowledgementTimeoutError"
33
+ /** @type {"BACKGROUND_JOB_ENQUEUE_ACKNOWLEDGEMENT_TIMEOUT"} */
34
+ this.code = "BACKGROUND_JOB_ENQUEUE_ACKNOWLEDGEMENT_TIMEOUT"
35
+ this.acknowledgementTimeoutMs = acknowledgementTimeoutMs
36
+ this.attemptHistory = Object.freeze(attemptHistory.map((attempt) => Object.freeze({
37
+ acknowledgementWaitElapsedMs: attempt.acknowledgementWaitElapsedMs,
38
+ attemptElapsedMs: attempt.attemptElapsedMs,
39
+ attemptKind: attempt.attemptKind,
40
+ attemptNumber: attempt.attemptNumber,
41
+ explicitlyRejected: attempt.explicitlyRejected,
42
+ generationFenced: attempt.generationFenced,
43
+ requestSent: attempt.requestSent
44
+ })))
45
+ this.generationId = generationId
46
+ this.jobName = jobName
47
+ this.producerInvocationId = producerInvocationId
48
+ this.producerProofPresent = producerProofPresent
49
+ }
50
+ }
@@ -8,6 +8,36 @@ export const DEFAULT_BACKGROUND_JOB_QUEUE = "default"
8
8
  export const QUEUE_CONCURRENCY_KEY_PREFIX = "queue:"
9
9
  /** @type {import("./types.js").BackgroundJobExecutionMode[]} */
10
10
  export const BACKGROUND_JOB_EXECUTION_MODES = ["inline", "forked", "pooled", "spawned"]
11
+ /** @type {import("./types.js").BackgroundJobActiveStatus[]} */
12
+ export const BACKGROUND_JOB_ACTIVE_STATUSES = ["queued", "handed_off"]
13
+ /** @type {import("./types.js").BackgroundJobTerminalStatus[]} */
14
+ export const BACKGROUND_JOB_TERMINAL_STATUSES = ["cancelled", "completed", "failed", "orphaned"]
15
+ /** @type {import("./types.js").BackgroundJobStatus[]} */
16
+ export const BACKGROUND_JOB_STATUSES = [...BACKGROUND_JOB_ACTIVE_STATUSES, ...BACKGROUND_JOB_TERMINAL_STATUSES]
17
+
18
+ /**
19
+ * Normalizes persisted or transported job status vocabulary.
20
+ * @param {string} value - Candidate status.
21
+ * @returns {import("./types.js").BackgroundJobStatus} - Known status.
22
+ */
23
+ export function normalizeBackgroundJobStatus(value) {
24
+ const status = BACKGROUND_JOB_STATUSES.find((candidate) => candidate === value)
25
+
26
+ if (status) return status
27
+
28
+ throw new Error(`Unknown background job status: ${value}`)
29
+ }
30
+
31
+ /**
32
+ * Validates a stable logical schedule key at every persistence boundary.
33
+ * @param {string} scheduleKey - Stable schedule key.
34
+ * @returns {string} - Validated key.
35
+ */
36
+ export function normalizeBackgroundJobScheduleKey(scheduleKey) {
37
+ if (typeof scheduleKey === "string" && scheduleKey.length > 0 && scheduleKey.length <= 255) return scheduleKey
38
+
39
+ throw VelociousError.safe("background job scheduleKey must be a non-empty string of at most 255 characters")
40
+ }
11
41
 
12
42
  /**
13
43
  * Normalizes a job queue.
@@ -8,7 +8,9 @@ import PlatformVelociousJob from "./platform-job.js"
8
8
  import {
9
9
  cancelScheduledBackgroundJobForConfiguration,
10
10
  enqueueBackgroundJobForConfiguration,
11
- replaceScheduledBackgroundJobForConfiguration
11
+ getScheduledBackgroundJobForConfiguration,
12
+ replaceScheduledBackgroundJobForConfiguration,
13
+ wakeScheduledBackgroundJobForConfiguration
12
14
  } from "./runtime.js"
13
15
 
14
16
  /**
@@ -84,4 +86,27 @@ export default class VelociousJob extends PlatformVelociousJob {
84
86
 
85
87
  return await cancelScheduledBackgroundJobForConfiguration({configuration, scheduleKey})
86
88
  }
89
+
90
+ /**
91
+ * Reads current ownership and optional terminal history for a stable key.
92
+ * @param {string} scheduleKey - Stable logical schedule key.
93
+ * @param {{includeLatestTerminal?: boolean}} [options] - Lookup options.
94
+ * @returns {Promise<import("./types.js").BackgroundJobScheduledLookupResult>} - Normalized stable schedule jobs.
95
+ */
96
+ static async getScheduledJob(scheduleKey, options = {}) {
97
+ const configuration = await configurationResolver()
98
+
99
+ return await getScheduledBackgroundJobForConfiguration({configuration, scheduleKey, ...options})
100
+ }
101
+
102
+ /**
103
+ * Expedites a future queued owner without creating another job.
104
+ * @param {string} scheduleKey - Stable logical schedule key.
105
+ * @returns {Promise<import("./types.js").BackgroundJobWakeResult>} - Wake result.
106
+ */
107
+ static async wakeScheduled(scheduleKey) {
108
+ const configuration = await configurationResolver()
109
+
110
+ return await wakeScheduledBackgroundJobForConfiguration({configuration, scheduleKey})
111
+ }
87
112
  }
@@ -67,18 +67,46 @@ export default class LocalBackgroundJobsAdapter extends BackgroundJobsAdapter {
67
67
  }
68
68
 
69
69
  /**
70
- * Rejects stable-key cancellation, which is outside the local adapter contract.
71
- * @param {string} _scheduleKey - Unsupported stable key.
72
- * @returns {Promise<import("./types.js").BackgroundJobCancellationResult>} - Never resolves.
70
+ * Cancels or detaches the current owner of a stable schedule key.
71
+ * @param {string} scheduleKey - Stable schedule key.
72
+ * @returns {Promise<import("./types.js").BackgroundJobCancellationResult>} - Cancellation result.
73
73
  */
74
- async cancelScheduled(_scheduleKey) { throw new Error("cancelScheduled is not supported by the local background-jobs adapter") }
74
+ async cancelScheduled(scheduleKey) {
75
+ await this.ensureReady()
76
+ return await this.store.cancelScheduled(scheduleKey)
77
+ }
78
+
79
+ /**
80
+ * Replaces the current owner of a stable schedule key.
81
+ * @param {{scheduleKey: string, jobName: string, args: Array<ReturnType<typeof JSON.parse>>, options?: import("./types.js").BackgroundJobOptions}} args - Replacement request.
82
+ * @returns {Promise<import("./types.js").BackgroundJobReplacementResult>} - Replacement result.
83
+ */
84
+ async replaceScheduled(args) {
85
+ await this.ensureReady()
86
+ this.registry.resolve(args.jobName)
87
+ return await this.store.replaceScheduled(args)
88
+ }
75
89
 
76
90
  /**
77
- * Rejects stable-key replacement, which is outside the local adapter contract.
78
- * @param {{scheduleKey: string, jobName: string, args: Array<ReturnType<typeof JSON.parse>>, options?: import("./types.js").BackgroundJobOptions}} _args - Unsupported request.
79
- * @returns {Promise<import("./types.js").BackgroundJobReplacementResult>} - Never resolves.
91
+ * Reads stable ownership and optional terminal history.
92
+ * @param {string} scheduleKey - Stable schedule key.
93
+ * @param {{includeLatestTerminal?: boolean}} [options] - Lookup options.
94
+ * @returns {Promise<import("./types.js").BackgroundJobScheduledLookupResult>} - Normalized local jobs.
80
95
  */
81
- async replaceScheduled(_args) { throw new Error("replaceScheduled is not supported by the local background-jobs adapter") }
96
+ async getScheduledJob(scheduleKey, options) {
97
+ await this.ensureReady()
98
+ return await this.store.getScheduledJob(scheduleKey, options)
99
+ }
100
+
101
+ /**
102
+ * Makes a future queued stable owner due without replacing it.
103
+ * @param {string} scheduleKey - Stable schedule key.
104
+ * @returns {Promise<import("./types.js").BackgroundJobWakeResult>} - Exact wake result.
105
+ */
106
+ async wakeScheduled(scheduleKey) {
107
+ await this.ensureReady()
108
+ return await this.store.wakeScheduled(scheduleKey)
109
+ }
82
110
 
83
111
  /**
84
112
  * Finds the next eligible local job.