From 5be967074a9193778b4795cc83d39ea3995352aa Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:47 +0530 Subject: [PATCH 01/30] feat(node): scaffold taskito-node napi crate (enqueue + getJob) First napi-rs binding shell over taskito-core: JsQueue with SQLite storage, enqueue (opaque payload), and getJob. Modular layout (queue/conversion/config/error). Producer surface only; worker dispatch next. --- Cargo.toml | 2 +- crates/taskito-node/Cargo.toml | 20 ++++++++ crates/taskito-node/build.rs | 3 ++ crates/taskito-node/src/config.rs | 15 ++++++ crates/taskito-node/src/conversion.rs | 68 +++++++++++++++++++++++++++ crates/taskito-node/src/error.rs | 9 ++++ crates/taskito-node/src/lib.rs | 13 +++++ crates/taskito-node/src/queue.rs | 47 ++++++++++++++++++ 8 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 crates/taskito-node/Cargo.toml create mode 100644 crates/taskito-node/build.rs create mode 100644 crates/taskito-node/src/config.rs create mode 100644 crates/taskito-node/src/conversion.rs create mode 100644 crates/taskito-node/src/error.rs create mode 100644 crates/taskito-node/src/lib.rs create mode 100644 crates/taskito-node/src/queue.rs 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..c9975e4a --- /dev/null +++ b/crates/taskito-node/Cargo.toml @@ -0,0 +1,20 @@ +[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"] + +[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 } + +[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/config.rs b/crates/taskito-node/src/config.rs new file mode 100644 index 00000000..ebbe17a3 --- /dev/null +++ b/crates/taskito-node/src/config.rs @@ -0,0 +1,15 @@ +//! Plain option objects passed from JavaScript. napi maps snake_case Rust +//! fields to camelCase JS keys (`maxRetries`, `timeoutMs`). + +use napi_derive::napi; + +/// Per-enqueue overrides. All optional — omitted fields fall back to defaults +/// in [`crate::conversion::build_new_job`]. +#[napi(object)] +#[derive(Default)] +pub struct EnqueueOptions { + pub queue: Option, + pub priority: Option, + pub max_retries: Option, + pub timeout_ms: Option, +} diff --git a/crates/taskito-node/src/conversion.rs b/crates/taskito-node/src/conversion.rs new file mode 100644 index 00000000..7fdaad9b --- /dev/null +++ b/crates/taskito-node/src/conversion.rs @@ -0,0 +1,68 @@ +//! 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; +use napi_derive::napi; +use taskito_core::job::now_millis; +use taskito_core::{Job, NewJob}; + +use crate::config::EnqueueOptions; + +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. +pub fn build_new_job(task_name: String, payload: Vec, opts: EnqueueOptions) -> NewJob { + NewJob { + queue: opts.queue.unwrap_or_else(|| DEFAULT_QUEUE.to_string()), + task_name, + payload, + priority: opts.priority.unwrap_or(0), + scheduled_at: now_millis(), + max_retries: opts.max_retries.unwrap_or(DEFAULT_MAX_RETRIES), + timeout_ms: opts.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS), + unique_key: None, + metadata: None, + notes: None, + depends_on: Vec::new(), + expires_at: None, + result_ttl_ms: None, + namespace: None, + } +} + +/// 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.wire_name().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/error.rs b/crates/taskito-node/src/error.rs new file mode 100644 index 00000000..4984b178 --- /dev/null +++ b/crates/taskito-node/src/error.rs @@ -0,0 +1,9 @@ +//! 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()) +} diff --git a/crates/taskito-node/src/lib.rs b/crates/taskito-node/src/lib.rs new file mode 100644 index 00000000..77c49611 --- /dev/null +++ b/crates/taskito-node/src/lib.rs @@ -0,0 +1,13 @@ +//! 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 config; +mod conversion; +mod error; +mod queue; + +pub use queue::JsQueue; diff --git a/crates/taskito-node/src/queue.rs b/crates/taskito-node/src/queue.rs new file mode 100644 index 00000000..51ac07d9 --- /dev/null +++ b/crates/taskito-node/src/queue.rs @@ -0,0 +1,47 @@ +//! `JsQueue` — the producer/inspection surface over the core storage. + +use napi::bindgen_prelude::{Buffer, Result}; +use napi_derive::napi; +use taskito_core::{SqliteStorage, Storage, StorageBackend}; + +use crate::config::EnqueueOptions; +use crate::conversion::{build_new_job, job_to_js, JsJob}; +use crate::error::to_napi_err; + +/// A SQLite-backed Taskito queue handle exposed to JavaScript. +#[napi] +pub struct JsQueue { + storage: StorageBackend, +} + +#[napi] +impl JsQueue { + /// Open (creating if needed) a SQLite-backed queue at `db_path`. + #[napi(constructor)] + pub fn new(db_path: String) -> Result { + let storage = SqliteStorage::new(&db_path).map_err(to_napi_err)?; + Ok(Self { + storage: StorageBackend::Sqlite(storage), + }) + } + + /// Enqueue `task_name` with an opaque serialized `payload`. Returns the job id. + #[napi] + pub fn enqueue( + &self, + task_name: String, + payload: Buffer, + options: Option, + ) -> Result { + let new_job = build_new_job(task_name, payload.to_vec(), options.unwrap_or_default()); + let job = 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)) + } +} From b4341e825160a387febd9e4ca93abd593e14fa53 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:48 +0530 Subject: [PATCH 02/30] chore(node): node package + napi build wiring pnpm package with @napi-rs/cli build of the taskito-node crate into native/. native/package.json marks the generated CJS loader so the ESM package can require it. --- sdks/node/.gitignore | 8 ++++++++ sdks/node/native/package.json | 3 +++ sdks/node/package.json | 21 +++++++++++++++++++++ sdks/node/pnpm-lock.yaml | 24 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+) create mode 100644 sdks/node/.gitignore create mode 100644 sdks/node/native/package.json create mode 100644 sdks/node/package.json create mode 100644 sdks/node/pnpm-lock.yaml 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/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..051b5419 --- /dev/null +++ b/sdks/node/package.json @@ -0,0 +1,21 @@ +{ + "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" + }, + "napi": { + "name": "taskito" + }, + "scripts": { + "build:native": "napi build --platform --release --cargo-cwd ../../crates/taskito-node native", + "build": "pnpm run build:native" + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.4" + } +} diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml new file mode 100644 index 00000000..32962907 --- /dev/null +++ b/sdks/node/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@napi-rs/cli': + specifier: ^2.18.4 + version: 2.18.4 + +packages: + + '@napi-rs/cli@2.18.4': + resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} + engines: {node: '>= 10'} + hasBin: true + +snapshots: + + '@napi-rs/cli@2.18.4': {} From 21146df0d49095d4b0efee4d2f3719aacf39df7b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:48 +0530 Subject: [PATCH 03/30] feat(node): worker dispatcher via threadsafe fn NodeDispatcher implements WorkerDispatcher: each job is run in JS via a ThreadsafeFunction, the returned Promise is awaited through a oneshot, and a JobResult is reported back. JsQueue.runWorker wires scheduler + dispatcher + result-drain on napi's tokio runtime; JsWorker.stop() shuts it down. --- crates/taskito-node/Cargo.toml | 2 + crates/taskito-node/src/config.rs | 8 ++ crates/taskito-node/src/conversion.rs | 9 +++ crates/taskito-node/src/dispatcher.rs | 107 ++++++++++++++++++++++++++ crates/taskito-node/src/lib.rs | 3 + crates/taskito-node/src/queue.rs | 17 +++- crates/taskito-node/src/worker.rs | 88 +++++++++++++++++++++ 7 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 crates/taskito-node/src/dispatcher.rs create mode 100644 crates/taskito-node/src/worker.rs diff --git a/crates/taskito-node/Cargo.toml b/crates/taskito-node/Cargo.toml index c9975e4a..9e04debd 100644 --- a/crates/taskito-node/Cargo.toml +++ b/crates/taskito-node/Cargo.toml @@ -15,6 +15,8 @@ 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/src/config.rs b/crates/taskito-node/src/config.rs index ebbe17a3..6e687a55 100644 --- a/crates/taskito-node/src/config.rs +++ b/crates/taskito-node/src/config.rs @@ -13,3 +13,11 @@ pub struct EnqueueOptions { pub max_retries: Option, pub timeout_ms: Option, } + +/// Options for a running worker. `queues` defaults to `["default"]`. +#[napi(object)] +#[derive(Default)] +pub struct WorkerOptions { + pub queues: Option>, + pub channel_capacity: Option, +} diff --git a/crates/taskito-node/src/conversion.rs b/crates/taskito-node/src/conversion.rs index 7fdaad9b..a3ac6fd9 100644 --- a/crates/taskito-node/src/conversion.rs +++ b/crates/taskito-node/src/conversion.rs @@ -33,6 +33,15 @@ pub fn build_new_job(task_name: String, payload: Vec, opts: EnqueueOptions) } } +/// 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)] diff --git a/crates/taskito-node/src/dispatcher.rs b/crates/taskito-node/src/dispatcher.rs new file mode 100644 index 00000000..28e218b7 --- /dev/null +++ b/crates/taskito-node/src/dispatcher.rs @@ -0,0 +1,107 @@ +//! `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::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 tokio::sync::oneshot; + +use crate::conversion::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, +} + +impl NodeDispatcher { + pub fn new(callback: TaskCallback) -> Self { + Self { callback } + } +} + +#[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 result_tx = result_tx.clone(); + spawn(async move { + let result = run_one(&callback, 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, 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(()) + }, + ); + + let outcome = rx.await; + let wall_time_ns = started.elapsed().as_nanos() as i64; + match outcome { + Ok(Ok(result)) => JobResult::Success { + job_id: job.id, + result: Some(result), + task_name: job.task_name, + wall_time_ns, + }, + Ok(Err(err)) => failure(job, err.to_string(), wall_time_ns), + Err(_) => failure(job, "node task channel dropped".to_string(), wall_time_ns), + } +} + +/// Build a retryable [`JobResult::Failure`] from a job and error message. +fn failure(job: Job, error: String, wall_time_ns: i64) -> 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: false, + } +} diff --git a/crates/taskito-node/src/lib.rs b/crates/taskito-node/src/lib.rs index 77c49611..c675edbe 100644 --- a/crates/taskito-node/src/lib.rs +++ b/crates/taskito-node/src/lib.rs @@ -7,7 +7,10 @@ mod config; mod conversion; +mod dispatcher; mod error; mod queue; +mod worker; pub use queue::JsQueue; +pub use worker::JsWorker; diff --git a/crates/taskito-node/src/queue.rs b/crates/taskito-node/src/queue.rs index 51ac07d9..a7850923 100644 --- a/crates/taskito-node/src/queue.rs +++ b/crates/taskito-node/src/queue.rs @@ -1,12 +1,14 @@ //! `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::{SqliteStorage, Storage, StorageBackend}; -use crate::config::EnqueueOptions; -use crate::conversion::{build_new_job, job_to_js, JsJob}; +use crate::config::{EnqueueOptions, WorkerOptions}; +use crate::conversion::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; use crate::error::to_napi_err; +use crate::worker::{start_worker, JsWorker}; /// A SQLite-backed Taskito queue handle exposed to JavaScript. #[napi] @@ -44,4 +46,15 @@ impl JsQueue { let job = self.storage.get_job(&id).map_err(to_napi_err)?; Ok(job.map(job_to_js)) } + + /// 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, + ) -> JsWorker { + start_worker(self.storage.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..66258168 --- /dev/null +++ b/crates/taskito-node/src/worker.rs @@ -0,0 +1,88 @@ +//! Worker wiring — spawns the scheduler, dispatcher, and result-drain loops. + +use std::sync::Arc; + +use napi::bindgen_prelude::{spawn, spawn_blocking}; +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::conversion::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. +pub fn start_worker( + storage: StorageBackend, + options: WorkerOptions, + callback: ThreadsafeFunction, +) -> JsWorker { + let queues = options + .queues + .unwrap_or_else(|| vec![DEFAULT_QUEUE.to_string()]); + let capacity = options + .channel_capacity + .map(|c| c as usize) + .unwrap_or(DEFAULT_CHANNEL_CAPACITY); + + let scheduler = Arc::new(Scheduler::new( + storage, + queues, + SchedulerConfig::default(), + None, + )); + 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); + 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}"); + } + } + }); + + JsWorker { shutdown } +} From 23a8a7a4c1f55f1d7cc744f08f8a07818ae53cf3 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:48 +0530 Subject: [PATCH 04/30] feat(node): typescript Queue/Worker api + json serializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production TS package: Queue (task registry, enqueue, getResult), Worker lifecycle, pluggable Serializer (default JSON), typed native loader, error hierarchy. Strict tsconfig, Biome, Vitest — matching the repo's JS stack. --- sdks/node/biome.json | 24 + sdks/node/package.json | 42 +- sdks/node/pnpm-lock.yaml | 863 ++++++++++++++++++++++++++++++++++++ sdks/node/src/errors.ts | 15 + sdks/node/src/index.ts | 5 + sdks/node/src/native.ts | 20 + sdks/node/src/queue.ts | 57 +++ sdks/node/src/serializer.ts | 20 + sdks/node/src/types.ts | 9 + sdks/node/src/worker.ts | 38 ++ sdks/node/tsconfig.json | 22 + sdks/node/vitest.config.ts | 8 + 12 files changed, 1121 insertions(+), 2 deletions(-) create mode 100644 sdks/node/biome.json create mode 100644 sdks/node/src/errors.ts create mode 100644 sdks/node/src/index.ts create mode 100644 sdks/node/src/native.ts create mode 100644 sdks/node/src/queue.ts create mode 100644 sdks/node/src/serializer.ts create mode 100644 sdks/node/src/types.ts create mode 100644 sdks/node/src/worker.ts create mode 100644 sdks/node/tsconfig.json create mode 100644 sdks/node/vitest.config.ts diff --git a/sdks/node/biome.json b/sdks/node/biome.json new file mode 100644 index 00000000..236bd979 --- /dev/null +++ b/sdks/node/biome.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.8/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/package.json b/sdks/node/package.json index 051b5419..3b478d0f 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -8,14 +8,52 @@ "engines": { "node": ">=18" }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "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 native", - "build": "pnpm run build:native" + "build:ts": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "biome check src test", + "format": "biome format --write src test", + "test": "vitest run" }, "devDependencies": { - "@napi-rs/cli": "^2.18.4" + "@biomejs/biome": "^2.4.16", + "@napi-rs/cli": "^2.18.4", + "@types/node": "^25.9.2", + "typescript": "^6.0.3", + "vitest": "^4.1.8" } } diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index 32962907..9c8daaa0 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -8,17 +8,880 @@ importers: .: 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 + 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)) 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==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@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==} + + '@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==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + 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 + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + 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'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + 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'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.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'} + + 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==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + 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'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + 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 + + '@jridgewell/sourcemap-codec@1.5.5': {} + '@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': {} + + '@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))': + 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) + + '@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 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.1.0: {} + + 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 + + fsevents@2.3.3: + optional: true + + 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 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.12: {} + + obug@2.1.3: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + 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 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + tinybench@2.9.0: {} + + 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: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + vite@8.0.16(@types/node@25.9.3): + 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 + fsevents: 2.3.3 + + vitest@4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)) + '@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) + 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/errors.ts b/sdks/node/src/errors.ts new file mode 100644 index 00000000..33989640 --- /dev/null +++ b/sdks/node/src/errors.ts @@ -0,0 +1,15 @@ +/** 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"; + } +} diff --git a/sdks/node/src/index.ts b/sdks/node/src/index.ts new file mode 100644 index 00000000..b8db3dd7 --- /dev/null +++ b/sdks/node/src/index.ts @@ -0,0 +1,5 @@ +export { TaskitoError, TaskNotRegisteredError } from "./errors.js"; +export { Queue, type QueueOptions } from "./queue.js"; +export { JsonSerializer, type Serializer } from "./serializer.js"; +export type { EnqueueOptions, Job, TaskHandler, WorkerOptions } from "./types.js"; +export { Worker } from "./worker.js"; diff --git a/sdks/node/src/native.ts b/sdks/node/src/native.ts new file mode 100644 index 00000000..d2209d96 --- /dev/null +++ b/sdks/node/src/native.ts @@ -0,0 +1,20 @@ +// Typed loader for the napi-rs binding. The generated `native/index.js` is +// CommonJS (see native/package.json), so it is loaded via createRequire rather +// than a static import. +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const binding = require("../native/index.js") as typeof import("../native/index.js"); + +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, + JsJob, + JsTaskInvocation, + WorkerOptions, +} from "../native/index.js"; diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts new file mode 100644 index 00000000..d8cd3e36 --- /dev/null +++ b/sdks/node/src/queue.ts @@ -0,0 +1,57 @@ +import { JsQueue, type NativeQueue } from "./native.js"; +import { JsonSerializer, type Serializer } from "./serializer.js"; +import type { EnqueueOptions, Job, TaskHandler, WorkerOptions } from "./types.js"; +import { Worker } from "./worker.js"; + +/** Construction options for a {@link Queue}. */ +export interface QueueOptions { + /** Path to the SQLite database file (created if missing). */ + dbPath: 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. + */ +export class Queue { + private readonly native: NativeQueue; + private readonly serializer: Serializer; + private readonly tasks = new Map(); + + constructor(options: QueueOptions) { + this.native = new JsQueue(options.dbPath); + this.serializer = options.serializer ?? new JsonSerializer(); + } + + /** Register a task handler under `name`. */ + task(name: string, handler: TaskHandler): void { + this.tasks.set(name, handler); + } + + /** Enqueue `name` with positional `args`. Returns the new job id. */ + enqueue(name: string, args: unknown[] = [], options?: EnqueueOptions): string { + const payload = Buffer.from(this.serializer.serialize(args)); + return this.native.enqueue(name, payload, options); + } + + /** 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); + } + + /** Start a worker that runs the registered tasks. Hold the returned {@link Worker}. */ + runWorker(options?: WorkerOptions): Worker { + return Worker.start(this.native, this.tasks, this.serializer, options); + } +} diff --git a/sdks/node/src/serializer.ts b/sdks/node/src/serializer.ts new file mode 100644 index 00000000..688dcc39 --- /dev/null +++ b/sdks/node/src/serializer.ts @@ -0,0 +1,20 @@ +/** + * Pluggable codec for task arguments and results. Mirrors the Python shell's + * `Serializer` protocol so a future msgpack implementation can interoperate + * across languages. The Rust core stores payloads as opaque bytes. + */ +export interface Serializer { + serialize(value: unknown): Uint8Array; + deserialize(bytes: Uint8Array): unknown; +} + +/** 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/types.ts b/sdks/node/src/types.ts new file mode 100644 index 00000000..11fd1fda --- /dev/null +++ b/sdks/node/src/types.ts @@ -0,0 +1,9 @@ +export type { EnqueueOptions, JsJob as Job, WorkerOptions } from "./native.js"; + +/** + * A registered task: receives the deserialized positional args and returns a + * (possibly async) result. + */ +export type TaskHandler = ( + ...args: Args +) => Result | Promise; diff --git a/sdks/node/src/worker.ts b/sdks/node/src/worker.ts new file mode 100644 index 00000000..19b5fefe --- /dev/null +++ b/sdks/node/src/worker.ts @@ -0,0 +1,38 @@ +import { TaskNotRegisteredError } from "./errors.js"; +import type { JsTaskInvocation, NativeQueue, NativeWorker } from "./native.js"; +import type { Serializer } from "./serializer.js"; +import type { TaskHandler, WorkerOptions } from "./types.js"; + +/** 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, + tasks: ReadonlyMap, + serializer: Serializer, + options?: WorkerOptions, + ): Worker { + const callback = async (invocation: JsTaskInvocation): Promise => { + const handler = tasks.get(invocation.taskName); + if (!handler) { + throw new TaskNotRegisteredError(invocation.taskName); + } + const args = serializer.deserialize(invocation.payload) as unknown[]; + const result = await handler(...args); + return Buffer.from(serializer.serialize(result)); + }; + return new Worker(queue.runWorker(callback, options)); + } + + /** Stop the worker; in-flight results drain before background tasks exit. */ + stop(): void { + this.native.stop(); + } +} diff --git a/sdks/node/tsconfig.json b/sdks/node/tsconfig.json new file mode 100644 index 00000000..3e739cb4 --- /dev/null +++ b/sdks/node/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "verbatimModuleSyntax": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "test"] +} 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, + }, +}); From 38400a795a2a91a0b792cde363a52670211c5c30 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:49 +0530 Subject: [PATCH 05/30] test(node): e2e round-trip + serializer unit tests Vitest: enqueue -> node worker executes the task -> result read back (===5), plus JsonSerializer round-trip. README with quickstart and roadmap. --- sdks/node/README.md | 73 +++++++++++++++++++++++++++++++ sdks/node/test/roundtrip.test.ts | 36 +++++++++++++++ sdks/node/test/serializer.test.ts | 15 +++++++ 3 files changed, 124 insertions(+) create mode 100644 sdks/node/README.md create mode 100644 sdks/node/test/roundtrip.test.ts create mode 100644 sdks/node/test/serializer.test.ts diff --git a/sdks/node/README.md b/sdks/node/README.md new file mode 100644 index 00000000..20d65daa --- /dev/null +++ b/sdks/node/README.md @@ -0,0 +1,73 @@ +# 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. SQLite-backed; enqueue work and run workers in the same process or across +processes sharing the database. + +> **Status: early.** This is the first vertical slice — enqueue, a worker that +> runs JavaScript tasks, and result read-back over SQLite with a JSON +> serializer. See [Roadmap](#roadmap) for what is intentionally not here yet. + +## Install + +```bash +pnpm add taskito +``` + +Requires Node.js >= 18. + +## Quickstart + +```ts +import { Queue } from "taskito"; + +const queue = new Queue({ dbPath: "taskito.db" }); + +// Register a task (runs on the worker side). +queue.task("add", (a: number, b: number) => a + b); + +// Producer: enqueue work. +const id = queue.enqueue("add", [2, 3]); + +// Worker: process jobs. Tasks may be async. +const worker = queue.runWorker({ queues: ["default"] }); + +// Read the result back. +const result = queue.getResult(id); // 5 (once complete) + +worker.stop(); +``` + +## Serialization + +Args and results are serialized with a pluggable `Serializer` (default +`JsonSerializer`). The Rust core treats payloads as opaque bytes. Cross-language +jobs (e.g. Python ⇄ Node) require both sides to use a matching neutral +serializer — JSON today, msgpack later. + +```ts +import { Queue, type Serializer } from "taskito"; + +const queue = new Queue({ dbPath: "taskito.db", serializer: myMsgpackSerializer }); +``` + +## Development + +```bash +pnpm install +pnpm build # napi build (native addon) + tsc (dist/) +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. + +## Roadmap + +Deferred from this slice: PostgreSQL/Redis backends, workflows, mesh, +middleware/events, retry/rate-limit/concurrency configuration, cancellation, +prebuilt platform binaries + npm publish (host-only build for now), and +Python⇄Node cross-language interop. 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..3f8b3abf --- /dev/null +++ b/sdks/node/test/serializer.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { JsonSerializer } 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(); + }); +}); From eb2ecb1ec49fd63a0815d9e76e7f65e36af7eb07 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:49 +0530 Subject: [PATCH 06/30] refactor(node): split conversion into convert/ module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group marshalling into convert/{mod,job}.rs (room for stats/task_config submodules) — mirrors the Python py_queue split. No behavior change. --- .../src/{conversion.rs => convert/job.rs} | 0 crates/taskito-node/src/convert/mod.rs | 6 ++++++ crates/taskito-node/src/dispatcher.rs | 2 +- crates/taskito-node/src/lib.rs | 2 +- crates/taskito-node/src/queue.rs | 2 +- crates/taskito-node/src/worker.rs | 2 +- sdks/node/src/serializer.ts | 20 ------------------- 7 files changed, 10 insertions(+), 24 deletions(-) rename crates/taskito-node/src/{conversion.rs => convert/job.rs} (100%) create mode 100644 crates/taskito-node/src/convert/mod.rs delete mode 100644 sdks/node/src/serializer.ts diff --git a/crates/taskito-node/src/conversion.rs b/crates/taskito-node/src/convert/job.rs similarity index 100% rename from crates/taskito-node/src/conversion.rs rename to crates/taskito-node/src/convert/job.rs diff --git a/crates/taskito-node/src/convert/mod.rs b/crates/taskito-node/src/convert/mod.rs new file mode 100644 index 00000000..42e8b031 --- /dev/null +++ b/crates/taskito-node/src/convert/mod.rs @@ -0,0 +1,6 @@ +//! 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; + +pub use job::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; diff --git a/crates/taskito-node/src/dispatcher.rs b/crates/taskito-node/src/dispatcher.rs index 28e218b7..f3e61c02 100644 --- a/crates/taskito-node/src/dispatcher.rs +++ b/crates/taskito-node/src/dispatcher.rs @@ -15,7 +15,7 @@ use taskito_core::scheduler::JobResult; use taskito_core::worker::WorkerDispatcher; use tokio::sync::oneshot; -use crate::conversion::JsTaskInvocation; +use crate::convert::JsTaskInvocation; /// Task callback registered from JS: `(invocation) => Promise`. type TaskCallback = ThreadsafeFunction; diff --git a/crates/taskito-node/src/lib.rs b/crates/taskito-node/src/lib.rs index c675edbe..bf5f8ff1 100644 --- a/crates/taskito-node/src/lib.rs +++ b/crates/taskito-node/src/lib.rs @@ -6,7 +6,7 @@ //! execution back into JavaScript. mod config; -mod conversion; +mod convert; mod dispatcher; mod error; mod queue; diff --git a/crates/taskito-node/src/queue.rs b/crates/taskito-node/src/queue.rs index a7850923..63d41458 100644 --- a/crates/taskito-node/src/queue.rs +++ b/crates/taskito-node/src/queue.rs @@ -6,7 +6,7 @@ use napi_derive::napi; use taskito_core::{SqliteStorage, Storage, StorageBackend}; use crate::config::{EnqueueOptions, WorkerOptions}; -use crate::conversion::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; +use crate::convert::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; use crate::error::to_napi_err; use crate::worker::{start_worker, JsWorker}; diff --git a/crates/taskito-node/src/worker.rs b/crates/taskito-node/src/worker.rs index 66258168..c8b5aa88 100644 --- a/crates/taskito-node/src/worker.rs +++ b/crates/taskito-node/src/worker.rs @@ -10,7 +10,7 @@ use taskito_core::{Scheduler, SchedulerConfig, StorageBackend}; use tokio::sync::Notify; use crate::config::WorkerOptions; -use crate::conversion::JsTaskInvocation; +use crate::convert::JsTaskInvocation; use crate::dispatcher::NodeDispatcher; const DEFAULT_QUEUE: &str = "default"; diff --git a/sdks/node/src/serializer.ts b/sdks/node/src/serializer.ts deleted file mode 100644 index 688dcc39..00000000 --- a/sdks/node/src/serializer.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Pluggable codec for task arguments and results. Mirrors the Python shell's - * `Serializer` protocol so a future msgpack implementation can interoperate - * across languages. The Rust core stores payloads as opaque bytes. - */ -export interface Serializer { - serialize(value: unknown): Uint8Array; - deserialize(bytes: Uint8Array): unknown; -} - -/** 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)); - } -} From fe8de8b1f2fb9dbbf86a63ac88833d937d8ef794 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:49 +0530 Subject: [PATCH 07/30] chore(node): tsup dual esm/cjs build + serializers layout Bundle with tsup (esm+cjs+dts, shims), extensionless imports (moduleResolution Bundler, tsc noEmit typecheck), dual exports map. Native addon loaded via runtime-resolved createRequire so the bundler leaves it external. serializer.ts -> serializers/{serializer,json,index}. --- sdks/node/package.json | 9 +- sdks/node/pnpm-lock.yaml | 841 +++++++++++++++++++++++- sdks/node/src/index.ts | 10 +- sdks/node/src/native.ts | 16 +- sdks/node/src/queue.ts | 8 +- sdks/node/src/serializers/index.ts | 2 + sdks/node/src/serializers/json.ts | 12 + sdks/node/src/serializers/serializer.ts | 9 + sdks/node/src/types.ts | 2 +- sdks/node/src/worker.ts | 8 +- sdks/node/tsconfig.json | 14 +- sdks/node/tsup.config.ts | 12 + 12 files changed, 901 insertions(+), 42 deletions(-) create mode 100644 sdks/node/src/serializers/index.ts create mode 100644 sdks/node/src/serializers/json.ts create mode 100644 sdks/node/src/serializers/serializer.ts create mode 100644 sdks/node/tsup.config.ts diff --git a/sdks/node/package.json b/sdks/node/package.json index 3b478d0f..fcd3aa2a 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -8,12 +8,14 @@ "engines": { "node": ">=18" }, - "main": "./dist/index.js", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/index.cjs" } }, "files": [ @@ -43,7 +45,7 @@ "scripts": { "build": "pnpm run build:native && pnpm run build:ts", "build:native": "napi build --platform --release --cargo-cwd ../../crates/taskito-node native", - "build:ts": "tsc -p tsconfig.json", + "build:ts": "tsup", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "biome check src test", "format": "biome format --write src test", @@ -53,6 +55,7 @@ "@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" } diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index 9c8daaa0..c58ebd42 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -17,12 +17,15 @@ importers: '@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)) + version: 4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7)) packages: @@ -92,9 +95,175 @@ packages: '@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==} + '@napi-rs/cli@2.18.4': resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} engines: {node: '>= 10'} @@ -207,6 +376,144 @@ packages: '@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==} @@ -254,17 +561,59 @@ packages: '@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@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'} @@ -272,6 +621,11 @@ packages: 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==} @@ -288,11 +642,18 @@ packages: 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'} @@ -367,14 +728,38 @@ packages: 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'} @@ -389,15 +774,53 @@ packages: 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==} @@ -405,15 +828,34 @@ packages: 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'} @@ -426,14 +868,43 @@ packages: 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==} @@ -579,8 +1050,98 @@ snapshots: 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 + '@napi-rs/cli@2.18.4': {} '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -643,6 +1204,81 @@ snapshots: '@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': @@ -672,13 +1308,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.3))': + '@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) + vite: 8.0.16(@types/node@25.9.3)(esbuild@0.27.7) '@vitest/pretty-format@4.1.9': dependencies: @@ -704,16 +1340,70 @@ snapshots: 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@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 @@ -724,9 +1414,17 @@ snapshots: 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 @@ -776,12 +1474,35 @@ snapshots: 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: {} @@ -790,12 +1511,30 @@ snapshots: 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 @@ -817,16 +1556,69 @@ snapshots: '@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: @@ -836,14 +1628,48 @@ snapshots: 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): + vite@8.0.16(@types/node@25.9.3)(esbuild@0.27.7): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -852,12 +1678,13 @@ snapshots: 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)): + 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)) + '@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 @@ -874,7 +1701,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.16(@types/node@25.9.3) + 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 diff --git a/sdks/node/src/index.ts b/sdks/node/src/index.ts index b8db3dd7..76fd038f 100644 --- a/sdks/node/src/index.ts +++ b/sdks/node/src/index.ts @@ -1,5 +1,5 @@ -export { TaskitoError, TaskNotRegisteredError } from "./errors.js"; -export { Queue, type QueueOptions } from "./queue.js"; -export { JsonSerializer, type Serializer } from "./serializer.js"; -export type { EnqueueOptions, Job, TaskHandler, WorkerOptions } from "./types.js"; -export { Worker } from "./worker.js"; +export { TaskitoError, TaskNotRegisteredError } from "./errors"; +export { Queue, type QueueOptions } from "./queue"; +export { JsonSerializer, type Serializer } from "./serializers"; +export type { EnqueueOptions, Job, TaskHandler, WorkerOptions } from "./types"; +export { Worker } from "./worker"; diff --git a/sdks/node/src/native.ts b/sdks/node/src/native.ts index d2209d96..d80dec40 100644 --- a/sdks/node/src/native.ts +++ b/sdks/node/src/native.ts @@ -1,10 +1,13 @@ // Typed loader for the napi-rs binding. The generated `native/index.js` is -// CommonJS (see native/package.json), so it is loaded via createRequire rather -// than a static import. +// 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 binding = require("../native/index.js") as typeof import("../native/index.js"); +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; @@ -12,9 +15,4 @@ export const { JsQueue, JsWorker } = binding; export type NativeQueue = InstanceType; export type NativeWorker = InstanceType; -export type { - EnqueueOptions, - JsJob, - JsTaskInvocation, - WorkerOptions, -} from "../native/index.js"; +export type { EnqueueOptions, JsJob, JsTaskInvocation, WorkerOptions } from "../native/index"; diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index d8cd3e36..6debc0de 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -1,7 +1,7 @@ -import { JsQueue, type NativeQueue } from "./native.js"; -import { JsonSerializer, type Serializer } from "./serializer.js"; -import type { EnqueueOptions, Job, TaskHandler, WorkerOptions } from "./types.js"; -import { Worker } from "./worker.js"; +import { JsQueue, type NativeQueue } from "./native"; +import { JsonSerializer, type Serializer } from "./serializers"; +import type { EnqueueOptions, Job, TaskHandler, WorkerOptions } from "./types"; +import { Worker } from "./worker"; /** Construction options for a {@link Queue}. */ export interface QueueOptions { diff --git a/sdks/node/src/serializers/index.ts b/sdks/node/src/serializers/index.ts new file mode 100644 index 00000000..f9f86ba5 --- /dev/null +++ b/sdks/node/src/serializers/index.ts @@ -0,0 +1,2 @@ +export { JsonSerializer } from "./json"; +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/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 index 11fd1fda..bc338b18 100644 --- a/sdks/node/src/types.ts +++ b/sdks/node/src/types.ts @@ -1,4 +1,4 @@ -export type { EnqueueOptions, JsJob as Job, WorkerOptions } from "./native.js"; +export type { EnqueueOptions, JsJob as Job, WorkerOptions } from "./native"; /** * A registered task: receives the deserialized positional args and returns a diff --git a/sdks/node/src/worker.ts b/sdks/node/src/worker.ts index 19b5fefe..213061e2 100644 --- a/sdks/node/src/worker.ts +++ b/sdks/node/src/worker.ts @@ -1,7 +1,7 @@ -import { TaskNotRegisteredError } from "./errors.js"; -import type { JsTaskInvocation, NativeQueue, NativeWorker } from "./native.js"; -import type { Serializer } from "./serializer.js"; -import type { TaskHandler, WorkerOptions } from "./types.js"; +import { TaskNotRegisteredError } from "./errors"; +import type { JsTaskInvocation, NativeQueue, NativeWorker } from "./native"; +import type { Serializer } from "./serializers"; +import type { TaskHandler, WorkerOptions } from "./types"; /** A running worker. Hold it for the worker's lifetime; call {@link Worker.stop}. */ export class Worker { diff --git a/sdks/node/tsconfig.json b/sdks/node/tsconfig.json index 3e739cb4..3c4d1ed5 100644 --- a/sdks/node/tsconfig.json +++ b/sdks/node/tsconfig.json @@ -1,22 +1,18 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "Bundler", "lib": ["ES2022"], "types": ["node"], - "outDir": "dist", - "rootDir": "src", "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "verbatimModuleSyntax": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, + "ignoreDeprecations": "6.0", + "noEmit": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["src"], - "exclude": ["dist", "node_modules", "test"] + "include": ["src"] } diff --git a/sdks/node/tsup.config.ts b/sdks/node/tsup.config.ts new file mode 100644 index 00000000..7121edab --- /dev/null +++ b/sdks/node/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "cjs"], + dts: true, + shims: true, + clean: true, + sourcemap: true, + target: "node18", + outDir: "dist", +}); From 4af8f7405d46bcf5d03126e8fef46fd1ab9b02ab Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:50 +0530 Subject: [PATCH 08/30] feat(node): backends, enqueue options, task config, timeout Add postgres/redis backends (feature-gated open()), full enqueue options (delay, uniqueKey idempotency, metadata, namespace), per-task/queue config registration (retry backoff, concurrency, rate limit), and dispatcher-enforced task timeouts. Status surfaces as lowercase (as_str). --- crates/taskito-node/Cargo.toml | 5 ++ crates/taskito-node/src/backend.rs | 47 ++++++++++++++++ crates/taskito-node/src/config.rs | 53 ++++++++++++++++++- crates/taskito-node/src/convert/job.rs | 22 +++++--- crates/taskito-node/src/convert/mod.rs | 2 + .../taskito-node/src/convert/task_config.rs | 34 ++++++++++++ crates/taskito-node/src/dispatcher.rs | 28 +++++++--- crates/taskito-node/src/lib.rs | 1 + crates/taskito-node/src/queue.rs | 42 ++++++++++----- crates/taskito-node/src/worker.rs | 22 +++++--- 10 files changed, 221 insertions(+), 35 deletions(-) create mode 100644 crates/taskito-node/src/backend.rs create mode 100644 crates/taskito-node/src/convert/task_config.rs diff --git a/crates/taskito-node/Cargo.toml b/crates/taskito-node/Cargo.toml index 9e04debd..8678286c 100644 --- a/crates/taskito-node/Cargo.toml +++ b/crates/taskito-node/Cargo.toml @@ -9,6 +9,11 @@ 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"] } diff --git a/crates/taskito-node/src/backend.rs b/crates/taskito-node/src/backend.rs new file mode 100644 index 00000000..d50ec807 --- /dev/null +++ b/crates/taskito-node/src/backend.rs @@ -0,0 +1,47 @@ +//! 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::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"; + +/// 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 = options.pool_size.unwrap_or(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 = options.pool_size.unwrap_or(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 index 6e687a55..64e9a2ec 100644 --- a/crates/taskito-node/src/config.rs +++ b/crates/taskito-node/src/config.rs @@ -3,8 +3,26 @@ 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::conversion::build_new_job`]. +/// in [`crate::convert::build_new_job`]. #[napi(object)] #[derive(Default)] pub struct EnqueueOptions { @@ -12,6 +30,35 @@ pub struct EnqueueOptions { 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, +} + +/// 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"]`. @@ -20,4 +67,8 @@ pub struct EnqueueOptions { 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 index a3ac6fd9..0512c083 100644 --- a/crates/taskito-node/src/convert/job.rs +++ b/crates/taskito-node/src/convert/job.rs @@ -14,22 +14,32 @@ 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. -pub fn build_new_job(task_name: String, payload: Vec, opts: EnqueueOptions) -> NewJob { +/// `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>, +) -> NewJob { + let delay = opts.delay_ms.unwrap_or(0).max(0); NewJob { queue: opts.queue.unwrap_or_else(|| DEFAULT_QUEUE.to_string()), task_name, payload, priority: opts.priority.unwrap_or(0), - scheduled_at: now_millis(), + scheduled_at: now_millis() + delay, max_retries: opts.max_retries.unwrap_or(DEFAULT_MAX_RETRIES), timeout_ms: opts.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS), - unique_key: None, - metadata: None, + unique_key: opts.unique_key, + metadata: opts.metadata, notes: None, depends_on: Vec::new(), expires_at: None, result_ttl_ms: None, - namespace: None, + namespace: opts + .namespace + .or_else(|| queue_namespace.map(str::to_string)), } } @@ -65,7 +75,7 @@ pub fn job_to_js(job: Job) -> JsJob { id: job.id, queue: job.queue, task_name: job.task_name, - status: job.status.wire_name().to_string(), + status: job.status.as_str().to_string(), priority: job.priority, retry_count: job.retry_count, max_retries: job.max_retries, diff --git a/crates/taskito-node/src/convert/mod.rs b/crates/taskito-node/src/convert/mod.rs index 42e8b031..273539bd 100644 --- a/crates/taskito-node/src/convert/mod.rs +++ b/crates/taskito-node/src/convert/mod.rs @@ -2,5 +2,7 @@ //! concern; kept out of the logic modules so they read as intent, not plumbing. mod job; +mod task_config; pub use job::{build_new_job, job_to_js, JsJob, JsTaskInvocation}; +pub use task_config::{queue_config, task_config}; 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..65a00440 --- /dev/null +++ b/crates/taskito-node/src/convert/task_config.rs @@ -0,0 +1,34 @@ +//! Build core task/queue configuration from JS option inputs. + +use taskito_core::resilience::rate_limiter::RateLimitConfig; +use taskito_core::resilience::retry::RetryPolicy; +use taskito_core::scheduler::{QueueConfig, TaskConfig}; + +use crate::config::{QueueConfigInput, TaskConfigInput}; + +const DEFAULT_RETRY_BASE_MS: i64 = 1_000; +const DEFAULT_RETRY_MAX_MS: i64 = 300_000; +const DEFAULT_MAX_RETRIES: i32 = 3; + +/// Build a [`TaskConfig`] (retry policy, rate limit, concurrency cap) from JS input. +pub fn task_config(input: &TaskConfigInput) -> TaskConfig { + 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: input.rate_limit.as_deref().and_then(RateLimitConfig::parse), + circuit_breaker: None, + max_concurrent: input.max_concurrent, + } +} + +/// Build a [`QueueConfig`] (rate limit, concurrency cap) from JS input. +pub fn queue_config(input: &QueueConfigInput) -> QueueConfig { + QueueConfig { + rate_limit: input.rate_limit.as_deref().and_then(RateLimitConfig::parse), + max_concurrent: input.max_concurrent, + } +} diff --git a/crates/taskito-node/src/dispatcher.rs b/crates/taskito-node/src/dispatcher.rs index f3e61c02..85b6ec39 100644 --- a/crates/taskito-node/src/dispatcher.rs +++ b/crates/taskito-node/src/dispatcher.rs @@ -5,7 +5,7 @@ //! `Promise`, and reports a [`JobResult`] back to the scheduler. This is //! the Node mirror of the Python shell's worker pool. -use std::time::Instant; +use std::time::{Duration, Instant}; use crossbeam_channel::Sender; use napi::bindgen_prelude::{spawn, Buffer, Promise}; @@ -78,22 +78,34 @@ async fn run_one(callback: &TaskCallback, job: Job) -> JobResult { }, ); - let outcome = rx.await; + // 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 outcome { - Ok(Ok(result)) => JobResult::Success { + match timed { + Ok(Ok(Ok(result))) => JobResult::Success { job_id: job.id, result: Some(result), task_name: job.task_name, wall_time_ns, }, - Ok(Err(err)) => failure(job, err.to_string(), wall_time_ns), - Err(_) => failure(job, "node task channel dropped".to_string(), wall_time_ns), + Ok(Ok(Err(err))) => failure(job, err.to_string(), wall_time_ns, false), + Ok(Err(_)) => failure( + job, + "node task channel dropped".to_string(), + wall_time_ns, + false, + ), + 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) -> JobResult { +fn failure(job: Job, error: String, wall_time_ns: i64, timed_out: bool) -> JobResult { JobResult::Failure { job_id: job.id, error, @@ -102,6 +114,6 @@ fn failure(job: Job, error: String, wall_time_ns: i64) -> JobResult { task_name: job.task_name, wall_time_ns, should_retry: true, - timed_out: false, + timed_out, } } diff --git a/crates/taskito-node/src/lib.rs b/crates/taskito-node/src/lib.rs index bf5f8ff1..5fd001d5 100644 --- a/crates/taskito-node/src/lib.rs +++ b/crates/taskito-node/src/lib.rs @@ -5,6 +5,7 @@ //! marshals between JS values and the core and (later) dispatches task //! execution back into JavaScript. +mod backend; mod config; mod convert; mod dispatcher; diff --git a/crates/taskito-node/src/queue.rs b/crates/taskito-node/src/queue.rs index 63d41458..0ae2a24a 100644 --- a/crates/taskito-node/src/queue.rs +++ b/crates/taskito-node/src/queue.rs @@ -3,31 +3,33 @@ use napi::bindgen_prelude::{Buffer, Result}; use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; use napi_derive::napi; -use taskito_core::{SqliteStorage, Storage, StorageBackend}; +use taskito_core::{Storage, StorageBackend}; -use crate::config::{EnqueueOptions, WorkerOptions}; +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}; -/// A SQLite-backed Taskito queue handle exposed to JavaScript. +/// 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 SQLite-backed queue at `db_path`. - #[napi(constructor)] - pub fn new(db_path: String) -> Result { - let storage = SqliteStorage::new(&db_path).map_err(to_napi_err)?; - Ok(Self { - storage: StorageBackend::Sqlite(storage), - }) + /// 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. + /// 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, @@ -35,8 +37,15 @@ impl JsQueue { payload: Buffer, options: Option, ) -> Result { - let new_job = build_new_job(task_name, payload.to_vec(), options.unwrap_or_default()); - let job = self.storage.enqueue(new_job).map_err(to_napi_err)?; + 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) } @@ -55,6 +64,11 @@ impl JsQueue { callback: ThreadsafeFunction, options: Option, ) -> JsWorker { - start_worker(self.storage.clone(), options.unwrap_or_default(), callback) + 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 index c8b5aa88..873bc862 100644 --- a/crates/taskito-node/src/worker.rs +++ b/crates/taskito-node/src/worker.rs @@ -38,6 +38,7 @@ impl JsWorker { /// Start a worker over `storage` that runs `callback` for each dequeued job. pub fn start_worker( storage: StorageBackend, + namespace: Option, options: WorkerOptions, callback: ThreadsafeFunction, ) -> JsWorker { @@ -49,12 +50,21 @@ pub fn start_worker( .map(|c| c as usize) .unwrap_or(DEFAULT_CHANNEL_CAPACITY); - let scheduler = Arc::new(Scheduler::new( - storage, - queues, - SchedulerConfig::default(), - None, - )); + let mut config = SchedulerConfig::default(); + if let Some(batch) = options.batch_size { + config.batch_size = batch.max(1) as usize; + } + + // 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); From 0752546f6f5e49d4ea7596513dbb8450814f15d3 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:50 +0530 Subject: [PATCH 09/30] feat(node): backend/options/config typescript api Queue.open backends, task(name, fn, opts) + configureQueue registries, enqueue merges per-task defaults, runWorker assembles task/queue configs. build:native ships postgres+redis. --- sdks/node/package.json | 2 +- sdks/node/src/index.ts | 9 ++++- sdks/node/src/native.ts | 10 +++++- sdks/node/src/queue.ts | 80 ++++++++++++++++++++++++++++++++++------- sdks/node/src/types.ts | 38 +++++++++++++++++++- sdks/node/src/worker.ts | 77 ++++++++++++++++++++++++++++++++------- 6 files changed, 187 insertions(+), 29 deletions(-) diff --git a/sdks/node/package.json b/sdks/node/package.json index fcd3aa2a..a34f08bc 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -44,7 +44,7 @@ }, "scripts": { "build": "pnpm run build:native && pnpm run build:ts", - "build:native": "napi build --platform --release --cargo-cwd ../../crates/taskito-node native", + "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", diff --git a/sdks/node/src/index.ts b/sdks/node/src/index.ts index 76fd038f..543b3fec 100644 --- a/sdks/node/src/index.ts +++ b/sdks/node/src/index.ts @@ -1,5 +1,12 @@ export { TaskitoError, TaskNotRegisteredError } from "./errors"; export { Queue, type QueueOptions } from "./queue"; export { JsonSerializer, type Serializer } from "./serializers"; -export type { EnqueueOptions, Job, TaskHandler, WorkerOptions } from "./types"; +export type { + EnqueueOptions, + Job, + QueueLimits, + TaskHandler, + TaskOptions, + WorkerRunOptions, +} from "./types"; export { Worker } from "./worker"; diff --git a/sdks/node/src/native.ts b/sdks/node/src/native.ts index d80dec40..06885e1f 100644 --- a/sdks/node/src/native.ts +++ b/sdks/node/src/native.ts @@ -15,4 +15,12 @@ export const { JsQueue, JsWorker } = binding; export type NativeQueue = InstanceType; export type NativeWorker = InstanceType; -export type { EnqueueOptions, JsJob, JsTaskInvocation, WorkerOptions } from "../native/index"; +export type { + EnqueueOptions, + JsJob, + JsTaskInvocation, + OpenOptions, + QueueConfigInput, + TaskConfigInput, + WorkerOptions, +} from "../native/index"; diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index 6debc0de..30ae4bf6 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -1,39 +1,72 @@ -import { JsQueue, type NativeQueue } from "./native"; +import { TaskitoError } from "./errors"; +import { JsQueue, type NativeQueue, type OpenOptions } from "./native"; import { JsonSerializer, type Serializer } from "./serializers"; -import type { EnqueueOptions, Job, TaskHandler, WorkerOptions } from "./types"; +import type { + EnqueueOptions, + Job, + QueueLimits, + RegisteredTask, + TaskHandler, + TaskOptions, + WorkerRunOptions, +} from "./types"; import { Worker } from "./worker"; /** Construction options for a {@link Queue}. */ export interface QueueOptions { - /** Path to the SQLite database file (created if missing). */ - dbPath: string; + /** 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. + * 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 tasks = new Map(); + private readonly queueLimits = new Map(); constructor(options: QueueOptions) { - this.native = new JsQueue(options.dbPath); + this.native = JsQueue.open(toOpenOptions(options)); this.serializer = options.serializer ?? new JsonSerializer(); } - /** Register a task handler under `name`. */ - task(name: string, handler: TaskHandler): void { - this.tasks.set(name, handler); + /** 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, options); + return this.native.enqueue(name, payload, merged); } /** Fetch a job by id, or `null` if unknown. */ @@ -51,7 +84,28 @@ export class Queue { } /** Start a worker that runs the registered tasks. Hold the returned {@link Worker}. */ - runWorker(options?: WorkerOptions): Worker { - return Worker.start(this.native, this.tasks, this.serializer, options); + 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/types.ts b/sdks/node/src/types.ts index bc338b18..c9003d81 100644 --- a/sdks/node/src/types.ts +++ b/sdks/node/src/types.ts @@ -1,4 +1,4 @@ -export type { EnqueueOptions, JsJob as Job, WorkerOptions } from "./native"; +export type { EnqueueOptions, JsJob as Job } from "./native"; /** * A registered task: receives the deserialized positional args and returns a @@ -7,3 +7,39 @@ export type { EnqueueOptions, JsJob as Job, WorkerOptions } from "./native"; 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 index 213061e2..809aab54 100644 --- a/sdks/node/src/worker.ts +++ b/sdks/node/src/worker.ts @@ -1,7 +1,22 @@ import { TaskNotRegisteredError } from "./errors"; -import type { JsTaskInvocation, NativeQueue, NativeWorker } from "./native"; +import type { + JsTaskInvocation, + NativeQueue, + NativeWorker, + WorkerOptions as NativeWorkerOptions, + QueueConfigInput, + TaskConfigInput, +} from "./native"; import type { Serializer } from "./serializers"; -import type { TaskHandler, WorkerOptions } from "./types"; +import type { QueueLimits, RegisteredTask, WorkerRunOptions } from "./types"; + +/** 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 { @@ -13,22 +28,26 @@ export class Worker { * * @internal */ - static start( - queue: NativeQueue, - tasks: ReadonlyMap, - serializer: Serializer, - options?: WorkerOptions, - ): Worker { + static start(queue: NativeQueue, params: WorkerStartParams): Worker { + const { tasks, queueLimits, serializer, run } = params; const callback = async (invocation: JsTaskInvocation): Promise => { - const handler = tasks.get(invocation.taskName); - if (!handler) { + const task = tasks.get(invocation.taskName); + if (!task) { throw new TaskNotRegisteredError(invocation.taskName); } const args = serializer.deserialize(invocation.payload) as unknown[]; - const result = await handler(...args); + const result = await task.handler(...args); return Buffer.from(serializer.serialize(result)); }; - return new Worker(queue.runWorker(callback, options)); + + 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. */ @@ -36,3 +55,37 @@ export class Worker { 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, + })); +} From cdce79299996633da6fc15fa527261f7b2927122 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:50 +0530 Subject: [PATCH 10/30] test(node): delay, idempotency, retry, timeout --- sdks/node/test/options.test.ts | 86 ++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 sdks/node/test/options.test.ts 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"); +}); From 9e5544996ad30d6acd933b8c30d04940084686f9 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:50 +0530 Subject: [PATCH 11/30] feat(node): cancellation-aware dispatch + cancel/progress Dispatcher reads the cancel flag and reports Cancelled (not Failure) when a rejected task was cancel-requested. JsQueue gains cancelJob/requestCancel/isCancelRequested/updateProgress. --- crates/taskito-node/src/dispatcher.rs | 25 ++++++++++++++++++++----- crates/taskito-node/src/queue.rs | 27 +++++++++++++++++++++++++++ crates/taskito-node/src/worker.rs | 5 ++++- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/taskito-node/src/dispatcher.rs b/crates/taskito-node/src/dispatcher.rs index 85b6ec39..41367f00 100644 --- a/crates/taskito-node/src/dispatcher.rs +++ b/crates/taskito-node/src/dispatcher.rs @@ -13,6 +13,7 @@ use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFun 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; @@ -23,11 +24,12 @@ 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) -> Self { - Self { callback } + pub fn new(callback: TaskCallback, storage: StorageBackend) -> Self { + Self { callback, storage } } } @@ -43,9 +45,10 @@ impl WorkerDispatcher for NodeDispatcher { // 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, job).await; + let result = run_one(&callback, &storage, job).await; let _ = result_tx.send(result); }); } @@ -55,7 +58,7 @@ impl WorkerDispatcher for NodeDispatcher { } /// Invoke the JS task for one job and translate the outcome into a [`JobResult`]. -async fn run_one(callback: &TaskCallback, job: Job) -> JobResult { +async fn run_one(callback: &TaskCallback, storage: &StorageBackend, job: Job) -> JobResult { let started = Instant::now(); let invocation = JsTaskInvocation { id: job.id.clone(), @@ -93,7 +96,19 @@ async fn run_one(callback: &TaskCallback, job: Job) -> JobResult { task_name: job.task_name, wall_time_ns, }, - Ok(Ok(Err(err))) => failure(job, err.to_string(), wall_time_ns, false), + 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(), diff --git a/crates/taskito-node/src/queue.rs b/crates/taskito-node/src/queue.rs index 0ae2a24a..40b85337 100644 --- a/crates/taskito-node/src/queue.rs +++ b/crates/taskito-node/src/queue.rs @@ -56,6 +56,33 @@ impl JsQueue { 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] diff --git a/crates/taskito-node/src/worker.rs b/crates/taskito-node/src/worker.rs index 873bc862..542f76f3 100644 --- a/crates/taskito-node/src/worker.rs +++ b/crates/taskito-node/src/worker.rs @@ -55,6 +55,9 @@ pub fn start_worker( 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); @@ -77,7 +80,7 @@ pub fn start_worker( }); // Dispatcher loop: execute each job in JS, report results on `result_tx`. - let dispatcher = NodeDispatcher::new(callback); + let dispatcher = NodeDispatcher::new(callback, dispatcher_storage); spawn(async move { dispatcher.run(job_rx, result_tx).await; }); From 2098da298f889f5277b4f13570c3beec90b3c24c Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:51 +0530 Subject: [PATCH 12/30] feat(node): cooperative cancellation + job context AsyncLocalStorage job context (jobId, AbortSignal, setProgress) via currentJob(); the worker drives the signal by polling the cancel flag, and exposes cancel methods on Queue. --- sdks/node/src/context.ts | 26 ++++++++++++++++++++++++++ sdks/node/src/index.ts | 1 + sdks/node/src/queue.ts | 15 +++++++++++++++ sdks/node/src/worker.ts | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 sdks/node/src/context.ts 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/index.ts b/sdks/node/src/index.ts index 543b3fec..a0c6588e 100644 --- a/sdks/node/src/index.ts +++ b/sdks/node/src/index.ts @@ -1,3 +1,4 @@ +export { currentJob, type JobContext } from "./context"; export { TaskitoError, TaskNotRegisteredError } from "./errors"; export { Queue, type QueueOptions } from "./queue"; export { JsonSerializer, type Serializer } from "./serializers"; diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index 30ae4bf6..f38ec918 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -83,6 +83,21 @@ export class Queue { 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); + } + /** Start a worker that runs the registered tasks. Hold the returned {@link Worker}. */ runWorker(options?: WorkerRunOptions): Worker { return Worker.start(this.native, { diff --git a/sdks/node/src/worker.ts b/sdks/node/src/worker.ts index 809aab54..3fbe81b6 100644 --- a/sdks/node/src/worker.ts +++ b/sdks/node/src/worker.ts @@ -1,3 +1,4 @@ +import { type JobContext, runInContext } from "./context"; import { TaskNotRegisteredError } from "./errors"; import type { JsTaskInvocation, @@ -10,6 +11,9 @@ import type { 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; @@ -36,8 +40,32 @@ export class Worker { throw new TaskNotRegisteredError(invocation.taskName); } const args = serializer.deserialize(invocation.payload) as unknown[]; - const result = await task.handler(...args); - return Buffer.from(serializer.serialize(result)); + + // 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 = { From dd49bc544a5c80e77f1ac2fc78e8de83f6be26d0 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:51 +0530 Subject: [PATCH 13/30] test(node): cooperative cancellation --- sdks/node/test/cancel.test.ts | 56 +++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 sdks/node/test/cancel.test.ts 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); +}); From 5a585554e5a168eb6aa49588477bbb6a41b965a5 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:51 +0530 Subject: [PATCH 14/30] feat(node): inspection + management methods Split JsQueue across queue/{mod,inspect,admin}.rs (multiple #[napi] impl blocks). Inspect: stats(3), listJobs, getJobErrors, getMetrics. Manage: dead-letters (list/retry/delete/purge), pauseQueue/resumeQueue/listPausedQueues, purgeCompleted. convert/stats.rs maps the return shapes. --- crates/taskito-node/src/config.rs | 12 ++ crates/taskito-node/src/convert/mod.rs | 5 + crates/taskito-node/src/convert/stats.rs | 110 ++++++++++++++++++ crates/taskito-node/src/queue/admin.rs | 72 ++++++++++++ crates/taskito-node/src/queue/inspect.rs | 77 ++++++++++++ .../src/{queue.rs => queue/mod.rs} | 3 + 6 files changed, 279 insertions(+) create mode 100644 crates/taskito-node/src/convert/stats.rs create mode 100644 crates/taskito-node/src/queue/admin.rs create mode 100644 crates/taskito-node/src/queue/inspect.rs rename crates/taskito-node/src/{queue.rs => queue/mod.rs} (99%) diff --git a/crates/taskito-node/src/config.rs b/crates/taskito-node/src/config.rs index 64e9a2ec..a74d42c3 100644 --- a/crates/taskito-node/src/config.rs +++ b/crates/taskito-node/src/config.rs @@ -41,6 +41,18 @@ pub struct EnqueueOptions { 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 { diff --git a/crates/taskito-node/src/convert/mod.rs b/crates/taskito-node/src/convert/mod.rs index 273539bd..4b8d8837 100644 --- a/crates/taskito-node/src/convert/mod.rs +++ b/crates/taskito-node/src/convert/mod.rs @@ -2,7 +2,12 @@ //! 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/queue/admin.rs b/crates/taskito-node/src/queue/admin.rs new file mode 100644 index 00000000..e75a77ba --- /dev/null +++ b/crates/taskito-node/src/queue/admin.rs @@ -0,0 +1,72 @@ +//! 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::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 dead = self + .storage + .list_dead(limit.unwrap_or(DEFAULT_LIMIT), offset.unwrap_or(0)) + .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 { + 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 { + 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..bde11c76 --- /dev/null +++ b/crates/taskito-node/src/queue/inspect.rs @@ -0,0 +1,77 @@ +//! 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::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(); + let jobs = self + .storage + .list_jobs( + filter.status.as_deref().and_then(status_code), + filter.queue.as_deref(), + filter.task.as_deref(), + filter.limit.unwrap_or(DEFAULT_LIMIT), + filter.offset.unwrap_or(0), + 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.rs b/crates/taskito-node/src/queue/mod.rs similarity index 99% rename from crates/taskito-node/src/queue.rs rename to crates/taskito-node/src/queue/mod.rs index 40b85337..5f4f8009 100644 --- a/crates/taskito-node/src/queue.rs +++ b/crates/taskito-node/src/queue/mod.rs @@ -10,6 +10,9 @@ 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 { From 2944c52416f81136e2a8d5a6ba2cab8c054414d2 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:52 +0530 Subject: [PATCH 15/30] feat(node): inspection/management api + result waiter Queue surfaces stats/listJobs/getJobErrors/getMetrics, dead-letter + pause/resume + purge, and an async result(id,{timeoutMs}) waiter (JobFailedError/JobCancelledError). --- sdks/node/src/errors.ts | 19 ++++++++ sdks/node/src/index.ts | 13 ++++- sdks/node/src/native.ts | 5 ++ sdks/node/src/queue.ts | 105 +++++++++++++++++++++++++++++++++++++++- sdks/node/src/types.ts | 18 ++++++- 5 files changed, 157 insertions(+), 3 deletions(-) diff --git a/sdks/node/src/errors.ts b/sdks/node/src/errors.ts index 33989640..7763053b 100644 --- a/sdks/node/src/errors.ts +++ b/sdks/node/src/errors.ts @@ -13,3 +13,22 @@ export class TaskNotRegisteredError extends TaskitoError { 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 index a0c6588e..ff34258d 100644 --- a/sdks/node/src/index.ts +++ b/sdks/node/src/index.ts @@ -1,11 +1,22 @@ export { currentJob, type JobContext } from "./context"; -export { TaskitoError, TaskNotRegisteredError } from "./errors"; +export { + JobCancelledError, + JobFailedError, + TaskitoError, + TaskNotRegisteredError, +} from "./errors"; export { Queue, type QueueOptions } from "./queue"; export { JsonSerializer, type Serializer } from "./serializers"; export type { + DeadJob, EnqueueOptions, Job, + JobError, + JobFilter, + Metric, QueueLimits, + ResultOptions, + Stats, TaskHandler, TaskOptions, WorkerRunOptions, diff --git a/sdks/node/src/native.ts b/sdks/node/src/native.ts index 06885e1f..2d914ffc 100644 --- a/sdks/node/src/native.ts +++ b/sdks/node/src/native.ts @@ -17,7 +17,12 @@ export type NativeWorker = InstanceType; export type { EnqueueOptions, + JobFilter, + JsDeadJob, JsJob, + JsJobError, + JsMetric, + JsStats, JsTaskInvocation, OpenOptions, QueueConfigInput, diff --git a/sdks/node/src/queue.ts b/sdks/node/src/queue.ts index f38ec918..b292b11f 100644 --- a/sdks/node/src/queue.ts +++ b/sdks/node/src/queue.ts @@ -1,11 +1,17 @@ -import { TaskitoError } from "./errors"; +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, @@ -98,6 +104,103 @@ export class Queue { 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, { diff --git a/sdks/node/src/types.ts b/sdks/node/src/types.ts index c9003d81..4af6349e 100644 --- a/sdks/node/src/types.ts +++ b/sdks/node/src/types.ts @@ -1,4 +1,20 @@ -export type { EnqueueOptions, JsJob as Job } from "./native"; +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 From 4215f33442a2a1d291a688ce02963f65869df034 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:52 +0530 Subject: [PATCH 16/30] test(node): stats, listing, dead-letter, pause/resume --- sdks/node/test/inspection.test.ts | 86 +++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 sdks/node/test/inspection.test.ts 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 []; +} From 5c070e4de5782224afe9f8ab7b8ace9d88336eff Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:52 +0530 Subject: [PATCH 17/30] feat(node): msgpack serializer MsgpackSerializer (compact binary) alongside JsonSerializer; pluggable via Queue({ serializer }). --- sdks/node/package.json | 3 +++ sdks/node/pnpm-lock.yaml | 10 ++++++++++ sdks/node/src/index.ts | 2 +- sdks/node/src/serializers/index.ts | 1 + sdks/node/src/serializers/msgpack.ts | 16 ++++++++++++++++ 5 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 sdks/node/src/serializers/msgpack.ts diff --git a/sdks/node/package.json b/sdks/node/package.json index a34f08bc..1c2cc475 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -58,5 +58,8 @@ "tsup": "^8.5.0", "typescript": "^6.0.3", "vitest": "^4.1.8" + }, + "dependencies": { + "@msgpack/msgpack": "^3.1.3" } } diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index c58ebd42..abe9807c 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + '@msgpack/msgpack': + specifier: ^3.1.3 + version: 3.1.3 devDependencies: '@biomejs/biome': specifier: ^2.4.16 @@ -264,6 +268,10 @@ packages: '@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'} @@ -1142,6 +1150,8 @@ snapshots: '@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)': diff --git a/sdks/node/src/index.ts b/sdks/node/src/index.ts index ff34258d..18c0aa74 100644 --- a/sdks/node/src/index.ts +++ b/sdks/node/src/index.ts @@ -6,7 +6,7 @@ export { TaskNotRegisteredError, } from "./errors"; export { Queue, type QueueOptions } from "./queue"; -export { JsonSerializer, type Serializer } from "./serializers"; +export { JsonSerializer, MsgpackSerializer, type Serializer } from "./serializers"; export type { DeadJob, EnqueueOptions, diff --git a/sdks/node/src/serializers/index.ts b/sdks/node/src/serializers/index.ts index f9f86ba5..b9603b3e 100644 --- a/sdks/node/src/serializers/index.ts +++ b/sdks/node/src/serializers/index.ts @@ -1,2 +1,3 @@ export { JsonSerializer } from "./json"; +export { MsgpackSerializer } from "./msgpack"; export type { Serializer } from "./serializer"; 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); + } +} From 81de66b9cb035fca418833f0ccc41f3ed6c35893 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:53 +0530 Subject: [PATCH 18/30] test(node): msgpack round-trip + e2e --- sdks/node/test/serializer.test.ts | 33 +++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/sdks/node/test/serializer.test.ts b/sdks/node/test/serializer.test.ts index 3f8b3abf..f7eaa650 100644 --- a/sdks/node/test/serializer.test.ts +++ b/sdks/node/test/serializer.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { JsonSerializer } from "../src/index"; +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", () => { @@ -13,3 +16,29 @@ describe("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"); + }); +}); From d04794f30185722127dc603adf184146c47784cf Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:53 +0530 Subject: [PATCH 19/30] docs(node): document the full SDK surface --- sdks/node/README.md | 106 +++++++++++++++++++++++++++++++++----------- 1 file changed, 79 insertions(+), 27 deletions(-) diff --git a/sdks/node/README.md b/sdks/node/README.md index 20d65daa..5fe23297 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -2,12 +2,8 @@ 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. SQLite-backed; enqueue work and run workers in the same process or across -processes sharing the database. - -> **Status: early.** This is the first vertical slice — enqueue, a worker that -> runs JavaScript tasks, and result read-back over SQLite with a JSON -> serializer. See [Roadmap](#roadmap) for what is intentionally not here yet. +SDK. Enqueue work and run workers in the same process or across processes that +share storage (SQLite, PostgreSQL, or Redis). ## Install @@ -15,7 +11,7 @@ processes sharing the database. pnpm add taskito ``` -Requires Node.js >= 18. +Requires Node.js >= 18. Ships as dual ESM + CommonJS. ## Quickstart @@ -24,50 +20,106 @@ import { Queue } from "taskito"; const queue = new Queue({ dbPath: "taskito.db" }); -// Register a task (runs on the worker side). -queue.task("add", (a: number, b: number) => a + b); +// 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: enqueue work. -const id = queue.enqueue("add", [2, 3]); +// Producer. +const id = queue.enqueue("add", [2, 3], { priority: 5 }); -// Worker: process jobs. Tasks may be async. +// Worker. const worker = queue.runWorker({ queues: ["default"] }); -// Read the result back. -const result = queue.getResult(id); // 5 (once complete) - +// Await the result. +const result = await queue.result(id); // 5 worker.stop(); ``` -## Serialization +## Backends -Args and results are serialized with a pluggable `Serializer` (default -`JsonSerializer`). The Rust core treats payloads as opaque bytes. Cross-language -jobs (e.g. Python ⇄ Node) require both sides to use a matching neutral -serializer — JSON today, msgpack later. +```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 { Queue, type Serializer } from "taskito"; +import { currentJob } from "taskito"; -const queue = new Queue({ dbPath: "taskito.db", serializer: myMsgpackSerializer }); +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() }); ``` ## Development ```bash pnpm install -pnpm build # napi build (native addon) + tsc (dist/) +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. +`native/` and wraps it with a typed TypeScript API. Postgres/Redis backends are +compiled in via `--features postgres,redis`. -## Roadmap +## Not yet covered -Deferred from this slice: PostgreSQL/Redis backends, workflows, mesh, -middleware/events, retry/rate-limit/concurrency configuration, cancellation, +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. From 88c629e471fae96877a735525034003cf8a86c0e Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:53 +0530 Subject: [PATCH 20/30] feat(node): standalone cli (run, enqueue, inspect, manage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit taskito bin over JsQueue: enqueue, stats, jobs, dlq (list/retry/delete/purge), pause/resume/paused, cancel, and run (loads a user app module and runs its worker). commander; tsup builds a second cli entry with a preserved shebang; --json for machine output. Fully standalone — no Python. --- sdks/node/package.json | 6 ++- sdks/node/pnpm-lock.yaml | 9 +++++ sdks/node/src/cli/commands/cancel.ts | 14 +++++++ sdks/node/src/cli/commands/dlq.ts | 58 +++++++++++++++++++++++++++ sdks/node/src/cli/commands/enqueue.ts | 40 ++++++++++++++++++ sdks/node/src/cli/commands/jobs.ts | 46 +++++++++++++++++++++ sdks/node/src/cli/commands/queues.ts | 31 ++++++++++++++ sdks/node/src/cli/commands/run.ts | 53 ++++++++++++++++++++++++ sdks/node/src/cli/commands/stats.ts | 20 +++++++++ sdks/node/src/cli/connect.ts | 27 +++++++++++++ sdks/node/src/cli/index.ts | 37 +++++++++++++++++ sdks/node/src/cli/output.ts | 25 ++++++++++++ sdks/node/tsup.config.ts | 2 +- 13 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 sdks/node/src/cli/commands/cancel.ts create mode 100644 sdks/node/src/cli/commands/dlq.ts create mode 100644 sdks/node/src/cli/commands/enqueue.ts create mode 100644 sdks/node/src/cli/commands/jobs.ts create mode 100644 sdks/node/src/cli/commands/queues.ts create mode 100644 sdks/node/src/cli/commands/run.ts create mode 100644 sdks/node/src/cli/commands/stats.ts create mode 100644 sdks/node/src/cli/connect.ts create mode 100644 sdks/node/src/cli/index.ts create mode 100644 sdks/node/src/cli/output.ts diff --git a/sdks/node/package.json b/sdks/node/package.json index 1c2cc475..fd3bfc73 100644 --- a/sdks/node/package.json +++ b/sdks/node/package.json @@ -11,6 +11,9 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "taskito": "./dist/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -60,6 +63,7 @@ "vitest": "^4.1.8" }, "dependencies": { - "@msgpack/msgpack": "^3.1.3" + "@msgpack/msgpack": "^3.1.3", + "commander": "^15.0.0" } } diff --git a/sdks/node/pnpm-lock.yaml b/sdks/node/pnpm-lock.yaml index abe9807c..e7d1dd9f 100644 --- a/sdks/node/pnpm-lock.yaml +++ b/sdks/node/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@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 @@ -599,6 +602,10 @@ packages: 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'} @@ -1369,6 +1376,8 @@ snapshots: dependencies: readdirp: 4.1.2 + commander@15.0.0: {} + commander@4.1.1: {} confbox@0.1.8: {} diff --git a/sdks/node/src/cli/commands/cancel.ts b/sdks/node/src/cli/commands/cancel.ts new file mode 100644 index 00000000..1622e9a4 --- /dev/null +++ b/sdks/node/src/cli/commands/cancel.ts @@ -0,0 +1,14 @@ +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 queue = connect(command.optsWithGlobals() as GlobalOptions); + const cancelled = queue.cancelJob(id) || queue.requestCancel(id); + printJson({ id, cancelled }); + }); +} diff --git a/sdks/node/src/cli/commands/dlq.ts b/sdks/node/src/cli/commands/dlq.ts new file mode 100644 index 00000000..0113b0c0 --- /dev/null +++ b/sdks/node/src/cli/commands/dlq.ts @@ -0,0 +1,58 @@ +import type { Command } from "commander"; +import { connect, type GlobalOptions } from "../connect"; +import { printJson, printTable } from "../output"; + +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( + options.limit ? Number(options.limit) : undefined, + options.offset ? Number(options.offset) : undefined, + ); + 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); + printJson({ purged: queue.purgeDead(Number(options.olderThanMs ?? 0)) }); + }); +} diff --git a/sdks/node/src/cli/commands/enqueue.ts b/sdks/node/src/cli/commands/enqueue.ts new file mode 100644 index 00000000..0cc65714 --- /dev/null +++ b/sdks/node/src/cli/commands/enqueue.ts @@ -0,0 +1,40 @@ +import type { Command } from "commander"; +import { connect, type GlobalOptions } from "../connect"; +import { printJson } from "../output"; + +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 = argsJson ? (JSON.parse(argsJson) as unknown[]) : []; + const id = queue.enqueue(task, args, { + queue: options.queue, + priority: numeric(options.priority), + maxRetries: numeric(options.maxRetries), + delayMs: numeric(options.delayMs), + uniqueKey: options.uniqueKey, + }); + printJson({ id }); + }, + ); +} + +function numeric(value: string | undefined): number | undefined { + return value === undefined ? undefined : Number(value); +} diff --git a/sdks/node/src/cli/commands/jobs.ts b/sdks/node/src/cli/commands/jobs.ts new file mode 100644 index 00000000..4f8683f0 --- /dev/null +++ b/sdks/node/src/cli/commands/jobs.ts @@ -0,0 +1,46 @@ +import type { Command } from "commander"; +import { connect, type GlobalOptions } from "../connect"; +import { printJson, printTable } from "../output"; + +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: options.limit ? Number(options.limit) : undefined, + offset: options.offset ? Number(options.offset) : undefined, + }); + 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..297aecc1 --- /dev/null +++ b/sdks/node/src/cli/commands/run.ts @@ -0,0 +1,53 @@ +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { Command } from "commander"; +import type { Worker } from "../../index"; + +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: options.batchSize ? Number(options.batchSize) : undefined, + }); + + process.stdout.write( + `taskito worker running (queues: ${queues?.join(",") ?? "default"}) — Ctrl-C to stop\n`, + ); + const stop = () => { + worker.stop(); + 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..2a3f0102 --- /dev/null +++ b/sdks/node/src/cli/connect.ts @@ -0,0 +1,27 @@ +import { Queue, type QueueOptions } from "../index"; + +/** 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: options.poolSize ? Number(options.poolSize) : undefined, + 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..95ab4066 --- /dev/null +++ b/sdks/node/src/cli/index.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import { registerCancel } from "./commands/cancel"; +import { registerDlq } from "./commands/dlq"; +import { registerEnqueue } from "./commands/enqueue"; +import { registerJobs } from "./commands/jobs"; +import { registerQueues } from "./commands/queues"; +import { registerRun } from "./commands/run"; +import { registerStats } from "./commands/stats"; + +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/tsup.config.ts b/sdks/node/tsup.config.ts index 7121edab..e1ac2b62 100644 --- a/sdks/node/tsup.config.ts +++ b/sdks/node/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: { index: "src/index.ts", cli: "src/cli/index.ts" }, format: ["esm", "cjs"], dts: true, shims: true, From 745a114e782c64f79dbb7057f6d3236efce5054b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:54 +0530 Subject: [PATCH 21/30] test(node): cli integration (spawn bin) --- sdks/node/test/cli.test.ts | 90 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 sdks/node/test/cli.test.ts diff --git a/sdks/node/test/cli.test.ts b/sdks/node/test/cli.test.ts new file mode 100644 index 00000000..0e881ef3 --- /dev/null +++ b/sdks/node/test/cli.test.ts @@ -0,0 +1,90 @@ +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"); + writeFileSync( + app, + [ + `import { Queue } from ${JSON.stringify(indexUrl)};`, + `import { writeFileSync } from "node:fs";`, + `const queue = new Queue({ dbPath: ${JSON.stringify(db)} });`, + `queue.task("mark", () => { writeFileSync(${JSON.stringify(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" }); + 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; +} From 058ca3244e9dddb288f426177ffc28d0f457711f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:54 +0530 Subject: [PATCH 22/30] refactor(node): cli/commands barrel --- sdks/node/src/cli/commands/index.ts | 7 +++++++ sdks/node/src/cli/index.ts | 16 +++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 sdks/node/src/cli/commands/index.ts 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/index.ts b/sdks/node/src/cli/index.ts index 95ab4066..91178f47 100644 --- a/sdks/node/src/cli/index.ts +++ b/sdks/node/src/cli/index.ts @@ -1,12 +1,14 @@ #!/usr/bin/env node import { Command } from "commander"; -import { registerCancel } from "./commands/cancel"; -import { registerDlq } from "./commands/dlq"; -import { registerEnqueue } from "./commands/enqueue"; -import { registerJobs } from "./commands/jobs"; -import { registerQueues } from "./commands/queues"; -import { registerRun } from "./commands/run"; -import { registerStats } from "./commands/stats"; +import { + registerCancel, + registerDlq, + registerEnqueue, + registerJobs, + registerQueues, + registerRun, + registerStats, +} from "./commands"; const program = new Command(); From 50c7442e8464253640cea822bd382769538ea6bf Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:54 +0530 Subject: [PATCH 23/30] docs(node): document the cli --- sdks/node/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/sdks/node/README.md b/sdks/node/README.md index 5fe23297..eabdbddc 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -104,6 +104,26 @@ 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 From 6c2ec4efae07a4038b0d8bce1bff1247dcc681cc Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:55 +0530 Subject: [PATCH 24/30] chore(node): use local biome schema path --- sdks/node/biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdks/node/biome.json b/sdks/node/biome.json index 236bd979..5bb6bbe5 100644 --- a/sdks/node/biome.json +++ b/sdks/node/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { "includes": ["src/**/*.ts", "test/**/*.ts"] }, From 57f6f8ed9027c73b7a0563d47aecc5d20da83fc5 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:40:05 +0530 Subject: [PATCH 25/30] test(node): pass cli app paths via env, not code interpolation --- sdks/node/test/cli.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/sdks/node/test/cli.test.ts b/sdks/node/test/cli.test.ts index 0e881ef3..4d0cd43c 100644 --- a/sdks/node/test/cli.test.ts +++ b/sdks/node/test/cli.test.ts @@ -56,13 +56,15 @@ describe("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, [ - `import { Queue } from ${JSON.stringify(indexUrl)};`, - `import { writeFileSync } from "node:fs";`, - `const queue = new Queue({ dbPath: ${JSON.stringify(db)} });`, - `queue.task("mark", () => { writeFileSync(${JSON.stringify(marker)}, "ok"); return "ok"; });`, + `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"), @@ -70,7 +72,10 @@ describe("taskito CLI", () => { let child: ChildProcess | undefined; try { - child = spawn(process.execPath, [cliPath, "run", app], { stdio: "ignore" }); + 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"); From 04aa22635517f62543dc21fbfc610e9fb147bd37 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:53:41 +0530 Subject: [PATCH 26/30] feat(node): harden N-API input validation Reject invalid caller input at the binding boundary instead of forwarding it: zero pool size (r2d2 panic), negative enqueue options, negative pagination, unknown status filters, zero channel capacity, and malformed rate limits. Saturate the delay schedule against overflow. --- crates/taskito-node/src/backend.rs | 16 ++++++++-- crates/taskito-node/src/convert/job.rs | 28 +++++++++++++----- .../taskito-node/src/convert/task_config.rs | 29 ++++++++++++++----- crates/taskito-node/src/error.rs | 15 ++++++++++ crates/taskito-node/src/queue/admin.rs | 11 +++---- crates/taskito-node/src/queue/inspect.rs | 19 +++++++++--- crates/taskito-node/src/queue/mod.rs | 4 +-- crates/taskito-node/src/worker.rs | 14 +++++---- 8 files changed, 100 insertions(+), 36 deletions(-) diff --git a/crates/taskito-node/src/backend.rs b/crates/taskito-node/src/backend.rs index d50ec807..656f03fa 100644 --- a/crates/taskito-node/src/backend.rs +++ b/crates/taskito-node/src/backend.rs @@ -4,7 +4,7 @@ use napi::bindgen_prelude::{Error, Result, Status}; use taskito_core::{SqliteStorage, StorageBackend}; use crate::config::OpenOptions; -use crate::error::to_napi_err; +use crate::error::{invalid_arg, to_napi_err}; const DEFAULT_SQLITE_POOL: u32 = 8; #[cfg(feature = "postgres")] @@ -12,19 +12,29 @@ 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 = options.pool_size.unwrap_or(DEFAULT_SQLITE_POOL); + 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 = options.pool_size.unwrap_or(DEFAULT_POSTGRES_POOL); + 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)?; diff --git a/crates/taskito-node/src/convert/job.rs b/crates/taskito-node/src/convert/job.rs index 0512c083..ec3f3343 100644 --- a/crates/taskito-node/src/convert/job.rs +++ b/crates/taskito-node/src/convert/job.rs @@ -1,12 +1,13 @@ //! 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; +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; @@ -21,16 +22,27 @@ pub fn build_new_job( payload: Vec, opts: EnqueueOptions, queue_namespace: Option<&str>, -) -> NewJob { - let delay = opts.delay_ms.unwrap_or(0).max(0); - NewJob { +) -> 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), - scheduled_at: now_millis() + delay, - max_retries: opts.max_retries.unwrap_or(DEFAULT_MAX_RETRIES), - timeout_ms: opts.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS), + // 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, @@ -40,7 +52,7 @@ pub fn build_new_job( namespace: opts .namespace .or_else(|| queue_namespace.map(str::to_string)), - } + }) } /// Passed to the JS task callback for each dispatched job. `payload` is the diff --git a/crates/taskito-node/src/convert/task_config.rs b/crates/taskito-node/src/convert/task_config.rs index 65a00440..9961dac6 100644 --- a/crates/taskito-node/src/convert/task_config.rs +++ b/crates/taskito-node/src/convert/task_config.rs @@ -1,34 +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) -> TaskConfig { - TaskConfig { +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: input.rate_limit.as_deref().and_then(RateLimitConfig::parse), + 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) -> QueueConfig { - QueueConfig { - rate_limit: input.rate_limit.as_deref().and_then(RateLimitConfig::parse), +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/error.rs b/crates/taskito-node/src/error.rs index 4984b178..42882f53 100644 --- a/crates/taskito-node/src/error.rs +++ b/crates/taskito-node/src/error.rs @@ -7,3 +7,18 @@ use taskito_core::QueueError; 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/queue/admin.rs b/crates/taskito-node/src/queue/admin.rs index e75a77ba..c6973c38 100644 --- a/crates/taskito-node/src/queue/admin.rs +++ b/crates/taskito-node/src/queue/admin.rs @@ -6,7 +6,7 @@ use taskito_core::Storage; use super::JsQueue; use crate::convert::{dead_job_to_js, JsDeadJob}; -use crate::error::to_napi_err; +use crate::error::{non_negative, to_napi_err}; const DEFAULT_LIMIT: i64 = 50; @@ -15,10 +15,9 @@ impl JsQueue { /// List dead-letter entries (paginated). #[napi] pub fn dead_letters(&self, limit: Option, offset: Option) -> Result> { - let dead = self - .storage - .list_dead(limit.unwrap_or(DEFAULT_LIMIT), offset.unwrap_or(0)) - .map_err(to_napi_err)?; + 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()) } @@ -37,6 +36,7 @@ impl JsQueue { /// 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) @@ -46,6 +46,7 @@ impl JsQueue { /// 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) diff --git a/crates/taskito-node/src/queue/inspect.rs b/crates/taskito-node/src/queue/inspect.rs index bde11c76..8f8651c8 100644 --- a/crates/taskito-node/src/queue/inspect.rs +++ b/crates/taskito-node/src/queue/inspect.rs @@ -12,7 +12,7 @@ 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::to_napi_err; +use crate::error::{invalid_arg, non_negative, to_napi_err}; const DEFAULT_LIMIT: i64 = 50; @@ -44,14 +44,25 @@ impl JsQueue { #[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( - filter.status.as_deref().and_then(status_code), + status, filter.queue.as_deref(), filter.task.as_deref(), - filter.limit.unwrap_or(DEFAULT_LIMIT), - filter.offset.unwrap_or(0), + limit, + offset, self.namespace.as_deref(), ) .map_err(to_napi_err)?; diff --git a/crates/taskito-node/src/queue/mod.rs b/crates/taskito-node/src/queue/mod.rs index 5f4f8009..19d8e76c 100644 --- a/crates/taskito-node/src/queue/mod.rs +++ b/crates/taskito-node/src/queue/mod.rs @@ -42,7 +42,7 @@ impl JsQueue { ) -> 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 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 { @@ -93,7 +93,7 @@ impl JsQueue { &self, callback: ThreadsafeFunction, options: Option, - ) -> JsWorker { + ) -> Result { start_worker( self.storage.clone(), self.namespace.clone(), diff --git a/crates/taskito-node/src/worker.rs b/crates/taskito-node/src/worker.rs index 542f76f3..c028f8ef 100644 --- a/crates/taskito-node/src/worker.rs +++ b/crates/taskito-node/src/worker.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use napi::bindgen_prelude::{spawn, spawn_blocking}; +use napi::bindgen_prelude::{spawn, spawn_blocking, Result}; use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction}; use napi_derive::napi; use taskito_core::worker::WorkerDispatcher; @@ -36,18 +36,20 @@ impl JsWorker { } /// 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, -) -> JsWorker { +) -> 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) + .map(|c| (c as usize).max(1)) .unwrap_or(DEFAULT_CHANNEL_CAPACITY); let mut config = SchedulerConfig::default(); @@ -62,10 +64,10 @@ pub fn start_worker( // (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)); + 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)); + scheduler.register_queue_config(input.name.clone(), crate::convert::queue_config(input)?); } let scheduler = Arc::new(scheduler); let shutdown = scheduler.shutdown_handle(); @@ -97,5 +99,5 @@ pub fn start_worker( } }); - JsWorker { shutdown } + Ok(JsWorker { shutdown }) } From ab8e8fe47bf8027dd8ad4341f2b7da051aa44d3b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:53:54 +0530 Subject: [PATCH 27/30] docs(node): note JS task timeout cannot force-kill --- crates/taskito-node/src/dispatcher.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/taskito-node/src/dispatcher.rs b/crates/taskito-node/src/dispatcher.rs index 41367f00..873a126d 100644 --- a/crates/taskito-node/src/dispatcher.rs +++ b/crates/taskito-node/src/dispatcher.rs @@ -115,6 +115,10 @@ async fn run_one(callback: &TaskCallback, storage: &StorageBackend, job: Job) -> 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), } } From 0dedcdfe19c6095dd4a1122fdb75c159e027dd2e Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:53:56 +0530 Subject: [PATCH 28/30] feat(node): validate CLI flags and args; drain on run shutdown --- sdks/node/src/cli/commands/dlq.ts | 8 +++-- sdks/node/src/cli/commands/enqueue.ts | 13 +++----- sdks/node/src/cli/commands/jobs.ts | 5 +-- sdks/node/src/cli/commands/run.ts | 11 +++++-- sdks/node/src/cli/connect.ts | 3 +- sdks/node/src/cli/parse.ts | 45 +++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 sdks/node/src/cli/parse.ts diff --git a/sdks/node/src/cli/commands/dlq.ts b/sdks/node/src/cli/commands/dlq.ts index 0113b0c0..24a2d7be 100644 --- a/sdks/node/src/cli/commands/dlq.ts +++ b/sdks/node/src/cli/commands/dlq.ts @@ -1,6 +1,7 @@ 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"); @@ -14,8 +15,8 @@ export function registerDlq(program: Command): void { const globals = command.optsWithGlobals() as GlobalOptions; const queue = connect(globals); const dead = queue.deadLetters( - options.limit ? Number(options.limit) : undefined, - options.offset ? Number(options.offset) : undefined, + nonNegativeIntFlag(options.limit, "limit"), + nonNegativeIntFlag(options.offset, "offset"), ); if (globals.json) { printJson(dead); @@ -53,6 +54,7 @@ export function registerDlq(program: Command): void { .option("--older-than-ms ", "age cutoff in ms", "0") .action((options: { olderThanMs?: string }, command: Command) => { const queue = connect(command.optsWithGlobals() as GlobalOptions); - printJson({ purged: queue.purgeDead(Number(options.olderThanMs ?? 0)) }); + 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 index 0cc65714..1cab8b97 100644 --- a/sdks/node/src/cli/commands/enqueue.ts +++ b/sdks/node/src/cli/commands/enqueue.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { connect, type GlobalOptions } from "../connect"; import { printJson } from "../output"; +import { numberFlag, parseArgsArray } from "../parse"; interface EnqueueOptions { queue?: string; @@ -22,19 +23,15 @@ export function registerEnqueue(program: Command): void { .action( (task: string, argsJson: string | undefined, options: EnqueueOptions, command: Command) => { const queue = connect(command.optsWithGlobals() as GlobalOptions); - const args = argsJson ? (JSON.parse(argsJson) as unknown[]) : []; + const args = parseArgsArray(argsJson); const id = queue.enqueue(task, args, { queue: options.queue, - priority: numeric(options.priority), - maxRetries: numeric(options.maxRetries), - delayMs: numeric(options.delayMs), + priority: numberFlag(options.priority, "priority"), + maxRetries: numberFlag(options.maxRetries, "max-retries"), + delayMs: numberFlag(options.delayMs, "delay-ms"), uniqueKey: options.uniqueKey, }); printJson({ id }); }, ); } - -function numeric(value: string | undefined): number | undefined { - return value === undefined ? undefined : Number(value); -} diff --git a/sdks/node/src/cli/commands/jobs.ts b/sdks/node/src/cli/commands/jobs.ts index 4f8683f0..f87dfbed 100644 --- a/sdks/node/src/cli/commands/jobs.ts +++ b/sdks/node/src/cli/commands/jobs.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { connect, type GlobalOptions } from "../connect"; import { printJson, printTable } from "../output"; +import { nonNegativeIntFlag } from "../parse"; interface JobsOptions { status?: string; @@ -26,8 +27,8 @@ export function registerJobs(program: Command): void { status: options.status, queue: options.queue, task: options.task, - limit: options.limit ? Number(options.limit) : undefined, - offset: options.offset ? Number(options.offset) : undefined, + limit: nonNegativeIntFlag(options.limit, "limit"), + offset: nonNegativeIntFlag(options.offset, "offset"), }); if (globals.json) { printJson(jobs); diff --git a/sdks/node/src/cli/commands/run.ts b/sdks/node/src/cli/commands/run.ts index 297aecc1..ce12d4f4 100644 --- a/sdks/node/src/cli/commands/run.ts +++ b/sdks/node/src/cli/commands/run.ts @@ -2,6 +2,10 @@ 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; @@ -26,14 +30,17 @@ export function registerRun(program: Command): void { const queues = options.queues ? options.queues.split(",") : undefined; const worker = app.runWorker({ queues, - batchSize: options.batchSize ? Number(options.batchSize) : undefined, + batchSize: positiveIntFlag(options.batchSize, "batch-size"), }); process.stdout.write( `taskito worker running (queues: ${queues?.join(",") ?? "default"}) — Ctrl-C to stop\n`, ); - const stop = () => { + // `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); diff --git a/sdks/node/src/cli/connect.ts b/sdks/node/src/cli/connect.ts index 2a3f0102..df3b144a 100644 --- a/sdks/node/src/cli/connect.ts +++ b/sdks/node/src/cli/connect.ts @@ -1,4 +1,5 @@ import { Queue, type QueueOptions } from "../index"; +import { positiveIntFlag } from "./parse"; /** Global connection options shared by every CLI command. */ export interface GlobalOptions { @@ -18,7 +19,7 @@ export function connect(options: GlobalOptions): Queue { dbPath: options.db, backend: options.backend as QueueOptions["backend"], dsn: options.dsn, - poolSize: options.poolSize ? Number(options.poolSize) : undefined, + poolSize: positiveIntFlag(options.poolSize, "pool-size"), schema: options.schema, prefix: options.prefix, namespace: options.namespace, 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; +} From 4f021d5cde4d87707695841e7dd93e84ac8c2b11 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:53:56 +0530 Subject: [PATCH 29/30] fix(node): honor --json in cancel output --- sdks/node/src/cli/commands/cancel.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sdks/node/src/cli/commands/cancel.ts b/sdks/node/src/cli/commands/cancel.ts index 1622e9a4..ef20f639 100644 --- a/sdks/node/src/cli/commands/cancel.ts +++ b/sdks/node/src/cli/commands/cancel.ts @@ -7,8 +7,13 @@ export function registerCancel(program: Command): void { .command("cancel ") .description("Cancel a pending job, or request cancellation of a running one") .action((id: string, _options: unknown, command: Command) => { - const queue = connect(command.optsWithGlobals() as GlobalOptions); + const globals = command.optsWithGlobals() as GlobalOptions; + const queue = connect(globals); const cancelled = queue.cancelJob(id) || queue.requestCancel(id); - printJson({ id, cancelled }); + if (globals.json) { + printJson({ id, cancelled }); + return; + } + process.stdout.write(`${cancelled ? "cancelled" : "not cancelled"}: ${id}\n`); }); } From a14ee928d57100c754078b5b4d6b2f5f57e1e02b Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:53:56 +0530 Subject: [PATCH 30/30] test(node): cover N-API boundary validation --- sdks/node/test/validation.test.ts | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 sdks/node/test/validation.test.ts 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/); + }); +});