velocious 1.0.637 → 1.0.639

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +6 -3
  2. package/build/background-jobs/main.js +4 -2
  3. package/build/background-jobs/status-reporter.js +7 -4
  4. package/build/background-jobs/types.js +35 -1
  5. package/build/background-jobs/worker.js +104 -10
  6. package/build/frontend-models/base.js +61 -28
  7. package/build/frontend-models/query.js +20 -5
  8. package/build/frontend-models/remote-request-context.js +13 -1
  9. package/build/frontend-models/use-destroyed-event.js +8 -7
  10. package/build/frontend-models/use-model-class-event.js +6 -5
  11. package/build/frontend-models/use-updated-event.js +8 -7
  12. package/build/src/background-jobs/main.d.ts +3 -2
  13. package/build/src/background-jobs/main.d.ts.map +1 -1
  14. package/build/src/background-jobs/main.js +5 -3
  15. package/build/src/background-jobs/status-reporter.d.ts +6 -2
  16. package/build/src/background-jobs/status-reporter.d.ts.map +1 -1
  17. package/build/src/background-jobs/status-reporter.js +8 -5
  18. package/build/src/background-jobs/types.d.ts +136 -1
  19. package/build/src/background-jobs/types.d.ts.map +1 -1
  20. package/build/src/background-jobs/types.js +36 -2
  21. package/build/src/background-jobs/worker.d.ts +94 -21
  22. package/build/src/background-jobs/worker.d.ts.map +1 -1
  23. package/build/src/background-jobs/worker.js +101 -11
  24. package/build/src/frontend-models/base.d.ts.map +1 -1
  25. package/build/src/frontend-models/base.js +57 -27
  26. package/build/src/frontend-models/query.d.ts +28 -7
  27. package/build/src/frontend-models/query.d.ts.map +1 -1
  28. package/build/src/frontend-models/query.js +20 -6
  29. package/build/src/frontend-models/remote-request-context.d.ts +6 -0
  30. package/build/src/frontend-models/remote-request-context.d.ts.map +1 -1
  31. package/build/src/frontend-models/remote-request-context.js +13 -2
  32. package/build/src/frontend-models/use-destroyed-event.d.ts.map +1 -1
  33. package/build/src/frontend-models/use-destroyed-event.js +9 -8
  34. package/build/src/frontend-models/use-model-class-event.d.ts.map +1 -1
  35. package/build/src/frontend-models/use-model-class-event.js +7 -6
  36. package/build/src/frontend-models/use-updated-event.d.ts.map +1 -1
  37. package/build/src/frontend-models/use-updated-event.js +9 -8
  38. package/build/src/testing/browser-frontend-model-event-hook-scenarios.d.ts +6 -0
  39. package/build/src/testing/browser-frontend-model-event-hook-scenarios.d.ts.map +1 -1
  40. package/build/src/testing/browser-frontend-model-event-hook-scenarios.js +72 -1
  41. package/build/testing/browser-frontend-model-event-hook-scenarios.js +88 -0
  42. package/package.json +1 -1
  43. package/src/background-jobs/main.js +4 -2
  44. package/src/background-jobs/status-reporter.js +7 -4
  45. package/src/background-jobs/types.js +35 -1
  46. package/src/background-jobs/worker.js +104 -10
  47. package/src/frontend-models/base.js +61 -28
  48. package/src/frontend-models/query.js +20 -5
  49. package/src/frontend-models/remote-request-context.js +13 -1
  50. package/src/frontend-models/use-destroyed-event.js +8 -7
  51. package/src/frontend-models/use-model-class-event.js +6 -5
  52. package/src/frontend-models/use-updated-event.js +8 -7
  53. package/src/testing/browser-frontend-model-event-hook-scenarios.js +88 -0
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  * External packages (engines) that contribute data models, frontend-model resources and migrations to a consuming app (see [docs/packages.md](docs/packages.md))
14
14
  * Optional Rampway-owned durable deployment control plane mounted through the standard routes DSL on Velocious 1.0.577 or newer (see [docs/rampway-integration.md](docs/rampway-integration.md))
15
15
  * Controllers and views for HTTP endpoints
16
- * Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses, immutable per-operation remote request context, and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/websocket-channels.md](docs/websocket-channels.md))
16
+ * Frontend-model transport for creating, updating, querying, and subscribing to query-filtered lifecycle events over HTTP/WebSocket, with structured per-attribute validation error responses, immutable per-operation remote request context, registration-local tenant subscription partitioning, and one-budget WebSocket startup controls (see [docs/frontend-models.md](docs/frontend-models.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/websocket-channels.md](docs/websocket-channels.md))
17
17
  * Client-side offline sync mutation logs and frontend-model optimistic queueing primitives (see the [shared-resource sync developer guide](docs/shared-resource-sync-guide.md) and [offline sync architecture](docs/offline-sync.md))
18
18
  * Declarative client sync scopes with per-scope cursors, automatic mutation tracking, opt-in durable base-version conflict replay, realtime delivery, and immutable-handle project clients whose local database state plus remote pull/replay/realtime request context stay tenant-bound through reconnect (see [docs/sync-client.md](docs/sync-client.md), [docs/remote-request-context.md](docs/remote-request-context.md), and [docs/offline-sync.md](docs/offline-sync.md))
19
19
  * Reactive `useLiveQuery(Model.where(...))` queries for default databases plus immutable-handle tenant live-query sources whose committed events and refreshes stay on the captured physical tenant (see [docs/live-queries.md](docs/live-queries.md))
@@ -674,10 +674,13 @@ import useModelClassEvent from "velocious/build/src/frontend-models/use-model-cl
674
674
 
