Repository navigation
v0.1.0
docs: add comprehensive API design documentation
docs: add schema versioning and migration systemAdded comprehensive documentation for handling schema evolution with three migration strategies: version-only (schema defaults), sparse record (breaking changes), and typed tuple (strict chain). Includes version resolution, job processing flow, migration pruning with since, and inspection API.
docs: add comprehensive implementation planAdded detailed implementation roadmap covering 12 phases from project skeleton to workflows, including tech stack (Node.js 20+, TypeScript, ioredis, Redis 7+), Redis key layout, source structure with Lua scripts, and phase-by-phase breakdown of core engine, schema validation, result handles, retries, context, events, stall detection, scheduling, migrations, inspector/DLQ, middleware, and workflows. Includes test strategy and milestone definitions for alpha
docs: refactor to explicit backend adapters with namespace typesChanged from implicit connection config to explicit adapter pattern. Core taskora package now has zero database dependencies - ioredis moved to peer dependency of taskora/redis adapter. All public types consolidated under Taskora namespace to avoid collisions. Added multi-entrypoint package structure with separate taskora/redis and future taskora/postgres adapters. Updated architecture diagrams, API examples, and implementation
docs: add serialization system and split storage optimizationAdded pluggable serializer interface with JSON default and optional MessagePack/CBOR adapters. Implemented split storage pattern: job metadata in ziplist-encoded hash (3-4x less memory), serialized data/result in separate String keys. All keys share hash tag for Redis Cluster safety. Updated implementation plan with split storage details in Lua scripts and key layout. Added serializers/ directory structure with peer dependency pattern
docs: add flow control features (debounce, throttle, dedup, TTL, singleton)Added comprehensive flow control documentation in API_DESIGN.md covering debounce (last-wins timer reset), throttle (per-key rate limiting with drops), deduplication (single job per key), TTL/expiration (time-sensitive jobs), singleton (global one-at-a-time), concurrency per key (group-based limits), and cron overlap prevention. Updated comparison table with Celery/BullMQ. Added Phase 12 to implementation plan with Lua scripts for
docs: add collect flow control (debounce with accumulation)Added collect pattern that debounces while accumulating all dispatched data into an array passed to handler. Includes three flush triggers: delay (debounce timer), maxSize (item count limit), maxWait (anti-infinite-debounce). Handler receives TInput[] instead of TInput when collect is configured. Implemented with Lua scripts for atomic push/flush operations, Redis keys for items list/metadata/flush timer, and typed overloads. Added
docs: add cancellation, test utilities, telemetry, and realtime UI featuresAdded graceful cancellation with distinct "cancelled" state (not failed), onCancel cleanup hooks, AbortSignal integration, and cascade cancellation for workflows. Added test utilities package with in-memory runner, time control, and zero Redis dependency. Added built-in OpenTelemetry with automatic spans and context propagation. Added realtime frontend hooks with SSE streaming and React useJobStatus hook. Updated comparison
chore: add project setup guide for Claude Code AgentAdded CLAUDE.md with comprehensive project overview including tech stack (TypeScript 5.x ESM, pkgroll, Biome, Vitest with testcontainers), package structure (multi-entrypoint with taskora/redis adapter), source layout, conventions (Taskora namespace, adapter abstraction, Lua scripts, split storage), commands, and Phase 0 implementation scope
and CI workflow. All source files are typed shells for Phase 1.
mode with NodeNext, Biome linting, Vitest + testcontainers test infra,
library. Set up multi-entrypoint package (. + ./redis), TypeScript strict
feat: reset repo to taskora — Phase 0 project skeletonReplace jobify (BullMQ wrapper) with taskora, a from-scratch task queue
all three connection modes.
job promotion, concurrent processing, bulk dispatch, shutdown, and
default. 13 integration tests covering happy path, failures, delayed
interval), graceful shutdown, and pluggable Serializer with json()
backoff polling, concurrency control, lock extension (30s TTL/10s
fallback, Task class (dispatch/dispatchMany), Worker with exponential
and XADD events, Redis backend with SCRIPT LOAD/EVALSHA + NOSCRIPT
feat: implement Phase 1 core engine — full job lifecycleAdapter interface (8 methods), 7 atomic Lua scripts with split storage
docs: update CLAUDE.md — Phase 1 complete, next Phase 2
back to handler signature. @standard-schema/spec as dev dep (types only).
(before ack). TInput/TOutput inferred from schema when provided, falls
feat: implement Phase 2 — Standard Schema validation + type inferenceInput validated on dispatch (before enqueue), output validated in worker
-
10 new integration tests, all 36 tests passing
-
JobFailedError, TimeoutError error classes
-
Adapter: getState(), getResult(), getError() + updated enqueue(jobId)
-
Switch from INCR to client-side UUID v4 for job IDs
-
handle.getState() for lifecycle inspection
-
handle.result / handle.waitFor(ms) for polling job completion
feat: implement Phase 3 — ResultHandle + Job State- ResultHandle: sync dispatch, thenable (await = ensure enqueued)
docs: update CLAUDE.md — Phase 3 complete, next Phase 4
fixed/exponential/linear/custom strategies and ±25% jitter.
branching (retry→delayed vs permanent fail), backoff module with
maxDelay, jitter, retryOn/noRetryOn), ctx.retry() override, fail.lua
feat: implement Phase 4 — Retries with backoff strategiesDeclarative + manual retry support: RetryConfig (attempts, backoff,
docs: update CLAUDE.md — Phase 4 complete, next Phase 5
by default. handle.getProgress() and handle.getLogs() for querying.
Worker timeout races handler against setTimeout, TimeoutError not retried
feat: implement Phase 5 — Task Context (progress, logging, timeout)ctx.progress() and ctx.log.info/warn/error() as fire-and-forget writes.
docs: update CLAUDE.md — Phase 5 complete, next Phase 6
-
12 integration tests (81 total)
-
subscribe() method on Adapter interface
-
Lazy duplicate Redis connection for subscriber
-
Background XREAD BLOCK stream reader with per-event enrichment
-
App-level events (task:completed, task:failed, task:active, worker:ready, worker:closing)
feat: implement Phase 6 — Events- Typed event emitter on tasks (completed, failed, retrying, progress, active)
result-handle tests: 9.1s → 0.8s
Periodic state fallback (2s) as safety net.
waitFor() calls. XREVRANGE snapshot + state check eliminates race.
Shared JobWaiter uses single XREAD BLOCK connection for all pending
perf: push-based ResultHandle via shared XREAD connectionReplace polling loop (50ms→500ms backoff) with push-based awaitJob().
-
Delayed jobs: ZADD LT sets marker to earliest due time
-
Worker: no backoff, BZPOPMIN-driven loop with 2s block timeout
-
Dedicated blocking connection per task (lazy, auto-cleanup)
-
moveToActive.lua: promote delayed + dequeue + re-add marker atomically
-
All Lua scripts (enqueue, enqueueDelayed, fail, nack) ZADD marker
-
Marker ZSET: score=0 for immediate, score=timestamp for delayed
Zero-latency job pickup — Redis wakes worker instantly on enqueue.
perf: BZPOPMIN marker-based blocking dequeue (BullMQ-style)Replace polling with backoff (50ms→1s) with marker ZSET + BZPOPMIN.
docs: update CLAUDE.md — Phase 6 complete, next Phase 7
maxCount } configurable per-task and via app defaults.
second. Stalled/failed events emitted via stream. stall: { interval,
30s). maxStalledCount (default 1) — re-queue on first stall, fail on
SREMs healthy jobs. Worker runs check on configurable interval (default
stalled), then seed active IDs for next check. extendLock.lua already
feat: implement Phase 7 — Stall DetectionTwo-phase stalledCheck.lua: resolve stalled candidates (no lock = truly
refactor: extract shared url() and waitFor() into tests/helpers.ts
docs: update CLAUDE.md — Phase 7 complete, next Phase 8
-
8 unit + 12 integration tests (108 total)
-
10 new Adapter methods, 3 Lua scripts, Redis schedule keys
-
app.schedules.list/pause/resume/update/remove/trigger
-
Overlap prevention, missed run catch-up/catch-up-limit:N
-
TICK_SCHEDULER Lua: atomic claim of due schedules
-
Scheduler class: leader election (SET NX PX), configurable poll loop
-
cron-parser as optional peer dep (dynamic import)
-
Duration template literal type with parseDuration()
feat: implement Phase 8 — Scheduling / Cron- app.schedule(name, config) + inline schedule on task definition
-
9 integration tests (migrate, nack future, fail expired, inspect)
-
15 unit tests (version resolution, normalization, chain execution)
-
MigrationStatus type, getVersionDistribution() on Adapter interface
-
VERSION_DISTRIBUTION Lua script (scan wait/active/delayed + HGET _v)
-
Inspector class with migrations(taskName) — version distribution query
-
into() type helper for tuple migrations (locks return type to schema)
-
resolveVersion() + normalizeMigrations() + runMigrations() in migration.ts
-
Schema validation AFTER migration (.default() values applied)
-
Migration chain: tuple (version derived) and record (sparse, explicit)
-
Worker version check: nack future (silent), fail expired (below since)
feat: implement Phase 9 — Schema Versioning & Migrations- dispatch() stamps _v = task.version on enqueue (was hardcoded 1)
docs: update CLAUDE.md — Phase 9 complete, next Phase 10
retryFromDLQ, retryAllFromDLQ, trimDLQ
Adapter additions: listJobDetails, getJobDetails, getQueueStats,
-
RETRY_DLQ, RETRY_ALL_DLQ, TRIM_DLQ Lua scripts
-
Configurable maxAge retention (trim piggybacks on stall check)
-
deadLetters.list/retry/retryAll with atomic Lua scripts
-
DeadLetterManager as view over existing :failed sorted set
Dead Letter Queue:
-
Timeline from ts → processedOn → finishedOn
-
LIST_JOB_DETAILS Lua script: 1 RTT per task (LRANGE + HMGET + GET)
-
find(jobId) cross-task search, find(task, jobId) typed variant
-
stats() — LLEN/ZCARD pipeline, per-task or aggregated
-
active/waiting/delayed/failed/completed() queries with limit/offset
feat: implement Phase 10 — Inspector + Dead Letter QueueInspector expansion:
-
trimDLQ maxAge retention cleanup
-
deadLetters.retryAll() bulk re-enqueue
-
deadLetters.retry() with and without task name
-
find() with logs, timeline, typed variant
-
list queries (waiting, completed, failed, delayed, pagination)
-
stats per-task and cross-task aggregation
test: Inspector queries, stats, find, DLQ list/retry/retryAll/trim16 integration tests covering:
docs: update CLAUDE.md — Phase 10 complete, next Phase 11
-
10 integration tests (app-level, per-task, ordering, data/result, errors)
-
9 unit tests (compose order, errors, data/result mutation, next() guard)
-
Composition happens once per Worker at construction (not per job)
-
Pipeline: deserialize → migrate → validate → [middleware chain → handler]
-
MiddlewareContext extends Context with task, mutable data, mutable result
-
Execution order: app middleware → task middleware → handler
-
Task option middleware: [fn, fn] per-task middleware
-
app.use(middleware): app-level middleware, chainable, throws after start()
-
src/middleware.ts: compose() function (onion model, next()-called-twice guard)
feat: implement Phase 11 — MiddlewareKoa-style composable middleware for cross-cutting concerns:
docs: update CLAUDE.md — Phase 11 complete, next Phase 12
12c: Collect / Batch Accumulator (debounce + accumulator pattern)
12b: TTL, Singleton, Concurrency per Key, Cron overlap (worker-time)
docs: split Phase 12 into 12a/12b/12c in IMPLEMENTATION.md12a: Debounce, Throttle, Deduplication (dispatch-time Lua scripts)
-
Adapter interface: debounceEnqueue, throttleEnqueue, deduplicateEnqueue
-
ResultHandle: enqueued (bool|null), existingId (dedup redirect)
-
ThrottledError, DuplicateJobError (opt-in via throwOnReject)
feat: add flow control types, errors, and ResultHandle.enqueued- DispatchOptions: debounce, throttle, deduplicate, throwOnReject
-
Task.dispatch(): routes to flow control adapter methods
-
RedisBackend: debounceEnqueue, throttleEnqueue, deduplicateEnqueue
-
ACK/FAIL extended: clean dedupKey from job hash on completion/failure
-
DEDUPLICATE_ENQUEUE: check dedup key + job state, skip if exists
-
THROTTLE_ENQUEUE: counter with PX window, drop if over limit
feat: implement debounce, throttle, deduplication Lua scripts and adapter- DEBOUNCE: replace previous delayed job atomically, debounce key with PX
-
Dedup: existing handle, redirect result, key clears on complete/fail, while option, throwOnReject
-
Throttle: max limit drops excess, window reset, throwOnReject, independent keys
test: flow control — debounce, throttle, deduplication (13 tests)- Debounce: 5 rapid dispatches only last runs, data replacement, independent keys
-
keys.ts: new expired sorted set key
-
QueueStats gains expired field, listJobDetails accepts "expired" state
-
Adapter interface updated: enqueue/dequeue accept flow control options
-
TtlConfig, DequeueOptions interfaces in Taskora namespace
-
JobState gains "expired", DispatchOptions gains ttl/concurrencyKey/concurrencyLimit
feat: add TTL, singleton, concurrency-per-key types and ExpiredError- ExpiredError class in errors.ts
getQueueStats includes expired ZCARD
-
Backend: updated eval calls, dequeue accepts DequeueOptions,
-
DEBOUNCE/THROTTLE/DEDUPLICATE: pass through flow control fields
-
FAIL/NACK/STALLED_CHECK: DECR concurrency counter on job leaving active
-
ACK: KEYS[4]=marker, DECR concurrency counter, wake workers
concurrency-per-key INCR/block loop (up to 100 attempts)
-
MOVE_TO_ACTIVE: TTL check on promote + dequeue, singleton LLEN guard,
feat: implement TTL, singleton, concurrency-per-key Lua scripts and adapter- ENQUEUE/ENQUEUE_DELAYED: store expireAt, concurrencyKey, concurrencyLimit
-
index.ts: export ExpiredError
-
Inspector: expired() list method, stats includes expired, timeline handles expired state
-
App: TaskOptionsBase gains singleton/concurrencyLimit/ttl, parses Duration
-
Worker: builds DequeueOptions from task config, passes to blockingDequeue
-
Task.dispatch: computes expireAt from TTL, resolves concurrencyLimit
feat: wire TTL, singleton, concurrency-per-key through core + inspector- TaskConfig: singleton, concurrencyLimit, ttl (maxMs + onExpire)
counter released on failure, different keys independent
-
Concurrency per key: per-key limit respected, dispatch override,
-
Singleton: second job waits for first, non-singleton concurrent
discard mode, delayed expiration, inspector stats
test: TTL expiration, singleton, concurrency per key (12 tests)- TTL: expired not processed, dispatch override, non-expired OK,
docs: update CLAUDE.md — Phase 12a/12b complete, next Phase 12c
feat: add CollectConfig type and collectPush to Adapter interface
CollectTaskOptions<I,O> overload on app.task().
RedisBackend.collectPush(), Task.dispatch() collect path,
HDEL collectKey after drain to prevent double-drain on retry.
moveToActive.lua: drain collect buffer into :data at claim time,
flush sentinel in delayed set, inline maxSize immediate flush.
feat: implement collect (batch accumulator) — Lua, adapter, coreCOLLECT_PUSH Lua script: accumulate items per key, manage debounce
test: collect — debounce flush, dynamic key, maxSize, maxWait, retry (5 tests)
-
Redis keys: cancelled sorted set, cancel pub/sub channel
-
listJobDetails accepts "cancelled" state
-
extendLock return type: "extended" | "lost" | "cancelled"
-
Adapter: cancel(), finishCancel(), onCancel() methods
-
QueueStats.cancelled field
-
CancelledEvent type, TaskEventMap.cancelled, AppEventMap.task:cancelled
feat: add CancelledError, cancelled types, and Adapter cancel interface- CancelledError class (distinct from JobFailedError)
-
Active cancel tests: 10s → ~80ms
-
worker.start() now async — awaits subscription before polling
-
extendLock cancel detection retained as fallback safety net
-
controller.abort("cancelled") fires immediately on pub/sub message
-
Worker subscribes to cancel channel on start() via adapter.onCancel()
feat: instant cancel via Redis pub/sub — sub-100ms active job cancellation- cancel.lua PUBLISHes jobId to cancel channel for active jobs
docs: update CLAUDE.md — Phase 13 (Graceful Cancellation) complete
16 unit tests (no Docker needed).
Entrypoint: taskora/memory — memoryAdapter() factory.
debounce/throttle/dedup, collectPush, inspector, DLQ, scheduling.
dequeue, ack/fail/nack, extendLock, cancel/finishCancel, stalledCheck,
data structures (arrays, Maps, sorted arrays). All ~40 methods: enqueue,
feat: in-memory Adapter (taskora/memory) — zero-Redis queue backendMemoryBackend implements full Taskora.Adapter interface using plain JS
inspector, execute with auto-retry, from-app with multi-task chains).
25 unit tests (run, retry, middleware, dispatch, delay, cancel, collect,
-
MemoryBackend.getEarliestDelayedScore() for auto-retry advancement
-
App.registerExternalTask() / getRegisteredTasks()
-
Task._patchDeps() — swap adapter deps, returns restore fn
Internal:
-
dispatch/advanceTime/processAll/flush/clear/dispose
use memory backend; interconnected task chains work in-memory
-
createTestRunner({ from: app }) — patch all tasks on existing app to
-
importTask(task) — copy production task to memory adapter
returns {result, state, attempts, logs, progress, error, handle}
-
execute(task, data) — full queue pipeline with auto-retry advancement,
-
run(task, data) — direct handler execution with inline retry loop
API:
feat: TestRunner (taskora/test) — run, execute, importTask, from-app modecreateTestRunner() — in-memory test runner for unit testing without Redis.
docs: update CLAUDE.md — Phase 14 (Test Utilities) complete, Phases 15–16 deferred
-
Adapter additions: trimCompleted()
-
trimDLQ.lua + trimCompleted.lua: two-phase (age-based + count-based), batch 100
-
retention.failed: { maxAge: "7d", maxItems: 300 }
-
retention.completed: { maxAge: "1h", maxItems: 100 }
refactor: retention — auto-trim completed/failed sets with configurable limitsReplace single DLQ maxAge with full retention config:
when user adds task.on("failed") or app.on("task:failed").
job ID, attempt info, and retry status. Automatically suppressed
taskora logs to console.error with full stack trace, task name,
feat: default error logging — console.error with stack when no failed listenerWhen a task handler throws and no failed listener is registered,
-
Singleton: max 1 concurrent across 3 pods (6 jobs)
-
Stall recovery: pod-2 recovers stalled job from crashed pod-1
-
Cross-pod onCancel hook fires on the processing pod
-
Cross-pod cancel: cancel from pod-1 aborts active job on pod-2
-
Event fan-out: all pods receive completed event (XREAD, not XREADGROUP)
-
Dispatch from any pod, process on any pod
test: multi-instance integration tests — 8 tests across 5 scenarios- Job distribution across 3 pods without duplicates (50 jobs)
-
CLAUDE.md: convention — sync documentation/ with code changes
-
events.md: multi-instance fan-out behavior, default error logging note
docs: default error logging + multi-instance event fan-out in VitePress- error-handling.md: default console.error with stack, auto-suppression
-
API reference (manual), comparison page
-
Recipes: email queue, image processing, webhook delivery, rate-limited API, cron cleanup
-
Testing: overview, run vs execute, virtual time, patterns
-
Operations: inspector, DLQ/retention, stall detection, monitoring
-
Features: retry/backoff, TTL, cancellation, middleware, events, scheduling, flow control, batch processing, versioning
-
Guide: getting started, core concepts, tasks, dispatching, context, adapters, serializers, error handling
docs: VitePress documentation site — guide, features, operations, testing, recipesFull documentation site with VitePress:
-
.gitignore: exclude generated api/ subdirs, .vitepress/cache, .vitepress/dist
-
typedoc sidebar loaded dynamically in VitePress config
-
vitepress-plugin-llms: generates llms.txt and llms-full.txt for LLM indexing
-
build script: gen:typedoc runs before vitepress build
-
scripts/generate-typedoc.ts: run typedoc, rename module dirs, fix links/sidebar
feat: typedoc API reference + vitepress-plugin-llms- typedoc + typedoc-plugin-markdown + typedoc-vitepress-theme for auto-generated API docs
-
Hero image added to landing page
-
logo.svg: lucide:workflow (stroke=currentColor) for nav + hero
-
favicon.svg: lucide:workflow on violet rounded square
-
Hero gradient text + background glow
style: violet brand palette + workflow icon + hero image- Brand color: #3b7dff → #7c3aed (violet) with proper dark mode variant
-
favicon.svg: violet background, white nodes, checkmark — reads at 32x32
-
nav-logo.svg: compact version for header navigation
connected by curved paths with flow dots, checkmark on result node
style: custom SVG logo — dispatch → process → result flow diagram- logo.svg: hero image — three gradient nodes (dispatch, process, result)
in index.md, getting-started.md, scheduling.md
feat: vitepress-plugin-package-managers — auto bun/npm/pnpm/yarn tabsReplace manual ::: code-group install blocks with ::: pm-add
-
virtual:group-icons.css import
-
vitepress-plugin-group-icons for tab icons (bun/npm/pnpm/yarn logos)
fix: package-managers client registration + group-icons for tab icons- enhanceAppWithPackageManagers(app, { icons }) in theme — fixes empty render
-
base URL configurable via BASE_URL env var
-
.github/workflows/docs.yml: build library → gen:typedoc → vitepress build → deploy
-
vitepress-plugin-group-icons for tab icons
feat: navbar package manager switcher + GitHub Pages deployment- PackageManagerSwitch in nav-bar-content-after slot
Update README.md
fix: set BASE_URL=/taskora/ for GitHub Pages subpath deployment
Merge branch 'main' of https://github.com/kravetsone/taskora
-
Exported from taskora: chain, group, chord, Signature, WorkflowHandle
-
Late-bound dispatch pattern to break circular imports
-
Workflow types: WorkflowState, WorkflowAdvanceResult, WorkflowFailResult
-
Adapter interface: 6 new workflow methods + _wf/_wfNode enqueue fields
-
Task: .s(), .map(), .chunk(), _getDeps()
-
WorkflowHandle — thenable, .result, .cancel(), .getState()
-
flattenToDAG() — any composition tree → flat DAG (nodes + deps + terminals)
-
chord() — parallel header + callback with typed fan-in
-
group() with tuple output inference (InferOutputTuple)
-
chain() with 10 type-safe overloads, .pipe() for unlimited chaining
-
Signature class: task.s(data) / task.s() — serializable task snapshot
feat: add workflow composition layer (Signature, chain, group, chord)Phase 17a core — Celery-inspired Canvas with full TypeScript type safety.
-
enqueue() stores _wf/_wfNode in job hash when present
-
ENQUEUE Lua: extended with ARGV[11]/ARGV[12] for _wf/_wfNode fields
-
RedisBackend: same 6 methods using Redis hash (taskora:wf:{id})
-
failWorkflow: cascade — mark all pending/active nodes failed
input (single dep → direct, N deps → JSON array)
-
advanceWorkflow: mark node done, find ready dependents, compute
-
Workflow state stored in Map with per-node state/result tracking
getWorkflowState, cancelWorkflow, getWorkflowMeta
feat: implement workflow adapter methods (Memory + Redis)- MemoryBackend: createWorkflow, advanceWorkflow, failWorkflow,
-
Workflow failure + cascade
-
Workflow state tracking, runner.steps, task.map()
-
Chain/group/chord/nested execution end-to-end
-
DAG flattening (6 topologies incl. nested compositions)
-
chain/group/chord construction + validation
-
Signature creation, bound data, .pipe()
Tests (28 new):
-
clear()/dispose() reset workflow steps
-
processOne: advance/fail workflow after job ack/fail
-
runner.steps tracks workflow step execution history
TestRunner:
-
Reads _wf/_wfNode from job hash via getWorkflowMeta()
-
failWorkflow() after permanent fail — cascade cancel active jobs
-
advanceWorkflow() after successful ack — enqueue ready nodes
feat: integrate workflows into Worker and TestRunnerWorker:
-
CLAUDE.md: updated source layout, phase status, Phase 17a summary
-
Features table: added Workflows row
-
Sidebar: added Workflows to Composition section
WorkflowHandle, TTL, DAG internals, testing guide
Signatures, Chain, Group, Chord, Composability, Map/Chunk,
-
documentation/features/workflows.md: VitePress feature page —
Split into 17a (core primitives) and 17b (durable steps, wait-for-event)
adapter additions, type system, worker integration, Lua scripts.
docs: Phase 17a workflow design + user-facing documentation- docs/IMPLEMENTATION.md: full Phase 17a design — DAG model, Redis layout,
Updated all test files (19 files) to use createTaskora.
No backward compat alias needed (not yet published).
avoids generic app naming and conflicts with other frameworks.
refactor: rename taskora() → createTaskora()Factory function renamed for clarity — const taskora = createTaskora({...})
-
Workflow patterns: when to use chain/group/chord, error boundaries
-
Handler patterns: ctx.log, ctx.signal, idempotency, guard clauses
-
Project structure: taskora.ts, tasks/ by domain, worker.ts
-
Naming: createTaskora(), *Task suffix, kebab-case task names
docs: add Conventions & Best Practices guideNew page /guide/conventions covering:
-
Updated 25+ doc pages (guide, features, operations, recipes)
-
taskora.task(), taskora.start(), taskora.close() everywhere
-
Task variables: *Task suffix (sendEmailTask, processImageTask, etc.)
docs: apply conventions across all documentation- All examples: taskora() → createTaskora(), const app → const taskora
with status polling, order fulfillment chain
-
REST API (Elysia): route → workflow dispatch, long-running task
broadcasts, bot command → task dispatch pattern
docs(conventions): add real-world integration examples- Telegram bot (GramIO): task queue for rate-limited messages,
No separate workflows/ directory needed.
docs(conventions): merge workflows into tasks/ directoryWorkflows are compositions of tasks — keep them in the same folder.
-
"From App Mode" → "From Instance Mode" in testing (consistent with instance)
-
"app events" → "events" in monitoring (taskora variable used, not app)
-
"Create Your App" → "Create Your Instance" (matches instance terminology)
docs: fix inconsistent terminology across documentation- "longTaskTask" → "longRunningTask" in cancellation example
Backend normalizes cjson empty table ({}) → [] for array fields.
All scripts operate on a single workflow hash key — Cluster-safe.
-
CANCEL_WORKFLOW: mark all pending/active nodes, collect active job IDs
-
FAIL_WORKFLOW: mark node + workflow failed, collect active job IDs
-
ADVANCE_WORKFLOW: mark done, find ready deps, compute input, check terminals
-
CREATE_WORKFLOW: HMSET graph + initial node states
refactor: atomic Lua scripts for workflow state transitionsReplace inline Redis HGET/HSET calls with 4 atomic Lua scripts:
-
Redis atomicity: verify hash structure, _wf/_wfNode in job hash
-
task.map(): parallel batch dispatch
-
Cancel: pending steps not executed after cancel
-
Failure: permanent fail cascades to workflow
-
Workflow state: running → completed transition
-
Nested: chord of chains
-
Chord: group + callback fan-in
-
Group: parallel execution with tuple result
-
Chain: 2-step, 3-step, .pipe() syntax
test: workflow integration tests with real Redis12 tests covering end-to-end workflow execution:
workflow DAG visualization, and 4-phase implementation plan.
(Hono + React SPA + SSE), REST API surface, 8 UI views including
docs: add taskora/board dashboard design documentComprehensive design for in-house admin panel covering architecture
-
hono >= 4 as optional peer dependency
-
CLEAN_JOBS Lua script for bulk sorted set cleanup
-
Throughput counters: INCR per-minute buckets in ack/fail Lua scripts
getThroughput methods
-
Adapter: cleanJobs, getServerInfo, listWorkflows, getWorkflowDetail,
Backend additions:
-
Dark/light/auto theme, keyboard shortcuts, global search
-
Migrations insight with version distribution chart
-
DLQ view with error frequency grouping
-
Schedule management: pause/resume/trigger/delete
-
Workflow DAG visualization (@xyflow/react) with node state colors
-
Job detail with timeline, data/result/error/logs tabs, progress bar
-
Task detail with state tabs, job table, bulk retry/clean
-
Overview dashboard with stat cards + throughput chart (Recharts)
-
React 19 + Tailwind + Vite SPA, pre-built at publish time
UI (ui/ → src/board/static/):
-
Universal handler: works with Express, Fastify, Bun, Deno
-
Auth middleware hook, read-only mode, field redaction
throughput endpoints + SSE real-time event stream
-
Hono REST API: overview, jobs, schedules, workflows, DLQ, migrations,
Server (src/board/):
the taskora runtime.
feat: taskora/board — admin dashboard with Hono + React SPAPhase 18: full-featured admin panel for observing tasks and managing
-
Add examples/board-demo.ts with 4 sample tasks + schedules
-
SPA fallback: non-file routes serve index.html
resolution (source dev, dist published) instead of hono/bun serveStatic
fix: board static file serving + add board-demo example- Rewrite static serving to use fs.readFileSync with multi-path
-
Rewrite static file serving with multi-path fs resolution
-
keepPreviousData on all queries to prevent "Loading..." flash
-
All polling intervals reduced to 30s (safety net, not primary)
-
Job events soft-invalidate (background only, no loading state)
fix: SSE-first updates, eliminate polling flicker- SSE stats:update events directly patch query cache (no refetch)
starts with /api/ and calls next() to let the API handler respond.
returning index.html instead of JSON. Now checks if the path
fix: static handler must skip /api/ routesThe wildcard static handler was intercepting API requests and
/board/api/... Derive absolute base from window.location.pathname.
/board/jobs/abc — fetch resolved to /board/jobs/api/... instead of
fix: use absolute base path for API calls in productionRelative BASE='.' broke when SPA navigated to nested routes like
absolute paths that match the board mount point.
Vite base was './' (relative). Changed to '/board/' so HTML references
fix: set Vite base to /board/ for correct asset pathsAssets were loaded from /assets/ instead of /board/assets/ because
-
Job list shows "retried" badge + amber attempt count
-
Retrying states annotated "attempt failed, scheduling retry"
-
Retry-completed jobs shown in amber with "after N retry" label
-
Vertical timeline with colored dots per state
Timeline:
-
Avg keys/task and avg memory/task displayed on Overview
-
Expanded: peak memory, db size, connected clients
Redis info:
-
Per-task stacked area chart on Overview
-
getThroughput aggregates across tasks or reads per-task
-
Per-task metric keys: taskora:metrics:TASK:completed|failed:BUCKET
-
Move metric INCR from Lua to TypeScript (avoids hash tag issues)
fix: throughput metrics + per-task charts + better timelineThroughput:
-
Migrations link on task detail header
(Lua prefix had hash tags that broke key pattern)
-
Fix throughput: moved INCR from Lua to TS ack/fail methods
-
Completed-after-retry shown as amber badge (no text, just color)
-
State tab counts from live queue stats
-
Redis keys + memory stats displayed per task
-
Per-task throughput chart on TaskDetail page
feat: per-task throughput, key count, memory on task detail page- getTaskKeyStats adapter method: SCAN key count + MEMORY USAGE estimate
-
3 schedules: daily report, weekly report, sync every 30m
group (parallel resize 3 sizes), chord (3 resizes → aggregate)
-
Workflows: chain (resize→watermark→upload→notify),
-
resize/watermark/upload-cdn/notify/aggregate: workflow steps
-
generate-report: slow, reliable
-
webhook-delivery: ultra flaky (60% fail), fills DLQ fast, has TTL
-
sync-data: dispatched with delays, very flaky (35% fail)
-
process-image: slow (builds active), moderate failures
demo: rich board demo with 10 tasks, workflows, diverse states- send-email: burst dispatch, fast, rarely fails
-
Interval formatting (ms → human readable)
-
Delete button in expanded view
-
"View all X jobs" link to task detail page
-
Last execution: job link + state badge + result/error preview
-
Shows: type, expression, overlap policy, missed policy, dispatch data
feat: expandable schedule cards with execution detail- Click schedule row to expand detail panel
resize-and-aggregate
-
Demo uses named workflows: image-pipeline, multi-size-resize,
terminalTasks[]
-
listWorkflows returns tasks[] (all unique task names) instead of
-
Workflow detail header shows name prominently
state badge, node count, relative time
-
Workflow list: card layout showing name, all tasks (not just terminal),
-
Name stored in workflow graph, displayed in list + detail
chord(resize, resize, resize → aggregate)
group(resize, resize, resize)
chain(resize → watermark → upload-cdn → notify)
-
Auto-generated description from composition structure:
feat: workflow names + improved workflow list UI- WorkflowDispatchOptions.name — optional human-readable name
error analysis) and advantages (workflows, schedules, SSE, themes).
and bull-board. Identifies gaps (worker management, Prometheus,
docs: board ideas pool + Flower comparison20 ideas in 3 tiers, side-by-side comparison with Celery Flower
docs: add public /taskora skill for Claude Code
docs: add internal flows, state diagrams, and best practices to /taskora skill
docs: add AI Skills documentation page with installation guide
docs: expand AI Skills page with vitepress-plugin-llms usage guide
docs: add Further reading section to /taskora skill with llms.txt links
docs: add metadata.json manifest and frontmatter metadata for /taskora skill
scripts, pipelines, HGETALL, INFO, scheduled metrics).
driver end-to-end (enqueue, dequeue, BZPOPMIN blocking, ack, Lua
refactored ioredis path. A manual smoke test exercises the Bun
All 300 existing integration tests pass unchanged against the
and Sentinel unsupported
forced at connect, HGETALL normalized, Cluster
-
taskora/redis/bun Bun runtime only, routes via .send(), HELLO 2
-
taskora/redis/ioredis explicit ioredis entry
-
taskora/redis re-exports ioredis driver (no breaking change)
New exports:
~25 dumb commands route through a generic command() escape hatch.
blocking commands, pipelines, pubsub, connection management). The
covering the small non-trivial surface (Lua + NOSCRIPT recovery,
~67 Adapter methods are shared — the driver exposes 10 methods
All taskora state-machine logic, 27 Lua scripts, key layout, and
Bun.RedisClient (runtime built-in, zero peer deps under Bun).
implementations: ioredis (default, for Node/Cluster/Sentinel) and
feat(redis): add Bun.RedisClient backend via driver abstractionSplit the Redis adapter into a thin RedisDriver interface + two
Integration suite: 49.88s, scheduler suite: 40.8s → 12.4s.
client still uses close() for a clean QUIT.
use it for all clients that may be parked in a blocking command. Main
(ioredis: disconnect(false); Bun: close() — already non-graceful) and
Add a disconnect() method to RedisDriver for forceful termination
the full block timeout (~2s). Scheduler suite went from 12s to 41s.
BZPOPMIN / XREAD BLOCK / SUBSCRIBE, so every test teardown paid up to
onCancel sub-clients. QUIT can't be sent while a connection is stuck in
→ client.quit() for blocking-dequeue, event-reader, job-waiter, and
fix(redis): force-disconnect blocking clients on shutdownThe driver refactor replaced client.disconnect(false) with driver.close()
Bun-runtime global).
Deno + BunDriver is intentionally not supported (Bun.RedisClient is a
-
Deno 2.7 + taskora/redis/ioredis (via
deno run -A --node-modules-dir=auto npm:vitest/vitest.mjs run) -
Bun 1.3 + taskora/redis/bun
-
Bun 1.3 + taskora/redis/ioredis
-
Node 20 + taskora/redis/ioredis
Full 300-test suite now passes on:
Result
identically on all runtimes. Test logic unchanged.
array, string etc. directly, so the namespace import works
import * as z from "zod" — zod's top-level exports include object,
Fix: switch the four test files that use z.object(...) etc. to
schema.test.ts (4 tests) fail at module load time on Bun.
comes through as undefined. Made migration.test.ts (7 tests) and
Vitest the ESM↔CJS interop drops the re-export and the named z
Under Node's Vitest this binding survives module loading; under Bun's
(export { z } after import * as z from "./v4/classic/external.js").
Zod 4.x re-exports itself as a named z namespace from the ESM entry
Zod + Bun + Vitest: import { z } from "zod" is undefined
driver's pipeline serializes the eval calls first.
display order matches client-side dispatch order regardless of which
job hash. LIST_JOB_DETAILS for lrange mode sorts by (ts, seq) so
starts. Pass both through to adapter.enqueue which stores them in the
synchronously at the task.dispatch() call site, before the async IIFE
Fix: capture ts (wall-clock ms) and a process-monotonic seq
so ts/seq reflected Redis-arrival order, not client call order.
Root cause: dispatch order was captured per-driver after async yields,
jobs in raw LIFO order, and the test failed deterministically.
FIFO expectation. BunDriver exposes pure LPUSH semantics, which put
serialize parallel dispatches in an order that matched the test's
ioredis only because ioredis's internal command pipeline happened to
tests/integration/inspector-dlq.test.ts > waiting() passed under
Taskora: inspector.waiting() was latently flaky (1 test)
explain the leniency as a deliberate cross-driver normalization.
Fix: coerceArgs() now matches ioredis — undefined/null → "". Docs
active.
then skipped every subsequent dispatch because lastJobId was forever
which stalled jobs in active state. Scheduler (overlap=false default)
"TypeError: The first argument must be of type string, Buffer, ..."
string; BunDriver's strict coerceArgs() threw
void handler results. ioredis silently coerces undefined to empty
Worker.ack() passes JSON.stringify(undefined) → actual undefined for
BunDriver: coerceArgs throws on undefined (10 tests)
command() / blockingZPopMin() don't double-send.
working. Serialized behind a shared in-flight Promise so racing
used purely for subscribe() never receives HELLO 2 and pub/sub keeps
evalSha / blockingZPopMin / blockingXRead / pipeline exec). A client
issue HELLO on the first command-ish call (command / scriptLoad /
connect(), but duplicated clients start with helloSent=false and only
Fix: make HELLO 2 lazy. The main driver still negotiates eagerly in
and multi-instance cross-pod cancel.
reach the listener. This kills cancel.test.ts active-job cancellation
publish count is correct (num subscribers = 1), but messages never
handler once HELLO 2 has been negotiated on the connection — the
Bun's RedisClient.subscribe() silently stops invoking the message
BunDriver: HELLO 2 breaks pub/sub push delivery (4 tests)
guarantee parity.
real bugs and one latent flake that all needed fixing before CI could
BunDriver. Running the real 300-test suite cross-runtime surfaced four
fix(redis): make full integration suite pass on Node/Bun/Deno × ioredis/BunThe driver-abstraction refactor (cc0b54b) was only smoke-tested against
invocations.
and don't drift across deno run -A --node-modules-dir=auto npm:vitest/...
deno.lock is committed so Deno-based CI runs pin the exact npm dep set
Bun.RedisClient is a Bun-runtime global).
per runtime, and the known-skip rule (Deno × BunDriver, since
adapters. Records which combos are supported, the test-runner invocation
audit across Node 20 / Bun 1.3 / Deno 2.x and the ioredis / BunDriver
docs(testing): add cross-runtime testing matrix and deno.lockTESTING_MATRIX.md captures the empirical results of the Phase 1 compat
opaque duck-typing failure downstream.
Node surfaces a clear actionable error from the helper instead of an
guards described above. Attempting TASKORA_TEST_DRIVER=bun under
The 2 skipped tests under BunDriver are the ioredis-constructor-form
Deno + ioredis deno run -A --node-modules-dir=auto npm:vitest/vitest.mjs 300/300
Bun + BunDriver TASKORA_TEST_DRIVER=bun bunx --bun vitest 298 passed | 2 skipped
Bun + ioredis bunx --bun vitest 300/300
Node + ioredis vitest run 300/300
Verified on all four combos
local Redis without paying Docker startup per invocation.
workflow, and it also lets developers iterate against a persistent
jobs do when they provision Redis via services: redis: in the
already set, skipping testcontainers spin-up. This is what CI matrix
tests/setup.ts now returns early if process.env.REDIS_URL is
Setup honors pre-existing REDIS_URL
path (threads pool diverges between Node and Bun's Worker impl).
only combination that runs reliably under Bun's vitest-via-node-compat
pool: forks + singleFork: true is preserved on both — it's the
-
integrationkeepsglobalSetup: tests/setup.tsand longer timeouts -
unitruns ~125 tests in ~1s (no Docker, no testcontainers startup)
vitest.config.ts now defines unit and integration projects:
Vitest projects split
factory detection.
under TASKORA_TEST_DRIVER=bun instead of throwing from duck-typed
These are now guarded with it.skipIf(!isIoredis) so they remain green
(redisAdapter({ host, port }) and redisAdapter(new Redis(url()))).
Two lifecycle tests exercise ioredis-specific constructor forms
Driver-specific test guards
mechanical sed — no test logic touched.
../../src/redis/index.js to ../create-adapter.js. The swap is a
All 17 integration test files had their import path moved from
Bulk import swap
call-sites keep the familiar synchronous redisAdapter(url()) shape.
TASKORA_TEST_DRIVER (default: ioredis), using top-level await so
src/redis/ioredis.js or src/redis/bun.js based on
tests/create-adapter.ts module re-exports redisAdapter from either
site, without touching individual test files' logic. A new
test: parameterize integration suite by TASKORA_TEST_DRIVER env varThe integration suite now runs against any driver selected at the call
These double as local-dev shortcuts for reproducing any CI cell.
test:all - sequential run of all four combos
test:deno - deno run -A npm:vitest/vitest.mjs run
test:bun:ioredis - bunx --bun vitest run (Bun runtime + ioredis)
test:bun - TASKORA_TEST_DRIVER=bun bunx --bun vitest run
test:node:integration- only the integration project
test:node:unit - only the unit project (no Redis)
test:node - vitest run under Node
Added:
package.json scripts
matrix would be waste).
once (biome + pkgroll are runtime-agnostic, duplicating across the
Lint + build are factored into a separate lint-build job that runs
Docker-in-Docker and all four runtimes hit the same shared Redis.
pre-set URL and skips its own testcontainers spin-up — so CI avoids
REDIS_URL=redis://localhost:6379. tests/setup.ts honors this
Uses a GitHub Actions services: redis:7-alpine sidecar and pre-sets
Redis provisioning
-
deno →
deno run -A --node-modules-dir=auto npm:vitest/vitest.mjs runNode, which is what the old workflow silently did) without it `bunx` follows vitest's shebang and runs under -
bun →
bunx --bun vitest run(the--bunflag is critical — -
node →
npx vitest run
-
Runs vitest via the runtime-appropriate invocation:
tests/create-adapter.tspicks the right factory at module load. -
Sets
TASKORA_TEST_DRIVER=${{ matrix.driver }}sonode_modules tree is interchangeable).
manager across all cells — ioredis has no native addons, so the
-
Installs deps via
bun install --frozen-lockfile(one package
Each cell:
another — the whole point is to see where the runtimes actually differ.
fail-fast: false so one failing cell doesn't hide divergence in
Matrix shape
Bun-runtime global.
Deno + BunDriver is intentionally excluded: Bun.RedisClient is a
-
Deno + taskora/redis/ioredis (ioredis under Deno's npm interop)
-
Bun + taskora/redis/bun (Bun's native RedisClient)
-
Bun + taskora/redis/ioredis (ioredis under Bun's node-compat)
-
Node + taskora/redis/ioredis (golden baseline)
every supported runtime × driver combination on every PR:
Node, not Bun — --bun was missing) with a 4-cell matrix that exercises
ioredis because bunx vitest routes through the vitest shebang under
ci: add cross-runtime × cross-driver test matrixReplaces the single-cell workflow (which effectively only tested Node +
a red test can no longer be published.
runtime × driver cell fails, the registry push is skipped entirely —
trigger added) and publish_package depends on needs: tests. If any
publish.yml now calls test.yml as a reusable workflow (workflow_call
Gate publish on tests
don't want to double-bill wall-clock on every internal PR.
same repo — the push trigger already handled that commit, and we
(fork PRs), but a job-level if: skips it when the PR comes from the
on.pull_request stays in place to cover external contributors
PR.
repos — there's no reason not to catch regressions before opening a
feature branches too. GitHub Actions minutes are free for public
Drop the branches: [main] filter on on.push so the matrix runs on
ci: run tests on every push and gate publish on them## Run on every push
-
REDIS_URL=redis://localhost:6399 npx vitest run→ 300/300 pass -
bunx pkgroll→ clean build -
bunx biome check .→ 85 files, 0 errors
Verified
the justification rather than rewriting into runtime-assert bloat.
// biome-ignore lint/style/noNonNullAssertion: … comments carrying
calls provably return a value. Suppressed with targeted
or allTerminalDone boolean check two statements earlier — the .get()
terminal-node aggregation. Each one is guarded by an allDepsCompleted
two inside advanceWorkflow's dep-resolution branch, one in the
Three noNonNullAssertion reports in src/memory/backend.ts remained:
Manual
of them — pure formatter output.
of FIXABLE no-non-null-assertion diagnostics. No logic changes in any
of format / organizeImports / no-unused-template-literal / a handful
bunx biome check --write + one --unsafe pass fixed 14 files' worth
Autofixed
by taskora/board — committed for distribution, not authored code.
src/board/static is the pre-built React SPA output that gets served
}
"ignore": ["dist", "ui", "documentation", "src/board/static"]
"files": {
Exclude sub-projects from the root biome walk:
biome.json
after merges.
filtered CI never caught because it only ran against the main branch
of accumulated violations. Pre-existing issue that previous branch-
sub-project), both of which have their own linting concerns and dozens
into ui/ (the React SPA sub-project) and documentation/ (VitePress
run of bunx biome check . was red — the root biome config walked
chore: fix biome lint errors surfaced by push-triggered CIEnabling on: push: for the matrix workflow revealed that every recent
docs: fix ai-skills page to reflect universal Agent Skills spec (45+ agents)
chore: require SKILL.md + metadata.json sync in Documentation sync rule
10.97s, no dead links, no broken sidebar).
Docs build verified locally (bun run build in documentation/ —
a credible trust signal without bloating the file.
README was previously a 15-line stub; this gives first-time visitors
-
README.md— short matrix summary + link to the full docs. TheTesting sidebar.
-
documentation/.vitepress/config.ts— new page added to thethe library-level testing story.
the new cross-runtime page so users looking at test helpers also see
-
documentation/testing/index.md— top-of-page tip pointing atpublish gate, and local reproduction instructions.
root cause and fix for each, the workflow wiring including the
provisioning strategy, the five concrete bugs the matrix caught with
invocations (including the
bunx --bunload-bearing detail), Redisstory: matrix design, what each cell exercises, runtime-specific
-
documentation/testing/cross-runtime.md(new page) — the fullreproduce any matrix cell locally.
upstream Bun issue link, and a copy-paste block showing how to
section right after Quick Start, with the matrix table, totals, the
-
documentation/index.md— new "Battle-Tested Across Runtimes"
Changes
the docs or the npm page.
every cell being green. This should be visible to anyone landing on
live-Redis test runs per push, with the publish workflow gated on
both ioredis and native Bun.RedisClient), and Deno 2.x, totaling 1,200
the full 300-test integration suite against Node 20, Bun 1.3+ (with
CI matrix is a strong signal of library quality — every commit runs
docs: showcase cross-runtime test matrix on landing and READMETaskora is a new project and trust has to be earned. The cross-runtime
9 new unit tests (134 total).
App.implement() follow in subsequent commits.
Commit 1 of the contract phase — foundation only. App.register() and
without polluting the runtime object shape
-
TaskContract phantom types use declare const unique symbol for inference
-
isTaskContract(value) — runtime type guard
producers (edge, browser) where shipping a schema library is undesirable
-
staticContract<I, O>(config) — type-only contract for bundle-size-sensitive
-
defineTask(config) — runtime schemas via Standard Schema (Zod/Valibot/ArkType)
can import and dispatch contracts without pulling handler code into its bundle.
This is the foundation for splitting producer and consumer services: a producer
name, schemas, and defaults — with zero runtime dependency on App/Worker/Adapter.
feat(contract): add defineTask/staticContract primitives for producer/consumer splitIntroduces TaskContract<I, O> as a pure, serializable declaration of a task's
vitepress-plugin-package-managers (already wired in .vitepress/config.ts)
in documentation/**/*.md -- always use ::: pm-add from
-
CLAUDE.md: add convention banning hand-rolled npm/pnpm/bun/yarn code-groups
-
Bump skill version 0.1.0 -> 0.2.0 in metadata.json + SKILL.md frontmatter
REST endpoint table, field redaction details, production checklist
modes with inline comments, feature bullets, throughput backing, complete
-
SKILL.md Admin dashboard section rewritten: Board interface, four serve
replaces, Grafana/Datadog pipelines)
-
Cross-link from operations/monitoring -> board (board complements, not
table, recipes (nginx/sub-route/redaction/IP allowlist), production checklist
tour (Overview/Task/Job/DAG/Schedules/DLQ/Migrations/SSE), full REST API
serving (Bun/Deno/Hono/Express via @hono/node-server), all options, feature
docs(board): add dedicated operations/board page and expand skill coverage- New /operations/board page: quick start, Board interface, per-framework
reference it as expected.
into dist/ and both index.html and testing/cross-runtime.html
bun run build in documentation/ — 8.61s clean build. SVG copies
Verified
trimmed to drop the "bugs this matrix caught" phrasing.
for itself via the badge and SVG. The README wording was also
read as self-congratulatory for a young project — the matrix speaks
Removed from both the landing page and the cross-runtime page. It
Cut the "bugs the matrix caught" section
cross-runtime page header as <img src="/testing-matrix.svg" …>.
(#16a34a). Embedded inline in the landing page and in the
the VitePress light/dark toggle; accents use a fixed Tailwind-green
runs per pushfooter. Themed withcurrentColor` so text adapts to
and 300 / 300 per cell, plus a `1,200 live-Redis integration test
grid showing the four runtime × driver cells with green-check icons
New documentation/public/testing-matrix.svg — hand-authored 2x2
2. Custom matrix SVG
page. Auto-updating, links to the workflow run list, zero maintenance.
landing "Battle-Tested" section, and the top of the cross-runtime
Added actions/workflows/test.yml/badge.svg to the README, the
1. Live GitHub Actions status badge
docs: add CI status badge and custom matrix SVG, drop the bugs sectionTwo visual layers to give the cross-runtime story a credible anchor:
leftover references to the removed public/ file.
SVG with var(--vp-c-*) attributes intact, and there are no
index.html and testing/cross-runtime.html contains the inlined
bun run build — 9.79s clean. The rendered HTML for both
Verified
references it anymore.
-
Deleted
documentation/public/testing-matrix.svg— nothingwith
<TestingMatrix />. -
Replaced the
<img>tags inindex.mdandtesting/cross-runtime.mdvisualizers (
HeroBadges,JobLifecycleVisualizer, etc.). -
Registered globally in
theme/index.tsnext to the other
Wiring
OS dark, manual light / manual dark).
The matrix now renders correctly in every combination (OS light /
manual theme toggle — not just the OS prefers-color-scheme setting.
between the light and dark palettes, and they also respect the
These are all VitePress default theme variables that swap values
var(--vp-c-green-soft) — check icon background tint
var(--vp-c-green-1) — check icon + ring + count
var(--vp-c-bg-soft) — cell fill
var(--vp-c-border) — cell borders
var(--vp-c-text-3) — muted driver subtitles
var(--vp-c-text-1) — headline + runtime labels + footer
DOM, it reads VitePress's theme CSS variables directly:
into the component template. Because the SVG is now part of the page
Moved the SVG into .vitepress/components/TestingMatrix.vue, inlined
The fix
the whole graphic was unreadable in VitePress's dark theme.
parent page, so currentColor fell back to the default (black) and
<img> is a sandboxed document — it does not inherit CSS from the
public/testing-matrix.svg referenced via <img>. SVG loaded through
The previous commit shipped the matrix as a standalone
fix(docs): inline TestingMatrix as Vue component so dark theme works## The bug
var(--vp-c-*) theme variables from the previous fix commit).
renders correctly in both light and dark themes (using VitePress
bun run build in documentation/ — 6.66s clean. SVG component
Verified
Removed "(LTS)" from every mention of Node 20 — it is no longer LTS.
-
README.md table — Node 24 + Node 20 rows, "1,500" total.
-
documentation/testing/index.md tip — matrix summary + "1,500".
the Redis provisioning paragraph.
and the explicit cell names, "four runtimes" → "five cells" in
workflow YAML snippet updated to show the nodeVersion dimension
rows with Node 24 first and Node 20 second (marked maintenance),
-
documentation/testing/cross-runtime.md — matrix table grows to 5
-
documentation/index.md Battle-Tested section — "1,200" → "1,500".
test runs per push" (5 cells × 300).
300 per version"; footer bumped to "1,500 live-Redis integration
-
TestingMatrix.vue — cell 1 reads "Node 24 · Node 20" with "300 /
Doc updates across the board
invocation on the same version as the primary baseline.
runtime-agnostic so this is cosmetic, but it keeps every Node
The lint-build job also bumps to Node 24 — biome + pkgroll are
actions/setup-node@v4 version without a forest of if: steps.
Added a nodeVersion matrix dimension so each cell can set its own
-
Deno + ioredis
-
Bun + Bun.RedisClient
-
Bun + ioredis
-
Node 20 + ioredis (kept — maintenance coverage until Apr 2026)
-
Node 24 + ioredis (new — current Active LTS baseline)
Matrix grows from 4 to 5 cells:
library exclusively against a dying version was a bad signal.
April 2026. Labeling Node 20 as "LTS" was wrong, and testing a new
LTS since then, and Node 20 drops into end-of-life maintenance in
Node 20's LTS window ended in October 2025 — Node 24 has been Active
Node 20 → Node 20 + Node 24 in CI matrix
stay consistent with the six existing cards.
convention also documents the style template so future additions
card, and aim for a grid-friendly count (3, 6, or 9). The
ask the user before dropping or adding a landing-page hero feature
New rule added to CLAUDE.md in the Conventions section: always
orphan row the 7-card layout was producing.
goes 7 → 6, giving a clean 3×2 grid on desktop instead of the
the guide section but no longer headlines the home page. Card count
queues and "Koa-Style" is inside-baseball branding — it survives in
after being asked). Middleware is table-stakes for modern task
Koa-Style Middleware dropped from the hero array (per user decision
runtime, runner.run() vs runner.execute(), etc.).
features (retryOn / noRetryOn, Pause, resume, trigger, list at
it compiles, it works" are gone — each card now pitches actual
two short punchy declarations. Abstract value-prop clichés like "If
followed by a comma-separated list of real capabilities, then one or
Admin UI" template: concrete anchor (module name or primary concept)
All six remaining feature cards rewritten in the "Batteries-Included
Landing hero cards — style + count fix
docs + ci: style pass on hero cards, drop Koa middleware, add Node 24Three threads landed together, touching the same surface:
Commit 2 of the contract phase. App.implement() follows in Commit 3.
(separate app instance) executes, producer receives result + event
-
end-to-end: producer dispatches + subscribes to completed, worker
-
producer-only app starts and closes cleanly with no handlers
-
register() wraps an existing app.task()-declared task
-
register() is idempotent for the same contract
-
dispatch via BoundTask writes to Redis and shows up in inspector
5 new integration tests (180 integration total):
safety net in case a worker is somehow started against them directly.
-
Safety handler: contract-only registrations get a throwing handler as a
without conflict — both are first-class styles.
lets inline and contract-based declarations coexist on the same name
existing Task is wrapped as-is and its handler is left untouched. This
name already exists (from app.task() or a prior register call), the
-
App.register(contract): idempotent by task name. If a task with the same
by implement() in Commit 3.
against the underlying Task. Returned by register(); will also be returned
-
BoundTask<TInput, TOutput>: thin wrapper exposing dispatch/dispatchMany/on
start cleanly with no runtime overhead.
App.start() skips the Worker loop when false, so producer-only processes
-
Task.hasHandler: boolean flag (default true). Set to false by register().
completed/failed events from a worker running in a separate process.
work as normal — a producer can await handle.result and receive
is skipped for contract-only tasks, but event subscription and dispatch
and dispatch jobs without ever implementing a handler locally. Worker loop
feat(contract): add App.register() + BoundTask for producer-only processesEnables the producer/consumer split: a process can register a task contract
Build verified: bun run build 8.23s, no dead links.
addition to a marketing list.
The hero grid is now explicitly framed as a navigation index in
-
linkText uses concise active-voice CTAs, never generic ones.
deepest-dive docs page for that feature.
-
Every card MUST have
link:andlinkText:pointing at the
Extended the landing-page hero feature rule with two new clauses:
CLAUDE.md
tells the reader nothing about what they'll find.
retries" — rather than generic "Learn more" / "Read more" which
CTA text is active-voice and specific — "Define a task", "Configure
Batteries-Included UI → /operations/board "Explore the board" (unchanged)
First-Class Testing → /testing/ "Test your tasks"
Flow Control → /features/flow-control "Shape the flow"
Built-in Scheduling → /features/scheduling "Schedule tasks"
Retry & Backoff → /features/retry-backoff "Configure retries"
Type-Safe End-to-End → /guide/tasks "Define a task"
lives.
forcing users to hunt through the sidebar to find where each feature
Admin UI" was clickable — the other five cards were decorative,
deep-dive page for that topic. Previously only "Batteries-Included
linkText: so it doubles as a navigation entry to the canonical
docs(index): add click-to-deep-dive links to every hero feature cardEvery card in the home-page features grid now has link: and
Verified: bun run build 8.01s clean, href resolves in rendered HTML.
keyboard users — otherwise the link would be invisible to tabbing.
-
focus-visiblegets a 2px brand-colored outline offset by 4px foranchor styling so the matrix doesn't pick up blue underlines.
-
text-decoration: noneandcolor: inheritreset the defaultchrome.
affordance without requiring a "click me" badge or intrusive
graphic stays visually the same at rest, but the lift surfaces the
-
Hover: subtle
translateY(-2px)lift plusbrightness(1.05). Thejust announce "link" with no context.
not just "matrix" — important because screen readers otherwise
-
aria-labelon the wrapping anchor describes where the link goes,can't hijack window.opener.
readers don't lose their place in the docs, and the external link
-
target="_blank" rel="noopener noreferrer"— opens in a new tab so
UX details
diagrams.
people who scan text, and the full matrix for people who look at
now has two affordances for the same destination: the badge for
Same target as the status badge already uses, so the whole section
land on the latest CI run two clicks away.
every Test workflow run, sorted newest first. Click the graphic,
github.com/kravetsone/taskora/actions/workflows/test.yml — the list of
docs page is now wrapped in an pointing at
feat(docs): make TestingMatrix clickable — opens GitHub Actions runsThe SVG matrix visualization on the landing page and the cross-runtime
workflow composition (chain/group/chord from contracts).
Commit 3 of the contract phase. Commit 4 adds TaskContract.s() for
-
version override from implement options propagates to dispatched jobs
-
double implement() throws "already implemented"
BoundTask reference valid
-
implement() after register() upgrades the task and keeps the early
-
object form with middleware works
-
handler + options form applies concurrency (maxConcurrent === 3)
-
bare handler form runs end-to-end
6 new integration tests (186 integration total):
invalid trailing comma inside the import() call.
import type to work around a Biome formatter bug that inserted an
-
types.ts: switched from inline
import()expression to top-levelconstruction between register() and implement() (avoids copy-paste).
-
App.buildTaskDeps(): tiny helper extracted to share the TaskDeps
is not a double-implement — it's the intended upgrade path.
-
Double-implement throws a clear error. Implementing after register()
Documented as "conceptually readonly for external consumers".
since, migrations, middleware) dropped
readonlyto enable the merge. -
Task fields (handler, config, inputSchema, outputSchema, version,
upgrade — they point to the same Task instance.
place. Existing BoundTask references from register() stay valid after
contract-only registration (register() first, implement() later) in
-
Task._mergeImplementation(): internal method that upgrades a
concurrencyLimit, ttl, collect) only live on implement().
Worker-only fields (concurrency, middleware, onCancel, singleton,
stall, version): implement options → contract defaults → app defaults.
Resolution order for fields shared with the contract (retry, timeout,
})
handler: async (items, ctx) => { ... },
collect: { key: "user-emails", delay: "5s" },
taskora.implement(batchEmail, {
// handler is small relative to the config
// 3. Object form — required for collect tasks, preferred when the
)
{ concurrency: 4, middleware: [withTracing()] },
async (data, ctx) => { ... },
processImage,
taskora.implement(
// 2. Handler + worker-side options
taskora.implement(sendEmail, async (data, ctx) => { ... })
// 1. Bare handler — 90% of cases
Three call forms:
(implement) are now fully symmetric primitives.
execution config. Producer dispatch (register) and worker execution
take a contract (defined anywhere) and attach a handler + worker-side
feat(contract): add App.implement() — attach handlers to contracts with 3 overloadsCompletes the contract-first authoring path. With implement() workers can
screenshots later, one view at a time, without rushing a full gallery.
Schedules, DLQ, Migrations) as text-only for now — can add more
Leaving the other six views (Overview, Job detail, Workflow DAG,
"### Task detail" paragraph. Build verified clean in 7.43s.
relative markdown image in operations/board.md right below the
documentation/public/board/task-detail.jpg and referenced via a
63 KB JPG. No conversion to WebP needed at this size. Placed under
for the "taskora/board actually works and looks good" story.
jobs table with error reporting. It's the single highest-signal shot
one frame — sidebar nav, stat cards, throughput, state tabs, and the
Picked the Task detail view because it shows the most surface area in
refused" error message) visible inline.
jobs plus a retry scenario (attempt 2 with a real "SMTP connection
tabs with the current counts, and a job table with several completed
Failed, Completed), a throughput chart with ~1h of live data, state
per-task view for send-email: stat cards (Redis Keys, Memory, Active,
docs(board): add Task detail screenshot under the "What you get" sectionOne real screenshot from the board-demo seeded dashboard, showing the
Build verified: 8.69s clean.
moments.
here from the home page" and "reading about this specific view"
build time. And repeating it gives a visual anchor at both "landed
browser cache hits on the second render, zero extra bandwidth or
free pattern — VitePress serves the same /board/task-detail.jpg, the
Duplicating a single image file across two spots on one page is a
prose explains exactly what's in the frame.
-
Contextual slot — still under "### Task detail" where the
alt text so screen readers don't read the same description twice.
from the "Explore the board" CTA on the home hero grid. Different
dashboard?". First thing a visitor sees when they land on the page
-
Hero slot — right after the intro paragraph, before "## Why a
docs(board): also hoist the Task detail screenshot as a page heroThe same screenshot now appears twice on operations/board.md:
documentation/, and the using-taskora skill.
configuration. Commit 6 syncs documentation across docs/,
Commit 4 of the contract phase. Commit 5 adds validateOnDispatch
-
mixed composition: contract-based BoundTask with inline app.task()
-
BoundTask.map() dispatches one contract-defined job per item
two App instances, worker implements, workflow runs end-to-end
-
producer/consumer split: producer composes with register() across
-
chord() — group of contract tasks feeding a contract callback
-
group() with tuple result from contract-based tasks
-
chain() from BoundTasks created via implement()
6 new integration tests (192 integration total):
await chain(fetchUser.s({ id }), sendEmail.s()).dispatch()
}
sendEmail: app.register(sendEmailContract),
fetchUser: app.register(fetchUserContract),
const { fetchUser, sendEmail } = {
composition can destructure at the top of a module:
dispatchable, Signatures are composable. Users who want one-liner
keeps the model clean: contracts are pure data, BoundTasks are
The explicit path costs one extra line per contract (register()) and
dispatch time — both compromise the "no magic" principle.
would require either global App state or magical auto-registration at
workflow dispatcher can find the adapter. Adding contract-level .s()
register() required. But a Signature needs a Task reference so the
The obvious API would be sendEmailContract.s({ to: ... }) with no
Design note — why no TaskContract.s():
handler code in the producer bundle.
implements the same contracts and executes the resulting jobs. Zero
BoundTasks, and dispatches. A worker process in another deployment
registers contracts, composes chain/group/chord from the returned
Producer/consumer split workflows now work end-to-end: a producer
serializer at dispatch time.
the Task, which is how the workflow dispatcher resolves adapter and
delegate to the underlying Task — Signatures carry a reference back to
tasks compose identically to inline-declared ones. The three primitives
feat(contract): BoundTask.s() / map() / chunk() — workflow composition from contractsExtends BoundTask with the workflow composition surface so contract-based
docs/, documentation/, and the using-taskora skill.
Commit 5 of the contract phase. Commit 6 syncs documentation across
-
schemaless contract with skipValidation is a no-op
-
skipValidation: false is equivalent to default
-
skipValidation: true per-call overrides default-on
-
validateOnDispatch: false globally bypasses validation
-
default-on accepts valid data (smoke)
-
default-on validates and throws ValidationError on bad data
6 new integration tests (198 integration total):
2 and 3, and automatically threads the new field
2 copies of the inline TaskDeps construction that landed in Commits
-
app.task() and register() refactored to use buildTaskDeps() — removes
in both the regular and the collect paths
-
Task.dispatch() checks
deps.validateOnDispatch && !options.skipValidation -
DispatchOptions.skipValidation (optional) checked per dispatch
-
TaskDeps.validateOnDispatch (required field) threaded to each Task
-
TaskoraOptions.validateOnDispatch (default true) stored on App
Implementation:
-
Workers always validate. The safety net is still there.
anyway, and forcing them to throw would be surprising.
staticContract<I, O>() contracts without Zod can't validate
-
Edge runtimes care about bundle size: a producer that ships
doubles the cost.
body checked by tRPC/Zod at the router level) — re-validating
-
Producer may have already validated upstream (e.g. incoming HTTP
Rationale for the option:
is unchanged.
Precedence: per-call > global. Default behaviour (validate on dispatch)
sendEmail.dispatch(data, { skipValidation: true })
// Per-call: skip just this one
createTaskora({ adapter, validateOnDispatch: false })
// Global: skip validation for all dispatches in this app
boundary.
always runs before the handler, so job data is still checked at some
either globally or per-call. Worker-side validation is unaffected — it
feat(contract): add validateOnDispatch + skipValidation for producer-side schema checksAdds a knob to disable Standard Schema validation on the producer side,
Commit 6 closes Phase 19.
multi-service dispatch), not a growth trajectory.
are a release valve for concrete pressures (heavy deps, edge runtime,
production teams eventually do". The default is inline tasks — contracts
contracts as optional tooling for a specific problem, not as "what
guide/splitting-services, SKILL.md section) explicitly position
Framing note: all four user-facing docs (guide/contracts,
inventory. Phases line updated: "1–14, 17a, 18, and 19 complete".
-
CLAUDE.md — Phase 19 delivered block added with the full feature
by worker-side Standard Schema validation + payload versioning).
and why no Redis contract registry (runtime drift is already covered
is idempotent and not strict, why staticContract is a distinct primitive,
auto-binding, why BoundTask.s() and not TaskContract.s(), why register
for the decisions taken during Phase 19: why explicit register() over
-
docs/API_DESIGN.md — new §19 Contracts covering the design rationale
abstract updated to mention producer/consumer split via task contracts.
-
documentation/skills/metadata.json — version bumped 0.2.0 -> 0.3.0,
API surface with short examples. frontmatter version bumped 0.2.0 -> 0.3.0.
the default, contracts are for specific problems. Covers the full
right after "Starting workers & shutdown". Same framing: inline is
-
documentation/skills/using-taskora/SKILL.md — new Contracts section
after "Tasks" so readers encounter them in the natural order.
new entries: "Contracts" and "Splitting Services", placed right
-
documentation/.vitepress/config.ts — Essentials sidebar gets two
git submodules, TS path aliases, codegen, and copy-paste.
explicit anti-patterns section warns off private npm registries,
Opens with a tip block reinforcing that monolithic is the default;
two-entrypoints (default), and monorepo with workspace:* protocol.
two physical layouts for sharing contract files: single-package
-
documentation/guide/splitting-services.md — new page covering the
composition, and the validateOnDispatch knob.
staticContract, register, implement (all three overloads), workflow
service dispatch to a shared worker pool. Covers defineTask,
contract path: heavy handler deps, edge/browser producer, or multi-
the default and gives three concrete reasons to escape into the
"when to reach for contracts" that explicitly names inline tasks as
-
documentation/guide/contracts.md — new user-facing page. Leads with
split shipped across Commits 1–5.
docs(contract): Phase 19 user-facing docs across guide, skill, and design notesThree-way documentation sync for the contract-first producer/consumer
Closing the distance on v0.1.0.
5-cell cross-runtime matrix as evidence, not marketing.
versions may still break API until 1.0". Counts 332 tests and the
-
Frame v0.x honestly: "stable enough to use in production, but minor
AI Skills guide.
npx skills add kravetsone/taskora/documentation/skillsper thetaskora` works — the real command is
-
Fix a bogus install command that claimed `npx claude-skill install
-
Add npm version and license badges alongside the existing CI badge.
docs links, and the AI Skills install command.
catalog with deep links, package layout, cross-runtime test matrix,
showing schema inference + retry + typed dispatch/result, full feature
proper release README: install with any package manager, quick-start
-
Rewrite from "Work in progress — see site for full vision" into a
README.md:
explicit helps tooling).
only dist was listed — npm includes README by default but being
-
Add README.md and LICENSE to the files array explicitly (previously
(bun, deno, node, ioredis). Better npm search discoverability.
(inspector, board, admin-dashboard, dlq), and cross-runtime support
schema stack (standard-schema, zod, valibot, arktype), observability
rate-limit), composition (chain, group, chord, canvas, workflow, dag),
primitives (debounce, throttle, deduplicate, collect, ttl, singleton,
-
Expand keywords from 7 to 68 entries covering the flow-control
accumulate locally before adding this).
not evict stale hash-named chunks on its own — I saw 76 files
on a fresh checkout, but this keeps local builds honest (pkgroll does
-
Add prebuild script that cleans dist/ before pkgroll. CI already runs
renders properly instead of a blank canvas.
-
Add repository, homepage, bugs, author fields so the npmjs.com page
package.json:
chore(release): prepare package.json + README for first publishPre-release polish for v0.1.0. No code changes.
All 332 tests pass (134 unit + 198 integration).
ticks skip exec() entirely. lastIds still advances per entry.
distribute results and fire handlers in stream order. Enrichment-less
queue every enrichment command into a single shared pipeline, then
Rewrote as two-pass batching: parse all entries from an XREAD response,
with COUNT=100 that was up to ~200 sequential round trips per poll tick.
-
EventReader.poll() awaited one enrich() call per event (1–2 RTTs each);
LTRIM -10000 -1 in one pipeline. MAX_LOGS_PER_JOB constant with rationale.
deleted the whole key on job completion. Now pairs every RPUSH with
balloon a single :logs list to hundreds of MB before retention finally
-
addLog() did a raw RPUSH with no upper bound — a verbose handler could
both commands ride one round trip and succeed/fail together.
TTL-less metric keys on connection hiccups. Moved to a single pipeline so
-
incrMetric() fire-and-forget chained INCR → EXPIRE via .then(), leaking
perf(redis): collapse metric TTL race, cap :logs lists, batch XREAD enrichmentThree targeted improvements after reviewing src/redis/ against Redis anti-patterns:
Full Changelog: https://github.com/kravetsone/taskora/commits/v0.1.0