diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 447a9437..90423d22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,6 +36,20 @@ repos: types: [python] pass_filenames: false + - id: node-lint + name: node lint (biome) + entry: bash -c 'cd sdks/node && pnpm exec biome ci src test' + language: system + files: ^sdks/node/(src/|test/|biome\.json) + pass_filenames: false + + - id: node-typecheck + name: node typecheck (tsc) + entry: bash -c 'cd sdks/node && pnpm exec tsc --noEmit -p tsconfig.json' + language: system + files: ^sdks/node/(src/|test/|tsconfig\.json|package\.json) + pass_filenames: false + - id: dashboard-lint name: dashboard lint (biome) entry: bash -c 'cd dashboard && pnpm exec biome ci src/' diff --git a/NODE_SDK_TODO.md b/NODE_SDK_TODO.md index 6d1fef83..3b03ffe9 100644 --- a/NODE_SDK_TODO.md +++ b/NODE_SDK_TODO.md @@ -34,6 +34,8 @@ SDK, zero Python dependency, over the shared Rust core. | **Periodic + circuit-breaker** | `a4cc9a4` | `queue.registerPeriodic`; per-task `circuitBreaker` config | | **Workflows: fan-out / fan-in** | (this branch) | TS `WorkflowTracker` brain (`src/workflows/tracker.ts`) driven by the outcome callback; `.fanOut()` / `.fanIn()` builder steps; napi primitives `expandFanOut`/`checkFanOutCompletion`/`createDeferredJob`/`finalizeRunIfTerminal`/`getWorkflowRunPlan`/`workflowNodeForJob`/`cascadeSkipPending`; deferred-node submit. Storage-reconstructable (no submit-time registration) | | **Dashboard DAG completeness** | (this branch) | `JsWorkflowNode` carries `fanOutCount` + `compensation_*`; `/dag` handler enriches the raw graph with per-node `deps[]` + live `status` + job-id `id` so the SPA visualizer renders edges/colours/links | +| **Contrib integrations** | (this branch) | `src/contrib/{otel,prometheus,express,fastify,nest,sentry}.ts` — each an optional subpath export (`taskito/contrib/*`) with an optional peer dep. OTel + Prometheus + Sentry = `Middleware` over the events layer; Express/Fastify = REST router (shared `rest.ts` table) + dashboard mount (via extracted `createDashboardHandler`); Nest = `TaskitoModule.forRoot` + injectable `TaskitoService`. 17 new vitest tests | +| **Workflows: conditions / gates / sub-workflows / saga** | (this branch) | `WorkflowTracker` drives all deferred kinds. Conditions (`on_success`/`on_failure`/`always`), approval gates (`.gate`, `resolveGate`/`approveGate`/`rejectGate`, timeouts), nested sub-workflows (`.subWorkflow`, parent linkage + storage-driven child→parent resolve), saga compensation (`.step({compensate})`, reverse-dependency storage-driven rollback). `StepMetadata` + napi (`skip`/gate/sub-workflow/saga) extended. 12 new tests | **Verify everything green:** ```bash @@ -68,48 +70,44 @@ context managers) — do **not** port 1:1. Design Node-native equivalents: **Effort:** large (design-heavy). **Recommendation:** scope a minimal resource DI first (worker-scoped singletons + task-scoped factories); defer proxies entirely. -### 2. Contrib integrations — MEDIUM - -Python ships `contrib/` (OpenTelemetry, Sentry, Prometheus, Flask, Django, -FastAPI). Node equivalents: - -- **Observability:** OpenTelemetry (`@opentelemetry/api`), Prometheus - (`prom-client`) — implement as middleware over the existing events/middleware - layer (`src/middleware.ts`). Each = a `Middleware` that records spans/metrics. -- **Web frameworks:** Express / Fastify / Nest helpers (enqueue from a request, - mount the dashboard). Python's Flask/Django/FastAPI map to these. - -**Where:** new `sdks/node/src/contrib/` (one file per integration, optional peer -deps). **Effort:** medium. Each integration is small and independent → **one -commit each**. - -### 3. Advanced workflow features — LARGE (fan-out DONE; gates / sub-workflows / saga remain) - -DAG/linear + **fan-out / fan-in** work. The tracker-brain foundation is built: -a TS `WorkflowTracker` (`src/workflows/tracker.ts`) reacts to the worker outcome -callback, reconstructs the run plan from storage (DAG + step metadata — no -submit-time registration), and drives on-demand orchestration via napi -primitives (`expandFanOut`, `checkFanOutCompletion`, `createDeferredJob`, -`finalizeRunIfTerminal`, `getWorkflowRunPlan`, `workflowNodeForJob`, -`cascadeSkipPending`). `submit_workflow` takes `deferredNodeNames` (fan-out / -fan-in ∪ descendants get a node but no static job). Failures are fail-fast. - -**Still unbound: gates/conditions, sub-workflows, saga compensation.** They build -on this same foundation (the Python references are -`crates/taskito-python/src/py_queue/workflow_ops/{gates,saga}.rs` + the Python -`WorkflowTracker`): -- **gates/conditions** — a deferred node enters `waiting_approval`; resolve via a - new `resolveWorkflowGate` napi method + a JS-side timer (`setTimeout`) for - timeouts. Conditions = a `should_execute` check in `evaluateSuccessors`. -- **sub-workflows** — extend `submit_workflow` with `parent_run_id` / - `parent_node_name`; the tracker submits a child run for a sub-workflow node and - resolves the parent node when the child finalizes (populates `/children`). -- **saga** — reverse-topo compensation waves driven by the tracker; needs the - `setWorkflowNodeCompensation*` + run-state napi setters bound (storage methods - already exist on the `WorkflowStorage` trait). - -**Where:** `crates/taskito-node/src/queue/workflows.rs` + `src/workflows/tracker.ts`. -**Effort:** medium each now the brain exists. Each is a separate feature/commit. +### 2. Contrib integrations — DONE + +Shipped: OpenTelemetry + Prometheus + Sentry middleware, Express + Fastify (REST router + +dashboard mount), and a NestJS module +(`src/contrib/{otel,prometheus,sentry,express,fastify,nest}.ts`). Each is an optional +`taskito/contrib/*` subpath export with an optional peer dependency, its own tsup entry, +and tests. The REST logic is shared via a framework-neutral `src/contrib/rest.ts` route +table; the dashboard mount reuses `createDashboardHandler` (extracted from +`dashboard/server.ts`). NestJS uses an explicit `@Inject(TASKITO_QUEUE)` token to avoid +relying on esbuild decorator-metadata emission (`experimentalDecorators` on; biome +`unsafeParameterDecoratorsEnabled`). Sentry captures the real exception from `onError` +and reports it on dead-letter (one event/dead job), optional per-retry warnings. + +### 3. Advanced workflow features — DONE + +The TS `WorkflowTracker` (`src/workflows/tracker.ts`) reconstructs the run plan from +storage and drives every deferred step kind off the worker outcome stream: +- **fan-out / fan-in** — `expandFanOut` / `checkFanOutCompletion` + collector. +- **conditions** — `.step({ condition })` (`on_success`/`on_failure`/`always`); + `shouldExecute` gates dispatch in `evaluateSuccessors`, skips propagate via + `skipWorkflowNode`. (String predicates only — JS callables can't cross the + storage-reconstructable boundary.) +- **gates** — `.gate({ timeoutMs, onTimeout, message })` parks at `waiting_approval`; + `setWorkflowNodeWaitingApproval` + `resolveWorkflowGate` napi, JS `setTimeout` + (unref'd) for timeouts; `queue.workflows.resolveGate`/`approveGate`/`rejectGate` + resolve from any process (idempotent via a terminal-status guard). +- **sub-workflows** — `.subWorkflow({ workflow })`; child spec persisted as base64 + `SubWorkflowTransport` in `sub_workflow` metadata; `submit_workflow` gained + `parent_run_id`/`parent_node_name`; child→parent resolution is storage-driven + (read parent linkage on child finalize → `resolveWorkflowGate`). Nesting supported. +- **saga** — `.step({ compensate })`; on a failed run the tracker re-derives the next + compensable nodes from node status each rollback outcome (reverse-dependency, + storage-driven, no in-memory waves), enqueues idempotent rollback jobs + (`enqueueCompensation`, dedup `unique_key`), routes their outcomes via + `compensationNodeForJob`, and finalizes `compensated` / `compensation_failed`. + +`StepMetadata` (taskito-workflows crate) gained `gate` / `sub_workflow` / `compensate` +fields (JSON blob, no schema migration; `#[derive(Default)]` + spread literals). ### 4. Dashboard workflows DAG panel completeness — DONE @@ -239,7 +237,7 @@ pnpm run build:native # napi build, all features pnpm run build:ts # tsup dual ESM/CJS + .d.ts pnpm typecheck # tsc --noEmit (includes test/) pnpm lint # biome -pnpm test # vitest (48 tests) +pnpm test # vitest (81 tests) pnpm run build:dashboard # build the React SPA into static/dashboard ``` @@ -261,8 +259,9 @@ sdks/node/src/ webhooks/{store,deliverer,manager,types,index}.ts serializers/{serializer,json,msgpack,index}.ts dashboard/{server,routes,handlers,contract,static,metrics,api,index}.ts + contrib/{otel,prometheus,sentry,express,fastify,nest,rest}.ts # optional subpath exports cli/{index,connect,output,commands/*}.ts -sdks/node/test/*.test.ts # grouped by feature area +sdks/node/test/{core,worker,observability,integrations,workflows,dashboard}/*.test.ts # grouped by feature area ``` Memory: see `.claude/memory/session-history.md` (Node SDK section) for the running diff --git a/crates/taskito-node/src/queue/workflows.rs b/crates/taskito-node/src/queue/workflows.rs index 93556469..1c15d8e1 100644 --- a/crates/taskito-node/src/queue/workflows.rs +++ b/crates/taskito-node/src/queue/workflows.rs @@ -1,11 +1,11 @@ //! Workflow submission, advancement, and queries. //! //! Static DAG steps are pre-enqueued with a `depends_on` chain so the core -//! scheduler runs them in topological order. Fan-out / fan-in steps are -//! deferred — submitted without a job — and the worker-side tracker (see -//! `sdks/node/src/workflows/tracker.ts`) expands and enqueues them at runtime -//! via the primitives below. Gates, sub-workflows, and saga compensation are -//! not yet bound. +//! scheduler runs them in topological order. Deferred steps — fan-out / fan-in, +//! conditioned steps, gates, and sub-workflows — are submitted without a job; +//! the worker-side tracker (see `sdks/node/src/workflows/tracker.ts`) expands, +//! resolves, and enqueues them at runtime via the primitives below. On failure +//! the tracker can drive saga compensation, rolling nodes back in reverse order. use std::collections::{HashMap, HashSet}; @@ -57,6 +57,8 @@ impl JsQueue { queue_default: Option, params_json: Option, deferred_node_names: Option>, + parent_run_id: Option, + parent_node_name: Option, ) -> Result { let wf = self.workflow_store()?; let dag = dag_bytes.to_vec(); @@ -109,8 +111,8 @@ impl JsQueue { started_at: Some(now), completed_at: None, error: None, - parent_run_id: None, - parent_node_name: None, + parent_run_id, + parent_node_name, created_at: now, }; wf.create_workflow_run(&run).map_err(to_napi_err)?; @@ -555,6 +557,188 @@ impl JsQueue { })) } + /// Skip a single node whose condition evaluated false: cancel any backing + /// job (best-effort) and mark it `Skipped`. The tracker propagates the skip + /// to the node's successors so a not-taken branch settles cleanly. + #[napi] + pub fn skip_workflow_node(&self, run_id: String, node_name: String) -> Result<()> { + let wf = self.workflow_store()?; + if let Some(node) = wf + .get_workflow_node(&run_id, &node_name) + .map_err(to_napi_err)? + { + if let Some(job_id) = &node.job_id { + if let Err(e) = self.storage.cancel_job(job_id) { + log::warn!("[taskito-node] cancel_job({job_id}) during skip: {e}"); + } + } + } + wf.update_workflow_node_status(&run_id, &node_name, WorkflowNodeStatus::Skipped) + .map_err(to_napi_err)?; + Ok(()) + } + + /// Park a gate node at `WaitingApproval`. The run pauses here until + /// `resolve_workflow_gate` approves or rejects it (or a tracker timeout does). + #[napi] + pub fn set_workflow_node_waiting_approval( + &self, + run_id: String, + node_name: String, + ) -> Result<()> { + let wf = self.workflow_store()?; + wf.update_workflow_node_status(&run_id, &node_name, WorkflowNodeStatus::WaitingApproval) + .map_err(to_napi_err)?; + Ok(()) + } + + /// Resolve a waiting gate: approve → mark the node `Completed`; reject → + /// mark it `Failed` with `error`. The tracker then advances or skips the + /// gate's successors. Also used to resolve a parent node when its + /// sub-workflow child finalizes. + #[napi] + pub fn resolve_workflow_gate( + &self, + run_id: String, + node_name: String, + approved: bool, + error: Option, + ) -> Result<()> { + let wf = self.workflow_store()?; + if approved { + wf.set_workflow_node_completed(&run_id, &node_name, now_millis(), None) + .map_err(to_napi_err)?; + } else { + let msg = error.unwrap_or_else(|| "rejected".to_string()); + wf.set_workflow_node_error(&run_id, &node_name, &msg) + .map_err(to_napi_err)?; + } + Ok(()) + } + + /// Promote a node to `Running` — used when its sub-workflow child has been + /// submitted, so the parent node reflects in-flight work until the child + /// finalizes and resolves it. + #[napi] + pub fn set_workflow_node_running(&self, run_id: String, node_name: String) -> Result<()> { + let wf = self.workflow_store()?; + wf.set_workflow_node_running(&run_id, &node_name, now_millis()) + .map_err(to_napi_err)?; + Ok(()) + } + + /// Set a run's state (and optional error + completion timestamp). Used by the + /// saga tracker to transition through `compensating` → `compensated` / + /// `compensation_failed`. + #[napi] + pub fn set_workflow_run_state( + &self, + run_id: String, + state: String, + error: Option, + completed_at: Option, + ) -> Result<()> { + let wf = self.workflow_store()?; + let state = WorkflowState::from_str_val(&state) + .ok_or_else(|| reason(format!("unknown workflow state '{state}'")))?; + wf.update_workflow_run_state(&run_id, state, error.as_deref()) + .map_err(to_napi_err)?; + if let Some(ts) = completed_at { + wf.set_workflow_run_completed(&run_id, ts) + .map_err(to_napi_err)?; + } + Ok(()) + } + + /// Mark a node's compensation succeeded (status `Compensated`). + #[napi] + pub fn set_workflow_node_compensated( + &self, + run_id: String, + node_name: String, + completed_at: i64, + ) -> Result<()> { + let wf = self.workflow_store()?; + wf.set_workflow_node_compensated(&run_id, &node_name, completed_at) + .map_err(to_napi_err)?; + Ok(()) + } + + /// Mark a node's compensation failed (status `CompensationFailed`). + #[napi] + pub fn set_workflow_node_compensation_failed( + &self, + run_id: String, + node_name: String, + error: String, + completed_at: i64, + ) -> Result<()> { + let wf = self.workflow_store()?; + wf.set_workflow_node_compensation_failed(&run_id, &node_name, &error, completed_at) + .map_err(to_napi_err)?; + Ok(()) + } + + /// Enqueue a node's rollback (compensation) job and bind it to the node + /// (status `Compensating`). The job carries a compensation marker so its + /// outcome routes to saga handling, and a dedup `unique_key` so concurrent + /// workers never double-compensate the same node. Returns the job id. + #[napi] + #[allow(clippy::too_many_arguments)] + pub fn enqueue_compensation( + &self, + run_id: String, + node_name: String, + payload: Buffer, + task_name: String, + queue: String, + max_retries: i32, + timeout_ms: i64, + priority: i32, + ) -> Result { + let wf = self.workflow_store()?; + let now = now_millis(); + let new_job = NewJob { + queue, + task_name, + payload: payload.to_vec(), + priority, + scheduled_at: now, + max_retries, + timeout_ms, + unique_key: Some(format!("compensation:{run_id}:{node_name}")), + metadata: Some(compensation_metadata_json(&run_id, &node_name)), + notes: None, + depends_on: vec![], + expires_at: None, + result_ttl_ms: None, + namespace: self.namespace.clone(), + }; + let job = self.storage.enqueue(new_job).map_err(to_napi_err)?; + // Cancel the job if binding fails, so a compensation job can't run + // untracked by the saga state machine (mirrors create_deferred_job). + if let Err(err) = wf + .set_workflow_node_compensation_job(&run_id, &node_name, &job.id, now) + .map_err(to_napi_err) + { + let _ = self.storage.cancel_job(&job.id); + return Err(err); + } + Ok(job.id) + } + + /// The run + node a compensation job belongs to, or `null` if `job_id` is not + /// a compensation job. The tracker checks this before normal node handling so + /// a rollback outcome advances the saga rather than the forward run. + #[napi] + pub fn compensation_node_for_job(&self, job_id: String) -> Result> { + let Some(job) = self.storage.get_job(&job_id).map_err(to_napi_err)? else { + return Ok(None); + }; + Ok(parse_compensation_metadata(job.metadata.as_deref()) + .map(|(run_id, node_name)| JsWorkflowNodeRef { run_id, node_name })) + } + /// Fail-fast cascade: skip every still-pending node of a run and cancel its /// job. Called by the tracker when a managed run's node fails. #[napi] @@ -658,9 +842,43 @@ fn workflow_metadata_json(run_id: &str, node_name: &str) -> String { .to_string() } +/// Like `workflow_metadata_json` but flags a node's rollback (compensation) job. +fn compensation_metadata_json(run_id: &str, node_name: &str) -> String { + serde_json::json!({ + "workflow_run_id": run_id, + "workflow_node_name": node_name, + "compensation": true, + }) + .to_string() +} + /// Parse `{workflow_run_id, workflow_node_name}` from a job's metadata blob. +/// Returns `None` for compensation jobs so they never advance the forward run. fn parse_workflow_metadata(metadata: Option<&str>) -> Option<(String, String)> { let parsed: serde_json::Value = serde_json::from_str(metadata?).ok()?; + if parsed + .get("compensation") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + return None; + } + let run_id = parsed.get("workflow_run_id")?.as_str()?.to_string(); + let node_name = parsed.get("workflow_node_name")?.as_str()?.to_string(); + Some((run_id, node_name)) +} + +/// Parse a compensation job's `{workflow_run_id, workflow_node_name}` — `None` +/// unless the metadata carries `compensation: true`. +fn parse_compensation_metadata(metadata: Option<&str>) -> Option<(String, String)> { + let parsed: serde_json::Value = serde_json::from_str(metadata?).ok()?; + if parsed + .get("compensation") + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } let run_id = parsed.get("workflow_run_id")?.as_str()?.to_string(); let node_name = parsed.get("workflow_node_name")?.as_str()?.to_string(); Some((run_id, node_name)) diff --git a/crates/taskito-python/src/py_queue/workflow_ops/test_helpers.rs b/crates/taskito-python/src/py_queue/workflow_ops/test_helpers.rs index fc963dd7..370427dd 100644 --- a/crates/taskito-python/src/py_queue/workflow_ops/test_helpers.rs +++ b/crates/taskito-python/src/py_queue/workflow_ops/test_helpers.rs @@ -226,15 +226,7 @@ pub(crate) fn make_step_metadata_json(steps: &[(&str, &str)]) -> String { (*node).to_string(), StepMetadata { task_name: (*task).to_string(), - queue: None, - args_template: None, - kwargs_template: None, - max_retries: None, - timeout_ms: None, - priority: None, - fan_out: None, - fan_in: None, - condition: None, + ..Default::default() }, ) }) diff --git a/crates/taskito-python/src/py_workflow/mod.rs b/crates/taskito-python/src/py_workflow/mod.rs index 4aeac44b..f2a97081 100644 --- a/crates/taskito-python/src/py_workflow/mod.rs +++ b/crates/taskito-python/src/py_workflow/mod.rs @@ -92,6 +92,7 @@ impl PyWorkflowBuilder { fan_out, fan_in, condition, + ..Default::default() }, ); self.step_order.push(name.to_string()); diff --git a/crates/taskito-workflows/src/definition.rs b/crates/taskito-workflows/src/definition.rs index 16de2c62..77bbedf3 100644 --- a/crates/taskito-workflows/src/definition.rs +++ b/crates/taskito-workflows/src/definition.rs @@ -3,27 +3,40 @@ use serde::{Deserialize, Serialize}; /// Metadata for a single step in a workflow definition. /// /// Stored alongside the DAG structure to map node names to task queue details. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Every optional field is `#[serde(default)]` so the JSON blob stays +/// backward-compatible as new step kinds are added (no schema migration). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StepMetadata { pub task_name: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub queue: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub args_template: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub kwargs_template: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub max_retries: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub timeout_ms: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub priority: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub fan_out: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub fan_in: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] pub condition: Option, + /// JSON `{timeoutMs, onTimeout, message}` marking an approval gate node. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gate: Option, + /// Serialized child-workflow spec marking a sub-workflow node (the tracker + /// submits it as a child run and resolves this node when the child finalizes). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sub_workflow: Option, + /// Rollback task name — if the run fails, the tracker compensates this node + /// (in reverse-dependency order) by running this task with the node's result. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compensate: Option, } /// A persisted workflow definition: the DAG structure plus per-step metadata. diff --git a/crates/taskito-workflows/src/tests.rs b/crates/taskito-workflows/src/tests.rs index d1e70147..1234e668 100644 --- a/crates/taskito-workflows/src/tests.rs +++ b/crates/taskito-workflows/src/tests.rs @@ -122,15 +122,7 @@ fn test_get_ready_nodes_diamond_dag() { name.to_string(), StepMetadata { task_name: format!("task_{name}"), - queue: None, - args_template: None, - kwargs_template: None, - max_retries: None, - timeout_ms: None, - priority: None, - fan_out: None, - fan_in: None, - condition: None, + ..Default::default() }, ); } diff --git a/crates/taskito-workflows/tests/storage_contract.rs b/crates/taskito-workflows/tests/storage_contract.rs index ee12e69e..c704de89 100644 --- a/crates/taskito-workflows/tests/storage_contract.rs +++ b/crates/taskito-workflows/tests/storage_contract.rs @@ -26,15 +26,7 @@ use taskito_workflows::{ fn make_step(task_name: &str) -> StepMetadata { StepMetadata { task_name: task_name.to_string(), - queue: None, - args_template: None, - kwargs_template: None, - max_retries: None, - timeout_ms: None, - priority: None, - fan_out: None, - fan_in: None, - condition: None, + ..Default::default() } } diff --git a/docs/content/docs/node/contrib/index.mdx b/docs/content/docs/node/contrib/index.mdx new file mode 100644 index 00000000..e9924a2f --- /dev/null +++ b/docs/content/docs/node/contrib/index.mdx @@ -0,0 +1,42 @@ +--- +title: Contrib Integrations +description: "Optional helpers for OpenTelemetry, Prometheus, Express, Fastify, and NestJS." +--- + +import { Cards, Card } from "fumadocs-ui/components/card"; +import { Callout } from "fumadocs-ui/components/callout"; + +Contrib modules are optional integrations that ship alongside the core SDK but +are **not exported from the main `taskito` barrel**. Each lives behind its own +subpath import and requires you to install its peer dependency separately. + +```bash +# Only install what you use. +pnpm add @opentelemetry/api # for taskito/contrib/otel +pnpm add prom-client # for taskito/contrib/prometheus +pnpm add @sentry/node # for taskito/contrib/sentry +pnpm add express # for taskito/contrib/express +pnpm add fastify # for taskito/contrib/fastify +pnpm add @nestjs/common reflect-metadata # for taskito/contrib/nest +``` + + + Contrib modules never auto-initialize. Nothing runs until you call the export + and register it with your framework or queue. If a peer dependency is missing, + importing its subpath fails with a standard module-not-found error. + + +## What's in contrib + + + + + diff --git a/docs/content/docs/node/contrib/meta.json b/docs/content/docs/node/contrib/meta.json new file mode 100644 index 00000000..0547d2e9 --- /dev/null +++ b/docs/content/docs/node/contrib/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Contrib", + "pages": ["index", "observability", "web-frameworks"] +} diff --git a/docs/content/docs/node/contrib/observability.mdx b/docs/content/docs/node/contrib/observability.mdx new file mode 100644 index 00000000..90fc9bf4 --- /dev/null +++ b/docs/content/docs/node/contrib/observability.mdx @@ -0,0 +1,170 @@ +--- +title: Observability +description: "OpenTelemetry tracing, Prometheus metrics, and Sentry error reporting for the Node.js SDK." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +## OpenTelemetry + +Peer dependency: `@opentelemetry/api`. + +```ts +import { otelMiddleware } from "taskito/contrib/otel"; + +queue.use(otelMiddleware()); +``` + +One span is created per execution attempt, named +`taskito.execute.`. The span ends with status `OK` on success and +`ERROR` (with the exception recorded) on throw. A retry is a new attempt, so +it produces a new span. + +### Span attributes + +| Attribute | Value | +|---|---| +| `taskito.job_id` | The job's UUID | +| `taskito.task_name` | Registered task name | + +### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `tracerName` | `string` | `"taskito"` | Name passed to `trace.getTracer()` | +| `attributePrefix` | `string` | `"taskito"` | Prefix applied to span attribute keys | +| `spanName` | `(ctx) => string` | — | Override the span name per execution | +| `extraAttributes` | `(ctx) => Attributes` | — | Attach additional attributes to every span | +| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip tracing for a task | + +```ts +queue.use( + otelMiddleware({ + tracerName: "my-service", + attributePrefix: "app", + spanName: (ctx) => `job.${ctx.taskName}`, + extraAttributes: (ctx) => ({ "app.env": process.env.NODE_ENV ?? "dev" }), + taskFilter: (name) => !name.startsWith("internal."), + }), +); +``` + + + `otelMiddleware` does not configure an exporter or a tracer provider. Set + those up with the OpenTelemetry SDK before calling `queue.runWorker()`. + + +--- + +## Prometheus + +Peer dependency: `prom-client`. + +```ts +import { + prometheusMiddleware, + PrometheusStatsCollector, +} from "taskito/contrib/prometheus"; +import { register } from "prom-client"; +import express from "express"; + +// 1. Instrument job executions. +queue.use(prometheusMiddleware()); + +// 2. Poll queue/DLQ depth gauges every 10 s. +const collector = new PrometheusStatsCollector(queue); +collector.start(); + +// 3. Expose metrics. +const app = express(); +app.get("/metrics", async (_req, res) => { + res.set("Content-Type", register.contentType); + res.end(await register.metrics()); +}); +``` + +### Metrics + +| Metric | Type | Labels | Description | +|---|---|---|---| +| `taskito_jobs_total` | Counter | `task`, `status` | Finished executions by outcome (`status` = `completed` or `failed`) | +| `taskito_job_duration_seconds` | Histogram | `task` | Execution duration | +| `taskito_active_workers` | Gauge | — | Workers currently running | +| `taskito_retries_total` | Counter | `task` | Retry attempts | +| `taskito_queue_depth` | Gauge | `queue` | Pending jobs per queue (polled by `PrometheusStatsCollector`) | +| `taskito_dlq_size` | Gauge | — | Dead-letter queue size (polled) | + +### `prometheusMiddleware` options + +| Option | Type | Default | Description | +|---|---|---|---| +| `namespace` | `string` | `"taskito"` | Metric name prefix | +| `register` | `Registry` | global `register` | prom-client registry to register metrics into | +| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip a task | +| `buckets` | `number[]` | prom-client defaults | Histogram bucket boundaries (seconds) | + +### `PrometheusStatsCollector` options + +| Option | Type | Default | Description | +|---|---|---|---| +| `namespace` | `string` | `"taskito"` | Metric name prefix | +| `register` | `Registry` | global `register` | Registry to write gauges into | +| `intervalMs` | `number` | `10000` | Poll interval in milliseconds | + +The internal timer is unref'd so it does not prevent Node from exiting. +Call `.stop()` on graceful shutdown. + +```ts +process.on("SIGTERM", () => { + collector.stop(); + worker.stop(); +}); +``` + + + Each `prometheusMiddleware()` and `PrometheusStatsCollector` instance + registers its metrics into the supplied registry — pass the same `register` + instance to both if you want all metrics in one registry. + + +--- + +## Sentry + +Peer dependency: `@sentry/node`. Call `Sentry.init(...)` in your app before +registering the middleware. + +```ts +import * as Sentry from "@sentry/node"; +import { sentryMiddleware } from "taskito/contrib/sentry"; + +Sentry.init({ dsn: process.env.SENTRY_DSN }); + +queue.use(sentryMiddleware()); +``` + +The real exception (with its stack trace) is captured in `onError` and held +per job, then reported to Sentry when the job **dead-letters** — so each dead +job produces a single event carrying the original stack plus task/job/queue +tags. Successful jobs and jobs that recover on retry are never reported. + +### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `tagPrefix` | `string` | `"taskito"` | Prefix for Sentry tag keys | +| `captureRetries` | `boolean` | `false` | Also report each retried failure as a `"warning"` event | +| `level` | `SeverityLevel` | `"error"` | Severity for the terminal dead-letter event | +| `extraTags` | `(event) => Record` | — | Extra tags merged onto the event | +| `taskFilter` | `(taskName) => boolean` | — | Return `false` to skip a task | + +Tags set on every event: `taskito.task_name`, `taskito.job_id`, +`taskito.queue`, `taskito.retry_count`, and `taskito.timed_out` (when the job +timed out). + + + `sentryMiddleware` never calls `Sentry.init` — configure the DSN, environment, + and sampling with the Sentry SDK yourself. If a task dead-letters from a + timeout without throwing, a synthetic error is captured so the failure is + still recorded. + diff --git a/docs/content/docs/node/contrib/web-frameworks.mdx b/docs/content/docs/node/contrib/web-frameworks.mdx new file mode 100644 index 00000000..2669b101 --- /dev/null +++ b/docs/content/docs/node/contrib/web-frameworks.mdx @@ -0,0 +1,173 @@ +--- +title: Web Frameworks +description: "REST routers and dashboard mounts for Express, Fastify, and NestJS." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +## Express + +Peer dependency: `express`. + +```ts +import express from "express"; +import { taskitoRouter, taskitoDashboard } from "taskito/contrib/express"; + +const app = express(); + +// REST API — mounted at /tasks +app.use("/tasks", taskitoRouter(queue)); + +// Dashboard SPA — mounted at /admin +app.use("/admin", taskitoDashboard(queue)); + +app.listen(3000); +``` + +### REST routes + +JSON body parsing is mounted internally on the router. + +| Method | Path | Description | +|---|---|---| +| `POST` | `/enqueue` | Enqueue a job. Body: `{ task, args?, options? }`. Returns `{ jobId }` with status 201. | +| `GET` | `/stats` | Overall queue statistics. | +| `GET` | `/stats/queues?queue=` | Per-queue statistics. | +| `GET` | `/jobs/:id` | Fetch a job by ID. | +| `GET` | `/jobs/:id/errors` | Error history for a job. | +| `GET` | `/jobs/:id/result?timeoutMs=` | Wait for a result (long-poll). | +| `POST` | `/jobs/:id/cancel` | Request cancellation. | +| `GET` | `/dead-letters?limit=&offset=` | List dead-lettered jobs. | +| `POST` | `/dead-letters/:id/retry` | Retry a dead-lettered job. | + +### `taskitoRouter` options + +| Option | Type | Default | Description | +|---|---|---|---| +| `includeRoutes` | `string[]` | all | Route names to expose (see below). | +| `excludeRoutes` | `string[]` | none | Route names to suppress. | +| `resultTimeoutMs` | `number` | `5000` | Max wait for `/jobs/:id/result`. | + +Route names: `enqueue`, `stats`, `queue-stats`, `job`, `job-errors`, +`job-result`, `cancel`, `dead-letters`, `retry-dead`. + +### Dashboard + +`taskitoDashboard(queue, { staticDir? })` returns a request handler that serves +the built dashboard SPA and proxies `/api/*` to the queue. Mount it under a +path prefix: + +```ts +app.use("/admin", taskitoDashboard(queue)); +``` + + + The SPA assets must be built first: `pnpm build:dashboard`. The built files + land in `node_modules/taskito/static/dashboard` by default; pass `staticDir` + to point elsewhere. + + +--- + +## Fastify + +Peer dependency: `fastify`. + +```ts +import Fastify from "fastify"; +import { + taskitoFastify, + taskitoDashboardPlugin, +} from "taskito/contrib/fastify"; + +const app = Fastify(); + +// REST API +await app.register(taskitoFastify, { queue, prefix: "/tasks" }); + +// Dashboard SPA +await app.register(taskitoDashboardPlugin, { queue, prefix: "/admin" }); + +await app.listen({ port: 3000 }); +``` + +`taskitoFastify` is a `FastifyPluginAsync`. It exposes the same nine routes as +the Express router (see the table above), mounted under `prefix`. + +The same `includeRoutes`, `excludeRoutes`, and `resultTimeoutMs` options are +accepted and passed in the register options object alongside `queue`: + +```ts +await app.register(taskitoFastify, { + queue, + prefix: "/tasks", + excludeRoutes: ["enqueue"], // read-only API + resultTimeoutMs: 10_000, +}); +``` + +`taskitoDashboardPlugin` accepts `queue`, `prefix`, and an optional +`staticDir`: + +```ts +await app.register(taskitoDashboardPlugin, { + queue, + prefix: "/admin", + staticDir: "/app/dashboard-assets", +}); +``` + +--- + +## NestJS + +Peer dependencies: `@nestjs/common` + `reflect-metadata`. + +```ts +import { Module } from "@nestjs/common"; +import { TaskitoModule } from "taskito/contrib/nest"; + +@Module({ + imports: [TaskitoModule.forRoot(queue)], +}) +export class AppModule {} +``` + +`TaskitoModule.forRoot(queue)` returns a `DynamicModule` that exports +`TaskitoService` to any module that imports it. + +### Injecting `TaskitoService` + +```ts +import { Injectable } from "@nestjs/common"; +import { TaskitoService } from "taskito/contrib/nest"; + +@Injectable() +export class ReportService { + constructor(private readonly tasks: TaskitoService) {} + + scheduleReport(reportId: string): string { + return this.tasks.enqueue("generateReport", [reportId], { + maxRetries: 2, + timeoutMs: 60_000, + }); + } +} +``` + +### `TaskitoService` methods + +| Method | Description | +|---|---| +| `enqueue(task, args?, options?)` | Enqueue a job, returns the job ID. | +| `result(id, options?)` | Wait for a result. | +| `getJob(id)` | Fetch job details by ID. | +| `stats()` | Overall queue statistics. | +| `requestCancel(id)` | Request cancellation of a running job. | +| `deadLetters(limit?, offset?)` | List dead-lettered jobs. | +| `tasks.queue` | The raw `Queue` instance for the full API surface. | + + + `TaskitoService` wraps the most common operations. For anything not listed — + workflows, distributed locks, metrics, etc. — access `tasks.queue` directly. + diff --git a/docs/content/docs/node/index.mdx b/docs/content/docs/node/index.mdx index 24bb8227..64bb0317 100644 --- a/docs/content/docs/node/index.mdx +++ b/docs/content/docs/node/index.mdx @@ -60,17 +60,17 @@ new Queue({ backend: "redis", dsn: "redis://localhost" }); // Redis + For the full API surface — serializers, cancellation, events, middleware, webhooks, distributed locks, periodic tasks, the CLI, and the dashboard — see the [Node SDK README](https://github.com/pratyushsharma/taskito/blob/master/sdks/node/README.md). - - - Gates, sub-workflows, and saga compensation are not yet available in the Node - SDK. Use the [Python SDK](/guides/workflows/gates) in the meantime. - diff --git a/docs/content/docs/node/meta.json b/docs/content/docs/node/meta.json index ecc9dcb8..c503e54c 100644 --- a/docs/content/docs/node/meta.json +++ b/docs/content/docs/node/meta.json @@ -1,5 +1,5 @@ { "title": "Node SDK", "root": true, - "pages": ["index", "workflows"] + "pages": ["index", "workflows", "contrib"] } diff --git a/docs/content/docs/node/workflows/conditions.mdx b/docs/content/docs/node/workflows/conditions.mdx new file mode 100644 index 00000000..ccb698d2 --- /dev/null +++ b/docs/content/docs/node/workflows/conditions.mdx @@ -0,0 +1,116 @@ +--- +title: Conditions +description: "Run a step only when its predecessors succeeded, failed, or either." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Every `.step()` runs by default only when all of its predecessors completed +successfully. Pass `condition` to change that: + +```ts +const handle = queue.workflows + .define("resilient-pipeline") + .step("riskyStep", "riskyTask") + .step("celebrate", "celebrateTask", { + after: "riskyStep", + condition: "on_success", // default — only runs if riskyStep completed + }) + .step("recover", "recoverTask", { + after: "riskyStep", + condition: "on_failure", // only runs if riskyStep dead-lettered + }) + .submit(); + +queue.runWorker(); + +const run = await handle.wait(); +console.log(run.state); // "failed" — even if recover ran (see below) +``` + +## Condition values + +| Value | When the step runs | +|---|---| +| `"on_success"` | All predecessors completed successfully (default) | +| `"on_failure"` | At least one predecessor dead-lettered | +| `"always"` | Regardless of predecessor outcome | + +A step whose condition is not met transitions to `skipped`. Skipped steps +propagate: all of their descendants are skipped too, unless those descendants +have a separate predecessor that did complete. + +## How it works + +The `WorkflowTracker` evaluates conditions when a predecessor node settles. It +reads the run plan from storage, checks each dependent node's condition against +the settled outcome, and either creates the deferred job or marks the node +`skipped`. Because the tracker reconstructs run state from storage on every +event, submit and execute may be different processes. + + celebrate["celebrate\n(on_success)"] + riskyStep --> recover["recover\n(on_failure)"] + + classDef skip fill:#fef9c3,color:#713f12,stroke:#ca8a04 + classDef run fill:#bbf7d0,color:#14532d,stroke:#16a34a + classDef fail fill:#fee2e2,color:#7f1d1d,stroke:#ef4444`} +/> + +When `riskyStep` fails: `celebrate` → `skipped`, `recover` → enqueued and runs. +When `riskyStep` succeeds: `recover` → `skipped`, `celebrate` → enqueued and runs. + +## Conditions are string predicates only + +`condition` accepts only the three string literals above — you cannot pass a +JavaScript function. Conditions are evaluated by the `WorkflowTracker` at +runtime, which runs inside the worker process and may differ from the process +that called `.submit()`. Because the workflow definition is serialised to and +reconstructed from storage, a JS closure cannot cross that boundary. If you need +dynamic branching, encode the decision in the task's return value and gate +downstream work on that (for example, use a fan-out with a zero-item array to +short-circuit a branch). + +## A failed run stays failed + +When `riskyStep` fails and `recover` runs, the workflow run still ends in state +`"failed"`. The `on_failure` handler executes but does not "recover" the run — +it is an error-handling side effect, not a circuit-breaker. To treat a failure +as a non-fatal branch, model it differently (for example, wrap the risky logic +in a task that catches internally and returns a status sentinel). + + + To roll back already-completed steps when a run fails, use + [Saga compensation](/node/workflows/saga) instead of `on_failure` conditions. + + +## `always` steps + +Use `"always"` for teardown or notification steps that should run regardless of +the upstream outcome: + +```ts +queue.workflows + .define("with-cleanup") + .step("provision", "provisionTask") + .step("work", "workTask", { after: "provision" }) + .step("cleanup", "cleanupTask", { + after: "work", + condition: "always", + }) + .submit(); +``` + +`cleanup` runs whether `work` succeeded or failed. + +## Step options + +| Option | Type | Description | +|---|---|---| +| `after` | `string \| string[]` | Predecessor node name(s) | +| `condition` | `"on_success" \| "on_failure" \| "always"` | Condition under which this step runs (default `"on_success"`) | +| `maxRetries` | `number` | Retry limit | +| `timeoutMs` | `number` | Per-attempt timeout | +| `priority` | `number` | Queue priority | +| `queue` | `string` | Queue name | diff --git a/docs/content/docs/node/workflows/fan-out.mdx b/docs/content/docs/node/workflows/fan-out.mdx index 40e44e80..471334a9 100644 --- a/docs/content/docs/node/workflows/fan-out.mdx +++ b/docs/content/docs/node/workflows/fan-out.mdx @@ -162,10 +162,3 @@ if (run.state === "failed") { console.error("failed nodes:", failed.map(n => n.nodeName)); } ``` - - - Gates, sub-workflows, and saga compensation are not yet available in the Node - SDK. For those, use the Python SDK — see [gates](/guides/workflows/gates), - [composition](/guides/workflows/composition), and - [sagas](/guides/workflows/sagas). - diff --git a/docs/content/docs/node/workflows/gates.mdx b/docs/content/docs/node/workflows/gates.mdx new file mode 100644 index 00000000..e3375418 --- /dev/null +++ b/docs/content/docs/node/workflows/gates.mdx @@ -0,0 +1,121 @@ +--- +title: Approval Gates +description: "Pause a workflow run until a human or external system approves or rejects." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +An approval gate pauses a workflow run until something external resolves it. +No task runs at the gate — the run simply waits. + +```ts +const handle = queue.workflows + .define("release-pipeline") + .step("build", "buildTask") + .gate("approve-deploy", { + after: "build", + message: "Review build artifacts before deploying to production.", + timeoutMs: 24 * 60 * 60 * 1000, // 24 h + onTimeout: "reject", + }) + .step("deploy", "deployTask", { after: "approve-deploy" }) + .submit(); + +queue.runWorker(); + +// Later — from any process that has access to the same storage: +queue.workflows.approveGate(handle.runId, "approve-deploy"); +``` + + gate["approve-deploy\n(waiting_approval)"]:::waiting + gate -- approved --> deploy:::run + gate -- rejected --> deploy_skip["deploy\n(skipped)"]:::skip + + classDef done fill:#bbf7d0,color:#14532d,stroke:#16a34a + classDef waiting fill:#fef9c3,color:#713f12,stroke:#ca8a04 + classDef run fill:#bfdbfe,color:#1e3a8a,stroke:#3b82f6 + classDef skip fill:#f3f4f6,color:#6b7280,stroke:#9ca3af`} +/> + +## Gate node status + +While a gate is pending, its node status is `waiting_approval`. The run itself +stays in `running` state. Downstream steps do not start until the gate resolves. + +```ts +const nodes = handle.nodes(); +const gate = nodes.find(n => n.nodeName === "approve-deploy"); +console.log(gate?.status); // "waiting_approval" +``` + +## Resolving a gate + +Get the `runId` from the submit handle and call one of the three resolution +methods. Resolution works from any process that opens the same storage — the +resolver does not need to be the worker process. + +```ts +// Approve — downstream steps are enqueued. +queue.workflows.approveGate(runId, "approve-deploy"); + +// Reject — downstream steps are skipped, run transitions to "failed". +queue.workflows.rejectGate(runId, "approve-deploy"); + +// Reject with a reason recorded in storage. +queue.workflows.rejectGate(runId, "approve-deploy", "Artifacts failed QA review."); + +// Unified helper — pass a boolean. +queue.workflows.resolveGate(runId, "approve-deploy", true); // approve +queue.workflows.resolveGate(runId, "approve-deploy", false, "Reason."); // reject +``` + +## Timeout behaviour + +When `timeoutMs` elapses the gate auto-resolves according to `onTimeout`: + +| `onTimeout` | Effect | +|---|---| +| `"reject"` (default) | Same as calling `rejectGate` — downstream skipped, run `failed` | +| `"approve"` | Same as calling `approveGate` — downstream proceeds | + +Setting `timeoutMs` without `onTimeout` defaults to `"reject"`. + +## Gate options + +| Option | Type | Default | Description | +|---|---|---|---| +| `after` | `string \| string[]` | — | Predecessor node name(s) | +| `message` | `string` | — | Human-readable description shown in the dashboard | +| `timeoutMs` | `number` | — | Auto-resolve after this many milliseconds | +| `onTimeout` | `"approve" \| "reject"` | `"reject"` | Resolution on timeout | + +## Rejection behaviour + +Rejecting a gate marks the gate node `failed` and skips its default +(`on_success`) successors; the workflow run ends in state `"failed"`. Any +`on_failure` / `always` successors still run — use those (see +[conditions](/node/workflows/conditions)) if you need a rejection branch. + + + Calling `approveGate` or `rejectGate` on a gate that has already been + resolved (approved, rejected, or timed out) is a no-op — the tracker ignores + stale resolutions. + + +## Webhooks and external triggers + +Because resolution is just an API call over shared storage, you can drive gates +from any external system. A common pattern is an HTTP endpoint in your API +server that receives a webhook and calls `approveGate`: + +```ts +app.post("/hooks/deploy-approval", async (req, res) => { + // Resolving a gate is a privileged state transition: authenticate the caller, + // verify the webhook signature, and authorize access to this run first. + const { runId, approved, reason } = req.body; + queue.workflows.resolveGate(runId, "approve-deploy", approved, reason); + res.sendStatus(204); +}); +``` diff --git a/docs/content/docs/node/workflows/index.mdx b/docs/content/docs/node/workflows/index.mdx index 857e4138..21b8d1f3 100644 --- a/docs/content/docs/node/workflows/index.mdx +++ b/docs/content/docs/node/workflows/index.mdx @@ -34,11 +34,24 @@ Requires the addon built with the `workflows` cargo feature (enabled by href="/node/workflows/fan-out" description="Split a step's result into parallel children and collect results." /> + + + + - - - Gates, sub-workflows, and saga compensation are not yet available in the Node - SDK. See the Python SDK for [gates](/guides/workflows/gates), - [composition](/guides/workflows/composition), and - [sagas](/guides/workflows/sagas). - diff --git a/docs/content/docs/node/workflows/meta.json b/docs/content/docs/node/workflows/meta.json index bf59b7cc..78b4fc06 100644 --- a/docs/content/docs/node/workflows/meta.json +++ b/docs/content/docs/node/workflows/meta.json @@ -1,4 +1,4 @@ { "title": "Workflows", - "pages": ["index", "fan-out"] + "pages": ["index", "fan-out", "conditions", "gates", "sub-workflows", "saga"] } diff --git a/docs/content/docs/node/workflows/saga.mdx b/docs/content/docs/node/workflows/saga.mdx new file mode 100644 index 00000000..6e09ad05 --- /dev/null +++ b/docs/content/docs/node/workflows/saga.mdx @@ -0,0 +1,151 @@ +--- +title: Saga Compensation +description: "Automatically roll back completed steps in reverse order when a workflow run fails." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +Saga compensation runs rollback tasks in reverse-dependency order when a +workflow run fails. Each step that completed and has a `compensate` task gets +rolled back; steps that never ran are left alone. + +## Basic example + +```ts +queue.task("reserveInventory", (orderId: string) => ({ reserved: true, orderId })); +queue.task("unreserveInventory", (result: { reserved: boolean; orderId: string }) => { /* undo */ }); + +queue.task("chargePayment", (orderId: string) => ({ charged: true, orderId })); +queue.task("refundPayment", (result: { charged: boolean; orderId: string }) => { /* undo */ }); + +queue.task("shipOrder", (orderId: string) => { throw new Error("Warehouse offline"); }); +// No compensate for ship — if it never succeeded, there's nothing to undo. + +const handle = queue.workflows + .define("checkout") + .step("reserve", "reserveInventory", { compensate: "unreserveInventory" }) + .step("charge", "chargePayment", { after: "reserve", compensate: "refundPayment" }) + .step("ship", "shipOrder", { after: "charge" }) + .submit(); + +queue.runWorker(); + +const run = await handle.wait(); +// ship fails → charge rolled back (refundPayment) → reserve rolled back (unreserveInventory) +// run.state === "compensated" +``` + + charge --> ship + ship -. "run fails" .-> refund + refund --> unreserve + + classDef done fill:#bbf7d0,color:#14532d,stroke:#16a34a + classDef fail fill:#fee2e2,color:#7f1d1d,stroke:#ef4444 + classDef comp fill:#ede9fe,color:#4c1d95,stroke:#7c3aed`} +/> + +## How it works + +1. `ship` dead-letters — the run's failure is detected by the tracker. +2. The tracker collects every completed node that has a `compensate` task, + ordered in reverse-dependency order (most downstream first). +3. It enqueues each compensate task, passing the forward step's result as the + single argument. Compensate tasks are ordinary registered tasks — they run in + the worker pool like any other job. +4. Rollbacks execute sequentially in that reverse order. Each inherits the + forward step's `queue`, `maxRetries`, and `timeoutMs`. +5. When all rollbacks complete the run transitions to `"compensated"`. +6. If a rollback itself dead-letters, the run ends in `"compensation_failed"` — + remaining un-rolled-back steps are left as-is (fail-stop; no partial retry). + +Compensation is storage-driven and idempotent: each rollback enqueue is +deduplicated, so a worker restart during compensation does not double-run a +rollback. + +## Run terminal states + +| State | Meaning | +|---|---| +| `"completed"` | All steps succeeded | +| `"failed"` | A step failed; no compensators were defined | +| `"compensated"` | A step failed; all compensators completed | +| `"compensation_failed"` | A step failed; at least one compensator also failed | + + + `"compensation_failed"` is a manual-intervention state. Inspect + `handle.nodes()` to identify which rollback failed and whether data is + partially consistent. + + +## Compensate task signature + +The compensate task receives the forward step's return value as its single +positional argument. Design forward tasks to return enough context for the +compensator to act: + +```ts +queue.task("chargePayment", async (orderId: string) => { + const charge = await stripe.charge(orderId); + return { chargeId: charge.id, orderId }; // compensator needs chargeId +}); + +queue.task("refundPayment", async (result: { chargeId: string; orderId: string }) => { + await stripe.refund(result.chargeId); +}); +``` + +## Steps without compensators + +Steps that do not declare `compensate` are skipped during rollback. A step that +never ran (because it was downstream of the failing step) is also skipped — only +**completed** steps with a compensate task are rolled back. + +```ts +queue.workflows + .define("partial-saga") + .step("a", "taskA", { compensate: "undoA" }) // rolled back if run fails after a completes + .step("b", "taskB", { after: "a" }) // no compensate — left as-is + .step("c", "taskC", { after: "b", compensate: "undoC" }) + .submit(); + +// If b fails: only a is rolled back (b never completed; c never ran). +// If c fails: c has no result yet, so only a is rolled back. +``` + +## Step options + +| Option | Type | Description | +|---|---|---| +| `after` | `string \| string[]` | Predecessor node name(s) | +| `compensate` | `string` | Registered task name to run as the rollback for this step | +| `maxRetries` | `number` | Retry limit for the forward step (inherited by compensate) | +| `timeoutMs` | `number` | Timeout for the forward step (inherited by compensate) | +| `priority` | `number` | Queue priority | +| `queue` | `string` | Queue name | + +## Combining with conditions + +Conditions and compensation compose. An `on_failure` handler runs first (as the +run settles toward failure); once the run is failed, the tracker then rolls back +the completed compensable steps: + +```ts +queue.workflows + .define("safe-checkout") + .step("reserve", "reserveInventory", { compensate: "unreserveInventory" }) + .step("charge", "chargePayment", { after: "reserve", compensate: "refundPayment" }) + .step("ship", "shipOrder", { after: "charge" }) + .step("notifyFailure", "sendFailureEmail", { + after: "ship", + condition: "on_failure", + }) + .submit(); +``` diff --git a/docs/content/docs/node/workflows/sub-workflows.mdx b/docs/content/docs/node/workflows/sub-workflows.mdx new file mode 100644 index 00000000..d9d20766 --- /dev/null +++ b/docs/content/docs/node/workflows/sub-workflows.mdx @@ -0,0 +1,131 @@ +--- +title: Sub-Workflows +description: "Compose workflow runs hierarchically — a parent step delegates to a child workflow." +--- + +import { Callout } from "fumadocs-ui/components/callout"; + +A sub-workflow step delegates to a fully independent child workflow run. The +parent node goes `running` while the child executes and resolves when the child +reaches a terminal state. + +## Basic composition + +Build the child spec with `.build()` (not `.submit()`) and pass it to +`.subWorkflow()` on the parent: + +```ts +// Child workflow spec — .build() returns a WorkflowSpec, does NOT submit. +const processChunk = queue.workflows + .define("process-chunk") + .step("validate", "validateTask") + .step("transform", "transformTask", { after: "validate" }) + .step("store", "storeTask", { after: "transform" }) + .build(); + +// Parent workflow. +const handle = queue.workflows + .define("full-pipeline") + .step("prepare", "prepareTask") + .subWorkflow("process", { + after: "prepare", + workflow: processChunk, + }) + .step("finish", "finishTask", { after: "process" }) + .submit(); + +queue.runWorker(); + +const run = await handle.wait(); +console.log(run.state); // "completed" | "failed" +``` + + process["process\n(sub-workflow)"]:::sub + subgraph child ["child run: process-chunk"] + v[validate] --> t[transform] --> s[store] + end + process -. spawns .-> child + process --> finish:::run + + classDef done fill:#bbf7d0,color:#14532d,stroke:#16a34a + classDef sub fill:#ede9fe,color:#4c1d95,stroke:#7c3aed + classDef run fill:#bfdbfe,color:#1e3a8a,stroke:#3b82f6`} +/> + +## How it works + +1. When `prepare` completes, the tracker creates the `process` node with kind + `sub_workflow` and submits the child as a linked run — a separate entry in + storage with its own nodes and state. +2. The parent `process` node status is `running` while the child is in flight. +3. The worker tracks the child run's outcome events. +4. When the child run finalises: + - **Child completed** → parent node transitions to `completed`; `finish` is + enqueued normally. + - **Child failed** → parent node transitions to `failed`; `finish` and any + other downstream nodes are `skipped`; the parent run ends `failed`. + +The child is a first-class run. It appears in dashboard queries, can be +inspected independently, and is visible via `queue.workflows.children()`. + +## Listing child runs + +```ts +const children = queue.workflows.children(handle.runId); +// WorkflowRun[] — each with id, state, parentRunId, parentNodeName, ... +``` + +## Nesting + +Child workflows may themselves contain sub-workflow steps. There is no enforced +depth limit, but deeply nested hierarchies make observability harder — prefer +fan-out for homogeneous parallel work and sub-workflows for distinct named +pipelines. + +A workflow's entry step must be a plain `.step` (a deferred kind — sub-workflow, +gate, fan-out, or conditioned step — only runs once a predecessor settles), so +give each sub-workflow an `after`: + +```ts +const inner = queue.workflows + .define("inner") + .step("a", "taskA") + .build(); + +const middle = queue.workflows + .define("middle") + .step("x", "taskX") + .subWorkflow("inner", { after: "x", workflow: inner }) + .build(); + +queue.workflows + .define("outer") + .step("init", "initTask") + .subWorkflow("middle", { after: "init", workflow: middle }) + .submit(); +``` + +## Sub-workflow options + +| Option | Type | Description | +|---|---|---| +| `after` | `string \| string[]` | Predecessor node name(s) | +| `workflow` | `WorkflowSpec` | Child workflow built with `.build()` | + + + Pass the result of `.build()`, not `.submit()`. Calling `.submit()` would + immediately enqueue the child as a standalone run unlinked from the parent. + + +## Failure propagation + +If the child run fails, the parent node is marked `failed` and the parent run +ends `failed`. The child's own node statuses are preserved and visible through +`queue.workflows.children()`. This mirrors how a failed step propagates through +the DAG — downstream steps are skipped. + +To handle child failures without failing the parent, wrap the sub-workflow node +in a conditional branch: place an `on_failure` step after it and an `on_success` +step for the happy path. See [Conditions](/node/workflows/conditions). diff --git a/sdks/node/README.md b/sdks/node/README.md index 74cff6c4..1d512112 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -273,8 +273,73 @@ and run fail and downstream steps are skipped. An empty item list completes the fan-out immediately and runs the fan-in with `[]`. Children and the combiner each run on the fan-out step's `queue` / `maxRetries` / `timeoutMs` / `priority`. -Gates, sub-workflows, and saga compensation are not yet bound — for those, use -the Python SDK. +### Conditions + +A step's `condition` gates it on its predecessors' outcomes: `on_success` +(default — all predecessors completed), `on_failure` (a predecessor failed — an +error handler), or `always`. A step whose condition isn't met is `skipped`, and +the skip propagates downstream. + +```ts +queue.workflows + .define("with-handler") + .step("risky", "risky") + .step("recover", "rollback", { after: "risky", condition: "on_failure" }) + .step("notify", "notifyOk", { after: "risky", condition: "on_success" }) + .submit(); +``` + +### Approval gates + +A `gate` step pauses the run (`waiting_approval`) until resolved out-of-band. +Resolve from any process (it reads the plan from storage); an optional timeout +auto-resolves per `onTimeout`. + +```ts +const handle = queue.workflows + .define("publish") + .step("build", "build") + .gate("review", { after: "build", timeoutMs: 86_400_000, onTimeout: "reject" }) + .step("ship", "ship", { after: "review" }) + .submit(); + +queue.workflows.approveGate(handle.runId, "review"); // or rejectGate(..., reason) +``` + +### Sub-workflows + +A `subWorkflow` step runs a child workflow as a node; the parent advances when +the child finalizes (child failure fails the parent node). Build the child with +`.build()` (don't submit it directly). + +```ts +const child = queue.workflows.define("child").step("a", "taskA").build(); + +queue.workflows + .define("parent") + .step("prep", "prep") + .subWorkflow("sub", { after: "prep", workflow: child }) + .step("finish", "finish", { after: "sub" }) + .submit(); + +queue.workflows.children(handle.runId); // the spawned child run(s) +``` + +### Saga compensation + +Give a step a `compensate` task and, if the run fails, the tracker rolls back +each completed compensable step in reverse-dependency order, passing the step's +result to its compensator. The run ends `compensated`, or `compensation_failed` +if a rollback itself fails. + +```ts +queue.workflows + .define("checkout") + .step("reserve", "reserve", { compensate: "unreserve" }) + .step("charge", "charge", { after: "reserve", compensate: "refund" }) + .step("ship", "ship", { after: "charge" }) // if this fails → refund, then unreserve + .submit(); +``` ## Dashboard @@ -324,6 +389,71 @@ queue.runWorker({ Other tunables: `bindAddr`, `advertiseAddr` (NAT), `affinityWeight`, `localBuffer`, `stealBatch`, `stealThreshold`, `virtualNodes`, `stealRateLimit`. +## Contrib integrations + +Optional integrations live under the `taskito/contrib/*` subpaths. Each requires its +framework as a peer dependency you install yourself; none are pulled in by the main +package or exported from the `taskito` barrel. + +### Observability + +```ts +import { otelMiddleware } from "taskito/contrib/otel"; // peer: @opentelemetry/api +import { prometheusMiddleware, PrometheusStatsCollector } from "taskito/contrib/prometheus"; // peer: prom-client + +queue.use(otelMiddleware()); // one span per execution: taskito.execute. +queue.use(prometheusMiddleware()); // taskito_jobs_total, _job_duration_seconds, _active_workers, _retries_total + +const collector = new PrometheusStatsCollector(queue); // polls queue depth + DLQ size +collector.start(); +// expose `await register.metrics()` from your HTTP server +``` + +OTel options: `tracerName`, `attributePrefix`, `spanName(ctx)`, `extraAttributes(ctx)`, +`taskFilter(name)`. Prometheus options: `namespace`, `register`, `taskFilter`, `buckets` +(metrics for one namespace are built once per registry, so multiple middlewares are safe). + +```ts +import { sentryMiddleware } from "taskito/contrib/sentry"; // peer: @sentry/node +queue.use(sentryMiddleware()); // call Sentry.init(...) yourself first +``` + +The exception (with its stack) is captured from `onError` and reported when the job +dead-letters — one event per dead job, tagged with task/job/queue. Set `captureRetries` +to also report each intermediate failure as a warning. Other options: `tagPrefix`, +`level`, `extraTags(event)`, `taskFilter`. + +### Web frameworks + +`taskitoRouter` / the Fastify plugin expose a JSON API (enqueue + inspection); a separate +helper mounts the dashboard (SPA + `/api/*`) into your app. + +```ts +import { taskitoRouter, taskitoDashboard } from "taskito/contrib/express"; // peer: express +app.use("/tasks", taskitoRouter(queue)); // POST /enqueue, GET /stats, /jobs/:id, ... +app.use("/admin", taskitoDashboard(queue)); // dashboard SPA + /api/* + +import { taskitoFastify, taskitoDashboardPlugin } from "taskito/contrib/fastify"; // peer: fastify +app.register(taskitoFastify, { queue, prefix: "/tasks" }); +app.register(taskitoDashboardPlugin, { queue, prefix: "/admin" }); +``` + +Both routers take `includeRoutes` / `excludeRoutes` (route names: `enqueue`, `stats`, +`queue-stats`, `job`, `job-errors`, `job-result`, `cancel`, `dead-letters`, `retry-dead`) +and `resultTimeoutMs`. + +NestJS exposes an injectable service: + +```ts +import { TaskitoModule, TaskitoService } from "taskito/contrib/nest"; // peers: @nestjs/common, reflect-metadata + +@Module({ imports: [TaskitoModule.forRoot(queue)] }) +export class AppModule {} + +// constructor(private readonly tasks: TaskitoService) {} +// this.tasks.enqueue("add", [2, 3]); this.tasks.queue gives the full API +``` + ## Development ```bash @@ -340,7 +470,5 @@ compiled in via `--features postgres,redis`. ## Not yet covered -Advanced workflow features (gates, sub-workflows, saga compensation — fan-out is -covered above), resources/proxies/interception, contrib integrations, prebuilt -platform binaries + npm publish (host-only build for now), and Python⇄Node -cross-language interop. +Resources / dependency-injection, prebuilt platform binaries + npm publish +(host-only build for now), and Python⇄Node cross-language interop. diff --git a/sdks/node/biome.json b/sdks/node/biome.json index 5bb6bbe5..0d638626 100644 --- a/sdks/node/biome.json +++ b/sdks/node/biome.json @@ -16,6 +16,9 @@ } }, "javascript": { + "parser": { + "unsafeParameterDecoratorsEnabled": true + }, "formatter": { "quoteStyle": "double", "semicolons": "always" diff --git a/sdks/node/package.json b/sdks/node/package.json index 19837be4..002d1abc 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -19,6 +19,36 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./contrib/otel": { + "types": "./dist/contrib/otel.d.ts", + "import": "./dist/contrib/otel.js", + "require": "./dist/contrib/otel.cjs" + }, + "./contrib/prometheus": { + "types": "./dist/contrib/prometheus.d.ts", + "import": "./dist/contrib/prometheus.js", + "require": "./dist/contrib/prometheus.cjs" + }, + "./contrib/express": { + "types": "./dist/contrib/express.d.ts", + "import": "./dist/contrib/express.js", + "require": "./dist/contrib/express.cjs" + }, + "./contrib/fastify": { + "types": "./dist/contrib/fastify.d.ts", + "import": "./dist/contrib/fastify.js", + "require": "./dist/contrib/fastify.cjs" + }, + "./contrib/nest": { + "types": "./dist/contrib/nest.d.ts", + "import": "./dist/contrib/nest.js", + "require": "./dist/contrib/nest.cjs" + }, + "./contrib/sentry": { + "types": "./dist/contrib/sentry.d.ts", + "import": "./dist/contrib/sentry.js", + "require": "./dist/contrib/sentry.cjs" } }, "files": [ @@ -60,7 +90,18 @@ "devDependencies": { "@biomejs/biome": "^2.4.16", "@napi-rs/cli": "^2.18.4", + "@nestjs/common": "^11.1.27", + "@nestjs/core": "^11.1.27", + "@nestjs/testing": "^11.1.27", + "@opentelemetry/api": "^1.9.1", + "@sentry/node": "^10.58.0", + "@types/express": "^5.0.6", "@types/node": "^25.9.2", + "express": "^5.2.1", + "fastify": "^5.8.5", + "prom-client": "^15.1.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", "tsup": "^8.5.0", "typescript": "^6.0.3", "vitest": "^4.1.8" @@ -68,5 +109,37 @@ "dependencies": { "@msgpack/msgpack": "^3.1.3", "commander": "^15.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@opentelemetry/api": "^1.9.0", + "@sentry/node": "^8.0.0 || ^9.0.0 || ^10.0.0", + "express": "^4.18.0 || ^5.0.0", + "fastify": "^4.0.0 || ^5.0.0", + "prom-client": "^15.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@nestjs/common": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@sentry/node": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + }, + "prom-client": { + "optional": true + }, + "reflect-metadata": { + "optional": true + } } } diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index e7d1dd9f..372b15b6 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -21,9 +21,42 @@ importers: '@napi-rs/cli': specifier: ^2.18.4 version: 2.18.4 + '@nestjs/common': + specifier: ^11.1.27 + version: 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.1.27 + version: 11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/testing': + specifier: ^11.1.27 + version: 11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@sentry/node': + specifier: ^10.58.0 + version: 10.58.0 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 '@types/node': specifier: ^25.9.2 version: 25.9.3 + express: + specifier: ^5.2.1 + version: 5.2.1 + fastify: + specifier: ^5.8.5 + version: 5.8.5 + prom-client: + specifier: ^15.1.3 + version: 15.1.3 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 tsup: specifier: ^8.5.0 version: 8.5.1(postcss@8.5.15)(typescript@6.0.3) @@ -32,7 +65,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.8 - version: 4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)) packages: @@ -93,6 +126,9 @@ packages: cpu: [x64] os: [win32] + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -258,6 +294,24 @@ packages: cpu: [x64] os: [win32] + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -271,6 +325,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + '@msgpack/msgpack@3.1.3': resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} engines: {node: '>= 18'} @@ -286,9 +344,92 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nestjs/common@11.1.27': + resolution: {integrity: sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==} + peerDependencies: + class-transformer: '>=0.4.1' + class-validator: '>=0.13.2' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/core@11.1.27': + resolution: {integrity: sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==} + engines: {node: '>= 20'} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/testing@11.1.27': + resolution: {integrity: sha512-I35po13UHZZeGenLWJ3QYwh77RsLau5RcFKWBZ4waVHeARpwjtC7v7n7lGh98swLQdGmZgTnbvKaZ0B5dsUIKA==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/platform-express': ^11.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -525,24 +666,103 @@ packages: cpu: [x64] os: [win32] + '@sentry/core@10.58.0': + resolution: {integrity: sha512-bkIbh2c6dzwhrWn/FGWu7j8hf6TAat2XxpkGM91LiN09fLYUXIMwcohVsXqze5l2cq35TnvqmSROAbRNr27GVw==} + engines: {node: '>=18'} + + '@sentry/node-core@10.58.0': + resolution: {integrity: sha512-7dTbYuoaSwSmF2GWDl7KK+sXQL8iqaZeZ2I/aFm+SvPZLckZF3OGFb2VsluWsSXQLnxtxPX9QP93viyK+VZsuA==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/core': + optional: true + '@opentelemetry/exporter-trace-otlp-http': + optional: true + '@opentelemetry/instrumentation': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + '@opentelemetry/semantic-conventions': + optional: true + + '@sentry/node@10.58.0': + resolution: {integrity: sha512-KICgacBS+I/eWzFlAembutSwFwy0WVSrGp8UMV9n1XZqqu4EBTlALRsbLNlDSv61UgH85L9L3vk91tgq6nJXAA==} + engines: {node: '>=18'} + + '@sentry/opentelemetry@10.58.0': + resolution: {integrity: sha512-qKOGVmt02wDaq7E70VekG8Z9XM641trJPoTHSeVUfGaXVcmGc46ZldTNtfWbxJq/8f/fge2pap60gn066ido2Q==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + + '@sentry/server-utils@10.58.0': + resolution: {integrity: sha512-PywIl2jvl+tO5R4j+n72Lcf3ItanHcaMN/oL1U9ZHE8icaT2zpo2W4uOaslpQeQvqPC24HGZ3BW2etzsCFQbag==} + engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/node@25.9.3': resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -572,11 +792,34 @@ packages: '@vitest/utils@4.1.9': resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -584,16 +827,42 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.2.0: + resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + + bintrees@1.0.2: + resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: esbuild: '>=0.18' + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -602,6 +871,9 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + commander@15.0.0: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} @@ -617,9 +889,33 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -629,25 +925,91 @@ packages: supports-color: optional: true + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.4.0: + resolution: {integrity: sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -657,18 +1019,103 @@ packages: picomatch: optional: true + file-type@21.3.4: + resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} + engines: {node: '>=20'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-my-way@9.6.0: + resolution: {integrity: sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==} + engines: {node: '>=20'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + import-in-the-middle@3.1.0: + resolution: {integrity: sha512-c0AeAV8VcwZzfYE7euTZY3H+VXUPMVugiovdosq80lqEXJmOekg3zGUAYg6KImHMaMuBoTUfTv7xNpUFdy0hJA==} + engines: {node: '>=18'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.4.0: + resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} + engines: {node: '>= 10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -750,6 +1197,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + load-esm@1.0.3: + resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} + engines: {node: '>=13.2.0'} + load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -757,9 +1208,32 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -771,14 +1245,40 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -789,6 +1289,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -818,14 +1328,72 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + prom-client@15.1.3: + resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==} + engines: {node: ^16 || ^18 || >=20} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -836,9 +1404,68 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -847,17 +1474,32 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true + tdigest@0.1.2: + resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -865,6 +1507,10 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -883,6 +1529,18 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + toad-cache@3.7.1: + resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -912,6 +1570,10 @@ packages: typescript: optional: true + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -920,9 +1582,25 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1012,6 +1690,9 @@ packages: engines: {node: '>=8'} hasBin: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + snapshots: '@biomejs/biome@2.5.0': @@ -1049,6 +1730,8 @@ snapshots: '@biomejs/cli-win32-x64@2.5.0': optional: true + '@borewit/text-codec@0.2.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1143,6 +1826,29 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.4.0 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.4.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1157,6 +1863,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/csprng@1.1.0': {} + '@msgpack/msgpack@3.1.3': {} '@napi-rs/cli@2.18.4': {} @@ -1168,8 +1876,74 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.4 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/core@11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.4.2 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + + '@nestjs/testing@11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + dependencies: + '@nestjs/common': 11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.27(@nestjs/common@11.1.27(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.1.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@oxc-project/types@0.133.0': {} + '@pinojs/redact@0.4.0': {} + '@rolldown/binding-android-arm64@1.0.3': optional: true @@ -1296,26 +2070,114 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.0': optional: true + '@sentry/core@10.58.0': {} + + '@sentry/node-core@10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1)': + dependencies: + '@sentry/core': 10.58.0 + '@sentry/opentelemetry': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) + import-in-the-middle: 3.1.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + + '@sentry/node@10.58.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@sentry/core': 10.58.0 + '@sentry/node-core': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) + '@sentry/opentelemetry': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) + '@sentry/server-utils': 10.58.0 + import-in-the-middle: 3.1.0 + transitivePeerDependencies: + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + + '@sentry/opentelemetry@10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + '@sentry/core': 10.58.0 + + '@sentry/server-utils@10.58.0': + dependencies: + '@sentry/core': 10.58.0 + '@standard-schema/spec@1.1.0': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 optional: true + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.9.3 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.9.3 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 25.9.3 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + '@types/node@25.9.3': dependencies: undici-types: 7.24.6 + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.9.3 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.9.3 + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -1357,25 +2219,84 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abstract-logging@2.0.1: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn@8.17.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + any-promise@1.3.0: {} assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} + + avvio@9.2.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + bintrees@1.0.2: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 load-tsconfig: 0.2.5 + bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@6.2.2: {} chokidar@4.0.3: dependencies: readdirp: 4.1.2 + cjs-module-lexer@2.2.0: {} + commander@15.0.0: {} commander@4.1.1: {} @@ -1384,16 +2305,50 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 + depd@2.0.0: {} + + dequal@2.0.3: {} + detect-libc@2.1.2: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -1423,27 +2378,208 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + escape-html@1.0.3: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + etag@1.8.1: {} + expect-type@1.3.0: {} + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.4.0: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.2 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.2: {} + + fastify@5.8.5: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.2.0 + fast-json-stringify: 6.4.0 + find-my-way: 9.6.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.8.4 + toad-cache: 3.7.1 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-my-way@9.6.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.2 rollup: 4.62.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + import-in-the-middle@3.1.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.4.0: {} + + is-promise@4.0.0: {} + + iterare@1.2.1: {} + joycon@3.1.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.32.0: optional: true @@ -1497,12 +2633,26 @@ snapshots: lines-and-columns@1.2.4: {} + load-esm@1.0.3: {} + load-tsconfig@0.2.5: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mlly@1.8.2: dependencies: acorn: 8.17.0 @@ -1510,6 +2660,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + module-details-from-path@1.0.4: {} + ms@2.1.3: {} mz@2.7.0: @@ -1520,16 +2672,54 @@ snapshots: nanoid@3.3.12: {} + negotiator@1.0.0: {} + object-assign@4.1.1: {} + object-inspect@1.13.4: {} + obug@2.1.3: {} + on-exit-leak-free@2.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} picocolors@1.1.1: {} picomatch@4.0.4: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + pirates@4.0.7: {} pkg-types@1.3.1: @@ -1550,10 +2740,60 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + prom-client@15.1.3: + dependencies: + '@opentelemetry/api': 1.9.1 + tdigest: 0.1.2 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + quick-format-unescaped@4.0.4: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + readdirp@4.1.2: {} + real-require@0.2.0: {} + + real-require@1.0.0: {} + + reflect-metadata@0.2.2: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resolve-from@5.0.0: {} + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -1606,16 +2846,111 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + secure-json-parse@4.1.0: {} + + semver@7.8.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map@0.7.6: {} + split2@4.2.0: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -1626,6 +2961,10 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + tdigest@0.1.2: + dependencies: + bintrees: 1.0.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -1634,6 +2973,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1647,12 +2990,21 @@ snapshots: tinyrainbow@3.1.0: {} + toad-cache@3.7.1: {} + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsup@8.5.1(postcss@8.5.15)(typescript@6.0.3): dependencies: @@ -1682,12 +3034,28 @@ snapshots: - tsx - yaml + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@6.0.3: {} ufo@1.6.4: {} + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + uint8array-extras@1.5.0: {} + undici-types@7.24.6: {} + unpipe@1.0.0: {} + + vary@1.1.2: {} + vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7): dependencies: lightningcss: 1.32.0 @@ -1700,7 +3068,7 @@ snapshots: esbuild: 0.27.7 fsevents: 2.3.3 - vitest@4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)) @@ -1723,6 +3091,7 @@ snapshots: vite: 8.0.16(@types/node@25.9.3)(esbuild@0.27.7) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 25.9.3 transitivePeerDependencies: - msw @@ -1731,3 +3100,5 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + wrappy@1.0.2: {} diff --git a/sdks/node/src/contrib/express.ts b/sdks/node/src/contrib/express.ts new file mode 100644 index 00000000..c3bd416c --- /dev/null +++ b/sdks/node/src/contrib/express.ts @@ -0,0 +1,77 @@ +// Express integration for Taskito. Optional — import from `taskito/contrib/express`; +// requires `express` as a peer. +// +// app.use("/tasks", taskitoRouter(queue)); // JSON API (enqueue + inspection) +// app.use("/admin", taskitoDashboard(queue)); // the dashboard SPA + /api/* + +import { fileURLToPath } from "node:url"; +import express, { type RequestHandler, type Router } from "express"; +import { createDashboardHandler } from "../dashboard"; +import type { Queue } from "../queue"; +import { createLogger } from "../utils"; +import { buildRestRoutes, flattenQueryParams, type RestOptions, type RestRequest } from "./rest"; + +const log = createLogger("contrib:express"); + +/** Options for {@link taskitoRouter}. */ +export type TaskitoRouterOptions = RestOptions; + +/** Options for {@link taskitoDashboard}. */ +export interface TaskitoDashboardOptions { + /** Path to the built SPA assets (defaults to the package's bundled `static/dashboard`). */ + staticDir?: string; +} + +/** + * Build an Express {@link Router} exposing the Taskito REST API (enqueue, stats, job + * lookup, cancel, dead-letters). JSON body parsing is mounted on the router itself. + */ +export function taskitoRouter(queue: Queue, options: TaskitoRouterOptions = {}): Router { + const router = express.Router(); + router.use(express.json()); + + for (const route of buildRestRoutes(options)) { + const handler: RequestHandler = async (req, res) => { + const request: RestRequest = { + params: req.params as Record, + query: flattenQueryParams(req.query), + body: req.body, + }; + try { + const result = await route.handle(queue, request); + res.status(result.status).json(result.body); + } catch (err) { + // Log internally; don't leak internal error details to API callers. + log.error(() => `${route.method} ${route.path} failed`, err); + res.status(500).json({ error: "internal server error" }); + } + }; + if (route.method === "GET") { + router.get(route.path, handler); + } else { + router.post(route.path, handler); + } + } + return router; +} + +// Resolved relative to this entry's location in dist/contrib/ → package `static/dashboard`. +const STATIC_REL = ["..", "..", "static", "dashboard"].join("/"); + +function defaultStaticDir(): string { + return fileURLToPath(new URL(STATIC_REL, import.meta.url)); +} + +/** + * Build an Express middleware that serves the Taskito dashboard (SPA + `/api/*`). Mount + * it under a path — Express strips the mount prefix, which the dashboard handler expects. + */ +export function taskitoDashboard( + queue: Queue, + options: TaskitoDashboardOptions = {}, +): RequestHandler { + const handler = createDashboardHandler(queue, options.staticDir ?? defaultStaticDir()); + return (req, res) => { + handler(req, res); + }; +} diff --git a/sdks/node/src/contrib/fastify.ts b/sdks/node/src/contrib/fastify.ts new file mode 100644 index 00000000..574203b1 --- /dev/null +++ b/sdks/node/src/contrib/fastify.ts @@ -0,0 +1,84 @@ +// Fastify integration for Taskito. Optional — import from `taskito/contrib/fastify`; +// requires `fastify` as a peer. +// +// app.register(taskitoFastify, { queue, prefix: "/tasks" }); // JSON API +// app.register(taskitoDashboardPlugin, { queue, prefix: "/admin" }); // dashboard SPA + /api/* + +import { fileURLToPath } from "node:url"; +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; +import { createDashboardHandler } from "../dashboard"; +import type { Queue } from "../queue"; +import { buildRestRoutes, flattenQueryParams, type RestOptions, type RestRequest } from "./rest"; + +/** Options for {@link taskitoFastify}. */ +export interface TaskitoFastifyOptions extends RestOptions { + /** The queue to operate over. */ + queue: Queue; +} + +/** Options for {@link taskitoDashboardPlugin}. */ +export interface TaskitoDashboardPluginOptions { + /** The queue to operate over. */ + queue: Queue; + /** Path to the built SPA assets (defaults to the package's bundled `static/dashboard`). */ + staticDir?: string; +} + +/** + * Fastify plugin exposing the Taskito REST API (enqueue, stats, job lookup, cancel, + * dead-letters). Register with a `prefix` to mount the routes under a base path. + */ +export const taskitoFastify: FastifyPluginAsync = async ( + fastify, + options, +) => { + const { queue } = options; + for (const route of buildRestRoutes(options)) { + fastify.route({ + method: route.method, + url: route.path, + handler: async (request: FastifyRequest, reply: FastifyReply) => { + const request_: RestRequest = { + params: request.params as Record, + query: flattenQueryParams(request.query), + body: request.body, + }; + const result = await route.handle(queue, request_); + reply.code(result.status).send(result.body); + }, + }); + } +}; + +// Resolved relative to this entry's location in dist/contrib/ → package `static/dashboard`. +const STATIC_REL = ["..", "..", "static", "dashboard"].join("/"); + +function defaultStaticDir(): string { + return fileURLToPath(new URL(STATIC_REL, import.meta.url)); +} + +/** + * Fastify plugin that serves the Taskito dashboard (SPA + `/api/*`). Register with a + * `prefix`; the plugin strips it from the raw URL so the dashboard handler sees `/api/*` + * and SPA paths, then hands the raw request/response to the handler via `reply.hijack()`. + */ +export const taskitoDashboardPlugin: FastifyPluginAsync = async ( + fastify, + options, +) => { + const handler = createDashboardHandler(options.queue, options.staticDir ?? defaultStaticDir()); + const prefix = fastify.prefix; + + // Leave the request body stream intact so the dashboard handler can read POST bodies. + fastify.addContentTypeParser("*", (_request, payload, done) => done(null, payload)); + + const serve = (request: FastifyRequest, reply: FastifyReply): void => { + const url = request.raw.url ?? "/"; + request.raw.url = url.startsWith(prefix) ? url.slice(prefix.length) || "/" : url; + reply.hijack(); + handler(request.raw, reply.raw); + }; + + fastify.all("/", serve); + fastify.all("/*", serve); +}; diff --git a/sdks/node/src/contrib/nest.ts b/sdks/node/src/contrib/nest.ts new file mode 100644 index 00000000..478c883f --- /dev/null +++ b/sdks/node/src/contrib/nest.ts @@ -0,0 +1,72 @@ +// NestJS integration for Taskito. Optional — import from `taskito/contrib/nest`; +// requires `@nestjs/common` (and `reflect-metadata`) as peers. +// +// @Module({ imports: [TaskitoModule.forRoot(queue)] }) +// export class AppModule {} +// +// // then inject anywhere: +// constructor(private readonly tasks: TaskitoService) {} + +import "reflect-metadata"; +import { type DynamicModule, Inject, Injectable, Module } from "@nestjs/common"; +import type { Queue } from "../queue"; +import type { DeadJob, EnqueueOptions, Job, ResultOptions, Stats } from "../types"; + +/** DI token for the underlying {@link Queue}. Provided by {@link TaskitoModule.forRoot}. */ +export const TASKITO_QUEUE = Symbol("TASKITO_QUEUE"); + +/** + * Injectable wrapper over a Taskito {@link Queue}. Exposes the common producer/inspection + * methods; reach the full API via {@link TaskitoService.queue}. + */ +@Injectable() +export class TaskitoService { + constructor(@Inject(TASKITO_QUEUE) readonly queue: Queue) {} + + /** Enqueue `task` with positional `args`. Returns the job id. */ + enqueue(task: string, args?: unknown[], options?: EnqueueOptions): string { + return this.queue.enqueue(task, args ?? [], options); + } + + /** Await a job's terminal result. */ + result(id: string, options?: ResultOptions): Promise { + return this.queue.result(id, options); + } + + /** Fetch a job by id, or `null` if unknown. */ + getJob(id: string): Job | null { + return this.queue.getJob(id); + } + + /** Aggregate counts across all queues. */ + stats(): Stats { + return this.queue.stats(); + } + + /** Request cooperative cancellation of a job. */ + requestCancel(id: string): boolean { + return this.queue.requestCancel(id); + } + + /** List dead-letter entries. */ + deadLetters(limit?: number, offset?: number): DeadJob[] { + return this.queue.deadLetters(limit, offset); + } +} + +/** + * Dynamic Nest module that provides {@link TaskitoService} bound to a queue. Register it + * once at the root with {@link TaskitoModule.forRoot}. + */ +@Module({}) +// biome-ignore lint/complexity/noStaticOnlyClass: Nest dynamic modules are decorated classes with a static forRoot factory. +export class TaskitoModule { + /** Provide a {@link TaskitoService} backed by `queue`. */ + static forRoot(queue: Queue): DynamicModule { + return { + module: TaskitoModule, + providers: [{ provide: TASKITO_QUEUE, useValue: queue }, TaskitoService], + exports: [TaskitoService], + }; + } +} diff --git a/sdks/node/src/contrib/otel.ts b/sdks/node/src/contrib/otel.ts new file mode 100644 index 00000000..60f5b3cd --- /dev/null +++ b/sdks/node/src/contrib/otel.ts @@ -0,0 +1,75 @@ +// OpenTelemetry tracing for Taskito task execution. Optional integration — +// import from `taskito/contrib/otel`; requires `@opentelemetry/api` as a peer. +// +// Register with `queue.use(otelMiddleware())`. Each execution attempt becomes one +// span (`taskito.execute.`); a retry is a fresh attempt and thus a new span. + +import { type Attributes, type Span, SpanStatusCode, trace } from "@opentelemetry/api"; +import type { Middleware, TaskContext } from "../middleware"; + +/** Options for {@link otelMiddleware}. */ +export interface OtelMiddlewareOptions { + /** Tracer name passed to `trace.getTracer` (default `"taskito"`). */ + tracerName?: string; + /** Prefix for span attribute keys (default `"taskito"`). */ + attributePrefix?: string; + /** Override the span name (default `".execute."`). */ + spanName?: (ctx: TaskContext) => string; + /** Extra attributes merged onto the span at start. */ + extraAttributes?: (ctx: TaskContext) => Attributes; + /** Only trace tasks for which this returns true (default: all). */ + taskFilter?: (taskName: string) => boolean; +} + +/** + * Build {@link Middleware} that wraps each task execution in an OpenTelemetry span. + * The span starts in `before`, ends `OK` in `after`, and ends `ERROR` (recording the + * exception) in `onError`. + */ +export function otelMiddleware(options: OtelMiddlewareOptions = {}): Middleware { + const tracerName = options.tracerName ?? "taskito"; + const prefix = options.attributePrefix ?? "taskito"; + const tracer = trace.getTracer(tracerName); + const spans = new Map(); + + const tracked = (taskName: string): boolean => options.taskFilter?.(taskName) ?? true; + + return { + before(ctx) { + if (!tracked(ctx.taskName)) { + return; + } + const name = options.spanName?.(ctx) ?? `${prefix}.execute.${ctx.taskName}`; + const span = tracer.startSpan(name); + span.setAttribute(`${prefix}.job_id`, ctx.jobId); + span.setAttribute(`${prefix}.task_name`, ctx.taskName); + const extra = options.extraAttributes?.(ctx); + if (extra) { + span.setAttributes(extra); + } + spans.set(ctx.jobId, span); + }, + + after(ctx) { + const span = spans.get(ctx.jobId); + if (!span) { + return; + } + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + spans.delete(ctx.jobId); + }, + + onError(ctx, error) { + const span = spans.get(ctx.jobId); + if (!span) { + return; + } + const message = error instanceof Error ? error.message : String(error); + span.recordException(error instanceof Error ? error : message); + span.setStatus({ code: SpanStatusCode.ERROR, message }); + span.end(); + spans.delete(ctx.jobId); + }, + }; +} diff --git a/sdks/node/src/contrib/prometheus.ts b/sdks/node/src/contrib/prometheus.ts new file mode 100644 index 00000000..370bc058 --- /dev/null +++ b/sdks/node/src/contrib/prometheus.ts @@ -0,0 +1,193 @@ +// Prometheus metrics for Taskito. Optional integration — import from +// `taskito/contrib/prometheus`; requires `prom-client` as a peer. +// +// Register the middleware with `queue.use(prometheusMiddleware())` to record per-job +// counters/histograms, and run a `PrometheusStatsCollector` to poll queue depth / DLQ +// size. Expose `register.metrics()` from your HTTP server. + +import { Counter, register as defaultRegister, Gauge, Histogram, type Registry } from "prom-client"; +import type { Middleware } from "../middleware"; +import type { Queue } from "../queue"; + +/** The per-(registry, namespace) metric bundle, created once and reused. */ +interface TaskitoMetrics { + jobsTotal: Counter<"task" | "status">; + jobDuration: Histogram<"task">; + activeWorkers: Gauge; + retriesTotal: Counter<"task">; + queueDepth: Gauge<"queue">; + dlqSize: Gauge; +} + +// prom-client throws on duplicate registration, so a namespace's metrics must be built +// exactly once per registry. Cache them — mirrors the Python contrib's `_metric_stores`. +const stores = new WeakMap>(); + +function getMetrics(register: Registry, namespace: string, buckets?: number[]): TaskitoMetrics { + let byNamespace = stores.get(register); + if (!byNamespace) { + byNamespace = new Map(); + stores.set(register, byNamespace); + } + const cached = byNamespace.get(namespace); + if (cached) { + return cached; + } + const registers = [register]; + const metrics: TaskitoMetrics = { + jobsTotal: new Counter({ + name: `${namespace}_jobs_total`, + help: "Total finished jobs by task and outcome.", + labelNames: ["task", "status"], + registers, + }), + jobDuration: new Histogram({ + name: `${namespace}_job_duration_seconds`, + help: "Task execution duration in seconds.", + labelNames: ["task"], + ...(buckets ? { buckets } : {}), + registers, + }), + activeWorkers: new Gauge({ + name: `${namespace}_active_workers`, + help: "Jobs currently executing.", + registers, + }), + retriesTotal: new Counter({ + name: `${namespace}_retries_total`, + help: "Total job retries by task.", + labelNames: ["task"], + registers, + }), + queueDepth: new Gauge({ + name: `${namespace}_queue_depth`, + help: "Pending jobs per queue.", + labelNames: ["queue"], + registers, + }), + dlqSize: new Gauge({ + name: `${namespace}_dlq_size`, + help: "Total jobs in the dead-letter queue.", + registers, + }), + }; + byNamespace.set(namespace, metrics); + return metrics; +} + +/** Options shared by the middleware and the stats collector. */ +interface CommonOptions { + /** Metric name prefix (default `"taskito"`). */ + namespace?: string; + /** Registry to register on (default the prom-client global `register`). */ + register?: Registry; +} + +/** Options for {@link prometheusMiddleware}. */ +export interface PrometheusMiddlewareOptions extends CommonOptions { + /** Only record tasks for which this returns true (default: all). */ + taskFilter?: (taskName: string) => boolean; + /** Custom histogram buckets (seconds) for job duration. */ + buckets?: number[]; +} + +/** + * Build {@link Middleware} that records job counters and duration histograms. Tracks + * in-flight jobs as a gauge and retries as a counter. + */ +export function prometheusMiddleware(options: PrometheusMiddlewareOptions = {}): Middleware { + const namespace = options.namespace ?? "taskito"; + const register = options.register ?? defaultRegister; + const metrics = getMetrics(register, namespace, options.buckets); + const tracked = (taskName: string): boolean => options.taskFilter?.(taskName) ?? true; + const starts = new Map(); + + const finish = (jobId: string, task: string, status: "completed" | "failed"): void => { + const start = starts.get(jobId); + if (start === undefined) { + return; + } + starts.delete(jobId); + metrics.activeWorkers.dec(); + metrics.jobDuration.observe({ task }, (performance.now() - start) / 1000); + metrics.jobsTotal.inc({ task, status }); + }; + + return { + before(ctx) { + if (!tracked(ctx.taskName)) { + return; + } + metrics.activeWorkers.inc(); + starts.set(ctx.jobId, performance.now()); + }, + after(ctx) { + finish(ctx.jobId, ctx.taskName, "completed"); + }, + onError(ctx) { + finish(ctx.jobId, ctx.taskName, "failed"); + }, + onRetry(event) { + if (tracked(event.taskName)) { + metrics.retriesTotal.inc({ task: event.taskName }); + } + }, + }; +} + +/** Options for {@link PrometheusStatsCollector}. */ +export interface PrometheusStatsCollectorOptions extends CommonOptions { + /** Poll interval in milliseconds (default 10000). */ + intervalMs?: number; +} + +/** + * Periodically samples queue depth and dead-letter size into gauges. Call {@link start} + * to begin polling and {@link stop} to end it (the timer is unref'd, so it never keeps + * the process alive on its own). + */ +export class PrometheusStatsCollector { + private readonly metrics: TaskitoMetrics; + private readonly intervalMs: number; + private timer: ReturnType | undefined; + + constructor( + private readonly queue: Queue, + options: PrometheusStatsCollectorOptions = {}, + ) { + const namespace = options.namespace ?? "taskito"; + const register = options.register ?? defaultRegister; + this.metrics = getMetrics(register, namespace); + const intervalMs = options.intervalMs ?? 10_000; + if (!Number.isFinite(intervalMs) || intervalMs <= 0) { + throw new RangeError(`intervalMs must be a positive finite number, got ${intervalMs}`); + } + this.intervalMs = intervalMs; + } + + /** Begin polling. Sampling runs immediately, then every `intervalMs`. */ + start(): void { + if (this.timer) { + return; + } + this.sample(); + this.timer = setInterval(() => this.sample(), this.intervalMs); + this.timer.unref(); + } + + /** Stop polling. */ + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + /** Read current queue/DLQ stats into the gauges. Public for one-shot scrapes. */ + sample(): void { + for (const [name, stats] of Object.entries(this.queue.statsAllQueues())) { + this.metrics.queueDepth.set({ queue: name }, stats.pending); + } + this.metrics.dlqSize.set(this.queue.stats().dead); + } +} diff --git a/sdks/node/src/contrib/rest.ts b/sdks/node/src/contrib/rest.ts new file mode 100644 index 00000000..bb366897 --- /dev/null +++ b/sdks/node/src/contrib/rest.ts @@ -0,0 +1,192 @@ +// Framework-neutral REST route table shared by the Express and Fastify helpers. +// Each route maps a plain request (params/query/body) to a status + JSON body, so the +// adapters only translate framework request/response objects — no logic duplicated. + +import type { EnqueueOptions } from "../native"; +import type { Queue } from "../queue"; + +/** Options controlling which routes a helper exposes. */ +export interface RestOptions { + /** Expose only these route names (mutually exclusive with `excludeRoutes`). */ + includeRoutes?: string[]; + /** Expose all routes except these. */ + excludeRoutes?: string[]; + /** Max ms `GET /jobs/:id/result` blocks for a terminal state (default 5000). */ + resultTimeoutMs?: number; +} + +/** A framework-neutral request. */ +export interface RestRequest { + params: Record; + query: Record; + body: unknown; +} + +// Keys that would pollute Object.prototype if carried over from user-controlled query. +const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +/** Pick a single string from a parsed query value (`string` or `string[]`). */ +function firstString(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value) && typeof value[0] === "string") { + return value[0]; + } + return undefined; +} + +/** + * Flatten a framework's parsed query (`string | string[]`) into plain + * `string | undefined` values. Built via `Object.fromEntries` over filtered + * entries — never a dynamic `obj[userKey] =` write — and prototype-polluting + * keys are dropped, so user-controlled query names can't inject properties. + */ +export function flattenQueryParams(query: unknown): Record { + const entries = Object.entries((query as Record) ?? {}) + .filter(([key]) => !FORBIDDEN_KEYS.has(key)) + .map(([key, value]) => [key, firstString(value)] as const) + .filter((entry): entry is readonly [string, string] => entry[1] !== undefined); + return Object.fromEntries(entries); +} + +/** A framework-neutral response: HTTP status + a JSON-serializable body. */ +export interface RestResponse { + status: number; + body: unknown; +} + +/** One REST route. `path` uses `:param` syntax understood by both Express and Fastify. */ +export interface RestRoute { + name: string; + method: "GET" | "POST"; + path: string; + handle: (queue: Queue, req: RestRequest) => RestResponse | Promise; +} + +function toInt(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** Drop the raw result Buffer from a job view — use the result route for the value. */ +function jobView(job: NonNullable>): Record { + const { result: _result, ...rest } = job; + return rest; +} + +function defineRoutes(resultTimeoutMs: number): RestRoute[] { + return [ + { + name: "enqueue", + method: "POST", + path: "/enqueue", + handle: (queue, { body }) => { + const input = body as { task?: unknown; args?: unknown; options?: unknown } | undefined; + if (!input || typeof input.task !== "string") { + return { status: 400, body: { error: "body must include a 'task' string" } }; + } + const args = Array.isArray(input.args) ? input.args : []; + const options = (input.options as EnqueueOptions | undefined) ?? undefined; + const jobId = queue.enqueue(input.task, args, options); + return { status: 201, body: { jobId } }; + }, + }, + { + name: "stats", + method: "GET", + path: "/stats", + handle: (queue) => ({ status: 200, body: queue.stats() }), + }, + { + name: "queue-stats", + method: "GET", + path: "/stats/queues", + handle: (queue, { query }) => { + const body = query.queue ? queue.statsByQueue(query.queue) : queue.statsAllQueues(); + return { status: 200, body }; + }, + }, + { + name: "job", + method: "GET", + path: "/jobs/:id", + handle: (queue, { params }) => { + const job = queue.getJob(params.id ?? ""); + return job + ? { status: 200, body: jobView(job) } + : { status: 404, body: { error: "not found" } }; + }, + }, + { + name: "job-errors", + method: "GET", + path: "/jobs/:id/errors", + handle: (queue, { params }) => ({ status: 200, body: queue.getJobErrors(params.id ?? "") }), + }, + { + name: "job-result", + method: "GET", + path: "/jobs/:id/result", + handle: async (queue, { params, query }) => { + const id = params.id ?? ""; + const timeoutMs = toInt(query.timeoutMs) ?? resultTimeoutMs; + try { + const result = await queue.result(id, { timeoutMs }); + return { status: 200, body: { jobId: id, status: "completed", result } }; + } catch (err) { + const status = queue.getJob(id)?.status ?? "pending"; + const error = err instanceof Error ? err.message : String(err); + return { status: 200, body: { jobId: id, status, error } }; + } + }, + }, + { + name: "cancel", + method: "POST", + path: "/jobs/:id/cancel", + handle: (queue, { params }) => ({ + status: 200, + body: { cancelled: queue.requestCancel(params.id ?? "") }, + }), + }, + { + name: "dead-letters", + method: "GET", + path: "/dead-letters", + handle: (queue, { query }) => ({ + status: 200, + body: queue.deadLetters(toInt(query.limit), toInt(query.offset)), + }), + }, + { + name: "retry-dead", + method: "POST", + path: "/dead-letters/:id/retry", + handle: (queue, { params }) => ({ + status: 200, + body: { jobId: queue.retryDead(params.id ?? "") }, + }), + }, + ]; +} + +/** Build the route set for a helper, applying `includeRoutes`/`excludeRoutes`. */ +export function buildRestRoutes(options: RestOptions = {}): RestRoute[] { + if (options.includeRoutes && options.excludeRoutes) { + throw new Error("includeRoutes and excludeRoutes are mutually exclusive"); + } + const routes = defineRoutes(options.resultTimeoutMs ?? 5000); + if (options.includeRoutes) { + const allow = new Set(options.includeRoutes); + return routes.filter((route) => allow.has(route.name)); + } + if (options.excludeRoutes) { + const deny = new Set(options.excludeRoutes); + return routes.filter((route) => !deny.has(route.name)); + } + return routes; +} diff --git a/sdks/node/src/contrib/sentry.ts b/sdks/node/src/contrib/sentry.ts new file mode 100644 index 00000000..54f578a3 --- /dev/null +++ b/sdks/node/src/contrib/sentry.ts @@ -0,0 +1,95 @@ +// Sentry error reporting for Taskito. Optional integration — import from +// `taskito/contrib/sentry`; requires `@sentry/node` as a peer (and `Sentry.init(...)` +// called by your app first). +// +// Register with `queue.use(sentryMiddleware())`. The real exception (with its stack) is +// captured from `onError` and reported when the job dead-letters — one event per dead +// job. Set `captureRetries` to also report each intermediate failure as a warning. + +import type { SeverityLevel } from "@sentry/node"; +import * as Sentry from "@sentry/node"; +import type { OutcomeEvent } from "../events"; +import type { Middleware } from "../middleware"; + +/** Options for {@link sentryMiddleware}. */ +export interface SentryMiddlewareOptions { + /** Prefix for Sentry tag keys (default `"taskito"`). */ + tagPrefix?: string; + /** Also capture each retried failure as a `"warning"` event (default false). */ + captureRetries?: boolean; + /** Severity level for the terminal dead-letter capture (default `"error"`). */ + level?: SeverityLevel; + /** Extra tags merged onto the captured event. */ + extraTags?: (event: OutcomeEvent) => Record; + /** Only report tasks for which this returns true (default: all). */ + taskFilter?: (taskName: string) => boolean; +} + +/** + * Build {@link Middleware} that reports failing tasks to Sentry. The exception captured + * in `onError` is held per job and sent when the job dead-letters, so each dead job + * yields a single event carrying the original stack trace plus job/queue tags. + */ +export function sentryMiddleware(options: SentryMiddlewareOptions = {}): Middleware { + const prefix = options.tagPrefix ?? "taskito"; + const deadLevel = options.level ?? "error"; + const captureRetries = options.captureRetries ?? false; + const tracked = (taskName: string): boolean => options.taskFilter?.(taskName) ?? true; + + // The latest real exception per in-flight job, captured in onError (it carries the + // stack; the outcome events only carry a message string). + const lastError = new Map(); + + const tagsFor = (event: OutcomeEvent): Record => { + const tags: Record = { + [`${prefix}.task_name`]: event.taskName, + [`${prefix}.job_id`]: event.jobId, + }; + if (event.queue) { + tags[`${prefix}.queue`] = event.queue; + } + if (event.retryCount !== undefined) { + tags[`${prefix}.retry_count`] = String(event.retryCount); + } + if (event.timedOut) { + tags[`${prefix}.timed_out`] = "true"; + } + return { ...tags, ...options.extraTags?.(event) }; + }; + + const capture = (error: unknown, event: OutcomeEvent, level: SeverityLevel): void => { + Sentry.withScope((scope) => { + scope.setLevel(level); + scope.setTags(tagsFor(event)); + Sentry.captureException(error); + }); + }; + + const errorFor = (event: OutcomeEvent, fallback: string): unknown => + lastError.get(event.jobId) ?? new Error(event.error ?? fallback); + + return { + onError(ctx, error) { + if (tracked(ctx.taskName)) { + lastError.set(ctx.jobId, error); + } + }, + onRetry(event) { + if (captureRetries && tracked(event.taskName)) { + capture(errorFor(event, `${event.taskName} failed`), event, "warning"); + } + }, + onDeadLetter(event) { + if (tracked(event.taskName)) { + capture(errorFor(event, `${event.taskName} dead-lettered`), event, deadLevel); + } + lastError.delete(event.jobId); + }, + onCompleted(event) { + lastError.delete(event.jobId); + }, + onCancel(event) { + lastError.delete(event.jobId); + }, + }; +} diff --git a/sdks/node/src/dashboard/index.ts b/sdks/node/src/dashboard/index.ts index ef977f78..01399bb3 100644 --- a/sdks/node/src/dashboard/index.ts +++ b/sdks/node/src/dashboard/index.ts @@ -27,4 +27,4 @@ export function serveDashboard(queue: Queue, options: DashboardOptions = {}): Se return server; } -export { createDashboardServer } from "./server"; +export { createDashboardHandler, createDashboardServer } from "./server"; diff --git a/sdks/node/src/dashboard/server.ts b/sdks/node/src/dashboard/server.ts index 7d0a5b8f..add41982 100644 --- a/sdks/node/src/dashboard/server.ts +++ b/sdks/node/src/dashboard/server.ts @@ -12,17 +12,30 @@ const log = createLogger("dashboard"); /** Max request body size (1 MiB) — reject larger payloads to bound memory. */ const MAX_BODY_BYTES = 1024 * 1024; -/** Build (but do not start) the dashboard server over `queue`, serving the SPA from `staticDir`. */ -export function createDashboardServer(queue: Queue, staticDir: string): Server { +/** + * Build a Node `http` request handler that serves the dashboard SPA from `staticDir` + * plus the `/api/*` JSON contract over `queue`. Use this to mount the dashboard into an + * existing server (e.g. an Express or Fastify app); {@link createDashboardServer} wraps + * it in a standalone server. + */ +export function createDashboardHandler( + queue: Queue, + staticDir: string, +): (req: IncomingMessage, res: ServerResponse) => void { const assets = new StaticAssets(staticDir); - return createServer((req, res) => { + return (req, res) => { void dispatch(queue, assets, req, res).catch((error) => { log.error(() => "dashboard dispatch failed", error); if (!res.headersSent) { sendJson(res, 500, { error: "internal server error" }); } }); - }); + }; +} + +/** Build (but do not start) the dashboard server over `queue`, serving the SPA from `staticDir`. */ +export function createDashboardServer(queue: Queue, staticDir: string): Server { + return createServer(createDashboardHandler(queue, staticDir)); } async function dispatch( diff --git a/sdks/node/src/workflows/builder.ts b/sdks/node/src/workflows/builder.ts index 20bf578f..c57cdf30 100644 --- a/sdks/node/src/workflows/builder.ts +++ b/sdks/node/src/workflows/builder.ts @@ -2,12 +2,18 @@ import { successorsOf, transitiveDeferred } from "./plan"; import type { FanInStepOptions, FanOutStepOptions, + GateStepOptions, StepMetadataJson, + SubWorkflowStepOptions, WorkflowHandle, WorkflowSpec, WorkflowStepOptions, } from "./types"; +/** Sentinel task names for nodes that run no job of their own. */ +const GATE_TASK = "__gate__"; +const SUBWORKFLOW_TASK = "__subworkflow__"; + /** * Fluent builder for a workflow DAG. Each {@link WorkflowBuilder.step} adds a * node; `after` declares edges. Steps may reference predecessors that are added @@ -26,6 +32,8 @@ export class WorkflowBuilder { private readonly seen = new Set(); /** Nodes that run no static job — expanded/enqueued by the tracker. */ private readonly deferredSeeds = new Set(); + /** Child workflows keyed by their sub-workflow node name. */ + private readonly subWorkflows: Record = {}; constructor( private readonly name: string, @@ -42,8 +50,15 @@ export class WorkflowBuilder { max_retries: options.maxRetries, timeout_ms: options.timeoutMs, priority: options.priority, + condition: options.condition, + compensate: options.compensate, }; this.stepArgs[name] = options.args ?? []; + // A conditioned step is enqueued by the tracker once its predecessors + // settle (so it can evaluate the condition), not statically at submit. + if (options.condition) { + this.deferredSeeds.add(name); + } return this; } @@ -85,6 +100,40 @@ export class WorkflowBuilder { return this; } + /** + * Add an approval gate. The run pauses here (status `waiting_approval`) until + * resolved via `queue.workflows.resolveGate`/`approveGate`/`rejectGate`, or + * until `timeoutMs` elapses (then `onTimeout` decides). + */ + gate(name: string, options: GateStepOptions): this { + this.addNode(name, options.after); + this.stepMetadata[name] = { + task_name: GATE_TASK, + gate: JSON.stringify({ + timeoutMs: options.timeoutMs, + onTimeout: options.onTimeout ?? "reject", + message: options.message, + }), + }; + this.stepArgs[name] = []; + this.deferredSeeds.add(name); + return this; + } + + /** + * Add a sub-workflow step. At runtime the tracker submits `options.workflow` + * as a child run and resolves this node when the child finalizes. Build the + * child with `queue.workflows.define(...)….build()`. + */ + subWorkflow(name: string, options: SubWorkflowStepOptions): this { + this.addNode(name, options.after); + this.stepMetadata[name] = { task_name: SUBWORKFLOW_TASK }; + this.stepArgs[name] = []; + this.subWorkflows[name] = options.workflow; + this.deferredSeeds.add(name); + return this; + } + /** Materialize the validated spec without submitting. */ build(): WorkflowSpec { for (const edge of this.edges) { @@ -114,6 +163,7 @@ export class WorkflowBuilder { stepMetadata: { ...this.stepMetadata }, stepArgs: { ...this.stepArgs }, deferredNodeNames: [...deferred], + subWorkflows: { ...this.subWorkflows }, }; } diff --git a/sdks/node/src/workflows/index.ts b/sdks/node/src/workflows/index.ts index 546b1507..ad2876b7 100644 --- a/sdks/node/src/workflows/index.ts +++ b/sdks/node/src/workflows/index.ts @@ -4,7 +4,10 @@ export { WorkflowTracker } from "./tracker"; export type { FanInStepOptions, FanOutStepOptions, + GateStepOptions, + SubWorkflowStepOptions, WorkflowAdvance, + WorkflowCondition, WorkflowHandle, WorkflowNode, WorkflowRun, diff --git a/sdks/node/src/workflows/manager.ts b/sdks/node/src/workflows/manager.ts index f3dcb519..74b4f7ec 100644 --- a/sdks/node/src/workflows/manager.ts +++ b/sdks/node/src/workflows/manager.ts @@ -1,7 +1,10 @@ import type { NativeQueue } from "../native"; import type { Serializer } from "../serializers"; import { WorkflowBuilder } from "./builder"; +import { WorkflowTracker } from "./tracker"; import type { + StepMetadataJson, + SubWorkflowTransport, WorkflowHandle, WorkflowNode, WorkflowRun, @@ -27,9 +30,9 @@ const DEFAULT_WAIT_POLL_MS = 100; * The `queue.workflows` facade: define and submit workflows, then query runs. * * Supports DAG / linear workflows (steps pre-enqueued with `depends_on` chains, - * sequenced by the core scheduler) plus fan-out / fan-in steps the worker-side - * tracker expands at runtime (see `tracker.ts`). Gates, sub-workflows, and saga - * compensation are not yet available in the Node SDK. + * sequenced by the core scheduler) plus the deferred step kinds the worker-side + * tracker drives at runtime (see `tracker.ts`): fan-out / fan-in, conditions, + * approval gates, sub-workflows, and saga compensation. */ export class WorkflowManager { constructor( @@ -71,6 +74,25 @@ export class WorkflowManager { return this.native.getWorkflowChildren(runId); } + /** + * Resolve a workflow gate that is `waiting_approval`, then advance (or skip) + * its successors. Works from any process — the run plan is read from storage. + * Idempotent: resolving an already-resolved gate is a no-op. + */ + resolveGate(runId: string, nodeName: string, approved: boolean, error?: string): void { + new WorkflowTracker(this.native, this.serializer).resolveGate(runId, nodeName, approved, error); + } + + /** Approve a waiting gate (shorthand for `resolveGate(runId, node, true)`). */ + approveGate(runId: string, nodeName: string): void { + this.resolveGate(runId, nodeName, true); + } + + /** Reject a waiting gate (shorthand for `resolveGate(runId, node, false, error)`). */ + rejectGate(runId: string, nodeName: string, error?: string): void { + this.resolveGate(runId, nodeName, false, error); + } + /** List runs, optionally filtered by definition name and/or state. */ list(options?: { definitionName?: string; @@ -87,41 +109,70 @@ export class WorkflowManager { } private submitSpec(spec: WorkflowSpec, options?: WorkflowSubmitOptions): WorkflowHandle { - const dag = { - nodes: spec.nodes.map((name) => ({ name })), - edges: spec.edges.map((edge) => ({ from: edge.from, to: edge.to, weight: 1.0 })), - }; - const dagBytes = Buffer.from(JSON.stringify(dag)); - - // Static nodes are enqueued by `submitWorkflow` from these payloads. For - // deferred nodes the tracker enqueues later, so we persist their args in - // `args_template` (base64) — the only storage-reconstructable channel. - const deferred = new Set(spec.deferredNodeNames); - const stepMetadata = { ...spec.stepMetadata }; + const transport = this.toTransport(spec); const nodePayloads: Record = {}; - for (const name of spec.nodes) { - const payload = Buffer.from(this.serializer.serialize(spec.stepArgs[name] ?? [])); - nodePayloads[name] = payload; - const meta = stepMetadata[name]; - if (deferred.has(name) && meta) { - stepMetadata[name] = { ...meta, args_template: payload.toString("base64") }; - } + for (const [name, b64] of Object.entries(transport.nodePayloads)) { + nodePayloads[name] = Buffer.from(b64, "base64"); } - const paramsJson = options?.params === undefined ? null : JSON.stringify(options.params); const runId = this.native.submitWorkflow( - spec.name, - spec.version, - dagBytes, - JSON.stringify(stepMetadata), + transport.name, + transport.version, + Buffer.from(transport.dag, "base64"), + transport.stepMetadata, nodePayloads, options?.queueDefault ?? null, paramsJson, - spec.deferredNodeNames, + transport.deferredNodeNames, + null, + null, ); return this.makeHandle(runId); } + /** + * Flatten a spec (and any nested sub-workflows) to its base64/JSON transport + * form: serialize each node's args once, stamp deferred nodes' `args_template` + * (the only storage-reconstructable channel for the tracker), and embed each + * child workflow into its parent node's `sub_workflow` metadata. + */ + private toTransport(spec: WorkflowSpec): SubWorkflowTransport { + const dag = { + nodes: spec.nodes.map((name) => ({ name })), + edges: spec.edges.map((edge) => ({ from: edge.from, to: edge.to, weight: 1.0 })), + }; + const deferred = new Set(spec.deferredNodeNames); + const stepMetadata: Record = {}; + const nodePayloads: Record = {}; + for (const name of spec.nodes) { + const base = spec.stepMetadata[name]; + if (!base) { + throw new Error(`workflow step '${name}' is missing metadata`); + } + const b64 = Buffer.from(this.serializer.serialize(spec.stepArgs[name] ?? [])).toString( + "base64", + ); + nodePayloads[name] = b64; + const meta: StepMetadataJson = { ...base }; + if (deferred.has(name)) { + meta.args_template = b64; + } + const child = spec.subWorkflows?.[name]; + if (child) { + meta.sub_workflow = JSON.stringify(this.toTransport(child)); + } + stepMetadata[name] = meta; + } + return { + name: spec.name, + version: spec.version, + dag: Buffer.from(JSON.stringify(dag)).toString("base64"), + stepMetadata: JSON.stringify(stepMetadata), + nodePayloads, + deferredNodeNames: spec.deferredNodeNames, + }; + } + private makeHandle(runId: string): WorkflowHandle { return { runId, diff --git a/sdks/node/src/workflows/tracker.ts b/sdks/node/src/workflows/tracker.ts index 41ca5d81..8085e2e3 100644 --- a/sdks/node/src/workflows/tracker.ts +++ b/sdks/node/src/workflows/tracker.ts @@ -2,6 +2,7 @@ import type { JsOutcome, NativeQueue } from "../native"; import type { Serializer } from "../serializers"; import { createLogger } from "../utils"; import { predecessorsOf, successorsOf, transitiveDeferred } from "./plan"; +import type { SubWorkflowTransport } from "./types"; const log = createLogger("workflow-tracker"); @@ -22,6 +23,9 @@ const TERMINAL_NODE_STATUS = new Set([ /** A fan-out child node name (`parent[3]`) → its parent (`parent`), else null. */ const FAN_OUT_CHILD = /^(.+)\[\d+\]$/; +/** Predecessor outcomes that satisfy an `on_success` condition. */ +const SUCCEEDED_STATUS = new Set(["completed", "cache_hit"]); + /** snake_case step metadata as stored in the workflow definition. */ interface StepMeta { task_name: string; @@ -32,6 +36,21 @@ interface StepMeta { args_template?: string; fan_out?: string; fan_in?: string; + /** `"on_success"` (default) | `"on_failure"` | `"always"` — gates node entry. */ + condition?: string; + /** JSON `{timeoutMs?, onTimeout?, message?}` marking an approval gate node. */ + gate?: string; + /** JSON `SubWorkflowTransport` marking a sub-workflow node. */ + sub_workflow?: string; + /** Rollback task name run if the workflow fails (saga compensation). */ + compensate?: string; +} + +/** Parsed gate config from a node's `gate` metadata. */ +interface GateConfig { + timeoutMs?: number; + onTimeout?: "approve" | "reject"; + message?: string; } /** The reconstructed structure + config of a workflow run. */ @@ -40,8 +59,13 @@ interface RunPlan { predecessors: Map; deferred: Set; meta: Map; + /** True if any node declares a compensator (the run can run a saga rollback). */ + hasCompensation: boolean; } +/** Node statuses still awaiting (or undergoing) compensation rollback. */ +const COMPENSABLE_STATUS = new Set(["completed", "cache_hit"]); + /** * The runtime workflow brain. Driven from the worker's outcome callback, it * advances a run as each node-job settles. For plain DAGs the core scheduler @@ -58,6 +82,8 @@ interface RunPlan { */ export class WorkflowTracker { private readonly plans = new Map(); + /** Pending gate-timeout timers, keyed `runId\0node` (unref'd). */ + private readonly gateTimers = new Map>(); constructor( private readonly native: NativeQueue, @@ -76,6 +102,12 @@ export class WorkflowTracker { } try { + // A rollback job advances the saga, not the forward run — check it first. + const comp = this.native.compensationNodeForJob(outcome.jobId); + if (comp) { + this.onCompensationOutcome(comp.runId, comp.nodeName, succeeded, outcome.error ?? null); + return; + } const ref = this.native.workflowNodeForJob(outcome.jobId); if (!ref) { return; // not a workflow job @@ -103,20 +135,202 @@ export class WorkflowTracker { // Record the node's outcome. For plain DAGs the core also cascades + finalizes. const advance = this.native.markWorkflowNodeResult(jobId, succeeded, error, managed); if (!managed) { - this.evictIfTerminal(runId, advance?.finalState ?? null); + this.settle(runId, advance?.finalState ?? null); return; } const parent = fanOutParentOf(nodeName); if (parent !== null && isFanOut(plan, parent)) { this.onFanOutChild(runId, parent, plan); - } else if (succeeded) { + } else { + // Success and failure both flow through successor evaluation: each + // successor's condition decides whether it runs or is skipped, so an + // `on_failure` handler fires and an `on_success` step skips after a fault. this.evaluateSuccessors(runId, nodeName, plan); + } + + this.settle(runId, this.native.finalizeRunIfTerminal(runId)); + } + + /** + * Wrap up after a node settles. A failed run with compensable nodes starts a + * saga rollback instead of finishing; otherwise the plan is evicted and, if + * this run is a sub-workflow child, its parent node is resolved. + */ + private settle(runId: string, finalState: string | null): void { + if (finalState === "failed" && this.startCompensationIfEligible(runId)) { + return; // run moved to `compensating`; it finalizes when rollback completes + } + this.evictIfTerminal(runId, finalState); + if (finalState !== null) { + this.resolveParentIfChild(runId, finalState); + } + } + + /** + * If `childRunId` is a sub-workflow child, resolve its parent node (success → + * complete, failure → fail), advance the parent's successors, and recurse for + * nested parents. Idempotent: a parent node already resolved is left alone. + */ + private resolveParentIfChild(childRunId: string, finalState: string): void { + const child = this.native.getWorkflowRun(childRunId); + const parentRunId = child?.parentRunId; + const parentNode = child?.parentNodeName; + if (!parentRunId || !parentNode) { + return; + } + const parentStatus = this.nodeStatuses(parentRunId).get(parentNode); + if (parentStatus === undefined || TERMINAL_NODE_STATUS.has(parentStatus)) { + return; // parent gone, or already resolved (e.g. by a racing worker) + } + const succeeded = finalState === "completed"; + this.native.resolveWorkflowGate( + parentRunId, + parentNode, + succeeded, + succeeded ? null : `sub-workflow ${finalState}`, + ); + const parentPlan = this.plan(parentRunId); + if (parentPlan) { + this.evaluateSuccessors(parentRunId, parentNode, parentPlan); + } + this.settle(parentRunId, this.native.finalizeRunIfTerminal(parentRunId)); + } + + /** Whether a node declares a compensator (saga rollback task). */ + private isCompensable(node: string, plan: RunPlan): boolean { + return Boolean(plan.meta.get(node)?.compensate); + } + + /** + * Begin saga compensation for a failed run that has completed compensable + * nodes: move the run to `compensating` and dispatch the first rollback wave. + * Returns false (run finalizes normally) when there's nothing to roll back. + */ + private startCompensationIfEligible(runId: string): boolean { + const plan = this.plan(runId); + if (!plan?.hasCompensation) { + return false; + } + const eligible = this.native + .getWorkflowNodes(runId) + .some((n) => this.isCompensable(n.nodeName, plan) && COMPENSABLE_STATUS.has(n.status)); + if (!eligible) { + return false; + } + this.native.setWorkflowRunState(runId, "compensating", null, null); + this.advanceCompensation(runId, plan); + return true; + } + + /** + * Drive one step of compensation: dispatch every compensable node that's ready + * (all its compensable successors already rolled back) in reverse-dependency + * order, then finalize the run once nothing is in flight. Storage-driven and + * idempotent — re-derived from node status on every rollback outcome. + */ + private advanceCompensation(runId: string, plan: RunPlan): void { + let nodes = this.native.getWorkflowNodes(runId); + const compensable = (node: { nodeName: string }) => this.isCompensable(node.nodeName, plan); + const anyFailed = nodes.some((n) => compensable(n) && n.status === "compensation_failed"); + + if (!anyFailed) { + const status = new Map(nodes.map((n) => [n.nodeName, n.status])); + for (const node of nodes) { + if (!compensable(node) || !COMPENSABLE_STATUS.has(node.status)) { + continue; + } + if (this.compensationReady(node.nodeName, status, plan)) { + this.enqueueCompensation(runId, node.nodeName, node.jobId ?? null, plan); + } + } + nodes = this.native.getWorkflowNodes(runId); // re-read after dispatch + } + + // Still working while any rollback is in flight, or (absent a failure) any + // compensable node is still awaiting rollback. + const working = nodes.some((n) => { + if (!compensable(n)) { + return false; + } + return n.status === "compensating" || (!anyFailed && COMPENSABLE_STATUS.has(n.status)); + }); + if (working) { + return; + } + + const failed = nodes.some((n) => compensable(n) && n.status === "compensation_failed"); + const now = Date.now(); + if (failed) { + this.native.setWorkflowRunState(runId, "compensation_failed", "compensation failed", now); } else { - this.native.cascadeSkipPending(runId); // fail-fast + this.native.setWorkflowRunState(runId, "compensated", null, now); } + this.plans.delete(runId); + } - this.evictIfTerminal(runId, this.native.finalizeRunIfTerminal(runId)); + /** A node is ready to roll back once all its compensable successors have. */ + private compensationReady(node: string, status: Map, plan: RunPlan): boolean { + for (const succ of plan.successors.get(node) ?? []) { + if (!this.isCompensable(succ, plan)) { + continue; // a non-compensable successor did nothing to undo + } + const s = status.get(succ); + if (s !== "compensated" && s !== "compensation_failed") { + return false; + } + } + return true; + } + + /** Enqueue a node's rollback job, passing the node's forward result as its arg. */ + private enqueueCompensation( + runId: string, + node: string, + jobId: string | null, + plan: RunPlan, + ): void { + const meta = plan.meta.get(node); + if (!meta?.compensate) { + return; + } + const job = jobId ? this.native.getJob(jobId) : null; + const result = job?.result ? this.serializer.deserialize(job.result) : null; + const payload = Buffer.from(this.serializer.serialize([result])); + this.native.enqueueCompensation( + runId, + node, + payload, + meta.compensate, + meta.queue ?? DEFAULT_QUEUE, + meta.max_retries ?? DEFAULT_MAX_RETRIES, + meta.timeout_ms ?? DEFAULT_TIMEOUT_MS, + meta.priority ?? 0, + ); + } + + /** Record a rollback's outcome and advance (or finalize) the saga. */ + private onCompensationOutcome( + runId: string, + node: string, + succeeded: boolean, + error: string | null, + ): void { + const now = Date.now(); + if (succeeded) { + this.native.setWorkflowNodeCompensated(runId, node, now); + } else { + this.native.setWorkflowNodeCompensationFailed( + runId, + node, + error ?? "compensation failed", + now, + ); + } + const plan = this.plan(runId); + if (plan) { + this.advanceCompensation(runId, plan); + } } /** Drop a finished run's cached plan so the cache doesn't grow unbounded. */ @@ -135,7 +349,9 @@ export class WorkflowTracker { if (completion.succeeded) { this.onFanOutParentDone(runId, parent, completion.childJobIds, plan); } else { - this.native.cascadeSkipPending(runId); // a child failed → fail-fast + // A child failed → the parent is now `failed`; evaluating its successors + // skips the (on_success) fan-in and anything downstream. + this.evaluateSuccessors(runId, parent, plan); } } @@ -217,17 +433,27 @@ export class WorkflowTracker { ); } - /** Enqueue/expand any deferred successor whose predecessors are now all terminal. */ + /** Enqueue/expand/skip any deferred successor whose predecessors are now all terminal. */ private evaluateSuccessors(runId: string, nodeName: string, plan: RunPlan): void { for (const succ of plan.successors.get(nodeName) ?? []) { if (!plan.deferred.has(succ) || !this.allPredecessorsTerminal(runId, succ, plan)) { continue; } + // Evaluate the condition first: a not-taken branch is skipped (and the + // skip propagates) before we consider what kind of node it is. + if (!this.shouldExecute(runId, succ, plan)) { + this.skipNode(runId, succ, plan); + continue; + } const meta = plan.meta.get(succ); if (meta?.fan_in) { continue; // fan-in jobs are created from the fan-out completion path } - if (meta?.fan_out) { + if (meta?.gate) { + this.enterGate(runId, succ, meta); + } else if (meta?.sub_workflow) { + this.submitSubWorkflow(runId, succ, meta); + } else if (meta?.fan_out) { this.expandFanOut(runId, succ, plan); } else { this.createDeferredJob(runId, succ, plan); @@ -235,6 +461,98 @@ export class WorkflowTracker { } } + /** Submit a node's child workflow as a linked run; mark the node `running`. */ + private submitSubWorkflow(runId: string, node: string, meta: StepMeta): void { + if (!meta.sub_workflow) { + return; + } + const child: SubWorkflowTransport = JSON.parse(meta.sub_workflow); + const payloads: Record = {}; + for (const [name, b64] of Object.entries(child.nodePayloads)) { + payloads[name] = Buffer.from(b64, "base64"); + } + this.native.submitWorkflow( + child.name, + child.version, + Buffer.from(child.dag, "base64"), + child.stepMetadata, + payloads, + null, + null, + child.deferredNodeNames, + runId, + node, + ); + this.native.setWorkflowNodeRunning(runId, node); + } + + /** Park a gate node at `waiting_approval` and arm its timeout, if any. */ + private enterGate(runId: string, node: string, meta: StepMeta): void { + this.native.setWorkflowNodeWaitingApproval(runId, node); + const gate: GateConfig = meta.gate ? JSON.parse(meta.gate) : {}; + if (!gate.timeoutMs || gate.timeoutMs <= 0) { + return; + } + const key = gateKey(runId, node); + const approved = gate.onTimeout === "approve"; + const timer = setTimeout(() => { + this.gateTimers.delete(key); + this.resolveGate(runId, node, approved, approved ? undefined : "gate timeout"); + }, gate.timeoutMs); + timer.unref(); // never keep the process alive waiting on an approval + this.gateTimers.set(key, timer); + } + + /** + * Approve or reject a waiting gate, then advance (or skip) its successors. + * Safe to call from any process and idempotent: a gate already resolved (by + * the other of manual-vs-timeout) is a no-op. + */ + resolveGate(runId: string, node: string, approved: boolean, error?: string): void { + this.clearGateTimer(runId, node); + const current = this.nodeStatuses(runId).get(node); + if (current === undefined || TERMINAL_NODE_STATUS.has(current)) { + return; // gone, or already resolved by the timeout / another caller + } + this.native.resolveWorkflowGate(runId, node, approved, error ?? null); + const plan = this.plan(runId); + if (!plan) { + return; + } + this.evaluateSuccessors(runId, node, plan); + this.evictIfTerminal(runId, this.native.finalizeRunIfTerminal(runId)); + } + + private clearGateTimer(runId: string, node: string): void { + const key = gateKey(runId, node); + const timer = this.gateTimers.get(key); + if (timer) { + clearTimeout(timer); + this.gateTimers.delete(key); + } + } + + /** Whether a node's condition is satisfied by its predecessors' outcomes. */ + private shouldExecute(runId: string, node: string, plan: RunPlan): boolean { + const condition = plan.meta.get(node)?.condition ?? "on_success"; + const preds = plan.predecessors.get(node) ?? []; + if (preds.length === 0 || condition === "always") { + return true; + } + const status = this.nodeStatuses(runId); + const predStatuses = preds.map((p) => status.get(p) ?? ""); + if (condition === "on_failure") { + return predStatuses.some((s) => s === "failed"); + } + return predStatuses.every((s) => SUCCEEDED_STATUS.has(s)); // on_success + } + + /** Mark a not-taken node skipped, then propagate the skip to its successors. */ + private skipNode(runId: string, node: string, plan: RunPlan): void { + this.native.skipWorkflowNode(runId, node); + this.evaluateSuccessors(runId, node, plan); + } + /** Enqueue a plain deferred node using its persisted args. */ private createDeferredJob(runId: string, node: string, plan: RunPlan): void { const meta = plan.meta.get(node); @@ -316,18 +634,26 @@ export class WorkflowTracker { Object.entries(JSON.parse(raw.stepMetadata) as Record), ); const successors = successorsOf(edges); - const seeds = [...meta].filter(([, m]) => m.fan_out || m.fan_in).map(([name]) => name); + const seeds = [...meta] + .filter(([, m]) => m.fan_out || m.fan_in || m.condition || m.gate || m.sub_workflow) + .map(([name]) => name); const plan: RunPlan = { successors, predecessors: predecessorsOf(edges), deferred: transitiveDeferred(seeds, successors), meta, + hasCompensation: [...meta.values()].some((m) => m.compensate), }; this.plans.set(runId, plan); return plan; } } +/** Stable key for a run's gate node in the timer map. */ +function gateKey(runId: string, node: string): string { + return `${runId}${node}`; +} + /** `parent[3]` → `parent`; a non-child name → null. */ function fanOutParentOf(nodeName: string): string | null { return FAN_OUT_CHILD.exec(nodeName)?.[1] ?? null; diff --git a/sdks/node/src/workflows/types.ts b/sdks/node/src/workflows/types.ts index cec02768..bb75fc3d 100644 --- a/sdks/node/src/workflows/types.ts +++ b/sdks/node/src/workflows/types.ts @@ -20,6 +20,14 @@ export type WorkflowRunState = | "compensated" | "compensation_failed"; +/** + * When a step runs, based on its predecessors' outcomes: + * - `on_success` (default) — every predecessor completed + * - `on_failure` — at least one predecessor failed (an error handler) + * - `always` — regardless of predecessor outcomes + */ +export type WorkflowCondition = "on_success" | "on_failure" | "always"; + /** Per-step options when building a workflow. */ export interface WorkflowStepOptions { /** Predecessor step name(s) this step runs after. */ @@ -31,6 +39,13 @@ export interface WorkflowStepOptions { maxRetries?: number; timeoutMs?: number; priority?: number; + /** Gate this step on its predecessors' outcomes (default `on_success`). */ + condition?: WorkflowCondition; + /** + * Rollback task name for saga compensation. If the run fails, this task is run + * with the step's result as its single argument, in reverse-dependency order. + */ + compensate?: string; } /** Common per-step task config shared by every step kind. */ @@ -67,6 +82,23 @@ export interface FanInStepOptions extends StepTaskOptions { task: string; } +/** + * Options for an approval gate. The gate runs no task: the run pauses at the + * gate (status `waiting_approval`) until resolved via + * `queue.workflows.resolveGate` / `approveGate` / `rejectGate`, or until + * `timeoutMs` elapses (then `onTimeout` decides the outcome). + */ +export interface GateStepOptions { + /** Predecessor step name(s) the gate runs after. */ + after: string | string[]; + /** Auto-resolve after this many ms (no timeout if omitted). */ + timeoutMs?: number; + /** What a timeout does — `"approve"` or `"reject"` (default `"reject"`). */ + onTimeout?: "approve" | "reject"; + /** Human-readable reason shown to approvers (recorded in metadata). */ + message?: string; +} + /** snake_case step metadata, matching the Rust `StepMetadata` shape. */ export interface StepMetadataJson { task_name: string; @@ -80,6 +112,43 @@ export interface StepMetadataJson { fan_out?: string; /** JSON `{from}` marking a fan-in node (the fan-out it collects). */ fan_in?: string; + /** Entry condition: `on_success` | `on_failure` | `always`. */ + condition?: string; + /** JSON `{timeoutMs?, onTimeout?, message?}` marking an approval gate node. */ + gate?: string; + /** JSON {@link SubWorkflowTransport} marking a sub-workflow node. */ + sub_workflow?: string; + /** Rollback task name for saga compensation. */ + compensate?: string; +} + +/** + * Options for a sub-workflow step. The step runs no task of its own; at runtime + * the tracker submits `workflow` as a child run and resolves this node when the + * child finalizes (success → completed, failure → failed). + */ +export interface SubWorkflowStepOptions { + /** Predecessor step name(s) the sub-workflow runs after. */ + after: string | string[]; + /** The child workflow to run — build it with `queue.workflows.define(...).build()`. */ + workflow: WorkflowSpec; +} + +/** + * A child workflow flattened to base64/JSON so it round-trips through storage + * (in a parent node's `sub_workflow` metadata) and can be submitted as a child + * run by the tracker without re-serializing user args. + */ +export interface SubWorkflowTransport { + name: string; + version: number; + /** base64 of the serialized DAG (`SerializableGraph` JSON). */ + dag: string; + /** JSON-encoded `Record`. */ + stepMetadata: string; + /** Node name → base64 of the node's serialized args payload. */ + nodePayloads: Record; + deferredNodeNames: string[]; } /** A built workflow definition, ready to submit. */ @@ -92,6 +161,8 @@ export interface WorkflowSpec { stepArgs: Record; /** Nodes the tracker enqueues on demand (fan-out/fan-in ∪ their descendants). */ deferredNodeNames: string[]; + /** Child workflows keyed by their sub-workflow node name. */ + subWorkflows?: Record; } /** Submit-time options. */ diff --git a/sdks/node/test/cancel.test.ts b/sdks/node/test/core/cancel.test.ts similarity index 96% rename from sdks/node/test/cancel.test.ts rename to sdks/node/test/core/cancel.test.ts index a8da0b24..1b19b776 100644 --- a/sdks/node/test/cancel.test.ts +++ b/sdks/node/test/core/cancel.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { currentJob, Queue, type Worker } from "../src/index"; +import { currentJob, Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/inspection.test.ts b/sdks/node/test/core/inspection.test.ts similarity index 99% rename from sdks/node/test/inspection.test.ts rename to sdks/node/test/core/inspection.test.ts index 3cc9485c..97dfeea6 100644 --- a/sdks/node/test/inspection.test.ts +++ b/sdks/node/test/core/inspection.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { type DeadJob, JobFailedError, Queue, type Worker } from "../src/index"; +import { type DeadJob, JobFailedError, Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/locks.test.ts b/sdks/node/test/core/locks.test.ts similarity index 97% rename from sdks/node/test/locks.test.ts rename to sdks/node/test/core/locks.test.ts index 0f9b3875..97b179fb 100644 --- a/sdks/node/test/locks.test.ts +++ b/sdks/node/test/core/locks.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { LockNotAcquiredError, Queue } from "../src/index"; +import { LockNotAcquiredError, Queue } from "../../src/index"; function freshQueue(): Queue { const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-lock-")), "queue.db"); diff --git a/sdks/node/test/logger.test.ts b/sdks/node/test/core/logger.test.ts similarity index 93% rename from sdks/node/test/logger.test.ts rename to sdks/node/test/core/logger.test.ts index d5078d42..7db018a7 100644 --- a/sdks/node/test/logger.test.ts +++ b/sdks/node/test/core/logger.test.ts @@ -1,5 +1,5 @@ import { beforeEach, expect, it } from "vitest"; -import { createLogger, setLogLevel, setLogSink } from "../src/index"; +import { createLogger, setLogLevel, setLogSink } from "../../src/index"; let lines: string[]; diff --git a/sdks/node/test/options.test.ts b/sdks/node/test/core/options.test.ts similarity index 97% rename from sdks/node/test/options.test.ts rename to sdks/node/test/core/options.test.ts index 50a80e3c..d08ecd0e 100644 --- a/sdks/node/test/options.test.ts +++ b/sdks/node/test/core/options.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/roundtrip.test.ts b/sdks/node/test/core/roundtrip.test.ts similarity index 95% rename from sdks/node/test/roundtrip.test.ts rename to sdks/node/test/core/roundtrip.test.ts index 7ee514d9..c2ee6c66 100644 --- a/sdks/node/test/roundtrip.test.ts +++ b/sdks/node/test/core/roundtrip.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/serializer.test.ts b/sdks/node/test/core/serializer.test.ts similarity index 98% rename from sdks/node/test/serializer.test.ts rename to sdks/node/test/core/serializer.test.ts index f7eaa650..215a407f 100644 --- a/sdks/node/test/serializer.test.ts +++ b/sdks/node/test/core/serializer.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { JsonSerializer, MsgpackSerializer, Queue, type Worker } from "../src/index"; +import { JsonSerializer, MsgpackSerializer, Queue, type Worker } from "../../src/index"; describe("JsonSerializer", () => { it("round-trips structured values", () => { diff --git a/sdks/node/test/typed.test.ts b/sdks/node/test/core/typed.test.ts similarity index 93% rename from sdks/node/test/typed.test.ts rename to sdks/node/test/core/typed.test.ts index 9cbc76e2..8be14435 100644 --- a/sdks/node/test/typed.test.ts +++ b/sdks/node/test/core/typed.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/webhooks.test.ts b/sdks/node/test/core/webhooks.test.ts similarity index 97% rename from sdks/node/test/webhooks.test.ts rename to sdks/node/test/core/webhooks.test.ts index 489a0d6f..98548f5b 100644 --- a/sdks/node/test/webhooks.test.ts +++ b/sdks/node/test/core/webhooks.test.ts @@ -5,7 +5,7 @@ import type { AddressInfo } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, WebhookValidationError, type Worker } from "../src/index"; +import { Queue, WebhookValidationError, type Worker } from "../../src/index"; let worker: Worker | undefined; let target: Server | undefined; diff --git a/sdks/node/test/dashboard.test.ts b/sdks/node/test/dashboard/server.test.ts similarity index 98% rename from sdks/node/test/dashboard.test.ts rename to sdks/node/test/dashboard/server.test.ts index 8f3f00c8..924b9316 100644 --- a/sdks/node/test/dashboard.test.ts +++ b/sdks/node/test/dashboard/server.test.ts @@ -7,9 +7,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeAll, beforeEach, expect, it } from "vitest"; -import { Queue, serveDashboard, type Worker } from "../src/index"; +import { Queue, serveDashboard, type Worker } from "../../src/index"; -const pkgRoot = fileURLToPath(new URL("..", import.meta.url)); +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); const staticDir = join(pkgRoot, "static", "dashboard"); beforeAll(() => { diff --git a/sdks/node/test/cli.test.ts b/sdks/node/test/integrations/cli.test.ts similarity index 92% rename from sdks/node/test/cli.test.ts rename to sdks/node/test/integrations/cli.test.ts index 4d0cd43c..74817d32 100644 --- a/sdks/node/test/cli.test.ts +++ b/sdks/node/test/integrations/cli.test.ts @@ -5,9 +5,9 @@ import { join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; -const pkgRoot = fileURLToPath(new URL("..", import.meta.url)); -const cliPath = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); -const indexUrl = pathToFileURL(fileURLToPath(new URL("../dist/index.js", import.meta.url))).href; +const pkgRoot = fileURLToPath(new URL("../..", import.meta.url)); +const cliPath = fileURLToPath(new URL("../../dist/cli.js", import.meta.url)); +const indexUrl = pathToFileURL(fileURLToPath(new URL("../../dist/index.js", import.meta.url))).href; beforeAll(() => { if (!existsSync(cliPath)) { diff --git a/sdks/node/test/integrations/express.test.ts b/sdks/node/test/integrations/express.test.ts new file mode 100644 index 00000000..ee84ef37 --- /dev/null +++ b/sdks/node/test/integrations/express.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import express from "express"; +import { afterEach, expect, it } from "vitest"; +import { taskitoDashboard, taskitoRouter } from "../../src/contrib/express"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; +let close: (() => Promise) | undefined; + +afterEach(async () => { + worker?.stop(); + worker = undefined; + await close?.(); + close = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-exp-")), "q.db") }); +} + +/** Mount `configure` on a fresh Express app and return its base URL. */ +async function serve(configure: (app: express.Express) => void): Promise { + const app = express(); + configure(app); + const server = app.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + close = () => new Promise((resolve) => server.close(() => resolve())); + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${port}`; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("enqueues, runs, and reports results over the REST router", async () => { + const queue = newQueue(); + queue.task("add", (a: number, b: number) => a + b); + const base = await serve((app) => app.use("/tasks", taskitoRouter(queue))); + + const enqueued = await fetch(`${base}/tasks/enqueue`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ task: "add", args: [2, 3] }), + }); + expect(enqueued.status).toBe(201); + const { jobId } = (await enqueued.json()) as { jobId: string }; + expect(typeof jobId).toBe("string"); + + worker = queue.runWorker(); + expect(await waitFor(() => queue.stats().completed >= 1)).toBe(true); + + const result = await (await fetch(`${base}/tasks/jobs/${jobId}/result`)).json(); + expect(result).toMatchObject({ jobId, status: "completed", result: 5 }); + + const stats = (await (await fetch(`${base}/tasks/stats`)).json()) as { completed: number }; + expect(stats.completed).toBeGreaterThanOrEqual(1); + + const job = (await (await fetch(`${base}/tasks/jobs/${jobId}`)).json()) as Record< + string, + unknown + >; + expect(job.taskName).toBe("add"); + expect(job).not.toHaveProperty("result"); // raw buffer stripped from the job view +}); + +it("rejects an enqueue without a task name", async () => { + const queue = newQueue(); + const base = await serve((app) => app.use("/tasks", taskitoRouter(queue))); + const res = await fetch(`${base}/tasks/enqueue`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ args: [1] }), + }); + expect(res.status).toBe(400); +}); + +it("honors excludeRoutes", async () => { + const queue = newQueue(); + const base = await serve((app) => + app.use("/tasks", taskitoRouter(queue, { excludeRoutes: ["stats"] })), + ); + expect((await fetch(`${base}/tasks/stats`)).status).toBe(404); + expect((await fetch(`${base}/tasks/dead-letters`)).status).toBe(200); +}); + +it("mounts the dashboard JSON API", async () => { + const queue = newQueue(); + const base = await serve((app) => app.use("/admin", taskitoDashboard(queue))); + const res = await fetch(`${base}/admin/api/auth/status`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ setup_required: false }); +}); diff --git a/sdks/node/test/integrations/fastify.test.ts b/sdks/node/test/integrations/fastify.test.ts new file mode 100644 index 00000000..b0fd0f24 --- /dev/null +++ b/sdks/node/test/integrations/fastify.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Fastify from "fastify"; +import { afterEach, expect, it } from "vitest"; +import { taskitoDashboardPlugin, taskitoFastify } from "../../src/contrib/fastify"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; +let app: ReturnType | undefined; + +afterEach(async () => { + worker?.stop(); + worker = undefined; + await app?.close(); + app = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-fastify-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("serves the REST API through a prefixed plugin", async () => { + const queue = newQueue(); + queue.task("add", (a: number, b: number) => a + b); + app = Fastify(); + await app.register(taskitoFastify, { queue, prefix: "/tasks" }); + await app.ready(); + + const enqueued = await app.inject({ + method: "POST", + url: "/tasks/enqueue", + payload: { task: "add", args: [4, 5] }, + }); + expect(enqueued.statusCode).toBe(201); + const { jobId } = enqueued.json() as { jobId: string }; + + worker = queue.runWorker(); + expect(await waitFor(() => queue.stats().completed >= 1)).toBe(true); + + const result = await app.inject({ method: "GET", url: `/tasks/jobs/${jobId}/result` }); + expect(result.json()).toMatchObject({ jobId, status: "completed", result: 9 }); + + const stats = await app.inject({ method: "GET", url: "/tasks/stats" }); + expect((stats.json() as { completed: number }).completed).toBeGreaterThanOrEqual(1); +}); + +it("applies excludeRoutes", async () => { + const queue = newQueue(); + app = Fastify(); + await app.register(taskitoFastify, { queue, prefix: "/tasks", excludeRoutes: ["stats"] }); + await app.ready(); + + expect((await app.inject({ method: "GET", url: "/tasks/stats" })).statusCode).toBe(404); + expect((await app.inject({ method: "GET", url: "/tasks/dead-letters" })).statusCode).toBe(200); +}); + +it("mounts the dashboard JSON API under a prefix", async () => { + const queue = newQueue(); + app = Fastify(); + await app.register(taskitoDashboardPlugin, { queue, prefix: "/admin" }); + await app.listen({ port: 0, host: "127.0.0.1" }); + const { port } = app.server.address() as AddressInfo; + + const res = await fetch(`http://127.0.0.1:${port}/admin/api/auth/status`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ setup_required: false }); +}); diff --git a/sdks/node/test/integrations/nest.test.ts b/sdks/node/test/integrations/nest.test.ts new file mode 100644 index 00000000..560545a2 --- /dev/null +++ b/sdks/node/test/integrations/nest.test.ts @@ -0,0 +1,51 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Test, type TestingModule } from "@nestjs/testing"; +import { afterEach, expect, it } from "vitest"; +import { TaskitoModule, TaskitoService } from "../../src/contrib/nest"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; +let moduleRef: TestingModule | undefined; + +afterEach(async () => { + worker?.stop(); + worker = undefined; + await moduleRef?.close(); + moduleRef = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-nest-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("injects TaskitoService bound to the queue", async () => { + const queue = newQueue(); + queue.task("add", (a: number, b: number) => a + b); + + moduleRef = await Test.createTestingModule({ + imports: [TaskitoModule.forRoot(queue)], + }).compile(); + const service = moduleRef.get(TaskitoService); + + expect(service.queue).toBe(queue); + + const id = service.enqueue("add", [6, 7]); + worker = queue.runWorker(); + expect(await waitFor(() => queue.stats().completed >= 1)).toBe(true); + + expect(await service.result(id)).toBe(13); + expect(service.stats().completed).toBeGreaterThanOrEqual(1); +}); diff --git a/sdks/node/test/integrations/rest.test.ts b/sdks/node/test/integrations/rest.test.ts new file mode 100644 index 00000000..18415ea3 --- /dev/null +++ b/sdks/node/test/integrations/rest.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from "vitest"; +import { buildRestRoutes, flattenQueryParams } from "../../src/contrib/rest"; + +it("rejects mutually exclusive include/exclude route options", () => { + expect(() => buildRestRoutes({ includeRoutes: ["stats"], excludeRoutes: ["jobs"] })).toThrow( + /mutually exclusive/, + ); + expect(buildRestRoutes({ includeRoutes: ["stats"] }).map((r) => r.name)).toEqual(["stats"]); +}); + +it("flattens string and first-of-array query values", () => { + const out = flattenQueryParams({ status: "failed", tag: ["a", "b"], n: 5 }); + expect(out.status).toBe("failed"); + expect(out.tag).toBe("a"); + expect(out.n).toBeUndefined(); +}); + +it("ignores prototype-polluting query keys", () => { + const out = flattenQueryParams({ __proto__: "x", constructor: "y", limit: "10" }); + expect(out.limit).toBe("10"); + // No pollution of Object.prototype, and the dangerous keys aren't carried over. + expect(({} as Record).x).toBeUndefined(); + expect(Object.hasOwn(out, "__proto__")).toBe(false); +}); + +it("handles a null/undefined query", () => { + expect(flattenQueryParams(undefined)).toEqual({}); + expect(flattenQueryParams(null)).toEqual({}); +}); diff --git a/sdks/node/test/observability/otel.test.ts b/sdks/node/test/observability/otel.test.ts new file mode 100644 index 00000000..ce1dd014 --- /dev/null +++ b/sdks/node/test/observability/otel.test.ts @@ -0,0 +1,146 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type Attributes, + type SpanStatus, + SpanStatusCode, + type TracerProvider, + trace, +} from "@opentelemetry/api"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { otelMiddleware } from "../../src/contrib/otel"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; + +interface RecordedSpan { + name: string; + attributes: Attributes; + status?: SpanStatus; + exceptions: unknown[]; + ended: boolean; +} + +/** A minimal recording tracer provider installed globally so the middleware's + * `trace.getTracer()` resolves to spans we can inspect. */ +function installRecorder(): RecordedSpan[] { + const spans: RecordedSpan[] = []; + const provider = { + getTracer: () => ({ + startSpan(name: string) { + const span: RecordedSpan = { name, attributes: {}, exceptions: [], ended: false }; + spans.push(span); + return { + setAttribute(key: string, value: unknown) { + span.attributes[key] = value as Attributes[string]; + return this; + }, + setAttributes(attrs: Attributes) { + Object.assign(span.attributes, attrs); + return this; + }, + setStatus(status: SpanStatus) { + span.status = status; + return this; + }, + recordException(exception: unknown) { + span.exceptions.push(exception); + }, + end() { + span.ended = true; + }, + }; + }, + }), + } as unknown as TracerProvider; + trace.setGlobalTracerProvider(provider); + return spans; +} + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-otel-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +beforeEach(() => { + trace.disable(); +}); + +afterEach(() => { + worker?.stop(); + worker = undefined; + trace.disable(); +}); + +it("opens an OK span per successful task execution", async () => { + const spans = installRecorder(); + const queue = newQueue(); + queue.use(otelMiddleware()); + queue.task("add", (a: number, b: number) => a + b); + + queue.enqueue("add", [2, 3]); + worker = queue.runWorker(); + + expect(await waitFor(() => spans.length > 0 && spans[0]?.ended === true)).toBe(true); + const span = spans[0]; + expect(span?.name).toBe("taskito.execute.add"); + expect(span?.attributes["taskito.task_name"]).toBe("add"); + expect(typeof span?.attributes["taskito.job_id"]).toBe("string"); + expect(span?.status?.code).toBe(SpanStatusCode.OK); +}); + +it("records the exception and ERROR status when a task throws", async () => { + const spans = installRecorder(); + const queue = newQueue(); + queue.use(otelMiddleware()); + queue.task( + "boom", + () => { + throw new Error("kaboom"); + }, + { maxRetries: 0 }, + ); + + queue.enqueue("boom", []); + worker = queue.runWorker(); + + expect(await waitFor(() => spans.length > 0 && spans[0]?.ended === true)).toBe(true); + const span = spans[0]; + expect(span?.status?.code).toBe(SpanStatusCode.ERROR); + expect(span?.status?.message).toBe("kaboom"); + expect(span?.exceptions[0]).toBeInstanceOf(Error); +}); + +it("honors taskFilter and a custom span name", async () => { + const spans = installRecorder(); + const queue = newQueue(); + queue.use( + otelMiddleware({ + taskFilter: (name) => name === "traced", + spanName: (ctx) => `job:${ctx.taskName}`, + extraAttributes: () => ({ "app.tier": "batch" }), + }), + ); + queue.task("traced", () => 1); + queue.task("ignored", () => 2); + + queue.enqueue("ignored", []); + queue.enqueue("traced", []); + worker = queue.runWorker(); + + expect(await waitFor(() => spans.some((s) => s.name === "job:traced"))).toBe(true); + expect(spans.every((s) => s.name !== "ignored" && !s.name.includes("ignored"))).toBe(true); + const traced = spans.find((s) => s.name === "job:traced"); + expect(traced?.attributes["app.tier"]).toBe("batch"); +}); diff --git a/sdks/node/test/observability/prometheus.test.ts b/sdks/node/test/observability/prometheus.test.ts new file mode 100644 index 00000000..cdba77d5 --- /dev/null +++ b/sdks/node/test/observability/prometheus.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Registry } from "prom-client"; +import { afterEach, expect, it } from "vitest"; +import { PrometheusStatsCollector, prometheusMiddleware } from "../../src/contrib/prometheus"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-prom-")), "q.db") }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("records job counters and a duration histogram", async () => { + const register = new Registry(); + const queue = newQueue(); + queue.use(prometheusMiddleware({ register })); + queue.task("add", (a: number, b: number) => a + b); + queue.task( + "boom", + () => { + throw new Error("nope"); + }, + { maxRetries: 0 }, + ); + + queue.enqueue("add", [1, 2]); + queue.enqueue("boom", []); + worker = queue.runWorker(); + + expect(await waitFor(() => queue.stats().completed >= 1 && queue.stats().dead >= 1)).toBe(true); + + const text = await register.metrics(); + expect(text).toContain('taskito_jobs_total{task="add",status="completed"} 1'); + expect(text).toContain('taskito_jobs_total{task="boom",status="failed"} 1'); + expect(text).toContain("taskito_job_duration_seconds_count"); + expect(text).toContain("taskito_active_workers 0"); +}); + +it("shares one metric store per (registry, namespace)", () => { + const register = new Registry(); + // Two middlewares on the same registry/namespace must not double-register. + expect(() => { + prometheusMiddleware({ register }); + prometheusMiddleware({ register }); + }).not.toThrow(); +}); + +it("samples queue depth and DLQ size", async () => { + const register = new Registry(); + const queue = newQueue(); + queue.task( + "boom", + () => { + throw new Error("nope"); + }, + { maxRetries: 0 }, + ); + + // Pending jobs (no worker yet) → queue depth. + queue.enqueue("boom", []); + queue.enqueue("boom", []); + + const collector = new PrometheusStatsCollector(queue, { register, intervalMs: 60_000 }); + collector.start(); + let text = await register.metrics(); + expect(text).toContain('taskito_queue_depth{queue="default"} 2'); + + // Drain to the DLQ, then re-sample. + worker = queue.runWorker(); + expect(await waitFor(() => queue.stats().dead >= 2)).toBe(true); + collector.sample(); + text = await register.metrics(); + expect(text).toContain("taskito_dlq_size 2"); + collector.stop(); +}); diff --git a/sdks/node/test/observability/sentry.test.ts b/sdks/node/test/observability/sentry.test.ts new file mode 100644 index 00000000..4b54f02a --- /dev/null +++ b/sdks/node/test/observability/sentry.test.ts @@ -0,0 +1,104 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ErrorEvent } from "@sentry/node"; +import * as Sentry from "@sentry/node"; +import { afterEach, expect, it } from "vitest"; +import { sentryMiddleware } from "../../src/contrib/sentry"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; + +afterEach(async () => { + worker?.stop(); + worker = undefined; + await Sentry.close(); +}); + +function newQueue(): Queue { + return new Queue({ dbPath: join(mkdtempSync(join(tmpdir(), "taskito-sentry-")), "q.db") }); +} + +/** Init Sentry with a beforeSend hook that records events instead of sending them. */ +function captureEvents(): ErrorEvent[] { + const events: ErrorEvent[] = []; + Sentry.init({ + dsn: "https://examplePublicKey@o0.ingest.sentry.io/0", + beforeSend(event) { + events.push(event); + return null; // drop — never hits the network + }, + }); + return events; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("captures one event with the stack and tags when a job dead-letters", async () => { + const events = captureEvents(); + const queue = newQueue(); + queue.use(sentryMiddleware()); + queue.task( + "boom", + () => { + throw new Error("kaboom"); + }, + { maxRetries: 0 }, + ); + + queue.enqueue("boom", []); + worker = queue.runWorker(); + + expect(await waitFor(() => events.length > 0)).toBe(true); + expect(events).toHaveLength(1); + const event = events[0]; + expect(event?.level).toBe("error"); + expect(event?.tags?.["taskito.task_name"]).toBe("boom"); + expect(typeof event?.tags?.["taskito.job_id"]).toBe("string"); + expect(event?.exception?.values?.[0]?.value).toBe("kaboom"); +}); + +it("does not capture successful jobs", async () => { + const events = captureEvents(); + const queue = newQueue(); + queue.use(sentryMiddleware()); + queue.task("ok", () => 1); + + queue.enqueue("ok", []); + worker = queue.runWorker(); + + expect(await waitFor(() => queue.stats().completed >= 1)).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(events).toHaveLength(0); +}); + +it("reports retries as warnings when captureRetries is set", async () => { + const events = captureEvents(); + const queue = newQueue(); + queue.use(sentryMiddleware({ captureRetries: true })); + queue.task( + "flaky", + () => { + throw new Error("nope"); + }, + { maxRetries: 1, retryBackoff: { baseMs: 1, maxMs: 5 } }, + ); + + queue.enqueue("flaky", []); + worker = queue.runWorker(); + + // One retry (warning) + one dead-letter (error). + expect(await waitFor(() => events.length >= 2)).toBe(true); + const levels = events.map((e) => e.level); + expect(levels).toContain("warning"); + expect(levels).toContain("error"); +}); diff --git a/sdks/node/test/events.test.ts b/sdks/node/test/worker/events.test.ts similarity index 98% rename from sdks/node/test/events.test.ts rename to sdks/node/test/worker/events.test.ts index 6f0a2799..3a7e52e9 100644 --- a/sdks/node/test/events.test.ts +++ b/sdks/node/test/worker/events.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { type Middleware, type OutcomeEvent, Queue, type Worker } from "../src/index"; +import { type Middleware, type OutcomeEvent, Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/mesh.test.ts b/sdks/node/test/worker/mesh.test.ts similarity index 94% rename from sdks/node/test/mesh.test.ts rename to sdks/node/test/worker/mesh.test.ts index 155fcf3d..06703aba 100644 --- a/sdks/node/test/mesh.test.ts +++ b/sdks/node/test/worker/mesh.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/periodic.test.ts b/sdks/node/test/worker/periodic.test.ts similarity index 96% rename from sdks/node/test/periodic.test.ts rename to sdks/node/test/worker/periodic.test.ts index abafe584..3ba0476f 100644 --- a/sdks/node/test/periodic.test.ts +++ b/sdks/node/test/worker/periodic.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/workflows/conditions.test.ts b/sdks/node/test/workflows/conditions.test.ts new file mode 100644 index 00000000..66013ed3 --- /dev/null +++ b/sdks/node/test/workflows/conditions.test.ts @@ -0,0 +1,122 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function freshQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-cond-")), "queue.db"); + return new Queue({ dbPath }); +} + +function nodesByName(handle: { + nodes: () => T[]; +}): Record { + return Object.fromEntries(handle.nodes().map((n) => [n.nodeName, n])); +} + +it("runs an on_failure handler and skips on_success siblings when a step fails", async () => { + const queue = freshQueue(); + const ran: string[] = []; + + queue.task("risky", () => { + throw new Error("boom"); + }); + queue.task("recover", () => ran.push("recover")); + queue.task("celebrate", () => ran.push("celebrate")); + + const handle = queue.workflows + .define("cond-handler") + .step("risky", "risky", { maxRetries: 0 }) + .step("recover", "recover", { after: "risky", condition: "on_failure" }) + .step("celebrate", "celebrate", { after: "risky", condition: "on_success" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("failed"); // a node failed, even though it was handled + const nodes = nodesByName(handle); + expect(nodes.risky?.status).toBe("failed"); + expect(nodes.recover?.status).toBe("completed"); + expect(nodes.celebrate?.status).toBe("skipped"); + expect(ran).toEqual(["recover"]); +}); + +it("runs an `always` step regardless of upstream failure", async () => { + const queue = freshQueue(); + let cleaned = false; + + queue.task("risky", () => { + throw new Error("boom"); + }); + queue.task("cleanup", () => { + cleaned = true; + }); + + const handle = queue.workflows + .define("cond-always") + .step("risky", "risky", { maxRetries: 0 }) + .step("cleanup", "cleanup", { after: "risky", condition: "always" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + await handle.wait({ timeoutMs: 10_000 }); + + expect(cleaned).toBe(true); + expect(nodesByName(handle).cleanup?.status).toBe("completed"); +}); + +it("propagates a skip to downstream steps", async () => { + const queue = freshQueue(); + + queue.task("risky", () => { + throw new Error("boom"); + }); + queue.task("noop", () => 1); + + const handle = queue.workflows + .define("cond-propagate") + .step("risky", "risky", { maxRetries: 0 }) + .step("mid", "noop", { after: "risky", condition: "on_success" }) + .step("tail", "noop", { after: "mid" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("failed"); + const nodes = nodesByName(handle); + expect(nodes.mid?.status).toBe("skipped"); + expect(nodes.tail?.status).toBe("skipped"); // skip propagated through `mid` +}); + +it("runs an on_success step when its predecessor completes", async () => { + const queue = freshQueue(); + let value = 0; + + queue.task("produce", () => 21); + queue.task("consume", () => { + value = 42; + }); + + const handle = queue.workflows + .define("cond-happy") + .step("produce", "produce") + .step("consume", "consume", { after: "produce", condition: "on_success" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("completed"); + expect(value).toBe(42); + expect(nodesByName(handle).consume?.status).toBe("completed"); +}); diff --git a/sdks/node/test/workflows.test.ts b/sdks/node/test/workflows/dag.test.ts similarity index 97% rename from sdks/node/test/workflows.test.ts rename to sdks/node/test/workflows/dag.test.ts index 13266b73..2fe4bd19 100644 --- a/sdks/node/test/workflows.test.ts +++ b/sdks/node/test/workflows/dag.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/workflows-fanout.test.ts b/sdks/node/test/workflows/fanout.test.ts similarity index 98% rename from sdks/node/test/workflows-fanout.test.ts rename to sdks/node/test/workflows/fanout.test.ts index 7764dfc2..a7d27a11 100644 --- a/sdks/node/test/workflows-fanout.test.ts +++ b/sdks/node/test/workflows/fanout.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, it } from "vitest"; -import { Queue, type Worker } from "../src/index"; +import { Queue, type Worker } from "../../src/index"; let worker: Worker | undefined; diff --git a/sdks/node/test/workflows/gates.test.ts b/sdks/node/test/workflows/gates.test.ts new file mode 100644 index 00000000..972a70c1 --- /dev/null +++ b/sdks/node/test/workflows/gates.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function freshQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-gate-")), "queue.db"); + return new Queue({ dbPath }); +} + +function nodesByName(handle: { + nodes: () => T[]; +}): Record { + return Object.fromEntries(handle.nodes().map((n) => [n.nodeName, n])); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 6000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return false; +} + +it("pauses at a gate and runs downstream once approved", async () => { + const queue = freshQueue(); + let released = false; + + queue.task("prepare", () => 1); + queue.task("ship", () => { + released = true; + }); + + const handle = queue.workflows + .define("gate-approve") + .step("prepare", "prepare") + .gate("review", { after: "prepare" }) + .step("ship", "ship", { after: "review" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + + // The run parks at the gate until approved. + expect(await waitFor(() => nodesByName(handle).review?.status === "waiting_approval")).toBe(true); + expect(released).toBe(false); + expect(handle.status()?.state).toBe("running"); + + queue.workflows.approveGate(handle.runId, "review"); + + const run = await handle.wait({ timeoutMs: 8000 }); + expect(run.state).toBe("completed"); + expect(released).toBe(true); + expect(nodesByName(handle).review?.status).toBe("completed"); +}); + +it("skips downstream and fails the run when a gate is rejected", async () => { + const queue = freshQueue(); + queue.task("prepare", () => 1); + queue.task("ship", () => 2); + + const handle = queue.workflows + .define("gate-reject") + .step("prepare", "prepare") + .gate("review", { after: "prepare" }) + .step("ship", "ship", { after: "review" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + expect(await waitFor(() => nodesByName(handle).review?.status === "waiting_approval")).toBe(true); + + queue.workflows.rejectGate(handle.runId, "review", "not allowed"); + + const run = await handle.wait({ timeoutMs: 8000 }); + expect(run.state).toBe("failed"); + const nodes = nodesByName(handle); + expect(nodes.review?.status).toBe("failed"); + expect(nodes.ship?.status).toBe("skipped"); +}); + +it("auto-resolves a gate on timeout per onTimeout", async () => { + const queue = freshQueue(); + let shipped = false; + + queue.task("prepare", () => 1); + queue.task("ship", () => { + shipped = true; + }); + + const handle = queue.workflows + .define("gate-timeout") + .step("prepare", "prepare") + .gate("review", { after: "prepare", timeoutMs: 200, onTimeout: "approve" }) + .step("ship", "ship", { after: "review" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 8000 }); + + expect(run.state).toBe("completed"); // timeout approved the gate + expect(shipped).toBe(true); + expect(nodesByName(handle).review?.status).toBe("completed"); +}); diff --git a/sdks/node/test/workflows/saga.test.ts b/sdks/node/test/workflows/saga.test.ts new file mode 100644 index 00000000..ea3157f6 --- /dev/null +++ b/sdks/node/test/workflows/saga.test.ts @@ -0,0 +1,99 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function freshQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-saga-")), "queue.db"); + return new Queue({ dbPath }); +} + +function nodesByName(handle: { + nodes: () => T[]; +}): Record { + return Object.fromEntries(handle.nodes().map((n) => [n.nodeName, n])); +} + +it("compensates completed steps in reverse order when a later step fails", async () => { + const queue = freshQueue(); + const rollbacks: string[] = []; + + queue.task("reserve", () => "reservation"); + queue.task("charge", () => "charge-id"); + queue.task("ship", () => { + throw new Error("out of stock"); + }); + queue.task("unreserve", (forward: string) => rollbacks.push(`unreserve:${forward}`)); + queue.task("refund", (forward: string) => rollbacks.push(`refund:${forward}`)); + + const handle = queue.workflows + .define("saga-order") + .step("reserve", "reserve", { compensate: "unreserve" }) + .step("charge", "charge", { after: "reserve", compensate: "refund" }) + .step("ship", "ship", { after: "charge", maxRetries: 0 }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("compensated"); + // charge rolls back before reserve (reverse dependency order). + expect(rollbacks).toEqual(["refund:charge-id", "unreserve:reservation"]); + const nodes = nodesByName(handle); + expect(nodes.reserve?.status).toBe("compensated"); + expect(nodes.charge?.status).toBe("compensated"); + expect(nodes.ship?.status).toBe("failed"); +}); + +it("ends in compensation_failed when a compensator fails", async () => { + const queue = freshQueue(); + + queue.task("reserve", () => 1); + queue.task("ship", () => { + throw new Error("nope"); + }); + queue.task("unreserve", () => { + throw new Error("rollback failed"); + }); + + const handle = queue.workflows + .define("saga-bad-rollback") + .step("reserve", "reserve", { compensate: "unreserve", maxRetries: 0 }) + .step("ship", "ship", { after: "reserve", maxRetries: 0 }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("compensation_failed"); + expect(nodesByName(handle).reserve?.status).toBe("compensation_failed"); +}); + +it("fails normally with no compensation when no step declares a compensator", async () => { + const queue = freshQueue(); + + queue.task("reserve", () => 1); + queue.task("ship", () => { + throw new Error("nope"); + }); + + const handle = queue.workflows + .define("saga-none") + .step("reserve", "reserve") + .step("ship", "ship", { after: "reserve", maxRetries: 0 }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("failed"); + expect(nodesByName(handle).reserve?.status).toBe("completed"); // not rolled back +}); diff --git a/sdks/node/test/workflows/subworkflows.test.ts b/sdks/node/test/workflows/subworkflows.test.ts new file mode 100644 index 00000000..93ca3f30 --- /dev/null +++ b/sdks/node/test/workflows/subworkflows.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { Queue, type Worker } from "../../src/index"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function freshQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-subwf-")), "queue.db"); + return new Queue({ dbPath }); +} + +function nodesByName(handle: { + nodes: () => T[]; +}): Record { + return Object.fromEntries(handle.nodes().map((n) => [n.nodeName, n])); +} + +it("runs a child workflow as a node and advances the parent on completion", async () => { + const queue = freshQueue(); + const ran: string[] = []; + + queue.task("prep", () => ran.push("prep")); + queue.task("childA", () => ran.push("childA")); + queue.task("childB", () => ran.push("childB")); + queue.task("finish", () => ran.push("finish")); + + const child = queue.workflows + .define("child-pipeline") + .step("a", "childA") + .step("b", "childB", { after: "a" }) + .build(); + + const handle = queue.workflows + .define("parent-pipeline") + .step("prep", "prep") + .subWorkflow("sub", { after: "prep", workflow: child }) + .step("finish", "finish", { after: "sub" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("completed"); + expect(ran).toEqual(["prep", "childA", "childB", "finish"]); + const nodes = nodesByName(handle); + expect(nodes.sub?.status).toBe("completed"); + expect(nodes.finish?.status).toBe("completed"); + + const children = queue.workflows.children(handle.runId); + expect(children).toHaveLength(1); + expect(children[0]?.state).toBe("completed"); +}); + +it("fails the parent node and skips downstream when the child fails", async () => { + const queue = freshQueue(); + + queue.task("prep", () => 1); + queue.task("boom", () => { + throw new Error("child boom"); + }); + queue.task("finish", () => 2); + + const child = queue.workflows.define("child-fail").step("a", "boom", { maxRetries: 0 }).build(); + + const handle = queue.workflows + .define("parent-fail") + .step("prep", "prep") + .subWorkflow("sub", { after: "prep", workflow: child }) + .step("finish", "finish", { after: "sub" }) + .submit(); + + worker = queue.runWorker({ queues: ["default"] }); + const run = await handle.wait({ timeoutMs: 10_000 }); + + expect(run.state).toBe("failed"); + const nodes = nodesByName(handle); + expect(nodes.sub?.status).toBe("failed"); + expect(nodes.finish?.status).toBe("skipped"); + expect(queue.workflows.children(handle.runId)[0]?.state).toBe("failed"); +}); diff --git a/sdks/node/tsconfig.json b/sdks/node/tsconfig.json index 4a1c74c3..c800ae79 100644 --- a/sdks/node/tsconfig.json +++ b/sdks/node/tsconfig.json @@ -9,6 +9,7 @@ "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "verbatimModuleSyntax": true, + "experimentalDecorators": true, "ignoreDeprecations": "6.0", "noEmit": true, "skipLibCheck": true, diff --git a/sdks/node/tsup.config.ts b/sdks/node/tsup.config.ts index e1ac2b62..0ddf7c72 100644 --- a/sdks/node/tsup.config.ts +++ b/sdks/node/tsup.config.ts @@ -1,7 +1,16 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: { index: "src/index.ts", cli: "src/cli/index.ts" }, + entry: { + index: "src/index.ts", + cli: "src/cli/index.ts", + "contrib/otel": "src/contrib/otel.ts", + "contrib/prometheus": "src/contrib/prometheus.ts", + "contrib/express": "src/contrib/express.ts", + "contrib/fastify": "src/contrib/fastify.ts", + "contrib/nest": "src/contrib/nest.ts", + "contrib/sentry": "src/contrib/sentry.ts", + }, format: ["esm", "cjs"], dts: true, shims: true,