675
675
  useModelClassEvent(Subscription, ["create", "update"], () => {
676
676
  void loadSubscriptionStatus()
677
+ }, {
678
+ query: Subscription.where({workspaceId}),
679
+ requestContext: {workspaceId}
677
680
  })
678
681
  ```
679
682
 
680
- `useCreatedEvent`, `useUpdatedEvent`, and `useDestroyedEvent` are also available. `useUpdatedEvent` and `useDestroyedEvent` accept either a model class or model instance. Lifecycle subscriptions accept the same projection options as frontend-model queries for event records, including `select`, `preload`, `withCount`, `abilities`, and `queryData`.
683
+ `useCreatedEvent`, `useUpdatedEvent`, and `useDestroyedEvent` are also available. `useUpdatedEvent` and `useDestroyedEvent` accept either a model class or model instance. Lifecycle subscriptions accept the same projection options as frontend-model queries for event records, including `select`, `preload`, `withCount`, `abilities`, and `queryData`. Pass a registration-local `requestContext` when several tenant routes for the same model can be mounted concurrently. Omitting it inherits the configured transport context; passing `{}` explicitly replaces that context with an unscoped registration. Velocious captures it immutably, sends it to the tenant resolver, and partitions server subscriptions by its value: equal contexts retain multiplexing, while distinct contexts never share an event-filter request. The backend must still authorize the resolved tenant; request context is not proof of access.
681
684
 
682
685
  Frontend-model `group(...)` is attribute/path based and does not accept raw SQL fragments. Use model/relationship shapes (for example `Task.group({project: {account: ["id"]}})`) so grouping resolves through known relationships and mapped columns.
683
686
  Frontend-model `where(...)` supports nested relationship descriptors (for example `Task.where({project: {creatingUser: {reference: "owner-b"}}})`) and does not accept raw SQL fragments.
@@ -2392,7 +2395,7 @@ candidate without waiting for retired work to finish.
2392
2395
 
2393
2396
  Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options, or by deriving the key in a hydrated job instance's non-static `concurrencyKey()` method. Explicit enqueue options win. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).
2394
2397
 
2395
- Production apps can listen for `background-job-failed` (or its `all-error` mirror) to report accepted failed attempts, including retry and terminal-state metadata, and for `background-job-orphaned` to react to a specific job the main process reclaimed after its worker died mid-run — e.g. enqueue a targeted recovery for the work it left behind, instead of only polling for the aftermath. Orphan handlers run before the sweep waits for reclaimed jobs to be dispatched, so a stalled dispatcher does not delay application recovery. See [docs/background-jobs.md](docs/background-jobs.md#failure-events).
2398
+ Production apps can listen for `background-job-failed` (or its `all-error` mirror) to report accepted failed attempts, including retry and terminal-state metadata. Process-level pooled-runner failures also carry one shared `context.runnerFailure` snapshot for every affected job, with active handoff identities, runner/worker lifecycle and PIDs, exit code/signal, termination reason, and an explicit nullable OOM verdict. Listen for `background-job-orphaned` to react to a specific job the main process reclaimed after its worker died mid-run — e.g. enqueue a targeted recovery for the work it left behind, instead of only polling for the aftermath. Orphan handlers run before the sweep waits for reclaimed jobs to be dispatched, so a stalled dispatcher does not delay application recovery. See [docs/background-jobs.md](docs/background-jobs.md#failure-events).
2396
2399
 
2397
2400
  ## Setup
2398
2401
 
@@ -1668,6 +1668,7 @@ export default class BackgroundJobsMain {
1668
1668
  handoffId: message.handoffId,
1669
1669
  handedOffAtMs: message.handedOffAtMs,
1670
1670
  job: failedJob,
1671
+ runnerFailure: message.runnerFailure,
1671
1672
  workerId: message.workerId
1672
1673
  })
1673
1674
  }
@@ -1686,10 +1687,10 @@ export default class BackgroundJobsMain {
1686
1687
 
1687
1688
  /**
1688
1689
  * Runs emit background job failed.
1689
- * @param {{error: ReturnType<typeof JSON.parse>, handoffId?: string, handedOffAtMs?: number, job: import("./types.js").BackgroundJobRow, workerId?: string}} args - Failure event data.
1690
+ * @param {{error: ReturnType<typeof JSON.parse>, handoffId?: string, handedOffAtMs?: number, job: import("./types.js").BackgroundJobRow, runnerFailure?: import("./types.js").PooledRunnerFailure, workerId?: string}} args - Failure event data.
1690
1691
  * @returns {void}
1691
1692
  */
1692
- _emitBackgroundJobFailed({error, handoffId, handedOffAtMs, job, workerId}) {
1693
+ _emitBackgroundJobFailed({error, handoffId, handedOffAtMs, job, runnerFailure, workerId}) {
1693
1694
  const normalizedError = this._normalizeFailureError(error)
1694
1695
  const payload = {
1695
1696
  context: {
@@ -1700,6 +1701,7 @@ export default class BackgroundJobsMain {
1700
1701
  jobId: job.id,
1701
1702
  jobName: job.jobName,
1702
1703
  maxRetries: job.maxRetries,
1704
+ runnerFailure,
1703
1705
  stage: "background-job-failed",
1704
1706
  status: job.status,
1705
1707
  terminal: job.status === "failed" || job.status === "orphaned",
@@ -47,9 +47,10 @@ export default class BackgroundJobsStatusReporter {
47
47
  * @param {string} [args.handoffId] - Handoff lease id.
48
48
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
49
49
  * @param {string} [args.workerId] - Worker id.
50
+ * @param {import("./types.js").PooledRunnerFailure} [args.runnerFailure] - Pooled-child process failure provenance.
50
51
  * @returns {Promise<void>} - Resolves when reported.
51
52
  */
52
- async report({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}) {
53
+ async report({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, runnerFailure}) {
53
54
  const config = this.configuration.getBackgroundJobsConfig()
54
55
  const host = this.host || config.host
55
56
  const port = typeof this.port === "number" ? this.port : config.port
@@ -73,7 +74,8 @@ export default class BackgroundJobsStatusReporter {
73
74
  handoffId,
74
75
  workerId,
75
76
  handedOffAtMs,
76
- error: error ? normalizeBackgroundJobError(error) : undefined
77
+ error: error ? normalizeBackgroundJobError(error) : undefined,
78
+ runnerFailure
77
79
  })
78
80
  },
79
81
  onMessage: ({message, resolve, reject}) => {
@@ -100,17 +102,18 @@ export default class BackgroundJobsStatusReporter {
100
102
  * @param {string} [args.handoffId] - Handoff lease id.
101
103
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
102
104
  * @param {string} [args.workerId] - Worker id.
105
+ * @param {import("./types.js").PooledRunnerFailure} [args.runnerFailure] - Pooled-child process failure provenance.
103
106
  * @param {number} [args.maxDurationMs] - Max duration for retries.
104
107
  * @param {boolean} [args.retryPersistErrors] - Retry a `BackgroundJobUpdateError` (main's `job-update-error`, i.e. a transient DB failure while persisting the terminal status) instead of throwing immediately. Off by default so short-lived forked/spawned runners keep failing loudly and exit non-zero to be reclaimed; on for the long-lived worker, which cannot exit-to-reclaim and would otherwise strand the job in `handed_off`.
105
108
  * @returns {Promise<void>} - Resolves when reported.
106
109
  */
107
- async reportWithRetry({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, maxDurationMs, retryPersistErrors = false}) {
110
+ async reportWithRetry({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, runnerFailure, maxDurationMs, retryPersistErrors = false}) {
108
111
  let attempt = 0
109
112
  const startTime = Date.now()
110
113
 
111
114
  while (true) {
112
115
  try {
113
- await this.report({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId})
116
+ await this.report({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, runnerFailure})
114
117
  return
115
118
  } catch (error) {
116
119
  // A `BackgroundJobUpdateError` means main answered `job-update-error`, which it
@@ -6,6 +6,39 @@
6
6
  /** @typedef {"candidate" | "active" | "retired"} BackgroundJobsGenerationInitialState */
7
7
  /** @typedef {"starting" | "candidate" | "active" | "retiring" | "retired" | "stopped"} BackgroundJobsGenerationLifecycleState */
8
8
  /** @typedef {"missing-generation" | "unexpected-generation" | "malformed-generation" | "generation-mismatch" | "worker-admission-retired" | "worker-has-no-recoverable-handoffs"} BackgroundJobsGenerationRejectionReason */
9
+ /** @typedef {"exit" | "process-error" | "ipc-send"} PooledRunnerFailureOrigin */
10
+ /** @typedef {"starting" | "running" | "retiring"} PooledRunnerLifecycleState */
11
+ /** @typedef {"unexpected" | "job-timeout" | "worker-shutdown-timeout"} PooledRunnerTerminationReason */
12
+ /** @typedef {"running" | "retiring" | "stopping"} BackgroundJobsWorkerLifecycleState */
13
+ /**
14
+ * @typedef {object} PooledRunnerActiveJob
15
+ * @property {string | null} handoffId - Durable handoff lease id.
16
+ * @property {number | null} handedOffAtMs - Durable handoff timestamp.
17
+ * @property {string} jobId - Durable background job id.
18
+ * @property {string} jobName - Registered job class name.
19
+ * @property {string} workerId - Worker identity persisted with the handoff.
20
+ */
21
+ /**
22
+ * One process-failure snapshot shared by every job lost with a pooled child.
23
+ * @typedef {object} PooledRunnerFailure
24
+ * @property {PooledRunnerActiveJob[]} activeJobs - Jobs that were in flight when the child failed, ordered by job id.
25
+ * @property {number | null} exitCode - Child exit code, or null for signal/process errors.
26
+ * @property {string | null} generationId - Release generation identity, or null in legacy mode.
27
+ * @property {boolean | null} oomKilled - False when the observed exit rules OOM out; null when an unexpected SIGKILL cannot be distinguished from an OOM kill without supervisor/kernel evidence.
28
+ * @property {PooledRunnerFailureOrigin} origin - Worker observation that initiated failure handling.
29
+ * @property {number} runnerAgeMs - Child age when failure handling started.
30
+ * @property {number} runnerCreatedAtMs - Child creation timestamp.
31
+ * @property {boolean} runnerDetached - Whether the runner owned a detached process group.
32
+ * @property {number} runnerJobsRun - Previously acknowledged jobs handled by the child.
33
+ * @property {PooledRunnerLifecycleState} runnerLifecycle - Child lifecycle immediately before recovery.
34
+ * @property {number | null} runnerPid - Child process id when available.
35
+ * @property {import("node:child_process").ChildProcess["signalCode"]} signal - Child termination signal when available.
36
+ * @property {PooledRunnerTerminationReason} terminationReason - Why the worker expected or did not expect termination.
37
+ * @property {string | null} timeoutJobId - Job whose timeout initiated child termination, or null.
38
+ * @property {string} workerId - Stable generation-qualified worker id.
39
+ * @property {BackgroundJobsWorkerLifecycleState} workerLifecycle - Parent worker lifecycle immediately before recovery.
40
+ * @property {number} workerPid - Parent worker process id.
41
+ */
9
42
  /**
10
43
  * @typedef {object} LocalBackgroundJobsClock
11
44
  * @property {() => number} now - Current epoch milliseconds.
@@ -140,6 +173,7 @@
140
173
  * @property {string | undefined} handoffId - Handoff lease id from the worker report.
141
174
  * @property {number | undefined} handedOffAtMs - Handoff timestamp from the worker report.
142
175
  * @property {string | undefined} workerId - Worker id from the worker report.
176
+ * @property {PooledRunnerFailure | undefined} runnerFailure - Shared pooled-child process failure provenance.
143
177
  */
144
178
  /**
145
179
  * @typedef {"worker" | "client" | "reporter"} BackgroundJobSocketRole
@@ -162,7 +196,7 @@
162
196
  * @typedef {{type: "cancel-scheduled-error", error?: string}} BackgroundJobCancelScheduledErrorMessage
163
197
  * @typedef {{type: "job", payload: BackgroundJobPayload}} BackgroundJobJobMessage
164
198
  * @typedef {{type: "job-complete", jobId: string, handoffId?: string, workerId?: string, handedOffAtMs?: number}} BackgroundJobCompleteMessage
165
- * @typedef {{type: "job-failed", jobId: string, error?: ReturnType<typeof JSON.parse>, handoffId?: string, workerId?: string, handedOffAtMs?: number}} BackgroundJobFailedMessage
199
+ * @typedef {{type: "job-failed", jobId: string, error?: ReturnType<typeof JSON.parse>, handoffId?: string, workerId?: string, handedOffAtMs?: number, runnerFailure?: PooledRunnerFailure}} BackgroundJobFailedMessage
166
200
  * @typedef {{type: "job-reschedule", jobId: string, delayMs: number, handoffId?: string, workerId?: string, handedOffAtMs?: number}} BackgroundJobRescheduleMessage
167
201
  * @typedef {{type: "job-updated", jobId: string}} BackgroundJobUpdatedMessage
168
202
  * @typedef {{type: "job-update-error", jobId: string, error?: string}} BackgroundJobUpdateErrorMessage
@@ -22,6 +22,26 @@ import BackgroundJobsGenerationHandshakeTimeoutError, { DEFAULT_GENERATION_HANDS
22
22
  * @property {ReturnType<typeof setTimeout> | null} timer - The pending timeout timer, cleared on exit.
23
23
  * @property {ReturnType<typeof setTimeout> | null} sigkillTimer - The pending SIGKILL grace timer, cleared on exit.
24
24
  */
25
+ /**
26
+ * @typedef {object} PooledJobEntry
27
+ * @property {import("./types.js").BackgroundJobPayload & {id: string}} payload - Durable job payload.
28
+ * @property {(value: void) => void} [resolve] - Completion resolver.
29
+ * @property {Promise<void>} [pooledJob] - Tracked pooled-job promise.
30
+ * @property {ReturnType<typeof setTimeout> | null} [timeoutTimer] - Per-job timeout timer.
31
+ */
32
+ /**
33
+ * @typedef {object} PooledChildState
34
+ * @property {number} createdAtMs - Child creation timestamp.
35
+ * @property {number} jobsRun - Acknowledged jobs completed by this child.
36
+ * @property {Map<string, PooledJobEntry>} inflight - Jobs currently owned by this child.
37
+ * @property {number} lastDispatchSeq - Round-robin dispatch sequence.
38
+ * @property {boolean} retiring - Whether this child is draining before retirement.
39
+ * @property {boolean} [started] - Whether the child completed its startup handshake.
40
+ * @property {boolean} [settling] - Whether failure handling already owns this child.
41
+ * @property {ReturnType<typeof setTimeout> | null} [timeoutSigkillTimer] - Pending timeout SIGKILL timer.
42
+ * @property {import("./types.js").PooledRunnerTerminationReason} [terminationReason] - Expected termination reason.
43
+ * @property {string} [timeoutJobId] - Job whose timeout initiated termination.
44
+ */
25
45
  /** Grace period after SIGTERM before a lingering process runner is SIGKILLed. */
26
46
  const FORKED_CHILD_SIGKILL_GRACE_MS = 5000
27
47
  /**
@@ -234,7 +254,7 @@ export default class BackgroundJobsWorker {
234
254
  this.pooledJobQueueTrackers = new Map()
235
255
  /** @type {Set<import("node:child_process").ChildProcess>} */
236
256
  this.pooledChildren = new Set()
237
- /** @type {Map<import("node:child_process").ChildProcess, {createdAtMs: number, jobsRun: number, inflight: Map<string, {payload: import("./types.js").BackgroundJobPayload & {id: string}, resolve?: (value: void) => void, pooledJob?: Promise<void>, timeoutTimer?: ReturnType<typeof setTimeout> | null}>, lastDispatchSeq: number, retiring: boolean, started?: boolean, settling?: boolean, timeoutSigkillTimer?: ReturnType<typeof setTimeout> | null}>} */
257
+ /** @type {Map<import("node:child_process").ChildProcess, PooledChildState>} */
238
258
  this.pooledChildStates = new Map()
239
259
  /** @type {WeakSet<Promise<void>>} */
240
260
  this._pooledStartupFailureJobs = new WeakSet()
@@ -511,6 +531,11 @@ export default class BackgroundJobsWorker {
511
531
  if (this.inflightProcessChildren.size === 0) return
512
532
 
513
533
  for (const child of this.inflightProcessChildren) {
534
+ const pooledState = this.pooledChildStates.get(child)
535
+ if (pooledState && pooledState.inflight.size > 0 && !pooledState.terminationReason) {
536
+ pooledState.terminationReason = "worker-shutdown-timeout"
537
+ }
538
+
514
539
  try {
515
540
  child.kill("SIGTERM")
516
541
  } catch {
@@ -1046,7 +1071,7 @@ export default class BackgroundJobsWorker {
1046
1071
  try {
1047
1072
  child.send({type: "job", payload, sharedTransactionBroker: this._pooledJobSharedTransactionBrokerConfig()})
1048
1073
  } catch (error) {
1049
- void this._handlePooledChildFailure({child, error})
1074
+ void this._handlePooledChildFailure({child, error, origin: "ipc-send"})
1050
1075
  }
1051
1076
 
1052
1077
  return pooledJob
@@ -1131,7 +1156,10 @@ export default class BackgroundJobsWorker {
1131
1156
  const state = this.pooledChildStates.get(child)
1132
1157
 
1133
1158
  // Already settling/gone, or the job finished in the race with this timer.
1134
- if (!state || state.settling || !state.inflight.has(jobId)) return
1159
+ if (!state || state.settling || state.terminationReason || !state.inflight.has(jobId)) return
1160
+
1161
+ state.terminationReason = "job-timeout"
1162
+ state.timeoutJobId = jobId
1135
1163
 
1136
1164
  try {
1137
1165
  child.kill("SIGTERM")
@@ -1163,8 +1191,20 @@ export default class BackgroundJobsWorker {
1163
1191
  this.inflightProcessChildren.add(child)
1164
1192
  this.pooledChildStates.set(child, {createdAtMs: Date.now(), jobsRun: 0, inflight: new Map(), lastDispatchSeq: 0, retiring: false, started: false})
1165
1193
  child.on("message", (message) => this._handlePooledChildMessage({child, message}))
1166
- child.once("exit", (code, signal) => this._handlePooledChildFailure({child, error: new Error(`Pooled background job runner exited: code=${code} signal=${signal || "none"}`)}))
1167
- child.once("error", (error) => this._handlePooledChildFailure({child, error}))
1194
+ child.once("exit", (exitCode, signal) => this._handlePooledChildFailure({
1195
+ child,
1196
+ error: new Error(`Pooled background job runner exited: code=${exitCode} signal=${signal || "none"}`),
1197
+ exitCode,
1198
+ origin: "exit",
1199
+ signal
1200
+ }))
1201
+ child.once("error", (error) => this._handlePooledChildFailure({
1202
+ child,
1203
+ error,
1204
+ exitCode: child.exitCode,
1205
+ origin: "process-error",
1206
+ signal: child.signalCode
1207
+ }))
1168
1208
  return child
1169
1209
  }
1170
1210
 
@@ -1271,9 +1311,12 @@ export default class BackgroundJobsWorker {
1271
1311
  * @param {object} args - Failure details.
1272
1312
  * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
1273
1313
  * @param {ReturnType<typeof JSON.parse>} args.error - Failure.
1314
+ * @param {number | null} [args.exitCode] - Child exit code when observed.
1315
+ * @param {import("./types.js").PooledRunnerFailureOrigin} [args.origin] - Worker observation that initiated recovery.
1316
+ * @param {import("node:child_process").ChildProcess["signalCode"]} [args.signal] - Child termination signal when observed.
1274
1317
  * @returns {Promise<void>}
1275
1318
  */
1276
- async _handlePooledChildFailure({child, error}) {
1319
+ async _handlePooledChildFailure({child, error, exitCode = null, origin = "process-error", signal = null}) {
1277
1320
  const state = this.pooledChildStates.get(child)
1278
1321
  if (state?.settling) return
1279
1322
  if (state) {
@@ -1289,6 +1332,9 @@ export default class BackgroundJobsWorker {
1289
1332
  this.inflightProcessChildren.delete(child)
1290
1333
 
1291
1334
  const entries = state ? [...state.inflight.values()] : []
1335
+ const runnerFailure = state
1336
+ ? this._pooledRunnerFailure({child, exitCode, origin, signal, state})
1337
+ : undefined
1292
1338
  if (state) state.inflight.clear()
1293
1339
  this.pooledChildStates.delete(child)
1294
1340
 
@@ -1299,6 +1345,7 @@ export default class BackgroundJobsWorker {
1299
1345
  error,
1300
1346
  handoffId: entry.payload.handoffId,
1301
1347
  handedOffAtMs: entry.payload.handedOffAtMs,
1348
+ runnerFailure,
1302
1349
  workerId: entry.payload.workerId || this.workerId
1303
1350
  })
1304
1351
  if (entry.resolve) entry.resolve(undefined)
@@ -1326,6 +1373,51 @@ export default class BackgroundJobsWorker {
1326
1373
  await Promise.allSettled(failureReports)
1327
1374
  }
1328
1375
 
1376
+ /**
1377
+ * Captures one stable process snapshot before the failed child's state is removed.
1378
+ * @param {object} args - Failure details.
1379
+ * @param {import("node:child_process").ChildProcess} args.child - Failed pooled child.
1380
+ * @param {number | null} args.exitCode - Child exit code when observed.
1381
+ * @param {import("./types.js").PooledRunnerFailureOrigin} args.origin - Worker observation that initiated recovery.
1382
+ * @param {import("node:child_process").ChildProcess["signalCode"]} args.signal - Child termination signal when observed.
1383
+ * @param {PooledChildState} args.state - Child state immediately before recovery.
1384
+ * @returns {import("./types.js").PooledRunnerFailure} - Shared failure provenance.
1385
+ */
1386
+ _pooledRunnerFailure({child, exitCode, origin, signal, state}) {
1387
+ const terminationReason = state.terminationReason ?? "unexpected"
1388
+ const workerLifecycle = this.shouldStop ? "stopping" : this.isRetiring ? "retiring" : "running"
1389
+ const runnerLifecycle = state.started === false ? "starting" : state.retiring ? "retiring" : "running"
1390
+ const activeJobs = [...state.inflight.values()]
1391
+ .map((entry) => ({
1392
+ handoffId: entry.payload.handoffId ?? null,
1393
+ handedOffAtMs: entry.payload.handedOffAtMs ?? null,
1394
+ jobId: entry.payload.id,
1395
+ jobName: entry.payload.jobName,
1396
+ workerId: entry.payload.workerId ?? this.workerId
1397
+ }))
1398
+ .sort((left, right) => left.jobId.localeCompare(right.jobId))
1399
+
1400
+ return Object.freeze({
1401
+ activeJobs,
1402
+ exitCode,
1403
+ generationId: this.generationId ?? null,
1404
+ oomKilled: signal === "SIGKILL" && terminationReason === "unexpected" ? null : false,
1405
+ origin,
1406
+ runnerAgeMs: Math.max(0, Date.now() - state.createdAtMs),
1407
+ runnerCreatedAtMs: state.createdAtMs,
1408
+ runnerDetached: false,
1409
+ runnerJobsRun: state.jobsRun,
1410
+ runnerLifecycle,
1411
+ runnerPid: child.pid ?? null,
1412
+ signal,
1413
+ terminationReason,
1414
+ timeoutJobId: state.timeoutJobId ?? null,
1415
+ workerId: this.workerId,
1416
+ workerLifecycle,
1417
+ workerPid: process.pid
1418
+ })
1419
+ }
1420
+
1329
1421
  /**
1330
1422
  * Runs run job inline.
1331
1423
  * @param {import("./types.js").BackgroundJobPayload} payload - Payload.
@@ -1655,9 +1747,10 @@ export default class BackgroundJobsWorker {
1655
1747
  * @param {string} [args.handoffId] - Handoff lease id.
1656
1748
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
1657
1749
  * @param {string} [args.workerId] - Worker id.
1750
+ * @param {import("./types.js").PooledRunnerFailure} [args.runnerFailure] - Pooled-child process failure provenance.
1658
1751
  * @returns {Promise<void>} - Resolves when reported.
1659
1752
  */
1660
- async _reportJobResult({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}) {
1753
+ async _reportJobResult({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, runnerFailure}) {
1661
1754
  if (!this.statusReporter) return
1662
1755
 
1663
1756
  try {
@@ -1665,7 +1758,7 @@ export default class BackgroundJobsWorker {
1665
1758
  // long-lived and cannot exit to trigger orphan reclaim, so dropping the
1666
1759
  // completion here would strand the job in `handed_off` forever — fatal for a
1667
1760
  // `max_concurrency: 1` job (a stranded row blocks every future run).
1668
- await this.statusReporter.reportWithRetry({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, retryPersistErrors: true})
1761
+ await this.statusReporter.reportWithRetry({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, runnerFailure, retryPersistErrors: true})
1669
1762
  } catch (reportError) {
1670
1763
  console.error("Background job status reporting failed:", reportError)
1671
1764
  }
@@ -1683,15 +1776,16 @@ export default class BackgroundJobsWorker {
1683
1776
  * @param {string} [args.handoffId] - Handoff lease id.
1684
1777
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
1685
1778
  * @param {string} [args.workerId] - Worker id.
1779
+ * @param {import("./types.js").PooledRunnerFailure} [args.runnerFailure] - Pooled-child process failure provenance.
1686
1780
  * @returns {void}
1687
1781
  */
1688
- _reportJobResultInBackground({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}) {
1782
+ _reportJobResultInBackground({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, runnerFailure}) {
1689
1783
  /**
1690
1784
  * Defines report.
1691
1785
  * @type {Promise<void>} */
1692
1786
  let report
1693
1787
 
1694
- report = this._reportJobResult({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}).finally(() => {
1788
+ report = this._reportJobResult({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, runnerFailure}).finally(() => {
1695
1789
  this.inflightReports.delete(report)
1696
1790
  })
1697
1791
 
@@ -1779,6 +1779,17 @@ function frontendModelRequestContext() {
1779
1779
  return captureFrontendModelRemoteRequestContext(configuredContext)
1780
1780
  }
1781
1781
 
1782
+ /**
1783
+ * Captures the explicit lifecycle context or falls back to the configured transport context.
1784
+ * @param {import("../remote-request-context.js").RemoteRequestContext | undefined} requestContext - Registration-local context.
1785
+ * @returns {import("../remote-request-context.js").RemoteRequestContext} Frozen context snapshot.
1786
+ */
1787
+ function frontendModelEventRequestContext(requestContext) {
1788
+ if (requestContext === undefined) return frontendModelRequestContext()
1789
+
1790
+ return captureFrontendModelRemoteRequestContext(requestContext)
1791
+ }
1792
+
1782
1793
  /**
1783
1794
  * Runs ensure frontend model instance listener.
1784
1795
  * @param {FrontendModelEventSubscription} sub - Event subscription bucket.
@@ -1799,6 +1810,27 @@ function ensureFrontendModelInstanceListener(sub, id, instance) {
1799
1810
  return listener
1800
1811
  }
1801
1812
 
1813
+ /**
1814
+ * Removes one instance callback entry and tears down an empty listener/subscription bucket.
1815
+ * @param {FrontendModelEventSubscription} sub - Event subscription bucket.
1816
+ * @param {string} id - Model id.
1817
+ * @param {(listener: {instance: FrontendModelBase, updateCallbacks: Set<FrontendModelModelEventCallbackEntry>, destroyCallbacks: Set<FrontendModelDestroyEventCallbackEntry>}) => void} removeEntry - Callback entry removal.
1818
+ * @returns {void}
1819
+ */
1820
+ function removeFrontendModelInstanceListenerEntry(sub, id, removeEntry) {
1821
+ const current = sub.instanceListeners.get(id)
1822
+
1823
+ if (!current) return
1824
+
1825
+ removeEntry(current)
1826
+
1827
+ if (current.updateCallbacks.size === 0 && current.destroyCallbacks.size === 0) {
1828
+ sub.instanceListeners.delete(id)
1829
+ }
1830
+
1831
+ sub.maybeTeardown()
1832
+ }
1833
+
1802
1834
  /**
1803
1835
  * Runs frontend model command url.
1804
1836
  * @param {string} resourcePath - Resource path prefix.
@@ -3723,8 +3755,9 @@ export default class FrontendModelBase {
3723
3755
  * @returns {Promise<() => void>} - Unsubscribe callback.
3724
3756
  */
3725
3757
  static async onCreate(callback, options = {}) {
3726
- const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
3727
- const entry = {callback, ...frontendModelEventOptionsPayload(this, options)}
3758
+ const {requestContext, ...eventOptionsPayload} = frontendModelEventOptionsPayload(this, options)
3759
+ const sub = ensureFrontendModelEventSubscription(this, frontendModelEventRequestContext(requestContext))
3760
+ const entry = {callback, ...eventOptionsPayload}
3728
3761
 
3729
3762
  return await sub.registerClassCallback(sub.classCreateCallbacks, entry)
3730
3763
  }
@@ -3737,8 +3770,9 @@ export default class FrontendModelBase {
3737
3770
  * @returns {Promise<() => void>} - Unsubscribe callback.
3738
3771
  */
3739
3772
  static async onUpdate(callback, options = {}) {
3740
- const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
3741
- const entry = {callback, ...frontendModelEventOptionsPayload(this, options)}
3773
+ const {requestContext, ...eventOptionsPayload} = frontendModelEventOptionsPayload(this, options)
3774
+ const sub = ensureFrontendModelEventSubscription(this, frontendModelEventRequestContext(requestContext))
3775
+ const entry = {callback, ...eventOptionsPayload}
3742
3776
 
3743
3777
  return await sub.registerClassCallback(sub.classUpdateCallbacks, entry)
3744
3778
  }
@@ -3753,7 +3787,8 @@ export default class FrontendModelBase {
3753
3787
  static async onDestroy(callback, options = {}) {
3754
3788
  assertNoDestroyEventFilter(this, options)
3755
3789
 
3756
- const sub = ensureFrontendModelEventSubscription(this, frontendModelRequestContext())
3790
+ const {requestContext} = frontendModelEventOptionsPayload(this, options)
3791
+ const sub = ensureFrontendModelEventSubscription(this, frontendModelEventRequestContext(requestContext))
3757
3792
  const entry = {callback}
3758
3793
 
3759
3794
  return await sub.registerClassCallback(sub.classDestroyCallbacks, entry)
@@ -3771,24 +3806,23 @@ export default class FrontendModelBase {
3771
3806
  async onUpdate(callback, options = {}) {
3772
3807
  const self = /** @type {ReturnType<typeof JSON.parse>} */ (this)
3773
3808
  const ModelClass = frontendModelClassFor(this)
3774
- const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelRequestContext())
3809
+ const {requestContext, ...eventOptionsPayload} = frontendModelEventOptionsPayload(ModelClass, options)
3810
+ const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelEventRequestContext(requestContext))
3775
3811
  const id = String(self.id())
3776
- const entry = {callback, ...frontendModelEventOptionsPayload(ModelClass, options)}
3812
+ const entry = {callback, ...eventOptionsPayload}
3777
3813
  const listener = ensureFrontendModelInstanceListener(sub, id, this)
3778
3814
 
3779
3815
  listener.updateCallbacks.add(entry)
3780
- await sub.ensureSubscribed()
3781
-
3782
- return () => {
3783
- const current = sub.instanceListeners.get(id)
3784
3816
 
3785
- if (!current) return
3786
- current.updateCallbacks.delete(entry)
3817
+ try {
3818
+ await sub.ensureSubscribed()
3819
+ } catch (error) {
3820
+ removeFrontendModelInstanceListenerEntry(sub, id, (current) => current.updateCallbacks.delete(entry))
3821
+ throw error
3822
+ }
3787
3823
 
3788
- if (current.updateCallbacks.size === 0 && current.destroyCallbacks.size === 0) {
3789
- sub.instanceListeners.delete(id)
3790
- }
3791
- sub.maybeTeardown()
3824
+ return () => {
3825
+ removeFrontendModelInstanceListenerEntry(sub, id, (current) => current.updateCallbacks.delete(entry))
3792
3826
  }
3793
3827
  }
3794
3828
 
@@ -3804,24 +3838,23 @@ export default class FrontendModelBase {
3804
3838
 
3805
3839
  assertNoDestroyEventFilter(ModelClass, options)
3806
3840
 
3807
- const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelRequestContext())
3841
+ const {requestContext} = frontendModelEventOptionsPayload(ModelClass, options)
3842
+ const sub = ensureFrontendModelEventSubscription(ModelClass, frontendModelEventRequestContext(requestContext))
3808
3843
  const id = String(self.id())
3809
3844
  const entry = {callback}
3810
3845
  const listener = ensureFrontendModelInstanceListener(sub, id, this)
3811
3846
 
3812
3847
  listener.destroyCallbacks.add(entry)
3813
- await sub.ensureSubscribed()
3814
3848
 
3815
- return () => {
3816
- const current = sub.instanceListeners.get(id)
3817
-
3818
- if (!current) return
3819
- current.destroyCallbacks.delete(entry)
3849
+ try {
3850
+ await sub.ensureSubscribed()
3851
+ } catch (error) {
3852
+ removeFrontendModelInstanceListenerEntry(sub, id, (current) => current.destroyCallbacks.delete(entry))
3853
+ throw error
3854
+ }
3820
3855
 
3821
- if (current.updateCallbacks.size === 0 && current.destroyCallbacks.size === 0) {
3822
- sub.instanceListeners.delete(id)
3823
- }
3824
- sub.maybeTeardown()
3856
+ return () => {
3857
+ removeFrontendModelInstanceListenerEntry(sub, id, (current) => current.destroyCallbacks.delete(entry))
3825
3858
  }
3826
3859
  }
3827
3860
 
@@ -39,9 +39,15 @@ import isPlainObject from "../utils/plain-object.js"
39
39
  * @property {string[] | Record<string, string[]>} [abilities] - Ability actions to compute per record.
40
40
  * @property {string | Array<string | Record<string, FrontendModelTransportValue>> | Record<string, FrontendModelTransportValue>} [queryData] - Backend query data names/spec.
41
41
  */
42
+ /**
43
+ * FrontendModelEventRoutingOptions type.
44
+ * @typedef {object} FrontendModelEventRoutingOptions
45
+ * @property {FrontendModelQuery<import("./base.js").FrontendModelClass>} [query] - Query whose filters match events and whose projections shape event records.
46
+ * @property {import("../remote-request-context.js").RemoteRequestContext} [requestContext] - Registration-local remote routing context. Its captured value partitions lifecycle server subscriptions and replaces the transport-wide context for this registration.
47
+ */
42
48
  /**
43
49
  * Defines this typedef.
44
- * @typedef {FrontendModelProjectionOptions & {query?: FrontendModelQuery<import("./base.js").FrontendModelClass>}} FrontendModelEventOptionsObject
50
+ * @typedef {FrontendModelProjectionOptions & FrontendModelEventRoutingOptions} FrontendModelEventOptionsObject
45
51
  */
46
52
  /**
47
53
  * FrontendModelEventOptions type.
@@ -69,12 +75,16 @@ import isPlainObject from "../utils/plain-object.js"
69
75
  * @typedef {FrontendModelEventFilterPayload & {key: string}} FrontendModelEventFilterPayloadEntry
70
76
  */
71
77
  /**
72
- * FrontendModelEventOptionsPayload type.
73
- * @typedef {object} FrontendModelEventOptionsPayload
78
+ * FrontendModelEventQueryPayload type.
79
+ * @typedef {object} FrontendModelEventQueryPayload
74
80
  * @property {string | null} eventFilterKey - Stable event filter key, or null when no filter is present.
75
81
  * @property {FrontendModelEventFilterPayload | null} eventFilterPayload - Normalized event filter payload, or null when unfiltered.
76
82
  * @property {FrontendModelProjectionPayload} projectionPayload - Normalized event serialization projection payload.
77
83
  */
84
+ /**
85
+ * FrontendModelEventOptionsPayload type.
86
+ * @typedef {FrontendModelEventQueryPayload & {requestContext: import("../remote-request-context.js").RemoteRequestContext | undefined}} FrontendModelEventOptionsPayload
87
+ */
78
88
  /**
79
89
  * FrontendModelSort type.
80
90
  * @typedef {object} FrontendModelSort
@@ -1829,7 +1839,7 @@ export default class FrontendModelQuery {
1829
1839
 
1830
1840
  /**
1831
1841
  * Returns the eventOptionsPayload result.
1832
- * @returns {FrontendModelEventOptionsPayload} - Combined event filter and projection payload.
1842
+ * @returns {FrontendModelEventQueryPayload} - Combined event filter and projection payload.
1833
1843
  */
1834
1844
  eventOptionsPayload() {
1835
1845
  const eventFilterPayload = this.eventFilterPayload()
@@ -2236,5 +2246,10 @@ function frontendModelEventQuery(modelClass, options = {}) {
2236
2246
  * @returns {FrontendModelEventOptionsPayload} - Normalized event subscription payload.
2237
2247
  */
2238
2248
  export function frontendModelEventOptionsPayload(modelClass, options = {}) {
2239
- return frontendModelEventQuery(modelClass, options).eventOptionsPayload()
2249
+ const requestContext = options instanceof FrontendModelQuery ? undefined : options.requestContext
2250
+
2251
+ return {
2252
+ ...frontendModelEventQuery(modelClass, options).eventOptionsPayload(),
2253
+ requestContext
2254
+ }
2240
2255
  }
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- import {captureRemoteRequestContext, mergeRemoteRequestContext} from "../remote-request-context.js"
3
+ import {captureRemoteRequestContext, mergeRemoteRequestContext, remoteRequestContextKey} from "../remote-request-context.js"
4
4
 
5
5
  const RESERVED_KEYS = ["commandType", "customPath", "model", "payload", "requestContext", "requestId", "requests"]
6
6
  const REQUEST_CONTEXT_LABEL = "Frontend model request context"
@@ -17,6 +17,18 @@ export function captureFrontendModelRemoteRequestContext(value) {
17
17
  })
18
18
  }
19
19
 
20
+ /**
21
+ * Builds the canonical identity for one frontend-model remote request context.
22
+ * @param {ReturnType<typeof JSON.parse> | undefined} value - Context value.
23
+ * @returns {string} Stable serialized context key.
24
+ */
25
+ export function frontendModelRemoteRequestContextKey(value) {
26
+ return JSON.stringify({
27
+ context: remoteRequestContextKey(captureFrontendModelRemoteRequestContext(value)),
28
+ present: value !== undefined
29
+ })
30
+ }
31
+
20
32
  /**
21
33
  * Merges captured context into frontend-model command or subscription params.
22
34
  * @template {Record<string, ReturnType<typeof JSON.parse>>} TParams