diff --git a/Cargo.toml b/Cargo.toml index 428b79ee..4a4305b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/taskito-core", "crates/taskito-python", "crates/taskito-workflows", "crates/taskito-mesh"] +members = ["crates/taskito-core", "crates/taskito-python", "crates/taskito-node", "crates/taskito-workflows", "crates/taskito-mesh"] resolver = "2" [workspace.dependencies] diff --git a/crates/taskito-node/Cargo.toml b/crates/taskito-node/Cargo.toml new file mode 100644 index 00000000..8678286c --- /dev/null +++ b/crates/taskito-node/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "taskito-node" +version = "0.16.3" +edition = "2021" +description = "Node.js (napi-rs) bindings for the Taskito task-queue core" +license = "MIT" +publish = false + +[lib] +crate-type = ["cdylib"] + +[features] +default = [] +postgres = ["taskito-core/postgres"] +redis = ["taskito-core/redis"] + +[dependencies] +taskito-core = { path = "../taskito-core" } +napi = { version = "2", default-features = false, features = ["napi8", "tokio_rt"] } +napi-derive = "2" +tokio = { workspace = true } +crossbeam-channel = { workspace = true } +async-trait = { workspace = true } +log = { workspace = true } + +[build-dependencies] +napi-build = "2" diff --git a/crates/taskito-node/build.rs b/crates/taskito-node/build.rs new file mode 100644 index 00000000..0f1b0100 --- /dev/null +++ b/crates/taskito-node/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/crates/taskito-node/src/backend.rs b/crates/taskito-node/src/backend.rs new file mode 100644 index 00000000..656f03fa --- /dev/null +++ b/crates/taskito-node/src/backend.rs @@ -0,0 +1,57 @@ +//! Feature-gated construction of a [`StorageBackend`] from JS [`OpenOptions`]. + +use napi::bindgen_prelude::{Error, Result, Status}; +use taskito_core::{SqliteStorage, StorageBackend}; + +use crate::config::OpenOptions; +use crate::error::{invalid_arg, to_napi_err}; + +const DEFAULT_SQLITE_POOL: u32 = 8; +#[cfg(feature = "postgres")] +const DEFAULT_POSTGRES_POOL: u32 = 10; +#[cfg(feature = "postgres")] +const DEFAULT_POSTGRES_SCHEMA: &str = "taskito"; + +/// Resolve a connection pool size, rejecting an explicit zero — r2d2 requires +/// `max_size > 0` and panics otherwise, which would crash the whole process. +fn resolve_pool_size(pool_size: Option, default: u32) -> Result { + match pool_size { + Some(0) => Err(invalid_arg("poolSize must be greater than 0")), + Some(n) => Ok(n), + None => Ok(default), + } +} + +/// Open the storage backend named by `options.backend` (default `"sqlite"`). +/// Returns an error if a requested backend was not compiled into this addon. +pub fn open(options: &OpenOptions) -> Result { + match options.backend.as_deref().unwrap_or("sqlite") { + "sqlite" => { + let pool = resolve_pool_size(options.pool_size, DEFAULT_SQLITE_POOL)?; + let storage = SqliteStorage::with_pool_size(&options.dsn, pool).map_err(to_napi_err)?; + Ok(StorageBackend::Sqlite(storage)) + } + #[cfg(feature = "postgres")] + "postgres" => { + let schema = options.schema.as_deref().unwrap_or(DEFAULT_POSTGRES_SCHEMA); + let pool = resolve_pool_size(options.pool_size, DEFAULT_POSTGRES_POOL)?; + let storage = + taskito_core::PostgresStorage::with_schema_and_pool_size(&options.dsn, schema, pool) + .map_err(to_napi_err)?; + Ok(StorageBackend::Postgres(storage)) + } + #[cfg(feature = "redis")] + "redis" => { + let storage = match options.prefix.as_deref() { + Some(prefix) => taskito_core::RedisStorage::with_prefix(&options.dsn, prefix), + None => taskito_core::RedisStorage::new(&options.dsn), + } + .map_err(to_napi_err)?; + Ok(StorageBackend::Redis(storage)) + } + other => Err(Error::new( + Status::InvalidArg, + format!("backend '{other}' is not available (not a known backend, or this addon was built without its cargo feature)"), + )), + } +} diff --git a/crates/taskito-node/src/config.rs b/crates/taskito-node/src/config.rs new file mode 100644 index 00000000..a74d42c3 --- /dev/null +++ b/crates/taskito-node/src/config.rs @@ -0,0 +1,86 @@ +//! Plain option objects passed from JavaScript. napi maps snake_case Rust +//! fields to camelCase JS keys (`maxRetries`, `timeoutMs`). + +use napi_derive::napi; + +/// How to open a queue's storage backend. +#[napi(object)] +pub struct OpenOptions { + /// `"sqlite"` (default), `"postgres"`, or `"redis"`. The latter two require + /// the addon to be built with the matching cargo feature. + pub backend: Option, + /// SQLite file path, Postgres URL, or Redis URL. + pub dsn: String, + /// Connection pool size (SQLite/Postgres). + pub pool_size: Option, + /// Postgres schema (default `"taskito"`). + pub schema: Option, + /// Redis key prefix. + pub prefix: Option, + /// Optional namespace applied to enqueued jobs and the worker scheduler. + pub namespace: Option, +} + +/// Per-enqueue overrides. All optional — omitted fields fall back to defaults +/// in [`crate::convert::build_new_job`]. +#[napi(object)] +#[derive(Default)] +pub struct EnqueueOptions { + pub queue: Option, + pub priority: Option, + pub max_retries: Option, + pub timeout_ms: Option, + /// Run no earlier than `now + delayMs` (delayed/scheduled job). + pub delay_ms: Option, + /// Dedup key — a second enqueue with the same key is a no-op while the + /// first is pending/running (idempotency). + pub unique_key: Option, + /// Free-form metadata string stored with the job. + pub metadata: Option, + /// Namespace override for this job. + pub namespace: Option, +} + +/// Filter for [`crate::queue::JsQueue::list_jobs`]. All fields optional. +#[napi(object)] +#[derive(Default)] +pub struct JobFilter { + /// Lowercase status (`"pending"`, `"running"`, `"complete"`, `"failed"`, `"dead"`, `"cancelled"`). + pub status: Option, + pub queue: Option, + pub task: Option, + pub limit: Option, + pub offset: Option, +} + +/// Per-task configuration registered on the scheduler before the worker runs. +#[napi(object)] +pub struct TaskConfigInput { + pub name: String, + pub max_retries: Option, + pub retry_base_delay_ms: Option, + pub retry_max_delay_ms: Option, + pub max_concurrent: Option, + /// Rate-limit spec like `"100/m"`, `"50/s"`, `"3600/h"`. + pub rate_limit: Option, +} + +/// Per-queue configuration registered on the scheduler before the worker runs. +#[napi(object)] +pub struct QueueConfigInput { + pub name: String, + pub max_concurrent: Option, + pub rate_limit: Option, +} + +/// Options for a running worker. `queues` defaults to `["default"]`. +#[napi(object)] +#[derive(Default)] +pub struct WorkerOptions { + pub queues: Option>, + pub channel_capacity: Option, + /// Jobs claimed per scheduler poll (default 1). + pub batch_size: Option, + pub task_configs: Option>, + pub queue_configs: Option>, +} diff --git a/crates/taskito-node/src/convert/job.rs b/crates/taskito-node/src/convert/job.rs new file mode 100644 index 00000000..ec3f3343 --- /dev/null +++ b/crates/taskito-node/src/convert/job.rs @@ -0,0 +1,99 @@ +//! Marshalling between core job types and JS-facing shapes. Kept out of the +//! logic modules so `queue`/`worker` read as intent, not plumbing. + +use napi::bindgen_prelude::{Buffer, Result}; +use napi_derive::napi; +use taskito_core::job::now_millis; +use taskito_core::{Job, NewJob}; + +use crate::config::EnqueueOptions; +use crate::error::non_negative; + +const DEFAULT_QUEUE: &str = "default"; +const DEFAULT_MAX_RETRIES: i32 = 3; +const DEFAULT_TIMEOUT_MS: i64 = 300_000; + +/// Build a [`NewJob`] from a task name, opaque payload bytes, and JS options. +/// The core never interprets `payload` — the shell owns (de)serialization. +/// `queue_namespace` is the queue-level default applied when the enqueue call +/// doesn't override it. +pub fn build_new_job( + task_name: String, + payload: Vec, + opts: EnqueueOptions, + queue_namespace: Option<&str>, +) -> Result { + // Signed types reach us from JS; reject the negatives that would silently + // corrupt scheduling (premature timeout, retries disabled, past schedule). + let delay = non_negative(opts.delay_ms.unwrap_or(0), "delayMs")?; + let max_retries = match opts.max_retries { + Some(n) => non_negative(n as i64, "maxRetries")? as i32, + None => DEFAULT_MAX_RETRIES, + }; + let timeout_ms = match opts.timeout_ms { + Some(n) => non_negative(n, "timeoutMs")?, + None => DEFAULT_TIMEOUT_MS, + }; + Ok(NewJob { + queue: opts.queue.unwrap_or_else(|| DEFAULT_QUEUE.to_string()), + task_name, + payload, + priority: opts.priority.unwrap_or(0), + // Saturate so an extreme delay can't overflow into a past schedule. + scheduled_at: now_millis().saturating_add(delay), + max_retries, + timeout_ms, + unique_key: opts.unique_key, + metadata: opts.metadata, + notes: None, + depends_on: Vec::new(), + expires_at: None, + result_ttl_ms: None, + namespace: opts + .namespace + .or_else(|| queue_namespace.map(str::to_string)), + }) +} + +/// Passed to the JS task callback for each dispatched job. `payload` is the +/// opaque arg blob the shell deserializes before running the task. +#[napi(object)] +pub struct JsTaskInvocation { + pub id: String, + pub task_name: String, + pub payload: Buffer, +} + +/// JS-facing view of a stored [`Job`]. `result` is the opaque result blob (or +/// `null`); the shell deserializes it. +#[napi(object)] +pub struct JsJob { + pub id: String, + pub queue: String, + pub task_name: String, + pub status: String, + pub priority: i32, + pub retry_count: i32, + pub max_retries: i32, + pub result: Option, + pub error: Option, + pub created_at: i64, + pub completed_at: Option, +} + +/// Convert a core [`Job`] into its JS-facing shape. +pub fn job_to_js(job: Job) -> JsJob { + JsJob { + id: job.id, + queue: job.queue, + task_name: job.task_name, + status: job.status.as_str().to_string(), + priority: job.priority, + retry_count: job.retry_count, + max_retries: job.max_retries, + result: job.result.map(Buffer::from), + error: job.error, + created_at: job.created_at, + completed_at: job.completed_at, + } +} diff --git a/crates/taskito-node/src/convert/mod.rs b/crates/taskito-node/src/convert/mod.rs new file mode 100644 index 00000000..4b8d8837 --- /dev/null +++ b/crates/taskito-node/src/convert/mod.rs @@ -0,0 +1,13 @@ +//! Marshalling between core types and JS-facing shapes. One submodule per +//! concern; kept out of the logic modules so they read as intent, not plumbing. + +mod job; +mod stats; +mod task_config; + +pub use job::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; +pub use stats::{ + dead_job_to_js, job_error_to_js, metric_to_js, stats_to_js, status_code, JsDeadJob, JsJobError, + JsMetric, JsStats, +}; +pub use task_config::{queue_config, task_config}; diff --git a/crates/taskito-node/src/convert/stats.rs b/crates/taskito-node/src/convert/stats.rs new file mode 100644 index 00000000..c722c219 --- /dev/null +++ b/crates/taskito-node/src/convert/stats.rs @@ -0,0 +1,110 @@ +//! JS-facing shapes for inspection/management results. + +use napi_derive::napi; +use taskito_core::storage::models::{JobErrorRow, TaskMetricRow}; +use taskito_core::storage::{DeadJob, QueueStats}; + +/// Queue job counts by status. +#[napi(object)] +pub struct JsStats { + pub pending: i64, + pub running: i64, + pub completed: i64, + pub failed: i64, + pub dead: i64, + pub cancelled: i64, +} + +pub fn stats_to_js(stats: QueueStats) -> JsStats { + JsStats { + pending: stats.pending, + running: stats.running, + completed: stats.completed, + failed: stats.failed, + dead: stats.dead, + cancelled: stats.cancelled, + } +} + +/// A dead-letter entry. +#[napi(object)] +pub struct JsDeadJob { + pub id: String, + pub original_job_id: String, + pub queue: String, + pub task_name: String, + pub error: Option, + pub retry_count: i32, + pub failed_at: i64, + pub metadata: Option, + pub dlq_retry_count: i32, +} + +pub fn dead_job_to_js(dead: DeadJob) -> JsDeadJob { + JsDeadJob { + id: dead.id, + original_job_id: dead.original_job_id, + queue: dead.queue, + task_name: dead.task_name, + error: dead.error, + retry_count: dead.retry_count, + failed_at: dead.failed_at, + metadata: dead.metadata, + dlq_retry_count: dead.dlq_retry_count, + } +} + +/// One recorded error attempt for a job. +#[napi(object)] +pub struct JsJobError { + pub id: String, + pub job_id: String, + pub attempt: i32, + pub error: String, + pub failed_at: i64, +} + +pub fn job_error_to_js(error: JobErrorRow) -> JsJobError { + JsJobError { + id: error.id, + job_id: error.job_id, + attempt: error.attempt, + error: error.error, + failed_at: error.failed_at, + } +} + +/// A per-execution task metric. +#[napi(object)] +pub struct JsMetric { + pub task_name: String, + pub job_id: String, + pub wall_time_ns: i64, + pub memory_bytes: i64, + pub succeeded: bool, + pub recorded_at: i64, +} + +pub fn metric_to_js(metric: TaskMetricRow) -> JsMetric { + JsMetric { + task_name: metric.task_name, + job_id: metric.job_id, + wall_time_ns: metric.wall_time_ns, + memory_bytes: metric.memory_bytes, + succeeded: metric.succeeded, + recorded_at: metric.recorded_at, + } +} + +/// Map a lowercase status string to the core's `i32` code (for list filtering). +pub fn status_code(status: &str) -> Option { + match status { + "pending" => Some(0), + "running" => Some(1), + "complete" | "completed" => Some(2), + "failed" => Some(3), + "dead" => Some(4), + "cancelled" => Some(5), + _ => None, + } +} diff --git a/crates/taskito-node/src/convert/task_config.rs b/crates/taskito-node/src/convert/task_config.rs new file mode 100644 index 00000000..9961dac6 --- /dev/null +++ b/crates/taskito-node/src/convert/task_config.rs @@ -0,0 +1,47 @@ +//! Build core task/queue configuration from JS option inputs. + +use napi::bindgen_prelude::Result; +use taskito_core::resilience::rate_limiter::RateLimitConfig; +use taskito_core::resilience::retry::RetryPolicy; +use taskito_core::scheduler::{QueueConfig, TaskConfig}; + +use crate::config::{QueueConfigInput, TaskConfigInput}; +use crate::error::invalid_arg; + +const DEFAULT_RETRY_BASE_MS: i64 = 1_000; +const DEFAULT_RETRY_MAX_MS: i64 = 300_000; +const DEFAULT_MAX_RETRIES: i32 = 3; + +/// Parse an optional rate-limit spec, failing fast on a malformed value rather +/// than silently disabling throttling (a misconfigured limit is a config error). +fn parse_rate_limit(spec: Option<&str>) -> Result> { + match spec { + Some(s) => RateLimitConfig::parse(s) + .map(Some) + .ok_or_else(|| invalid_arg(format!("invalid rateLimit '{s}' (expected e.g. '100/m')"))), + None => Ok(None), + } +} + +/// Build a [`TaskConfig`] (retry policy, rate limit, concurrency cap) from JS input. +pub fn task_config(input: &TaskConfigInput) -> Result { + Ok(TaskConfig { + retry_policy: RetryPolicy { + max_retries: input.max_retries.unwrap_or(DEFAULT_MAX_RETRIES), + base_delay_ms: input.retry_base_delay_ms.unwrap_or(DEFAULT_RETRY_BASE_MS), + max_delay_ms: input.retry_max_delay_ms.unwrap_or(DEFAULT_RETRY_MAX_MS), + custom_delays_ms: None, + }, + rate_limit: parse_rate_limit(input.rate_limit.as_deref())?, + circuit_breaker: None, + max_concurrent: input.max_concurrent, + }) +} + +/// Build a [`QueueConfig`] (rate limit, concurrency cap) from JS input. +pub fn queue_config(input: &QueueConfigInput) -> Result { + Ok(QueueConfig { + rate_limit: parse_rate_limit(input.rate_limit.as_deref())?, + max_concurrent: input.max_concurrent, + }) +} diff --git a/crates/taskito-node/src/dispatcher.rs b/crates/taskito-node/src/dispatcher.rs new file mode 100644 index 00000000..873a126d --- /dev/null +++ b/crates/taskito-node/src/dispatcher.rs @@ -0,0 +1,138 @@ +//! `NodeDispatcher` — runs each job by calling back into JavaScript. +//! +//! Implements the core [`WorkerDispatcher`] trait. For every dequeued job it +//! invokes the JS task callback via a `ThreadsafeFunction`, awaits the returned +//! `Promise`, and reports a [`JobResult`] back to the scheduler. This is +//! the Node mirror of the Python shell's worker pool. + +use std::time::{Duration, Instant}; + +use crossbeam_channel::Sender; +use napi::bindgen_prelude::{spawn, Buffer, Promise}; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}; +use taskito_core::job::Job; +use taskito_core::scheduler::JobResult; +use taskito_core::worker::WorkerDispatcher; +use taskito_core::{Storage, StorageBackend}; +use tokio::sync::oneshot; + +use crate::convert::JsTaskInvocation; + +/// Task callback registered from JS: `(invocation) => Promise`. +type TaskCallback = ThreadsafeFunction; + +/// Executes jobs by dispatching them to a JavaScript callback. +pub struct NodeDispatcher { + callback: TaskCallback, + storage: StorageBackend, +} + +impl NodeDispatcher { + pub fn new(callback: TaskCallback, storage: StorageBackend) -> Self { + Self { callback, storage } + } +} + +#[async_trait::async_trait] +impl WorkerDispatcher for NodeDispatcher { + async fn run( + &self, + mut job_rx: tokio::sync::mpsc::Receiver, + result_tx: Sender, + ) { + // Run jobs concurrently — each invocation is independent and the JS + // side may be async. The scheduler bounds in-flight work via the + // channel capacity and per-task/queue concurrency gates. + while let Some(job) = job_rx.recv().await { + let callback = self.callback.clone(); + let storage = self.storage.clone(); + let result_tx = result_tx.clone(); + spawn(async move { + let result = run_one(&callback, &storage, job).await; + let _ = result_tx.send(result); + }); + } + } + + fn shutdown(&self) {} +} + +/// Invoke the JS task for one job and translate the outcome into a [`JobResult`]. +async fn run_one(callback: &TaskCallback, storage: &StorageBackend, job: Job) -> JobResult { + let started = Instant::now(); + let invocation = JsTaskInvocation { + id: job.id.clone(), + task_name: job.task_name.clone(), + payload: Buffer::from(job.payload.clone()), + }; + + // The callback runs on the JS thread and returns a Promise; bridge its + // awaited value back to this async context through a oneshot. + let (tx, rx) = oneshot::channel::>>(); + callback.call_with_return_value( + invocation, + ThreadsafeFunctionCallMode::NonBlocking, + move |promise: Promise| { + spawn(async move { + let outcome = promise.await.map(|buffer| buffer.to_vec()); + let _ = tx.send(outcome); + }); + Ok(()) + }, + ); + + // Enforce the per-job timeout (the core stores `timeout_ms` but leaves + // enforcement to the shell). `timeout_ms <= 0` means no limit. + let timed = if job.timeout_ms > 0 { + tokio::time::timeout(Duration::from_millis(job.timeout_ms as u64), rx).await + } else { + Ok(rx.await) + }; + let wall_time_ns = started.elapsed().as_nanos() as i64; + match timed { + Ok(Ok(Ok(result))) => JobResult::Success { + job_id: job.id, + result: Some(result), + task_name: job.task_name, + wall_time_ns, + }, + Ok(Ok(Err(err))) => { + // A rejected task that was cancel-requested is a cancellation, not a + // failure (the JS side aborts via the cancel signal). + if storage.is_cancel_requested(&job.id).unwrap_or(false) { + JobResult::Cancelled { + job_id: job.id, + task_name: job.task_name, + wall_time_ns, + } + } else { + failure(job, err.to_string(), wall_time_ns, false) + } + } + Ok(Err(_)) => failure( + job, + "node task channel dropped".to_string(), + wall_time_ns, + false, + ), + // We report the timeout, but the underlying JS promise cannot be force- + // killed and keeps running in the background — same limitation as the + // Python shell. Non-idempotent tasks should cooperate via the cancel + // signal (`currentJob().signal`) and check it on long operations. + Err(_) => failure(job, "task timed out".to_string(), wall_time_ns, true), + } +} + +/// Build a retryable [`JobResult::Failure`] from a job and error message. +fn failure(job: Job, error: String, wall_time_ns: i64, timed_out: bool) -> JobResult { + JobResult::Failure { + job_id: job.id, + error, + retry_count: job.retry_count, + max_retries: job.max_retries, + task_name: job.task_name, + wall_time_ns, + should_retry: true, + timed_out, + } +} diff --git a/crates/taskito-node/src/error.rs b/crates/taskito-node/src/error.rs new file mode 100644 index 00000000..42882f53 --- /dev/null +++ b/crates/taskito-node/src/error.rs @@ -0,0 +1,24 @@ +//! Translate core errors into napi errors surfaced to JavaScript. + +use napi::bindgen_prelude::{Error, Status}; +use taskito_core::QueueError; + +/// Map a core [`QueueError`] onto a napi [`Error`] (a thrown JS `Error`). +pub fn to_napi_err(err: QueueError) -> Error { + Error::new(Status::GenericFailure, err.to_string()) +} + +/// Build an `InvalidArg` error for caller-supplied input that fails validation +/// at the N-API boundary (negative pagination, zero pool size, bad rate limit…). +pub fn invalid_arg(message: impl Into) -> Error { + Error::new(Status::InvalidArg, message.into()) +} + +/// Reject a negative value for a field that must be non-negative. +pub fn non_negative(value: i64, field: &str) -> Result { + if value < 0 { + Err(invalid_arg(format!("{field} must be >= 0, got {value}"))) + } else { + Ok(value) + } +} diff --git a/crates/taskito-node/src/lib.rs b/crates/taskito-node/src/lib.rs new file mode 100644 index 00000000..5fd001d5 --- /dev/null +++ b/crates/taskito-node/src/lib.rs @@ -0,0 +1,17 @@ +//! Node.js (napi-rs) bindings for the Taskito task-queue core. +//! +//! A thin binding shell — peer to the Python shell in `crates/taskito-python`. +//! All scheduling and storage logic lives in `taskito-core`; this crate only +//! marshals between JS values and the core and (later) dispatches task +//! execution back into JavaScript. + +mod backend; +mod config; +mod convert; +mod dispatcher; +mod error; +mod queue; +mod worker; + +pub use queue::JsQueue; +pub use worker::JsWorker; diff --git a/crates/taskito-node/src/queue/admin.rs b/crates/taskito-node/src/queue/admin.rs new file mode 100644 index 00000000..c6973c38 --- /dev/null +++ b/crates/taskito-node/src/queue/admin.rs @@ -0,0 +1,73 @@ +//! Management (mutating) methods on `JsQueue`. + +use napi::bindgen_prelude::Result; +use napi_derive::napi; +use taskito_core::Storage; + +use super::JsQueue; +use crate::convert::{dead_job_to_js, JsDeadJob}; +use crate::error::{non_negative, to_napi_err}; + +const DEFAULT_LIMIT: i64 = 50; + +#[napi] +impl JsQueue { + /// List dead-letter entries (paginated). + #[napi] + pub fn dead_letters(&self, limit: Option, offset: Option) -> Result> { + let limit = non_negative(limit.unwrap_or(DEFAULT_LIMIT), "limit")?; + let offset = non_negative(offset.unwrap_or(0), "offset")?; + let dead = self.storage.list_dead(limit, offset).map_err(to_napi_err)?; + Ok(dead.into_iter().map(dead_job_to_js).collect()) + } + + /// Re-enqueue a dead-letter entry. Returns the new job id. + #[napi] + pub fn retry_dead(&self, dead_id: String) -> Result { + self.storage.retry_dead(&dead_id).map_err(to_napi_err) + } + + /// Delete a dead-letter entry. Returns false if it didn't exist. + #[napi] + pub fn delete_dead(&self, dead_id: String) -> Result { + self.storage.delete_dead(&dead_id).map_err(to_napi_err) + } + + /// Purge dead-letter entries older than `older_than_ms`. Returns the count removed. + #[napi] + pub fn purge_dead(&self, older_than_ms: i64) -> Result { + let older_than_ms = non_negative(older_than_ms, "olderThanMs")?; + self.storage + .purge_dead(older_than_ms) + .map(|n| n as i64) + .map_err(to_napi_err) + } + + /// Purge completed jobs older than `older_than_ms`. Returns the count removed. + #[napi] + pub fn purge_completed(&self, older_than_ms: i64) -> Result { + let older_than_ms = non_negative(older_than_ms, "olderThanMs")?; + self.storage + .purge_completed(older_than_ms) + .map(|n| n as i64) + .map_err(to_napi_err) + } + + /// Pause a queue — workers stop dispatching its jobs until resumed. + #[napi] + pub fn pause_queue(&self, queue: String) -> Result<()> { + self.storage.pause_queue(&queue).map_err(to_napi_err) + } + + /// Resume a paused queue. + #[napi] + pub fn resume_queue(&self, queue: String) -> Result<()> { + self.storage.resume_queue(&queue).map_err(to_napi_err) + } + + /// List the names of currently-paused queues. + #[napi] + pub fn list_paused_queues(&self) -> Result> { + self.storage.list_paused_queues().map_err(to_napi_err) + } +} diff --git a/crates/taskito-node/src/queue/inspect.rs b/crates/taskito-node/src/queue/inspect.rs new file mode 100644 index 00000000..8f8651c8 --- /dev/null +++ b/crates/taskito-node/src/queue/inspect.rs @@ -0,0 +1,88 @@ +//! Read-only inspection methods on `JsQueue`. + +use std::collections::HashMap; + +use napi::bindgen_prelude::Result; +use napi_derive::napi; +use taskito_core::Storage; + +use super::JsQueue; +use crate::config::JobFilter; +use crate::convert::{ + job_error_to_js, job_to_js, metric_to_js, stats_to_js, status_code, JsJob, JsJobError, + JsMetric, JsStats, +}; +use crate::error::{invalid_arg, non_negative, to_napi_err}; + +const DEFAULT_LIMIT: i64 = 50; + +#[napi] +impl JsQueue { + /// Job counts by status across all queues. + #[napi] + pub fn stats(&self) -> Result { + self.storage.stats().map(stats_to_js).map_err(to_napi_err) + } + + /// Job counts by status for a single queue. + #[napi] + pub fn stats_by_queue(&self, queue: String) -> Result { + self.storage + .stats_by_queue(&queue) + .map(stats_to_js) + .map_err(to_napi_err) + } + + /// Job counts by status, keyed by queue name. + #[napi] + pub fn stats_all_queues(&self) -> Result> { + let all = self.storage.stats_all_queues().map_err(to_napi_err)?; + Ok(all.into_iter().map(|(k, v)| (k, stats_to_js(v))).collect()) + } + + /// List jobs, optionally filtered by status/queue/task and paginated. + #[napi] + pub fn list_jobs(&self, filter: Option) -> Result> { + let filter = filter.unwrap_or_default(); + // An unrecognized status would otherwise silently widen the result set + // to every job; reject it so a typo fails loudly. + let status = match filter.status.as_deref() { + Some(s) => Some( + status_code(s) + .ok_or_else(|| invalid_arg(format!("unknown status filter '{s}'")))?, + ), + None => None, + }; + let limit = non_negative(filter.limit.unwrap_or(DEFAULT_LIMIT), "limit")?; + let offset = non_negative(filter.offset.unwrap_or(0), "offset")?; + let jobs = self + .storage + .list_jobs( + status, + filter.queue.as_deref(), + filter.task.as_deref(), + limit, + offset, + self.namespace.as_deref(), + ) + .map_err(to_napi_err)?; + Ok(jobs.into_iter().map(job_to_js).collect()) + } + + /// Error history for a job (one entry per failed attempt). + #[napi] + pub fn get_job_errors(&self, job_id: String) -> Result> { + let errors = self.storage.get_job_errors(&job_id).map_err(to_napi_err)?; + Ok(errors.into_iter().map(job_error_to_js).collect()) + } + + /// Per-execution task metrics within the last `since_ms` milliseconds. + #[napi] + pub fn get_metrics(&self, task: Option, since_ms: i64) -> Result> { + let metrics = self + .storage + .get_metrics(task.as_deref(), since_ms) + .map_err(to_napi_err)?; + Ok(metrics.into_iter().map(metric_to_js).collect()) + } +} diff --git a/crates/taskito-node/src/queue/mod.rs b/crates/taskito-node/src/queue/mod.rs new file mode 100644 index 00000000..19d8e76c --- /dev/null +++ b/crates/taskito-node/src/queue/mod.rs @@ -0,0 +1,104 @@ +//! `JsQueue` — the producer/inspection surface over the core storage. + +use napi::bindgen_prelude::{Buffer, Result}; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; +use napi_derive::napi; +use taskito_core::{Storage, StorageBackend}; + +use crate::config::{EnqueueOptions, OpenOptions, WorkerOptions}; +use crate::convert::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; +use crate::error::to_napi_err; +use crate::worker::{start_worker, JsWorker}; + +mod admin; +mod inspect; + +/// A Taskito queue handle (SQLite/Postgres/Redis) exposed to JavaScript. +#[napi] +pub struct JsQueue { + storage: StorageBackend, + namespace: Option, +} + +#[napi] +impl JsQueue { + /// Open (creating if needed) a queue's storage backend. + #[napi(factory)] + pub fn open(options: OpenOptions) -> Result { + let namespace = options.namespace.clone(); + let storage = crate::backend::open(&options)?; + Ok(Self { storage, namespace }) + } + + /// Enqueue `task_name` with an opaque serialized `payload`. Returns the job + /// id. When `options.uniqueKey` is set, a duplicate enqueue is a no-op while + /// the first job is pending/running (idempotency). + #[napi] + pub fn enqueue( + &self, + task_name: String, + payload: Buffer, + options: Option, + ) -> Result { + let opts = options.unwrap_or_default(); + let unique = opts.unique_key.is_some(); + let new_job = build_new_job(task_name, payload.to_vec(), opts, self.namespace.as_deref())?; + let job = if unique { + self.storage.enqueue_unique(new_job) + } else { + self.storage.enqueue(new_job) + } + .map_err(to_napi_err)?; + Ok(job.id) + } + + /// Fetch a job by id, or `null` if no such job exists. + #[napi] + pub fn get_job(&self, id: String) -> Result> { + let job = self.storage.get_job(&id).map_err(to_napi_err)?; + Ok(job.map(job_to_js)) + } + + /// Cancel a pending job immediately. Returns false if it was not pending. + #[napi] + pub fn cancel_job(&self, id: String) -> Result { + self.storage.cancel_job(&id).map_err(to_napi_err) + } + + /// Request cancellation of a running job (cooperative). Returns false if + /// there is no such running job. + #[napi] + pub fn request_cancel(&self, id: String) -> Result { + self.storage.request_cancel(&id).map_err(to_napi_err) + } + + /// Whether cancellation has been requested for a job. + #[napi] + pub fn is_cancel_requested(&self, id: String) -> Result { + self.storage.is_cancel_requested(&id).map_err(to_napi_err) + } + + /// Update a running job's progress (0–100), for observability. + #[napi] + pub fn update_progress(&self, id: String, progress: i32) -> Result<()> { + self.storage + .update_progress(&id, progress.clamp(0, 100)) + .map_err(to_napi_err) + } + + /// Start a worker that runs `callback` for each dequeued job. Returns a + /// [`JsWorker`] handle — call `stop()` on it to shut the worker down. + #[napi] + pub fn run_worker( + &self, + callback: ThreadsafeFunction, + options: Option, + ) -> Result { + start_worker( + self.storage.clone(), + self.namespace.clone(), + options.unwrap_or_default(), + callback, + ) + } +} diff --git a/crates/taskito-node/src/worker.rs b/crates/taskito-node/src/worker.rs new file mode 100644 index 00000000..c028f8ef --- /dev/null +++ b/crates/taskito-node/src/worker.rs @@ -0,0 +1,103 @@ +//! Worker wiring — spawns the scheduler, dispatcher, and result-drain loops. + +use std::sync::Arc; + +use napi::bindgen_prelude::{spawn, spawn_blocking, Result}; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; +use napi_derive::napi; +use taskito_core::worker::WorkerDispatcher; +use taskito_core::{Scheduler, SchedulerConfig, StorageBackend}; +use tokio::sync::Notify; + +use crate::config::WorkerOptions; +use crate::convert::JsTaskInvocation; +use crate::dispatcher::NodeDispatcher; + +const DEFAULT_QUEUE: &str = "default"; +const DEFAULT_CHANNEL_CAPACITY: usize = 128; + +/// Handle to a running worker. Hold it for the worker's lifetime; call +/// [`JsWorker::stop`] to shut it down. +#[napi] +pub struct JsWorker { + shutdown: Arc, +} + +#[napi] +impl JsWorker { + /// Stop the worker: the scheduler stops dispatching new jobs and the + /// background tasks exit once in-flight results drain. + #[napi] + pub fn stop(&self) { + // `notify_one` stores a permit if no waiter is parked yet, so the signal + // is never lost between scheduler poll iterations. + self.shutdown.notify_one(); + } +} + +/// Start a worker over `storage` that runs `callback` for each dequeued job. +/// Fails fast if any task/queue config is invalid (e.g. a malformed rate limit). +pub fn start_worker( + storage: StorageBackend, + namespace: Option, + options: WorkerOptions, + callback: ThreadsafeFunction, +) -> Result { + let queues = options + .queues + .unwrap_or_else(|| vec![DEFAULT_QUEUE.to_string()]); + // Clamp to >= 1: a zero-capacity Tokio/crossbeam channel panics on creation. + let capacity = options + .channel_capacity + .map(|c| (c as usize).max(1)) + .unwrap_or(DEFAULT_CHANNEL_CAPACITY); + + let mut config = SchedulerConfig::default(); + if let Some(batch) = options.batch_size { + config.batch_size = batch.max(1) as usize; + } + + // The dispatcher reads cancel flags directly, so it needs its own handle. + let dispatcher_storage = storage.clone(); + + // Per-task/queue config must be registered before the scheduler is shared + // (register_* take &mut self). + let mut scheduler = Scheduler::new(storage, queues, config, namespace); + for input in options.task_configs.iter().flatten() { + scheduler.register_task(input.name.clone(), crate::convert::task_config(input)?); + } + for input in options.queue_configs.iter().flatten() { + scheduler.register_queue_config(input.name.clone(), crate::convert::queue_config(input)?); + } + let scheduler = Arc::new(scheduler); + let shutdown = scheduler.shutdown_handle(); + + let (job_tx, job_rx) = tokio::sync::mpsc::channel(capacity); + let (result_tx, result_rx) = crossbeam_channel::bounded(capacity); + + // Scheduler loop: poll storage, dispatch ready jobs onto `job_tx`. + let scheduler_run = scheduler.clone(); + spawn(async move { + scheduler_run.run(job_tx).await; + }); + + // Dispatcher loop: execute each job in JS, report results on `result_tx`. + let dispatcher = NodeDispatcher::new(callback, dispatcher_storage); + spawn(async move { + dispatcher.run(job_rx, result_tx).await; + }); + + // Result-drain loop: apply outcomes to storage. crossbeam `recv` is + // blocking, so it runs on a blocking thread; it exits when every result + // sender has dropped (i.e. after the dispatcher and all in-flight jobs end). + let scheduler_results = scheduler; + spawn_blocking(move || { + while let Ok(result) = result_rx.recv() { + if let Err(err) = scheduler_results.handle_result(result) { + log::error!("[taskito-node] result handling error: {err}"); + } + } + }); + + Ok(JsWorker { shutdown }) +} diff --git a/sdks/node/.gitignore b/sdks/node/.gitignore new file mode 100644 index 00000000..3c038fb6 --- /dev/null +++ b/sdks/node/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +target/ + +# napi build artifacts (regenerated by `pnpm build`); keep the hand-written +# native/package.json that marks the generated loader as CommonJS. +native/* +!native/package.json diff --git a/sdks/node/README.md b/sdks/node/README.md new file mode 100644 index 00000000..eabdbddc --- /dev/null +++ b/sdks/node/README.md @@ -0,0 +1,145 @@ +# taskito (Node.js) + +Rust-powered task queue for Node.js — no broker required. A thin +[napi-rs](https://napi.rs) shell over the Taskito Rust core, peer to the Python +SDK. Enqueue work and run workers in the same process or across processes that +share storage (SQLite, PostgreSQL, or Redis). + +## Install + +```bash +pnpm add taskito +``` + +Requires Node.js >= 18. Ships as dual ESM + CommonJS. + +## Quickstart + +```ts +import { Queue } from "taskito"; + +const queue = new Queue({ dbPath: "taskito.db" }); + +// Register a task with optional per-task config. +queue.task("add", (a: number, b: number) => a + b, { + maxRetries: 3, + retryBackoff: { baseMs: 1000, maxMs: 60_000 }, + timeoutMs: 30_000, + maxConcurrent: 4, +}); + +// Producer. +const id = queue.enqueue("add", [2, 3], { priority: 5 }); + +// Worker. +const worker = queue.runWorker({ queues: ["default"] }); + +// Await the result. +const result = await queue.result(id); // 5 +worker.stop(); +``` + +## Backends + +```ts +new Queue({ dbPath: "taskito.db" }); // SQLite (default) +new Queue({ backend: "postgres", dsn: process.env.PG_URL, schema: "taskito" }); +new Queue({ backend: "redis", dsn: "redis://localhost", prefix: "taskito" }); +``` + +## Enqueue options + +`priority`, `maxRetries`, `timeoutMs`, `delayMs` (delayed run), `uniqueKey` +(idempotency — a duplicate enqueue is a no-op while the first job is +pending/running), `metadata`, `namespace`. + +## Cancellation + +Cancellation is cooperative. A running task reads its context via `currentJob()`: + +```ts +import { currentJob } from "taskito"; + +queue.task("download", async (url: string) => { + const { signal } = currentJob() ?? {}; + const res = await fetch(url, { signal }); + return res.text(); +}); + +queue.requestCancel(jobId); // aborts the task's signal +``` + +`cancelJob(id)` cancels a still-pending job. Tasks may report progress via +`currentJob()?.setProgress(0–100)`. + +## Inspection & management + +```ts +queue.stats(); // { pending, running, completed, failed, dead, cancelled } +queue.statsByQueue("default"); +queue.statsAllQueues(); +queue.listJobs({ status: "failed", limit: 50 }); +queue.getJobErrors(id); +queue.getMetrics(3600_000, "add"); + +queue.deadLetters(); // dead-letter entries +queue.retryDead(deadId); // re-enqueue +queue.deleteDead(deadId); +queue.purgeDead(olderThanMs); +queue.purgeCompleted(olderThanMs); + +queue.pauseQueue("default"); +queue.resumeQueue("default"); +queue.listPausedQueues(); +``` + +## Serializers + +Args and results are serialized with a pluggable `Serializer` (default +`JsonSerializer`; `MsgpackSerializer` for compact binary). The Rust core treats +payloads as opaque bytes. + +```ts +import { Queue, MsgpackSerializer } from "taskito"; +new Queue({ dbPath: "taskito.db", serializer: new MsgpackSerializer() }); +``` + +## CLI + +A standalone `taskito` command (no Python) operates the queue from the terminal: + +```bash +# Connect with --db (or --backend/--dsn for postgres/redis). +taskito --db taskito.db enqueue add '[2,3]' +taskito --db taskito.db stats +taskito --db taskito.db jobs --status failed +taskito --db taskito.db dlq list +taskito --db taskito.db dlq retry +taskito --db taskito.db pause default +taskito --db taskito.db cancel + +# Run a worker from a module that exports a configured Queue. +taskito run ./app.js --queues default,emails +``` + +`--json` on any read command prints machine-readable output. + +## Development + +```bash +pnpm install +pnpm build # napi build (native addon) + tsup (dual esm/cjs + .d.ts) +pnpm typecheck +pnpm lint +pnpm test +``` + +The native crate lives at `crates/taskito-node`; this package builds it into +`native/` and wraps it with a typed TypeScript API. Postgres/Redis backends are +compiled in via `--features postgres,redis`. + +## Not yet covered + +Workflows, mesh, middleware/events, distributed locks, periodic/cron tasks, +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 new file mode 100644 index 00000000..5bb6bbe5 --- /dev/null +++ b/sdks/node/biome.json @@ -0,0 +1,24 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "files": { + "includes": ["src/**/*.ts", "test/**/*.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always" + } + } +} diff --git a/sdks/node/native/package.json b/sdks/node/native/package.json new file mode 100644 index 00000000..5bbefffb --- /dev/null +++ b/sdks/node/native/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/sdks/node/package.json b/sdks/node/package.json new file mode 100644 index 00000000..fd3bfc73 --- /dev/null +++ b/sdks/node/package.json @@ -0,0 +1,69 @@ +{ + "name": "taskito", + "version": "0.16.3", + "description": "Rust-powered task queue for Node.js — no broker required.", + "license": "MIT", + "type": "module", + "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=18" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "taskito": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "native/index.js", + "native/index.d.ts", + "native/package.json", + "native/*.node", + "README.md" + ], + "keywords": [ + "task-queue", + "queue", + "jobs", + "worker", + "rust", + "napi" + ], + "repository": { + "type": "git", + "url": "https://github.com/ByteVeda/taskito.git", + "directory": "sdks/node" + }, + "napi": { + "name": "taskito" + }, + "scripts": { + "build": "pnpm run build:native && pnpm run build:ts", + "build:native": "napi build --platform --release --cargo-cwd ../../crates/taskito-node --features postgres,redis native", + "build:ts": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "biome check src test", + "format": "biome format --write src test", + "test": "vitest run" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.16", + "@napi-rs/cli": "^2.18.4", + "@types/node": "^25.9.2", + "tsup": "^8.5.0", + "typescript": "^6.0.3", + "vitest": "^4.1.8" + }, + "dependencies": { + "@msgpack/msgpack": "^3.1.3", + "commander": "^15.0.0" + } +} diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml new file mode 100644 index 00000000..e7d1dd9f --- /dev/null +++ b/sdks/node/pnpm-lock.yaml @@ -0,0 +1,1733 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@msgpack/msgpack': + specifier: ^3.1.3 + version: 3.1.3 + commander: + specifier: ^15.0.0 + version: 15.0.0 + devDependencies: + '@biomejs/biome': + specifier: ^2.4.16 + version: 2.5.0 + '@napi-rs/cli': + specifier: ^2.18.4 + version: 2.18.4 + '@types/node': + specifier: ^25.9.2 + version: 25.9.3 + tsup: + specifier: ^8.5.0 + version: 8.5.1(postcss@8.5.15)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + 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)) + +packages: + + '@biomejs/biome@2.5.0': + resolution: {integrity: sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.0': + resolution: {integrity: sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.0': + resolution: {integrity: sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.0': + resolution: {integrity: sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.0': + resolution: {integrity: sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.0': + resolution: {integrity: sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.0': + resolution: {integrity: sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.0': + resolution: {integrity: sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.0': + resolution: {integrity: sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@msgpack/msgpack@3.1.3': + resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} + engines: {node: '>= 18'} + + '@napi-rs/cli@2.18.4': + resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} + engines: {node: '>= 10'} + hasBin: true + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.62.0': + resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.0': + resolution: {integrity: sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.0': + resolution: {integrity: sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.0': + resolution: {integrity: sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.0': + resolution: {integrity: sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.0': + resolution: {integrity: sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.0': + resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.0': + resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.0': + resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.0': + resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.0': + resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.0': + resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.0': + resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.0': + resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.0': + resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.0': + resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.0': + resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.0': + resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.0': + resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.0': + resolution: {integrity: sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.0': + resolution: {integrity: sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.0': + resolution: {integrity: sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.0': + resolution: {integrity: sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.0': + resolution: {integrity: sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + 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' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.62.0: + resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@biomejs/biome@2.5.0': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.0 + '@biomejs/cli-darwin-x64': 2.5.0 + '@biomejs/cli-linux-arm64': 2.5.0 + '@biomejs/cli-linux-arm64-musl': 2.5.0 + '@biomejs/cli-linux-x64': 2.5.0 + '@biomejs/cli-linux-x64-musl': 2.5.0 + '@biomejs/cli-win32-arm64': 2.5.0 + '@biomejs/cli-win32-x64': 2.5.0 + + '@biomejs/cli-darwin-arm64@2.5.0': + optional: true + + '@biomejs/cli-darwin-x64@2.5.0': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.0': + optional: true + + '@biomejs/cli-linux-arm64@2.5.0': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.0': + optional: true + + '@biomejs/cli-linux-x64@2.5.0': + optional: true + + '@biomejs/cli-win32-arm64@2.5.0': + optional: true + + '@biomejs/cli-win32-x64@2.5.0': + optional: true + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@msgpack/msgpack@3.1.3': {} + + '@napi-rs/cli@2.18.4': {} + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.0': + optional: true + + '@rollup/rollup-android-arm64@4.62.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.0': + optional: true + + '@rollup/rollup-darwin-x64@4.62.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.27.7) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + acorn@8.17.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@6.2.2: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@15.0.0: {} + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.0 + + fsevents@2.3.3: + optional: true + + joycon@3.1.1: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.12: {} + + object-assign@4.1.1: {} + + obug@2.1.3: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.15): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.15 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + rollup@4.62.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.0 + '@rollup/rollup-android-arm64': 4.62.0 + '@rollup/rollup-darwin-arm64': 4.62.0 + '@rollup/rollup-darwin-x64': 4.62.0 + '@rollup/rollup-freebsd-arm64': 4.62.0 + '@rollup/rollup-freebsd-x64': 4.62.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.0 + '@rollup/rollup-linux-arm-musleabihf': 4.62.0 + '@rollup/rollup-linux-arm64-gnu': 4.62.0 + '@rollup/rollup-linux-arm64-musl': 4.62.0 + '@rollup/rollup-linux-loong64-gnu': 4.62.0 + '@rollup/rollup-linux-loong64-musl': 4.62.0 + '@rollup/rollup-linux-ppc64-gnu': 4.62.0 + '@rollup/rollup-linux-ppc64-musl': 4.62.0 + '@rollup/rollup-linux-riscv64-gnu': 4.62.0 + '@rollup/rollup-linux-riscv64-musl': 4.62.0 + '@rollup/rollup-linux-s390x-gnu': 4.62.0 + '@rollup/rollup-linux-x64-gnu': 4.62.0 + '@rollup/rollup-linux-x64-musl': 4.62.0 + '@rollup/rollup-openbsd-x64': 4.62.0 + '@rollup/rollup-openharmony-arm64': 4.62.0 + '@rollup/rollup-win32-arm64-msvc': 4.62.0 + '@rollup/rollup-win32-ia32-msvc': 4.62.0 + '@rollup/rollup-win32-x64-gnu': 4.62.0 + '@rollup/rollup-win32-x64-msvc': 4.62.0 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: + optional: true + + tsup@8.5.1(postcss@8.5.15)(typescript@6.0.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.15) + resolve-from: 5.0.0 + rollup: 4.62.0 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.15 + typescript: 6.0.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@6.0.3: {} + + ufo@1.6.4: {} + + undici-types@7.24.6: {} + + vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.3 + 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)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.27.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.3 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/sdks/node/src/cli/commands/cancel.ts b/sdks/node/src/cli/commands/cancel.ts new file mode 100644 index 00000000..ef20f639 --- /dev/null +++ b/sdks/node/src/cli/commands/cancel.ts @@ -0,0 +1,19 @@ +import type { Command } from "commander"; +import { connect, type GlobalOptions } from "../connect"; +import { printJson } from "../output"; + +export function registerCancel(program: Command): void { + program + .command("cancel ") + .description("Cancel a pending job, or request cancellation of a running one") + .action((id: string, _options: unknown, command: Command) => { + const globals = command.optsWithGlobals() as GlobalOptions; + const queue = connect(globals); + const cancelled = queue.cancelJob(id) || queue.requestCancel(id); + if (globals.json) { + printJson({ id, cancelled }); + return; + } + process.stdout.write(`${cancelled ? "cancelled" : "not cancelled"}: ${id}\n`); + }); +} diff --git a/sdks/node/src/cli/commands/dlq.ts b/sdks/node/src/cli/commands/dlq.ts new file mode 100644 index 00000000..24a2d7be --- /dev/null +++ b/sdks/node/src/cli/commands/dlq.ts @@ -0,0 +1,60 @@ +import type { Command } from "commander"; +import { connect, type GlobalOptions } from "../connect"; +import { printJson, printTable } from "../output"; +import { nonNegativeIntFlag } from "../parse"; + +export function registerDlq(program: Command): void { + const dlq = program.command("dlq").description("Dead-letter queue operations"); + + dlq + .command("list") + .description("List dead-letter entries") + .option("--limit ", "page size", "50") + .option("--offset ", "page offset", "0") + .action((options: { limit?: string; offset?: string }, command: Command) => { + const globals = command.optsWithGlobals() as GlobalOptions; + const queue = connect(globals); + const dead = queue.deadLetters( + nonNegativeIntFlag(options.limit, "limit"), + nonNegativeIntFlag(options.offset, "offset"), + ); + if (globals.json) { + printJson(dead); + return; + } + printTable( + dead.map((entry) => ({ + id: entry.id, + task: entry.taskName, + queue: entry.queue, + error: entry.error ?? "", + })), + ); + }); + + dlq + .command("retry ") + .description("Re-enqueue a dead-letter entry") + .action((deadId: string, _options: unknown, command: Command) => { + const queue = connect(command.optsWithGlobals() as GlobalOptions); + printJson({ id: queue.retryDead(deadId) }); + }); + + dlq + .command("delete ") + .description("Delete a dead-letter entry") + .action((deadId: string, _options: unknown, command: Command) => { + const queue = connect(command.optsWithGlobals() as GlobalOptions); + printJson({ deleted: queue.deleteDead(deadId) }); + }); + + dlq + .command("purge") + .description("Purge dead-letter entries older than a cutoff") + .option("--older-than-ms ", "age cutoff in ms", "0") + .action((options: { olderThanMs?: string }, command: Command) => { + const queue = connect(command.optsWithGlobals() as GlobalOptions); + const olderThanMs = nonNegativeIntFlag(options.olderThanMs, "older-than-ms") ?? 0; + printJson({ purged: queue.purgeDead(olderThanMs) }); + }); +} diff --git a/sdks/node/src/cli/commands/enqueue.ts b/sdks/node/src/cli/commands/enqueue.ts new file mode 100644 index 00000000..1cab8b97 --- /dev/null +++ b/sdks/node/src/cli/commands/enqueue.ts @@ -0,0 +1,37 @@ +import type { Command } from "commander"; +import { connect, type GlobalOptions } from "../connect"; +import { printJson } from "../output"; +import { numberFlag, parseArgsArray } from "../parse"; + +interface EnqueueOptions { + queue?: string; + priority?: string; + maxRetries?: string; + delayMs?: string; + uniqueKey?: string; +} + +export function registerEnqueue(program: Command): void { + program + .command("enqueue [argsJson]") + .description("Enqueue a task with a JSON array of args") + .option("-q, --queue ", "target queue") + .option("--priority ", "priority") + .option("--max-retries ", "max retries") + .option("--delay-ms ", "delay before the job runs (ms)") + .option("--unique-key ", "idempotency key") + .action( + (task: string, argsJson: string | undefined, options: EnqueueOptions, command: Command) => { + const queue = connect(command.optsWithGlobals() as GlobalOptions); + const args = parseArgsArray(argsJson); + const id = queue.enqueue(task, args, { + queue: options.queue, + priority: numberFlag(options.priority, "priority"), + maxRetries: numberFlag(options.maxRetries, "max-retries"), + delayMs: numberFlag(options.delayMs, "delay-ms"), + uniqueKey: options.uniqueKey, + }); + printJson({ id }); + }, + ); +} diff --git a/sdks/node/src/cli/commands/index.ts b/sdks/node/src/cli/commands/index.ts new file mode 100644 index 00000000..bdf498e3 --- /dev/null +++ b/sdks/node/src/cli/commands/index.ts @@ -0,0 +1,7 @@ +export { registerCancel } from "./cancel"; +export { registerDlq } from "./dlq"; +export { registerEnqueue } from "./enqueue"; +export { registerJobs } from "./jobs"; +export { registerQueues } from "./queues"; +export { registerRun } from "./run"; +export { registerStats } from "./stats"; diff --git a/sdks/node/src/cli/commands/jobs.ts b/sdks/node/src/cli/commands/jobs.ts new file mode 100644 index 00000000..f87dfbed --- /dev/null +++ b/sdks/node/src/cli/commands/jobs.ts @@ -0,0 +1,47 @@ +import type { Command } from "commander"; +import { connect, type GlobalOptions } from "../connect"; +import { printJson, printTable } from "../output"; +import { nonNegativeIntFlag } from "../parse"; + +interface JobsOptions { + status?: string; + queue?: string; + task?: string; + limit?: string; + offset?: string; +} + +export function registerJobs(program: Command): void { + program + .command("jobs") + .description("List jobs") + .option("--status ", "filter by status") + .option("-q, --queue ", "filter by queue") + .option("-t, --task ", "filter by task name") + .option("--limit ", "page size", "50") + .option("--offset ", "page offset", "0") + .action((options: JobsOptions, command: Command) => { + const globals = command.optsWithGlobals() as GlobalOptions; + const queue = connect(globals); + const jobs = queue.listJobs({ + status: options.status, + queue: options.queue, + task: options.task, + limit: nonNegativeIntFlag(options.limit, "limit"), + offset: nonNegativeIntFlag(options.offset, "offset"), + }); + if (globals.json) { + printJson(jobs); + return; + } + printTable( + jobs.map((job) => ({ + id: job.id, + task: job.taskName, + queue: job.queue, + status: job.status, + retries: `${job.retryCount}/${job.maxRetries}`, + })), + ); + }); +} diff --git a/sdks/node/src/cli/commands/queues.ts b/sdks/node/src/cli/commands/queues.ts new file mode 100644 index 00000000..c0239c59 --- /dev/null +++ b/sdks/node/src/cli/commands/queues.ts @@ -0,0 +1,31 @@ +import type { Command } from "commander"; +import { connect, type GlobalOptions } from "../connect"; +import { printJson } from "../output"; + +export function registerQueues(program: Command): void { + program + .command("pause ") + .description("Pause a queue") + .action((queueName: string, _options: unknown, command: Command) => { + const queue = connect(command.optsWithGlobals() as GlobalOptions); + queue.pauseQueue(queueName); + printJson({ paused: queueName }); + }); + + program + .command("resume ") + .description("Resume a paused queue") + .action((queueName: string, _options: unknown, command: Command) => { + const queue = connect(command.optsWithGlobals() as GlobalOptions); + queue.resumeQueue(queueName); + printJson({ resumed: queueName }); + }); + + program + .command("paused") + .description("List paused queues") + .action((_options: unknown, command: Command) => { + const queue = connect(command.optsWithGlobals() as GlobalOptions); + printJson({ paused: queue.listPausedQueues() }); + }); +} diff --git a/sdks/node/src/cli/commands/run.ts b/sdks/node/src/cli/commands/run.ts new file mode 100644 index 00000000..ce12d4f4 --- /dev/null +++ b/sdks/node/src/cli/commands/run.ts @@ -0,0 +1,60 @@ +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { Command } from "commander"; +import type { Worker } from "../../index"; +import { positiveIntFlag } from "../parse"; + +/** Grace period for in-flight results to drain after a stop signal. */ +const SHUTDOWN_GRACE_MS = 200; + +interface RunOptions { + queues?: string; + batchSize?: string; +} + +/** The minimal surface `run` needs from a user's app module. */ +interface WorkerApp { + runWorker(options?: { queues?: string[]; batchSize?: number }): Worker; +} + +export function registerRun(program: Command): void { + program + .command("run ") + .description( + "Run a worker. is a module exporting a configured Queue (default export or `queue`).", + ) + .option("--queues ", "comma-separated queue names") + .option("--batch-size ", "jobs claimed per poll") + .action(async (appPath: string, options: RunOptions) => { + const app = await loadApp(appPath); + const queues = options.queues ? options.queues.split(",") : undefined; + const worker = app.runWorker({ + queues, + batchSize: positiveIntFlag(options.batchSize, "batch-size"), + }); + + process.stdout.write( + `taskito worker running (queues: ${queues?.join(",") ?? "default"}) — Ctrl-C to stop\n`, + ); + // `stop()` only signals shutdown; give in-flight results a moment to drain + // before exiting so completed work isn't lost. + const stop = async () => { + worker.stop(); + await new Promise((done) => setTimeout(done, SHUTDOWN_GRACE_MS)); + process.exit(0); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + await new Promise(() => {}); + }); +} + +/** Import the user's app module and return its configured queue. */ +async function loadApp(appPath: string): Promise { + const module = (await import(pathToFileURL(resolve(appPath)).href)) as Record; + const candidate = module.default ?? module.queue; + if (!candidate || typeof (candidate as WorkerApp).runWorker !== "function") { + throw new Error(`module "${appPath}" must export a Queue (default export or \`queue\`)`); + } + return candidate as WorkerApp; +} diff --git a/sdks/node/src/cli/commands/stats.ts b/sdks/node/src/cli/commands/stats.ts new file mode 100644 index 00000000..70f1bf59 --- /dev/null +++ b/sdks/node/src/cli/commands/stats.ts @@ -0,0 +1,20 @@ +import type { Command } from "commander"; +import { connect, type GlobalOptions } from "../connect"; +import { printJson, printTable } from "../output"; + +export function registerStats(program: Command): void { + program + .command("stats") + .description("Show job counts by status") + .option("-q, --queue ", "limit to a single queue") + .action((options: { queue?: string }, command: Command) => { + const globals = command.optsWithGlobals() as GlobalOptions; + const queue = connect(globals); + const stats = options.queue ? queue.statsByQueue(options.queue) : queue.stats(); + if (globals.json) { + printJson(stats); + } else { + printTable([stats as unknown as Record]); + } + }); +} diff --git a/sdks/node/src/cli/connect.ts b/sdks/node/src/cli/connect.ts new file mode 100644 index 00000000..df3b144a --- /dev/null +++ b/sdks/node/src/cli/connect.ts @@ -0,0 +1,28 @@ +import { Queue, type QueueOptions } from "../index"; +import { positiveIntFlag } from "./parse"; + +/** Global connection options shared by every CLI command. */ +export interface GlobalOptions { + db?: string; + backend?: string; + dsn?: string; + poolSize?: string; + schema?: string; + prefix?: string; + namespace?: string; + json?: boolean; +} + +/** Build a {@link Queue} from the CLI's global connection options. */ +export function connect(options: GlobalOptions): Queue { + const queueOptions: QueueOptions = { + dbPath: options.db, + backend: options.backend as QueueOptions["backend"], + dsn: options.dsn, + poolSize: positiveIntFlag(options.poolSize, "pool-size"), + schema: options.schema, + prefix: options.prefix, + namespace: options.namespace, + }; + return new Queue(queueOptions); +} diff --git a/sdks/node/src/cli/index.ts b/sdks/node/src/cli/index.ts new file mode 100644 index 00000000..91178f47 --- /dev/null +++ b/sdks/node/src/cli/index.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import { + registerCancel, + registerDlq, + registerEnqueue, + registerJobs, + registerQueues, + registerRun, + registerStats, +} from "./commands"; + +const program = new Command(); + +program + .name("taskito") + .description("Taskito task queue — Node CLI") + .version("0.16.3") + .option("--db ", "SQLite database path (shorthand for --backend sqlite --dsn )") + .option("--backend ", "backend: sqlite | postgres | redis") + .option("--dsn ", "backend connection string") + .option("--pool-size ", "connection pool size") + .option("--schema ", "Postgres schema") + .option("--prefix ", "Redis key prefix") + .option("--namespace ", "job namespace") + .option("--json", "output raw JSON"); + +registerEnqueue(program); +registerStats(program); +registerJobs(program); +registerCancel(program); +registerQueues(program); +registerDlq(program); +registerRun(program); + +program.parseAsync(process.argv).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/sdks/node/src/cli/output.ts b/sdks/node/src/cli/output.ts new file mode 100644 index 00000000..5e229629 --- /dev/null +++ b/sdks/node/src/cli/output.ts @@ -0,0 +1,25 @@ +/** Print a value as pretty JSON. */ +export function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +/** Print rows as an aligned text table, or `(none)` when empty. */ +export function printTable(rows: Array>, columns?: string[]): void { + const first = rows[0]; + if (!first) { + process.stdout.write("(none)\n"); + return; + } + const cols = columns ?? Object.keys(first); + const widths = cols.map((col) => + Math.max(col.length, ...rows.map((row) => String(row[col] ?? "").length)), + ); + const renderRow = (cells: string[]) => + cells.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" "); + + const lines = [renderRow(cols), renderRow(widths.map((w) => "-".repeat(w)))]; + for (const row of rows) { + lines.push(renderRow(cols.map((col) => String(row[col] ?? "")))); + } + process.stdout.write(`${lines.join("\n")}\n`); +} diff --git a/sdks/node/src/cli/parse.ts b/sdks/node/src/cli/parse.ts new file mode 100644 index 00000000..96f28ee7 --- /dev/null +++ b/sdks/node/src/cli/parse.ts @@ -0,0 +1,45 @@ +/** Parsing helpers for CLI flags — fail fast with a clear message on bad input + * instead of forwarding `NaN`/invalid values into queue APIs. */ + +/** Parse an optional finite-number flag; throws on non-numeric input. */ +export function numberFlag(value: string | undefined, name: string): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`--${name} must be a number, got "${value}"`); + } + return parsed; +} + +/** Parse an optional non-negative-integer flag (pagination, cutoffs). */ +export function nonNegativeIntFlag(value: string | undefined, name: string): number | undefined { + const parsed = numberFlag(value, name); + if (parsed !== undefined && (!Number.isInteger(parsed) || parsed < 0)) { + throw new Error(`--${name} must be a non-negative integer, got "${value}"`); + } + return parsed; +} + +/** Parse an optional positive-integer flag (pool/batch sizes). */ +export function positiveIntFlag(value: string | undefined, name: string): number | undefined { + const parsed = numberFlag(value, name); + if (parsed !== undefined && (!Number.isInteger(parsed) || parsed <= 0)) { + throw new Error(`--${name} must be a positive integer, got "${value}"`); + } + return parsed; +} + +/** Parse a JSON array of task args; throws if the JSON isn't an array. */ +export function parseArgsArray(argsJson: string | undefined): unknown[] { + if (argsJson === undefined) return []; + let parsed: unknown; + try { + parsed = JSON.parse(argsJson); + } catch (error) { + throw new Error(`args must be valid JSON: ${(error as Error).message}`); + } + if (!Array.isArray(parsed)) { + throw new Error("args must be a JSON array, e.g. '[1, \"two\"]'"); + } + return parsed; +} diff --git a/sdks/node/src/context.ts b/sdks/node/src/context.ts new file mode 100644 index 00000000..2cda4e7d --- /dev/null +++ b/sdks/node/src/context.ts @@ -0,0 +1,26 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +/** + * Ambient context available to a running task via {@link currentJob}. Mirrors + * the Python shell's `current_job`. + */ +export interface JobContext { + /** The running job's id. */ + readonly jobId: string; + /** Aborts when cancellation is requested — check `signal.aborted` or listen. */ + readonly signal: AbortSignal; + /** Report progress (0–100) for observability. */ + setProgress(progress: number): void; +} + +const store = new AsyncLocalStorage(); + +/** The context of the task running on this async stack, or `undefined`. */ +export function currentJob(): JobContext | undefined { + return store.getStore(); +} + +/** Run `fn` with `context` bound as the ambient job context. @internal */ +export function runInContext(context: JobContext, fn: () => T): T { + return store.run(context, fn); +} diff --git a/sdks/node/src/errors.ts b/sdks/node/src/errors.ts new file mode 100644 index 00000000..7763053b --- /dev/null +++ b/sdks/node/src/errors.ts @@ -0,0 +1,34 @@ +/** Base class for all Taskito SDK errors. */ +export class TaskitoError extends Error { + constructor(message: string) { + super(message); + this.name = "TaskitoError"; + } +} + +/** Thrown when a worker dequeues a job whose task name was never registered. */ +export class TaskNotRegisteredError extends TaskitoError { + constructor(readonly taskName: string) { + super(`No task registered with name "${taskName}"`); + this.name = "TaskNotRegisteredError"; + } +} + +/** Thrown by {@link Queue.result} when the awaited job failed or dead-lettered. */ +export class JobFailedError extends TaskitoError { + constructor( + readonly jobId: string, + reason: string, + ) { + super(`Job ${jobId} failed: ${reason}`); + this.name = "JobFailedError"; + } +} + +/** Thrown by {@link Queue.result} when the awaited job was cancelled. */ +export class JobCancelledError extends TaskitoError { + constructor(readonly jobId: string) { + super(`Job ${jobId} was cancelled`); + this.name = "JobCancelledError"; + } +} diff --git a/sdks/node/src/index.ts b/sdks/node/src/index.ts new file mode 100644 index 00000000..18c0aa74 --- /dev/null +++ b/sdks/node/src/index.ts @@ -0,0 +1,24 @@ +export { currentJob, type JobContext } from "./context"; +export { + JobCancelledError, + JobFailedError, + TaskitoError, + TaskNotRegisteredError, +} from "./errors"; +export { Queue, type QueueOptions } from "./queue"; +export { JsonSerializer, MsgpackSerializer, type Serializer } from "./serializers"; +export type { + DeadJob, + EnqueueOptions, + Job, + JobError, + JobFilter, + Metric, + QueueLimits, + ResultOptions, + Stats, + TaskHandler, + TaskOptions, + WorkerRunOptions, +} from "./types"; +export { Worker } from "./worker"; diff --git a/sdks/node/src/native.ts b/sdks/node/src/native.ts new file mode 100644 index 00000000..2d914ffc --- /dev/null +++ b/sdks/node/src/native.ts @@ -0,0 +1,31 @@ +// Typed loader for the napi-rs binding. The generated `native/index.js` is +// CommonJS (see native/package.json). The path is computed at runtime so the +// bundler leaves the addon external (it is shipped alongside dist/, not inlined), +// and `import.meta.url` is shimmed in the CJS build by tsup. +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const bindingPath = fileURLToPath(new URL("../native/index.js", import.meta.url)); +const binding = require(bindingPath) as typeof import("../native/index"); + +export const { JsQueue, JsWorker } = binding; + +/** Instance types of the native classes, for typing fields and parameters. */ +export type NativeQueue = InstanceType; +export type NativeWorker = InstanceType; + +export type { + EnqueueOptions, + JobFilter, + JsDeadJob, + JsJob, + JsJobError, + JsMetric, + JsStats, + JsTaskInvocation, + OpenOptions, + QueueConfigInput, + TaskConfigInput, + WorkerOptions, +} from "../native/index"; diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts new file mode 100644 index 00000000..b292b11f --- /dev/null +++ b/sdks/node/src/queue.ts @@ -0,0 +1,229 @@ +import { JobCancelledError, JobFailedError, TaskitoError } from "./errors"; +import { JsQueue, type NativeQueue, type OpenOptions } from "./native"; +import { JsonSerializer, type Serializer } from "./serializers"; +import type { + DeadJob, + EnqueueOptions, + Job, + JobError, + JobFilter, + Metric, + QueueLimits, + RegisteredTask, + ResultOptions, + Stats, + TaskHandler, + TaskOptions, + WorkerRunOptions, +} from "./types"; +import { Worker } from "./worker"; + +/** Construction options for a {@link Queue}. */ +export interface QueueOptions { + /** SQLite file path — shorthand for `{ backend: "sqlite", dsn: path }`. */ + dbPath?: string; + /** `"sqlite"` (default), `"postgres"`, or `"redis"`. */ + backend?: "sqlite" | "postgres" | "redis"; + /** Backend connection string (SQLite path, Postgres URL, Redis URL). */ + dsn?: string; + /** Connection pool size (SQLite/Postgres). */ + poolSize?: number; + /** Postgres schema (default `"taskito"`). */ + schema?: string; + /** Redis key prefix. */ + prefix?: string; + /** Namespace applied to enqueued jobs and the worker scheduler. */ + namespace?: string; + /** Codec for task args/results. Defaults to {@link JsonSerializer}. */ + serializer?: Serializer; +} + +/** + * A Taskito queue: register tasks, enqueue work, read results, and run workers. + * Backed by the Rust core over SQLite, Postgres, or Redis. + */ +export class Queue { + private readonly native: NativeQueue; + private readonly serializer: Serializer; + private readonly tasks = new Map(); + private readonly queueLimits = new Map(); + + constructor(options: QueueOptions) { + this.native = JsQueue.open(toOpenOptions(options)); + this.serializer = options.serializer ?? new JsonSerializer(); + } + + /** Register a task handler under `name`, with optional per-task config. */ + task(name: string, handler: TaskHandler, options?: TaskOptions): void { + this.tasks.set(name, { handler, options }); + } + + /** Set per-queue concurrency / rate-limit applied when a worker runs. */ + configureQueue(name: string, limits: QueueLimits): void { + this.queueLimits.set(name, limits); + } + + /** Enqueue `name` with positional `args`. Returns the new job id. */ + enqueue(name: string, args: unknown[] = [], options?: EnqueueOptions): string { + const defaults = this.tasks.get(name)?.options; + const merged: EnqueueOptions = { + ...options, + maxRetries: options?.maxRetries ?? defaults?.maxRetries, + timeoutMs: options?.timeoutMs ?? defaults?.timeoutMs, + }; + const payload = Buffer.from(this.serializer.serialize(args)); + return this.native.enqueue(name, payload, merged); + } + + /** Fetch a job by id, or `null` if unknown. */ + getJob(id: string): Job | null { + return this.native.getJob(id); + } + + /** Deserialized result of a completed job, or `undefined` if not yet ready. */ + getResult(id: string): unknown { + const job = this.native.getJob(id); + if (!job?.result) { + return undefined; + } + return this.serializer.deserialize(job.result); + } + + /** Cancel a pending job. Returns false if it was not pending. */ + cancelJob(id: string): boolean { + return this.native.cancelJob(id); + } + + /** Request cooperative cancellation of a running job. Returns false if it is not running. */ + requestCancel(id: string): boolean { + return this.native.requestCancel(id); + } + + /** Whether cancellation has been requested for a job. */ + isCancelRequested(id: string): boolean { + return this.native.isCancelRequested(id); + } + + /** + * Await a job's terminal state and return its deserialized result. Rejects + * with {@link JobFailedError} / {@link JobCancelledError} on failure, and with + * {@link TaskitoError} if the wait times out. + */ + async result(id: string, options?: ResultOptions): Promise { + const timeoutMs = options?.timeoutMs ?? 30_000; + const pollMs = options?.pollMs ?? 50; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const job = this.native.getJob(id); + if (job) { + switch (job.status) { + case "complete": + return job.result ? this.serializer.deserialize(job.result) : undefined; + case "failed": + case "dead": + throw new JobFailedError(id, job.error ?? "job failed"); + case "cancelled": + throw new JobCancelledError(id); + } + } + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + throw new TaskitoError(`timed out waiting for job ${id}`); + } + + /** Job counts by status across all queues. */ + stats(): Stats { + return this.native.stats(); + } + + /** Job counts by status for a single queue. */ + statsByQueue(queue: string): Stats { + return this.native.statsByQueue(queue); + } + + /** Job counts by status, keyed by queue name. */ + statsAllQueues(): Record { + return this.native.statsAllQueues(); + } + + /** List jobs, optionally filtered and paginated. */ + listJobs(filter?: JobFilter): Job[] { + return this.native.listJobs(filter); + } + + /** Error history for a job (one entry per failed attempt). */ + getJobErrors(id: string): JobError[] { + return this.native.getJobErrors(id); + } + + /** Per-execution task metrics within the last `sinceMs` milliseconds. */ + getMetrics(sinceMs: number, task?: string): Metric[] { + return this.native.getMetrics(task ?? null, sinceMs); + } + + /** List dead-letter entries (paginated). */ + deadLetters(limit?: number, offset?: number): DeadJob[] { + return this.native.deadLetters(limit, offset); + } + + /** Re-enqueue a dead-letter entry. Returns the new job id. */ + retryDead(deadId: string): string { + return this.native.retryDead(deadId); + } + + /** Delete a dead-letter entry. Returns false if it didn't exist. */ + deleteDead(deadId: string): boolean { + return this.native.deleteDead(deadId); + } + + /** Purge dead-letter entries older than `olderThanMs`. Returns the count removed. */ + purgeDead(olderThanMs: number): number { + return this.native.purgeDead(olderThanMs); + } + + /** Purge completed jobs older than `olderThanMs`. Returns the count removed. */ + purgeCompleted(olderThanMs: number): number { + return this.native.purgeCompleted(olderThanMs); + } + + /** Pause a queue — workers stop dispatching its jobs until resumed. */ + pauseQueue(queue: string): void { + this.native.pauseQueue(queue); + } + + /** Resume a paused queue. */ + resumeQueue(queue: string): void { + this.native.resumeQueue(queue); + } + + /** Names of currently-paused queues. */ + listPausedQueues(): string[] { + return this.native.listPausedQueues(); + } + + /** Start a worker that runs the registered tasks. Hold the returned {@link Worker}. */ + runWorker(options?: WorkerRunOptions): Worker { + return Worker.start(this.native, { + tasks: this.tasks, + queueLimits: this.queueLimits, + serializer: this.serializer, + run: options, + }); + } +} + +/** Resolve a {@link QueueOptions} into the native open options. */ +function toOpenOptions(options: QueueOptions): OpenOptions { + const dsn = options.dsn ?? options.dbPath; + if (!dsn) { + throw new TaskitoError("Queue requires `dbPath` (SQLite) or `dsn`"); + } + return { + backend: options.backend ?? (options.dbPath ? "sqlite" : undefined), + dsn, + poolSize: options.poolSize, + schema: options.schema, + prefix: options.prefix, + namespace: options.namespace, + }; +} diff --git a/sdks/node/src/serializers/index.ts b/sdks/node/src/serializers/index.ts new file mode 100644 index 00000000..b9603b3e --- /dev/null +++ b/sdks/node/src/serializers/index.ts @@ -0,0 +1,3 @@ +export { JsonSerializer } from "./json"; +export { MsgpackSerializer } from "./msgpack"; +export type { Serializer } from "./serializer"; diff --git a/sdks/node/src/serializers/json.ts b/sdks/node/src/serializers/json.ts new file mode 100644 index 00000000..3dafde9e --- /dev/null +++ b/sdks/node/src/serializers/json.ts @@ -0,0 +1,12 @@ +import type { Serializer } from "./serializer"; + +/** Default serializer: JSON. Language-neutral and human-debuggable. */ +export class JsonSerializer implements Serializer { + serialize(value: unknown): Uint8Array { + return new TextEncoder().encode(JSON.stringify(value ?? null)); + } + + deserialize(bytes: Uint8Array): unknown { + return JSON.parse(new TextDecoder().decode(bytes)); + } +} diff --git a/sdks/node/src/serializers/msgpack.ts b/sdks/node/src/serializers/msgpack.ts new file mode 100644 index 00000000..2f62611e --- /dev/null +++ b/sdks/node/src/serializers/msgpack.ts @@ -0,0 +1,16 @@ +import { decode, encode } from "@msgpack/msgpack"; +import type { Serializer } from "./serializer"; + +/** + * MessagePack serializer — more compact than JSON and binary-safe. A drop-in + * alternative to {@link JsonSerializer} for Node↔Node workloads. + */ +export class MsgpackSerializer implements Serializer { + serialize(value: unknown): Uint8Array { + return encode(value ?? null); + } + + deserialize(bytes: Uint8Array): unknown { + return decode(bytes); + } +} diff --git a/sdks/node/src/serializers/serializer.ts b/sdks/node/src/serializers/serializer.ts new file mode 100644 index 00000000..937ede34 --- /dev/null +++ b/sdks/node/src/serializers/serializer.ts @@ -0,0 +1,9 @@ +/** + * Pluggable codec for task arguments and results. Mirrors the Python shell's + * `Serializer` protocol so implementations can interoperate across languages. + * The Rust core stores payloads as opaque bytes. + */ +export interface Serializer { + serialize(value: unknown): Uint8Array; + deserialize(bytes: Uint8Array): unknown; +} diff --git a/sdks/node/src/types.ts b/sdks/node/src/types.ts new file mode 100644 index 00000000..4af6349e --- /dev/null +++ b/sdks/node/src/types.ts @@ -0,0 +1,61 @@ +export type { + EnqueueOptions, + JobFilter, + JsDeadJob as DeadJob, + JsJob as Job, + JsJobError as JobError, + JsMetric as Metric, + JsStats as Stats, +} from "./native"; + +/** Options for {@link Queue.result}. */ +export interface ResultOptions { + /** Max time to wait for a terminal state (ms). Default 30000. */ + timeoutMs?: number; + /** Poll interval (ms). Default 50. */ + pollMs?: number; +} + +/** + * A registered task: receives the deserialized positional args and returns a + * (possibly async) result. + */ +export type TaskHandler = ( + ...args: Args +) => Result | Promise; + +/** Per-task defaults and resilience config, applied when registering a task. */ +export interface TaskOptions { + /** Retry budget (also the per-job default at enqueue). */ + maxRetries?: number; + /** Exponential backoff bounds for retries. */ + retryBackoff?: { baseMs?: number; maxMs?: number }; + /** Per-job timeout default (ms); enforced by the worker. */ + timeoutMs?: number; + /** Cap on concurrently-running jobs of this task. */ + maxConcurrent?: number; + /** Rate-limit spec like `"100/m"`, `"50/s"`, `"3600/h"`. */ + rateLimit?: string; +} + +/** Per-queue resilience config. */ +export interface QueueLimits { + maxConcurrent?: number; + rateLimit?: string; +} + +/** A task handler plus its registration options. */ +export interface RegisteredTask { + handler: TaskHandler; + options?: TaskOptions; +} + +/** Options for {@link Queue.runWorker}. */ +export interface WorkerRunOptions { + /** Queues to consume (default `["default"]`). */ + queues?: string[]; + /** In-flight channel capacity (default 128). */ + channelCapacity?: number; + /** Jobs claimed per scheduler poll (default 1). */ + batchSize?: number; +} diff --git a/sdks/node/src/worker.ts b/sdks/node/src/worker.ts new file mode 100644 index 00000000..3fbe81b6 --- /dev/null +++ b/sdks/node/src/worker.ts @@ -0,0 +1,119 @@ +import { type JobContext, runInContext } from "./context"; +import { TaskNotRegisteredError } from "./errors"; +import type { + JsTaskInvocation, + NativeQueue, + NativeWorker, + WorkerOptions as NativeWorkerOptions, + QueueConfigInput, + TaskConfigInput, +} from "./native"; +import type { Serializer } from "./serializers"; +import type { QueueLimits, RegisteredTask, WorkerRunOptions } from "./types"; + +/** How often a running job polls the storage cancel flag. */ +const CANCEL_POLL_INTERVAL_MS = 200; + +/** Inputs assembled by {@link Queue.runWorker}. */ +export interface WorkerStartParams { + tasks: ReadonlyMap; + queueLimits: ReadonlyMap; + serializer: Serializer; + run?: WorkerRunOptions; +} + +/** A running worker. Hold it for the worker's lifetime; call {@link Worker.stop}. */ +export class Worker { + private constructor(private readonly native: NativeWorker) {} + + /** + * Start a worker from a queue's task registry. Use {@link Queue.runWorker} + * rather than calling this directly. + * + * @internal + */ + static start(queue: NativeQueue, params: WorkerStartParams): Worker { + const { tasks, queueLimits, serializer, run } = params; + const callback = async (invocation: JsTaskInvocation): Promise => { + const task = tasks.get(invocation.taskName); + if (!task) { + throw new TaskNotRegisteredError(invocation.taskName); + } + const args = serializer.deserialize(invocation.payload) as unknown[]; + + // Drive a cooperative cancel signal from the storage flag, and expose the + // job context (id, signal, progress) to the handler. + const controller = new AbortController(); + const context: JobContext = { + jobId: invocation.id, + signal: controller.signal, + setProgress: (progress) => queue.updateProgress(invocation.id, progress), + }; + const poller = setInterval(() => { + try { + if (queue.isCancelRequested(invocation.id)) { + controller.abort(); + } + } catch { + // transient storage error — retry on the next tick + } + }, CANCEL_POLL_INTERVAL_MS); + poller.unref(); + + try { + const result = await runInContext(context, () => task.handler(...args)); + return Buffer.from(serializer.serialize(result)); + } finally { + clearInterval(poller); + } + }; + + const nativeOptions: NativeWorkerOptions = { + queues: run?.queues, + channelCapacity: run?.channelCapacity, + batchSize: run?.batchSize, + taskConfigs: buildTaskConfigs(tasks), + queueConfigs: buildQueueConfigs(queueLimits), + }; + return new Worker(queue.runWorker(callback, nativeOptions)); + } + + /** Stop the worker; in-flight results drain before background tasks exit. */ + stop(): void { + this.native.stop(); + } +} + +/** Collect per-task configs that actually set something. */ +function buildTaskConfigs(tasks: ReadonlyMap): TaskConfigInput[] { + const configs: TaskConfigInput[] = []; + for (const [name, task] of tasks) { + const options = task.options; + if ( + !options || + (options.maxRetries === undefined && + options.retryBackoff === undefined && + options.maxConcurrent === undefined && + options.rateLimit === undefined) + ) { + continue; + } + configs.push({ + name, + maxRetries: options.maxRetries, + retryBaseDelayMs: options.retryBackoff?.baseMs, + retryMaxDelayMs: options.retryBackoff?.maxMs, + maxConcurrent: options.maxConcurrent, + rateLimit: options.rateLimit, + }); + } + return configs; +} + +function buildQueueConfigs(limits: ReadonlyMap): QueueConfigInput[] { + return [...limits].map(([name, limit]) => ({ + name, + maxConcurrent: limit.maxConcurrent, + rateLimit: limit.rateLimit, + })); +} diff --git a/sdks/node/test/cancel.test.ts b/sdks/node/test/cancel.test.ts new file mode 100644 index 00000000..a8da0b24 --- /dev/null +++ b/sdks/node/test/cancel.test.ts @@ -0,0 +1,56 @@ +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"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +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, 25)); + } + return false; +} + +it("cancels a running task cooperatively via the abort signal", async () => { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-")), "queue.db"); + const queue = new Queue({ dbPath }); + + let observedAbort = false; + queue.task("longRunning", async () => { + const signal = currentJob()?.signal; + await new Promise((resolve) => { + if (!signal) { + resolve(); + return; + } + signal.addEventListener("abort", () => { + observedAbort = true; + resolve(); + }); + // Safety net so a broken cancel path can't hang the suite. + setTimeout(resolve, 3000); + }); + // Reject so the dispatcher records a cancellation (not a success). + throw new Error("aborted"); + }); + + const id = queue.enqueue("longRunning"); + worker = queue.runWorker(); + + expect(await waitFor(() => queue.getJob(id)?.status === "running")).toBe(true); + expect(queue.requestCancel(id)).toBe(true); + + expect(await waitFor(() => queue.getJob(id)?.status === "cancelled")).toBe(true); + expect(observedAbort).toBe(true); +}); diff --git a/sdks/node/test/cli.test.ts b/sdks/node/test/cli.test.ts new file mode 100644 index 00000000..4d0cd43c --- /dev/null +++ b/sdks/node/test/cli.test.ts @@ -0,0 +1,95 @@ +import { type ChildProcess, execSync, spawn } from "node:child_process"; +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +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; + +beforeAll(() => { + if (!existsSync(cliPath)) { + execSync("pnpm run build:ts", { cwd: pkgRoot, stdio: "ignore" }); + } +}, 60_000); + +function runCli(args: string[]): Promise<{ stdout: string; code: number }> { + return new Promise((resolve) => { + const child = spawn(process.execPath, [cliPath, ...args], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.on("close", (code) => resolve({ stdout, code: code ?? 0 })); + }); +} + +function tempDb(): string { + return join(mkdtempSync(join(tmpdir(), "taskito-cli-")), "cli.db"); +} + +describe("taskito CLI", () => { + it("enqueues and reports stats and jobs", async () => { + const db = tempDb(); + await runCli(["--db", db, "enqueue", "add", "[2,3]"]); + + const stats = JSON.parse((await runCli(["--db", db, "--json", "stats"])).stdout); + expect(stats.pending).toBe(1); + + const jobs = JSON.parse((await runCli(["--db", db, "--json", "jobs"])).stdout); + expect(jobs[0].taskName).toBe("add"); + }); + + it("pauses and lists paused queues", async () => { + const db = tempDb(); + await runCli(["--db", db, "pause", "emails"]); + const paused = JSON.parse((await runCli(["--db", db, "--json", "paused"])).stdout); + expect(paused.paused).toContain("emails"); + }); + + it("runs a worker from an app module", async () => { + const dir = mkdtempSync(join(tmpdir(), "taskito-cli-")); + const db = join(dir, "cli.db"); + const marker = join(dir, "done.txt"); + const app = join(dir, "app.mjs"); + // Static module body — paths are passed via env, never interpolated into + // source, so there is no dynamic-code-construction sink. + writeFileSync( + app, + [ + `const { Queue } = await import(process.env.TASKITO_INDEX);`, + `const { writeFileSync } = await import("node:fs");`, + `const queue = new Queue({ dbPath: process.env.TASKITO_DB });`, + `queue.task("mark", () => { writeFileSync(process.env.TASKITO_MARKER, "ok"); return "ok"; });`, + `queue.enqueue("mark");`, + `export default queue;`, + ].join("\n"), + ); + + let child: ChildProcess | undefined; + try { + child = spawn(process.execPath, [cliPath, "run", app], { + stdio: "ignore", + env: { ...process.env, TASKITO_INDEX: indexUrl, TASKITO_DB: db, TASKITO_MARKER: marker }, + }); + expect(await waitForFile(marker, 6000)).toBe(true); + } finally { + child?.kill("SIGTERM"); + } + }); +}); + +async function waitForFile(path: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(path)) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return false; +} diff --git a/sdks/node/test/inspection.test.ts b/sdks/node/test/inspection.test.ts new file mode 100644 index 00000000..3cc9485c --- /dev/null +++ b/sdks/node/test/inspection.test.ts @@ -0,0 +1,86 @@ +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"; + +let worker: Worker | undefined; + +afterEach(() => { + worker?.stop(); + worker = undefined; +}); + +function newQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-")), "queue.db"); + return new Queue({ dbPath }); +} + +it("reports stats and lists jobs", async () => { + const queue = newQueue(); + queue.task("add", (a: number, b: number) => a + b); + const id = queue.enqueue("add", [1, 2]); + + expect(queue.stats().pending).toBe(1); + expect(queue.listJobs({ status: "pending" }).map((job) => job.id)).toContain(id); + + worker = queue.runWorker(); + expect(await queue.result(id)).toBe(3); + expect(queue.stats().completed).toBe(1); +}); + +it("result() rejects with JobFailedError on a dead job", async () => { + const queue = newQueue(); + queue.task( + "boom", + () => { + throw new Error("nope"); + }, + { maxRetries: 0 }, + ); + const id = queue.enqueue("boom"); + worker = queue.runWorker(); + await expect(queue.result(id)).rejects.toBeInstanceOf(JobFailedError); +}); + +it("dead-letters a failing job and can retry it", async () => { + const queue = newQueue(); + queue.task( + "boom", + () => { + throw new Error("nope"); + }, + { maxRetries: 0 }, + ); + queue.enqueue("boom"); + worker = queue.runWorker(); + + const dead = await waitForDead(queue); + expect(dead.length).toBeGreaterThan(0); + const entry = dead[0]; + if (!entry) { + throw new Error("expected a dead-letter entry"); + } + expect(typeof queue.retryDead(entry.id)).toBe("string"); +}); + +it("pauses and resumes a queue", () => { + const queue = newQueue(); + expect(queue.listPausedQueues()).not.toContain("default"); + queue.pauseQueue("default"); + expect(queue.listPausedQueues()).toContain("default"); + queue.resumeQueue("default"); + expect(queue.listPausedQueues()).not.toContain("default"); +}); + +async function waitForDead(queue: Queue, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const dead = queue.deadLetters(); + if (dead.length > 0) { + return dead; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return []; +} diff --git a/sdks/node/test/options.test.ts b/sdks/node/test/options.test.ts new file mode 100644 index 00000000..50a80e3c --- /dev/null +++ b/sdks/node/test/options.test.ts @@ -0,0 +1,86 @@ +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 newQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-")), "queue.db"); + return new Queue({ dbPath }); +} + +async function waitForStatus( + queue: Queue, + id: string, + predicate: (status: string) => boolean, + timeoutMs = 5000, +): Promise<{ status: string; error?: string }> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const job = queue.getJob(id); + if (job && predicate(job.status)) { + return job; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("timed out waiting for job state"); +} + +it("delays a job until its delay elapses", async () => { + const queue = newQueue(); + queue.task("noop", () => "done"); + const id = queue.enqueue("noop", [], { delayMs: 400 }); + worker = queue.runWorker(); + + // Still pending well before the delay window. + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(queue.getResult(id)).toBeUndefined(); + + await waitForStatus(queue, id, (s) => s === "complete"); + expect(queue.getResult(id)).toBe("done"); +}); + +it("dedups enqueues sharing a uniqueKey", () => { + const queue = newQueue(); + queue.task("noop", () => null); + const first = queue.enqueue("noop", [], { uniqueKey: "k1" }); + const second = queue.enqueue("noop", [], { uniqueKey: "k1" }); + expect(second).toBe(first); +}); + +it("retries then dead-letters a failing task", async () => { + const queue = newQueue(); + queue.task( + "boom", + () => { + throw new Error("kaboom"); + }, + { maxRetries: 0 }, + ); + const id = queue.enqueue("boom"); + worker = queue.runWorker(); + + const job = await waitForStatus(queue, id, (s) => s === "dead"); + expect(job.status).toBe("dead"); +}); + +it("fails a task that exceeds its timeout", async () => { + const queue = newQueue(); + queue.task("slow", () => new Promise((resolve) => setTimeout(resolve, 500)), { + maxRetries: 0, + timeoutMs: 100, + }); + const id = queue.enqueue("slow"); + worker = queue.runWorker(); + + // If the timeout were not enforced the task would complete; it must dead-letter. + const job = await waitForStatus(queue, id, (s) => s === "dead"); + expect(job.status).toBe("dead"); +}); diff --git a/sdks/node/test/roundtrip.test.ts b/sdks/node/test/roundtrip.test.ts new file mode 100644 index 00000000..7ee514d9 --- /dev/null +++ b/sdks/node/test/roundtrip.test.ts @@ -0,0 +1,36 @@ +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; +}); + +it("enqueues, runs a task in a node worker, and reads the result back", async () => { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-")), "queue.db"); + const queue = new Queue({ dbPath }); + queue.task("add", (a: number, b: number) => a + b); + + const id = queue.enqueue("add", [2, 3]); + worker = queue.runWorker({ queues: ["default"] }); + + const result = await waitFor(() => queue.getResult(id)); + expect(result).toBe(5); +}); + +async function waitFor(read: () => T | undefined, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = read(); + if (value !== undefined) { + return value; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("timed out waiting for job result"); +} diff --git a/sdks/node/test/serializer.test.ts b/sdks/node/test/serializer.test.ts new file mode 100644 index 00000000..f7eaa650 --- /dev/null +++ b/sdks/node/test/serializer.test.ts @@ -0,0 +1,44 @@ +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"; + +describe("JsonSerializer", () => { + it("round-trips structured values", () => { + const serializer = new JsonSerializer(); + const value = { a: 1, b: [2, 3], c: "x" }; + expect(serializer.deserialize(serializer.serialize(value))).toEqual(value); + }); + + it("encodes undefined as null", () => { + const serializer = new JsonSerializer(); + expect(serializer.deserialize(serializer.serialize(undefined))).toBeNull(); + }); +}); + +describe("MsgpackSerializer", () => { + it("round-trips structured values", () => { + const serializer = new MsgpackSerializer(); + const value = { a: 1, b: [2, 3], c: "x", d: true }; + expect(serializer.deserialize(serializer.serialize(value))).toEqual(value); + }); +}); + +describe("Queue with a custom serializer", () => { + let worker: Worker | undefined; + afterEach(() => { + worker?.stop(); + worker = undefined; + }); + + it("runs a job end-to-end using msgpack", async () => { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-")), "queue.db"); + const queue = new Queue({ dbPath, serializer: new MsgpackSerializer() }); + queue.task("concat", (a: string, b: string) => a + b); + + const id = queue.enqueue("concat", ["foo", "bar"]); + worker = queue.runWorker(); + expect(await queue.result(id)).toBe("foobar"); + }); +}); diff --git a/sdks/node/test/validation.test.ts b/sdks/node/test/validation.test.ts new file mode 100644 index 00000000..63e637a8 --- /dev/null +++ b/sdks/node/test/validation.test.ts @@ -0,0 +1,48 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Queue } from "../src/index"; + +function newQueue(): Queue { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-")), "queue.db"); + return new Queue({ dbPath }); +} + +describe("N-API boundary validation", () => { + it("rejects a zero pool size instead of panicking r2d2", () => { + const dbPath = join(mkdtempSync(join(tmpdir(), "taskito-node-")), "queue.db"); + expect(() => new Queue({ dbPath, poolSize: 0 })).toThrow(/poolSize/); + }); + + it("rejects negative enqueue options", () => { + const queue = newQueue(); + expect(() => queue.enqueue("t", [], { maxRetries: -1 })).toThrow(/maxRetries/); + expect(() => queue.enqueue("t", [], { timeoutMs: -1 })).toThrow(/timeoutMs/); + expect(() => queue.enqueue("t", [], { delayMs: -1 })).toThrow(/delayMs/); + }); + + it("rejects negative pagination on listJobs", () => { + const queue = newQueue(); + expect(() => queue.listJobs({ limit: -1 })).toThrow(/limit/); + expect(() => queue.listJobs({ offset: -1 })).toThrow(/offset/); + }); + + it("rejects an unknown status filter rather than returning everything", () => { + const queue = newQueue(); + expect(() => queue.listJobs({ status: "nope" })).toThrow(/unknown status/); + }); + + it("rejects negative dead-letter pagination and purge cutoffs", () => { + const queue = newQueue(); + expect(() => queue.deadLetters(-1)).toThrow(/limit/); + expect(() => queue.purgeDead(-1)).toThrow(/olderThanMs/); + expect(() => queue.purgeCompleted(-1)).toThrow(/olderThanMs/); + }); + + it("fails fast on a malformed task rate limit instead of disabling it", () => { + const queue = newQueue(); + queue.task("rated", () => "ok", { rateLimit: "not-a-rate" }); + expect(() => queue.runWorker()).toThrow(/rateLimit/); + }); +}); diff --git a/sdks/node/tsconfig.json b/sdks/node/tsconfig.json new file mode 100644 index 00000000..3c4d1ed5 --- /dev/null +++ b/sdks/node/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "verbatimModuleSyntax": true, + "ignoreDeprecations": "6.0", + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/sdks/node/tsup.config.ts b/sdks/node/tsup.config.ts new file mode 100644 index 00000000..e1ac2b62 --- /dev/null +++ b/sdks/node/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { index: "src/index.ts", cli: "src/cli/index.ts" }, + format: ["esm", "cjs"], + dts: true, + shims: true, + clean: true, + sourcemap: true, + target: "node18", + outDir: "dist", +}); diff --git a/sdks/node/vitest.config.ts b/sdks/node/vitest.config.ts new file mode 100644 index 00000000..954cbeb6 --- /dev/null +++ b/sdks/node/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + testTimeout: 15000, + }, +});