From bb72fd6a6e171b33aa4b107c3288b933bde1e5dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Sun, 22 Feb 2026 17:47:12 -0400 Subject: [PATCH] feat: add execution runner --- .context/api-proposal.md | 241 ++++++++++++++++ .context/design.md | 455 +++++++++++++++++++++++++++++++ .context/plan.md | 239 ++++++++++++++++ src/__tests__/index.test.ts | 42 ++- src/index.ts | 30 +- src/lib/__tests__/runner.test.ts | 68 +++++ src/lib/builder.ts | 75 +++++ src/lib/context.ts | 11 + src/lib/dispose.ts | 3 + src/lib/errors.ts | 94 +++++++ src/lib/gen.ts | 3 + src/lib/retry.ts | 21 ++ src/lib/runner.ts | 31 +++ src/lib/types.ts | 53 ++++ 14 files changed, 1359 insertions(+), 7 deletions(-) create mode 100644 .context/api-proposal.md create mode 100644 .context/design.md create mode 100644 .context/plan.md create mode 100644 src/lib/__tests__/runner.test.ts create mode 100644 src/lib/builder.ts create mode 100644 src/lib/context.ts create mode 100644 src/lib/dispose.ts create mode 100644 src/lib/errors.ts create mode 100644 src/lib/gen.ts create mode 100644 src/lib/retry.ts create mode 100644 src/lib/runner.ts create mode 100644 src/lib/types.ts diff --git a/.context/api-proposal.md b/.context/api-proposal.md new file mode 100644 index 0000000..0fd390a --- /dev/null +++ b/.context/api-proposal.md @@ -0,0 +1,241 @@ +A better try for your functions + +```ts +import * as try$ from "hardtry" + +const signal = new AbortController().signal +const myFn = () => "value" +class MyErr extends Error {} + +const value = try$ + .retry(3) // provide a retry policy + .timeout(1000) // timeout for full execution scope + .signal(signal) // cancel with an abort signal + .run({ + try: () => myFn(), // run the function + catch: (_e) => new MyErr(), // map the error + }) + +// You can also do +const result = try$.run(() => myFn()) +``` + +Retries can be very specific: + +```ts +try$.retry(3) // Retry a specific number of times with linear backoff +try$.retry({ limit: 3, delayMs: 1000, backoff: "exponential" }) // Specific policy +try$.retry({ + limit: 3, + delayMs: 250, + backoff: "exponential", + shouldRetry: (error) => !(error instanceof try$.TimeoutError), +}) // Predicate-based retryability + +// Create reusable policies + +const retryPolicy = try$.retryOptions({ + limit: 3, + delayMs: 300, + backoff: "exponential", +}) + +const value = try$.retry(retryPolicy).run({ + try: () => myFn(), + catch: (_e) => new MyErr(), +}) +``` + +Timeouts are total-scoped in v1: + +```ts +try$.timeout(1000) // Scope covers all attempts, backoff delays, and catch execution +try$.timeout({ ms: 1000, scope: "total" }) +``` + +Provide an abort controller signal to the execution: + +```ts +const abortController = new AbortController() + +try$.signal(abortController.signal).run({ + try: (ctx) => fetchUser(user.id), + catch: (error) => error, +}) +``` + +Or pass the abort signal from the internal function: + +```ts +try$.timeout(3000).run({ + try: (ctx) => fetchUser(user.id, { signal: ctx.signal }), + catch: (error) => error, +}) +``` + +## Generator + +```ts +const getUser = (id: string): Promise => + Promise.resolve( + try$.run({ + try: () => fetchUser(id), + catch: () => new UserNotFound(id), + }) + ) + +const getProject = (id: string): Promise => + Promise.resolve( + try$.run({ + try: () => fetchProject(id), + catch: () => new ProjectNotFound(id), + }) + ) + +const value = try$.gen(function* (use) { + // Use "use" to unwrap the return value + const user = yield* use(getUser("123")) + const project = yield* use(getProject(user.id)) + + return project +}) + +// value is Project | UserNotFound | ProjectNotFound +``` + +## Disposer + +```ts +await using disposer = try$.dispose() +const conn = await connectDb() +// Pass the disposer to the resource +disposer.use(conn) +// Defer functionality for disposal +disposer.defer(() => console.log("cleanup")) +``` + +## `Promise.all` alternatives + +```ts +import * as try$ from "hardtry" + +const result = try$.all({ + async a() { + return getA() + }, + async b() { + return getB() + }, + async c() { + return getC(await this.$result.a) + }, +}) +``` + +Access abort signal and disposer: + +```ts +import * as try$ from "hardtry" + +const result = try$ + .timeout(5000) + .signal(signal) + .all({ + async a() { + return getA({ signal: this.$signal }) + }, + async b() { + const conn = await getDbConnection() + this.$disposer.defer(() => conn.close()) + return getB(conn) + }, + async c() { + return getC(await this.$result.a) + }, + }) +``` + +Use `Promise.allSettled`: + +```ts +const result = try$ + .timeout(5000) + .signal(signal) + .allSettled({ + async a() { + return getA({ signal: this.$signal }) + }, + async b() { + const conn = await getDbConnection() + this.$disposer.defer(() => conn.close()) + return getB(conn) + }, + async c() { + return getC(await this.$result.a) + }, + }) +``` + +## Task Orchestration + +```ts +const value = try$.flow({ + async cache() { + const data = await cache.get(key) + + if (data) return this.$exit(data) + + return null + }, + async api() { + await this.$result.cache // Await cache to resolve + + const res = await fetch("...", { signal: this.$signal }) // Access to abort signal + + return res + }, + async process() { + const rawData = await this.$result.api + + return this.$exit(transformData(rawData)) + }, +}) + +// The type of value is a union of the return types of all returns with $this.exit() +``` + +Internally, `this.$exit()` throws so we interrupt the flow. + +We type values return by `this.$exit` with `FlowExit` so we can extract them from the union. + +## Extensibility + +Wrap runner execution with custom functions. Wrap is only intended to wrap logic around your function execution: + +```ts +try$ + .wrap(span("fetchUser")) // Add a telemetry span trace + .run({ + try: () => fetchUser("1"), + catch: (error) => error, + }) + +try$ + .wrap(logTiming()) // Add logs for function execution + .run({ + try: () => fetchUser("1"), + catch: (error) => error, + }) +``` + +Wrap has access to the `TryCtx` so you are able to access information about your function run: + +```ts +try$ + .retry(3) + .wrap((ctx) => logAttempts(ctx.retry.attempt)) + .run({ + try: () => fetchUser("1"), + catch: (error) => error, + }) +``` diff --git a/.context/design.md b/.context/design.md new file mode 100644 index 0000000..a3a4687 --- /dev/null +++ b/.context/design.md @@ -0,0 +1,455 @@ +# `hardtry` Design Document + +## Overview + +`hardtry` is a TypeScript library that provides structured, composable error +handling for sync and async functions. It uses a fluent builder pattern to configure +retry, timeout, abort signal, and middleware before executing a function with +typed error mapping. + +Users import the library as a namespace: + +```ts +import * as try$ from "hardtry" +``` + +## Normative Spec (v1) + +This section is the single source of truth for v1 behavior. If examples in +other docs differ, this section wins. + +- API surface is `retry`, `timeout`, `signal`, `wrap`, `run`, `all`, + `allSettled`, `flow`, `gen`, `dispose`, and `retryOptions`. +- v1 exposes `run` only (no `runPromise`). +- Naming in docs and examples is always `try$`. +- `run` supports sync and async user functions. If `try` and `catch` are sync, + the result is sync. If either is async, the result is a `Promise`. +- Retry `limit` includes the first attempt (`retry(3)` means up to 3 total + attempts). +- v1 timeout scope is `total` only and covers the full execution window + (attempts, backoff waits, and `catch` execution). +- `wrap` runs around the full run execution (not per attempt). +- Control errors (`Panic`, abort, timeout) are never retried. + +### Failure Precedence + +When multiple failure conditions race, result resolution uses this order: + +| Priority (high -> low) | Outcome | +| ---------------------- | -------------------- | +| 1 | `Panic` | +| 2 | `CancellationError` | +| 3 | `TimeoutError` | +| 4 | mapped `catch` error | +| 5 | `UnhandledException` | + +`Panic` is only possible when a `catch` handler exists and throws. + +### Error Codes and Cause + +All framework errors expose: + +- `code` with stable machine values: + - `EXEC_CANCELLED` + - `EXEC_TIMEOUT` + - `EXEC_RETRY_EXHAUSTED` + - `EXEC_UNHANDLED_EXCEPTION` + - `EXEC_PANIC` +- `cause` preserving the original underlying value/error. + +## Architecture + +The library is organized around an **immutable fluent builder** that accumulates +configuration, then delegates to executor modules for the actual work. + +``` +src/ + index.ts # Public API surface: thin re-export layer + lib/ + types.ts # Shared TypeScript types and interfaces + errors.ts # Custom error classes + context.ts # TryCtx implementation (signal, retry info) + builder.ts # Fluent builder chain + retry.ts # Retry logic, backoff strategies, retryOptions() + timeout.ts # Timeout scoping (total) + signal.ts # External AbortSignal integration + runner.ts # run() terminal execution with try/catch mapping + gen.ts # Generator-based composition + dispose.ts # Disposer with Symbol.asyncDispose + all.ts # all() / allSettled() parallel execution + flow.ts # Task orchestration with $exit + wrap.ts # Middleware/extensibility + __tests__/ + builder.test.ts + retry.test.ts + timeout.test.ts + signal.test.ts + runner.test.ts + gen.test.ts + dispose.test.ts + all.test.ts + flow.test.ts + wrap.test.ts +``` + +### Module Dependency Graph + +```mermaid +graph TD + Entry["src/index.ts"] --> Builder["lib/builder.ts"] + Builder --> Retry["lib/retry.ts"] + Builder --> Timeout["lib/timeout.ts"] + Builder --> Signal["lib/signal.ts"] + Builder --> Wrap["lib/wrap.ts"] + Builder --> Runner["lib/runner.ts"] + Builder --> All["lib/all.ts"] + Builder --> Flow["lib/flow.ts"] + Runner --> Context["lib/context.ts"] + All --> Context + Flow --> Context + Entry --> Gen["lib/gen.ts"] + Entry --> Dispose["lib/dispose.ts"] + Entry --> RetryOpts["lib/retry.ts"] + Context --> Errors["lib/errors.ts"] + Retry --> Errors + Timeout --> Errors +``` + +### Request Flow + +For any call like `try$.retry(3).timeout(1000).run({...})`: + +```mermaid +sequenceDiagram + participant User + participant Index as src/index.ts + participant Builder as TryBuilder + participant Runner as runner.ts + participant Ctx as context.ts + + User->>Index: try$.retry(3) + Index->>Builder: new TryBuilder().retry(3) + Builder-->>User: TryBuilder{retry} + + User->>Builder: .timeout(1000) + Builder-->>User: TryBuilder{retry, timeout} + + User->>Builder: .run({try, catch}) + Builder->>Runner: executeRun(config, options) + Runner->>Ctx: createContext(config) + Ctx-->>Runner: TryCtx{signal, retry metadata} + Runner->>Runner: retry loop w/ timeout + Runner-->>User: T | E +``` + +## Builder Pattern + +### BuilderConfig + +The builder accumulates an immutable configuration object: + +```ts +interface BuilderConfig { + retry?: RetryPolicy + timeout?: TimeoutOptions + signal?: AbortSignal + wraps?: WrapFn[] +} +``` + +### TryBuilder + +Each chainable method returns a **new** builder instance. Terminal methods +delegate to their respective execution modules. + +```ts +class TryBuilder { + readonly #config: BuilderConfig + + constructor(config: BuilderConfig = {}) { + this.#config = config + } + + // Chainable config methods (return new builder) + + retry(policy: number | RetryPolicy): TryBuilder { + return new TryBuilder({ + ...this.#config, + retry: normalizeRetryPolicy(policy), + }) + } + + timeout(options: number | TimeoutOptions): TryBuilder { + return new TryBuilder({ + ...this.#config, + timeout: normalizeTimeoutOptions(options), + }) + } + + signal(signal: AbortSignal): TryBuilder { + return new TryBuilder({ ...this.#config, signal }) + } + + wrap(fn: WrapFn): TryBuilder { + return new TryBuilder({ + ...this.#config, + wraps: [...(this.#config.wraps ?? []), fn], + }) + } + + // Terminal methods (execute using accumulated config) + + run(options: RunOptions) { + return executeRun(this.#config, options) + } + + all(tasks: T) { + return executeAll(this.#config, tasks) + } + + allSettled(tasks: T) { + return executeAllSettled(this.#config, tasks) + } + + flow(tasks: T) { + return executeFlow(this.#config, tasks) + } +} +``` + +Config methods use last-write-wins semantics, except `wrap` which is additive +(appends to a list to form a middleware chain). + +### Public API Surface (src/index.ts) + +`src/index.ts` is a thin re-export layer with no logic. It creates a default +immutable `TryBuilder` instance and delegates to it so the namespace functions +mirror the builder: + +```ts +import { TryBuilder } from "./lib/builder" +import { executeGen } from "./lib/gen" +import { createDisposer } from "./lib/dispose" + +export { retryOptions } from "./lib/retry" + +const root = new TryBuilder() + +// Chainable entry points -- return a builder +export const retry: TryBuilder["retry"] = root.retry.bind(root) +export const timeout: TryBuilder["timeout"] = root.timeout.bind(root) +export const signal: TryBuilder["signal"] = root.signal.bind(root) +export const wrap: TryBuilder["wrap"] = root.wrap.bind(root) + +// Terminal entry points -- execute with empty config +export const run: TryBuilder["run"] = root.run.bind(root) +export const all: TryBuilder["all"] = root.all.bind(root) +export const allSettled: TryBuilder["allSettled"] = root.allSettled.bind(root) +export const flow: TryBuilder["flow"] = root.flow.bind(root) + +// Standalone functions (not part of builder chain) +export const gen = executeGen +export const dispose = createDisposer +``` + +This enables both usage styles: + +```ts +try$.retry(3).timeout(1000).signal(signal).run({ ... }) +const policy = try$.retryOptions({ limit: 3, delayMs: 300, backoff: "exponential" }) +try$.retry(policy).run({ ... }) +try$.run({ ... }) +try$.gen(function* (use) { ... }) +try$.dispose() +``` + +## Error Model + +All custom errors extend `Error` and preserve the original error as `cause`. + +| Error | When | +| --------------------- | --------------------------------------------------------------- | +| `TimeoutError` | Timeout expires (total scope) | +| `RetryExhaustedError` | All retry attempts have been exhausted | +| `CancellationError` | The provided `AbortSignal` fires | +| `UnhandledException` | The `try` function throws and no `catch` handler was provided | +| `Panic` | The `catch` handler itself throws -- an unrecoverable situation | + +### Error Hierarchy + +```mermaid +graph TD + BaseError["Error (native)"] + BaseError --> TimeoutError + BaseError --> RetryExhaustedError + BaseError --> CancellationError + BaseError --> UnhandledException + BaseError --> Panic +``` + +`UnhandledException` wraps the original thrown value so the caller gets a typed, +inspectable error instead of an unknown. `Panic` signals a bug in the error +handling code itself and should never be caught in normal application flow. + +## Module Responsibilities + +### lib/types.ts + +All shared TypeScript types and interfaces: + +- `TryCtx` -- context passed to `try` functions (carries `signal`, retry + metadata) +- `RunOptions` -- + `{ try: (ctx: TryCtx) => MaybePromise, catch: (e: unknown) => MaybePromise }` +- `RetryPolicy` -- `{ limit, delayMs, backoff }` and shorthand `number` +- `TimeoutOptions` -- `{ ms, scope: "total" }` in v1 +- `WrapFn` -- signature for wrap middleware +- `FlowExit` -- branded type for flow exit values +- `AllContext` / `FlowContext` -- `this`-context types with `$result`, `$signal`, + `$disposer`, `$exit` + +### lib/errors.ts + +Custom error classes as described in the error model above. + +### lib/context.ts + +Creates the `TryCtx` object passed into user functions. Manages the internal +`AbortController` that composes external signals with timeout signals. Carries +retry attempt metadata (current attempt number, total limit). + +### lib/retry.ts + +- `retryOptions()` factory for creating reusable policies +- Internal retry loop with linear and exponential backoff strategies +- Respects abort signals between retry attempts +- Normalizes shorthand (`3`) to full policy + (`{ limit: 3, delayMs: 0, backoff: "linear" }`) + +### lib/timeout.ts + +- **Total-scoped** timeout: wraps entire execution including retries, backoff, + and `catch` +- Creates an internal `AbortController` that races with the timeout +- Normalizes shorthand (`1000`) to full options + (`{ ms: 1000, scope: "total" }`) + +### lib/signal.ts + +- Wires an external `AbortSignal` into the internal `AbortController` +- Propagates abort to all child operations +- Cleans up listeners when execution completes + +### lib/runner.ts + +The core execution engine behind `run()`: + +1. Creates a `TryCtx` from the builder config +2. Applies wrap middleware chain (if any) +3. Enters the retry loop (if configured) +4. Sets up timeout (if configured) +5. Calls the user's `try` function with the context +6. On error: calls `catch` to map user-function errors, or returns + `UnhandledException` if no `catch` handler was provided +7. If `catch` throws: throws `Panic` + +Execution rules: + +- `run` has two call forms: + - object form: `run({ try, catch })` + - function form: `run(tryFn)` where missing `catch` implies + `UnhandledException` mapping on user throws +- If `.signal(...)` is configured, `CancellationError` is part of the possible + result union. +- `catch` can be async; timeout still applies to its execution. +- `catch` receives the original thrown user value for user-function failures. + Framework control errors bypass `catch`. + +### lib/gen.ts + +Generator-based composition for unwrapping results: + +```ts +const value = try$.gen(function* (use) { + const user = yield* use(getUser("123")) + const project = yield* use(getProject(user.id)) + return project +}) +// value is Project | UserNotFound | ProjectNotFound +``` + +`use()` yields the value on success or short-circuits the generator on error, +accumulating error types in the return union. + +### lib/dispose.ts + +Resource disposal using `Symbol.asyncDispose`: + +```ts +await using disposer = try$.dispose() +const conn = await connectDb() +disposer.use(conn) +disposer.defer(() => console.log("cleanup")) +``` + +- `dispose()` returns a disposer implementing `Symbol.asyncDispose` +- `.use(resource)` registers a disposable resource for cleanup +- `.defer(fn)` registers an arbitrary cleanup callback +- Disposal runs in reverse registration order +- Uses `AsyncDisposableStack` as the cleanup primitive +- If one cleanup throws, remaining cleanups still run and cleanup failures are + aggregated + +### lib/all.ts + +`Promise.all` / `Promise.allSettled` alternatives with richer context: + +- Named tasks with `this.$result` for inter-task dependency resolution +- `this.$signal` for abort signal access +- `this.$disposer` for resource cleanup registration +- `all()` fails fast on first error; `allSettled()` waits for all + +### lib/flow.ts + +Task orchestration with early exit: + +- `this.$exit(value)` throws internally to interrupt the flow +- `this.$result` for accessing prior task results +- `this.$signal` for abort signal access +- Return type is a union of all `$exit()` return types, extracted via + `FlowExit` branded type +- Early `$exit` still triggers registered cleanup deterministically via + `AsyncDisposableStack` + +### lib/wrap.ts + +Middleware system for extensibility: + +```ts +try$ + .wrap(span("fetchUser")) + .wrap(logTiming()) + .run(...) +``` + +- Wrap functions receive `TryCtx` and compose into a middleware chain +- Access to retry metadata: `ctx.retry.attempt` +- Wraps execute around full run execution (not per attempt) + +## Implementation Order + +Building bottom-up to minimize rework: + +1. `lib/types.ts` + `lib/errors.ts` -- foundation types +2. `lib/context.ts` -- TryCtx +3. `lib/runner.ts` -- basic `run()` without retry/timeout +4. `lib/retry.ts` -- retry logic + `retryOptions()` +5. `lib/timeout.ts` -- timeout logic +6. `lib/signal.ts` -- abort signal wiring +7. `lib/builder.ts` -- fluent chain wiring it all together +8. `lib/wrap.ts` -- middleware +9. `lib/gen.ts` -- generator composition +10. `lib/dispose.ts` -- disposer +11. `lib/all.ts` -- parallel execution +12. `lib/flow.ts` -- task orchestration +13. `index.ts` -- wire public re-exports diff --git a/.context/plan.md b/.context/plan.md new file mode 100644 index 0000000..5e3fc8c --- /dev/null +++ b/.context/plan.md @@ -0,0 +1,239 @@ +# hardtry Implementation Plan (v1) + +Goal: implement `hardtry` incrementally with behavior locked by +`.context/design.md`, validating each method before moving forward. + +## Progress Checklist + +- [x] Phase 0 - Foundation +- [x] Phase 1 - `run` (sync) +- [ ] Phase 2 - `run` (async) +- [ ] Phase 3 - `retryOptions` + `retry` +- [ ] Phase 4 - `timeout` (v1 total scope only) +- [ ] Phase 5 - `.signal(...)` cancellation +- [ ] Phase 6 - `wrap` middleware (full-run scope) +- [ ] Phase 7 - Builder API + root namespace exports +- [ ] Phase 8 - `dispose` with `AsyncDisposableStack` +- [ ] Phase 9 - `all` and `allSettled` +- [ ] Phase 10 - `flow` + `$exit` +- [ ] Phase 11 - `gen` +- [ ] Phase 12 - Hardening + release readiness + +## Principles + +- Build the smallest working slice first, then add one capability at a time. +- Add runtime tests and type tests at each step. +- Keep API surface stable (`try$`, `run`, `retryOptions`, etc.). +- Avoid behavior drift once a phase is marked complete. + +## Phase 0 - Foundation + +1. Create base files and exports + - `src/lib/types.ts` + - `src/lib/errors.ts` + - `src/lib/context.ts` + - `src/lib/runner.ts` + - `src/lib/builder.ts` + - wire minimal `src/index.ts` +2. Define core types + - `MaybePromise` + - `TryCtx` + - `RunOptions` and `RunTryFn` + - shared config types used by builder +3. Define error classes and codes + - `CancellationError` (`EXEC_CANCELLED`) + - `TimeoutError` (`EXEC_TIMEOUT`) + - `RetryExhaustedError` (`EXEC_RETRY_EXHAUSTED`) + - `UnhandledException` (`EXEC_UNHANDLED_EXCEPTION`) + - `Panic` (`EXEC_PANIC`) + - all include `cause` + +Exit criteria: + +- project compiles with basic stubs in place. + +## Phase 1 - `run` (sync) + +1. Implement `run(tryFn)` sync path + - if `tryFn` returns sync value, return sync value + - on throw without catch, return `UnhandledException` +2. Implement `run({ try, catch })` sync path + - `run({ ... })` requires both `try` and `catch` + - if `try` throws, call `catch` and return mapped error + - if `catch` throws, throw `Panic` +3. Add tests + - success sync + - throw to `UnhandledException` + - throw plus catch mapping + - catch throws `Panic` + +Exit criteria: + +- sync contract is stable and covered. + +## Phase 2 - `run` (async) + +1. Add async-aware execution + - if `try` returns a Promise, return a Promise + - if `catch` returns a Promise, return a Promise + - preserve sync return when both are sync + - object form still requires both `try` and `catch` +2. Keep same semantics as sync + - no behavior drift between sync and async + - `Panic` remains highest precedence for catch-throw path and is thrown +3. Add tests + - async success + - async reject to mapped catch + - async catch reject throws `Panic` + - mixed sync and async combinations + +Exit criteria: + +- overload behavior is deterministic and typed. + +## Phase 3 - `retryOptions` + `retry` + +1. Implement `retryOptions(policy)` helper + - normalize `{ limit, delayMs, backoff, shouldRetry?, maxDelayMs?, jitter? }` +2. Implement `.retry(...)` on builder + - `limit` includes first attempt + - explicit backoff formulas + - support `shouldRetry(error, ctx)` +3. Retry rules + - do not retry control errors (`Panic`, `CancellationError`, `TimeoutError`) +4. Add tests + - attempt counting + - linear and exponential formula correctness + - predicate-gated retry + +Exit criteria: + +- retry behavior matches docs. + +## Phase 4 - `timeout` (v1 total scope only) + +1. Implement `.timeout(ms | { ms, scope: "total" })` +2. Total scope includes + - all attempts + - backoff waits + - catch execution +3. Timeout maps to `TimeoutError` with cause +4. Add tests + - timeout during try + - timeout during backoff + - timeout during catch + +Exit criteria: + +- total-timeout semantics are locked. + +## Phase 5 - `.signal(...)` cancellation + +1. Implement external signal integration + - compose external `AbortSignal` into internal execution + - map to `CancellationError` with cause +2. Add precedence handling + - `Panic > CancellationError > TimeoutError > catch-mapped > UnhandledException` +3. Add tests + - pre-aborted signal + - mid-flight abort + - abort + timeout race + +Exit criteria: + +- cancellation semantics are stable and typed. + +## Phase 6 - `wrap` middleware (full-run scope) + +1. Implement `.wrap(fn)` additive chain +2. Wrap applies around full run execution (not per attempt) +3. Ensure context includes retry metadata +4. Add tests + - wrap order + - runs once per `run` + - interaction with retry, timeout, and signal + +Exit criteria: + +- wrap scope and ordering are fixed. + +## Phase 7 - Builder API + root namespace exports + +1. Finalize immutable `TryBuilder` +2. Finalize `src/index.ts` with one `root` builder instance + - bound exports: `retry`, `timeout`, `signal`, `wrap`, `run`, `all`, + `allSettled`, `flow` + - standalone exports: `gen`, `dispose`, `retryOptions` +3. Add API-level tests for namespace behavior + +Exit criteria: + +- public API is stable and consistent with docs. + +## Phase 8 - `dispose` with `AsyncDisposableStack` + +1. Implement `try$.dispose()` +2. Support `.use(resource)` and `.defer(fn)` +3. Reverse-order cleanup +4. Continue cleanups if one fails and aggregate cleanup failures +5. Add tests including early-exit and abort scenarios + +Exit criteria: + +- deterministic cleanup guarantees are enforced. + +## Phase 9 - `all` and `allSettled` + +1. Implement `all(tasks)` with named task map +2. Implement `allSettled(tasks)` with native-like settled typing +3. Provide `this.$result`, `this.$signal`, and `this.$disposer` +4. Add tests + - dependency access via `$result` + - mixed outcomes + - cancellation mid-flight + +Exit criteria: + +- task APIs behave consistently and inference is acceptable. + +## Phase 10 - `flow` + `$exit` + +1. Implement `flow(tasks)` orchestration +2. Implement `$exit(value)` early-return mechanism +3. Ensure early exit still triggers disposer cleanup +4. Add tests + - early exit with pending tasks + - cleanup on exit + - typed `FlowExit` extraction + +Exit criteria: + +- flow control and cleanup semantics are deterministic. + +## Phase 11 - `gen` + +1. Implement generator helper for result unwrapping +2. Preserve union typing for accumulated error types +3. Add runtime and type tests + +Exit criteria: + +- `gen` ergonomics and typing are stable. + +## Phase 12 - Hardening + release readiness + +1. Add race-condition matrix tests + - retry + timeout + abort races + - catch throwing, async catch, timeout during catch + - backoff interrupted by abort +2. Add public type tests for all APIs +3. Final docs consistency pass (`design.md` + `api-proposal.md`) +4. Run quality gates + - `bun run format` + - `bun run check` + - `bun run typecheck` + - `bun run test` + +Exit criteria: + +- v1 implementation complete and doc-aligned. diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 579fa11..80bb57f 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1,8 +1,42 @@ import { describe, expect, it } from "bun:test" -import { main } from "../index" +import { Panic, UnhandledException, run } from "../index" -describe("main", () => { - it("should return a placeholder string", () => { - expect(main()).toBe("Let's bake some pastry! 🥐") +describe("run sync", () => { + it("returns value when tryFn succeeds", () => { + const value = run(() => 42) + + expect(value).toBe(42) + }) + + it("returns UnhandledException in function form", () => { + const result = run(() => { + throw new Error("boom") + }) + + expect(result).toBeInstanceOf(UnhandledException) + }) + + it("maps error when object form includes try and catch", () => { + const result = run({ + catch: () => "mapped", + try: () => { + throw new Error("boom") + }, + }) + + expect(result).toBe("mapped") + }) + + it("throws Panic when catch throws", () => { + expect(() => + run({ + catch: () => { + throw new Error("catch failed") + }, + try: () => { + throw new Error("boom") + }, + }) + ).toThrow(Panic) }) }) diff --git a/src/index.ts b/src/index.ts index 4ac69da..e775efc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,27 @@ -export function main() { - return "Let's bake some pastry! 🥐" -} +import { TryBuilder } from "./lib/builder" +import { + CancellationError, + Panic, + RetryExhaustedError, + TimeoutError, + UnhandledException, +} from "./lib/errors" +import { retryOptions } from "./lib/retry" + +const root = new TryBuilder() + +export const retry: TryBuilder["retry"] = root.retry.bind(root) +export const timeout: TryBuilder["timeout"] = root.timeout.bind(root) +export const signal: TryBuilder["signal"] = root.signal.bind(root) +export const wrap: TryBuilder["wrap"] = root.wrap.bind(root) + +export const run: TryBuilder["run"] = root.run.bind(root) +export const all: TryBuilder["all"] = root.all.bind(root) +export const allSettled: TryBuilder["allSettled"] = root.allSettled.bind(root) +export const flow: TryBuilder["flow"] = root.flow.bind(root) + +export { createDisposer as dispose } from "./lib/dispose" +export { executeGen as gen } from "./lib/gen" + +export { retryOptions } +export { CancellationError, Panic, RetryExhaustedError, TimeoutError, UnhandledException } diff --git a/src/lib/__tests__/runner.test.ts b/src/lib/__tests__/runner.test.ts new file mode 100644 index 0000000..841a46e --- /dev/null +++ b/src/lib/__tests__/runner.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test" +import { Panic, UnhandledException } from "../errors" +import { executeRun } from "../runner" + +describe("executeRun", () => { + it("returns success value in function form", () => { + const result = executeRun({}, () => "ok" as const) + + expect(result).toBe("ok") + }) + + it("returns UnhandledException when function form throws", () => { + const result = executeRun({}, () => { + throw new Error("boom") + }) + + expect(result).toBeInstanceOf(UnhandledException) + }) + + it("maps errors in object form with try and catch", () => { + const result = executeRun( + {}, + { + catch: () => "mapped" as const, + try: () => { + throw new Error("boom") + }, + } + ) + + expect(result).toBe("mapped") + }) + + it("throws Panic when catch throws", () => { + expect(() => + executeRun( + {}, + { + catch: () => { + throw new Error("catch failed") + }, + try: () => { + throw new Error("boom") + }, + } + ) + ).toThrow(Panic) + }) + + it("passes normalized context to try", () => { + const result = executeRun( + { + retry: { limit: 3 }, + }, + (ctx) => ({ + attempt: ctx.retry.attempt, + hasSignal: Boolean(ctx.signal), + limit: ctx.retry.limit, + }) + ) + + expect(result).toEqual({ + attempt: 1, + hasSignal: false, + limit: 3, + }) + }) +}) diff --git a/src/lib/builder.ts b/src/lib/builder.ts new file mode 100644 index 0000000..7adef7f --- /dev/null +++ b/src/lib/builder.ts @@ -0,0 +1,75 @@ +import type { UnhandledException } from "./errors" +import type { + BuilderConfig, + RetryPolicy, + RunTryFn, + RunWithCatchOptions, + TaskMap, + TimeoutOptions, + WrapFn, +} from "./types" +import { normalizeRetryPolicy } from "./retry" +import { executeRun } from "./runner" + +function normalizeTimeoutOptions(options: number | TimeoutOptions): TimeoutOptions { + if (typeof options === "number") { + return { ms: options, scope: "total" } + } + + return options +} + +export class TryBuilder { + readonly #config: BuilderConfig + + constructor(config: BuilderConfig = {}) { + this.#config = config + } + + retry(policy: number | RetryPolicy): TryBuilder { + return new TryBuilder({ + ...this.#config, + retry: normalizeRetryPolicy(policy), + }) + } + + timeout(options: number | TimeoutOptions): TryBuilder { + return new TryBuilder({ + ...this.#config, + timeout: normalizeTimeoutOptions(options), + }) + } + + signal(signal: AbortSignal): TryBuilder { + return new TryBuilder({ ...this.#config, signal }) + } + + wrap(fn: WrapFn): TryBuilder { + return new TryBuilder({ + ...this.#config, + wraps: [...(this.#config.wraps ?? []), fn], + }) + } + + run(tryFn: RunTryFn): T | UnhandledException + run(options: RunWithCatchOptions): T | E + + run(input: RunTryFn | RunWithCatchOptions) { + return executeRun(this.#config, input) + } + + all(_tasks: TaskMap): never { + void this.#config + throw new Error("all is not implemented yet") + } + + allSettled(_tasks: TaskMap): never { + void this.#config + throw new Error("allSettled is not implemented yet") + } + + flow(_tasks: TaskMap): never { + void this.#config + throw new Error("flow is not implemented yet") + } +} diff --git a/src/lib/context.ts b/src/lib/context.ts new file mode 100644 index 0000000..0b8bd38 --- /dev/null +++ b/src/lib/context.ts @@ -0,0 +1,11 @@ +import type { BuilderConfig, TryCtx } from "./types" + +export function createContext(config: BuilderConfig): TryCtx { + return { + retry: { + attempt: 1, + limit: config.retry?.limit ?? 1, + }, + signal: config.signal, + } +} diff --git a/src/lib/dispose.ts b/src/lib/dispose.ts new file mode 100644 index 0000000..2b2e3bc --- /dev/null +++ b/src/lib/dispose.ts @@ -0,0 +1,3 @@ +export function createDisposer(): never { + throw new Error("dispose is not implemented yet") +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..126e193 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,94 @@ +import type { ErrorCode } from "./types" + +export interface ExecutionErrorOptions { + cause?: unknown + message?: string +} + +interface ExecutionErrorInit extends ExecutionErrorOptions { + code: ErrorCode + defaultMessage: string + name: string +} + +abstract class ExecutionError extends Error { + readonly code: ErrorCode + + protected constructor(options: ExecutionErrorInit) { + const { cause, code, defaultMessage, message, name } = options + + super(message ?? defaultMessage, cause === undefined ? undefined : { cause }) + this.code = code + this.name = name + } +} + +export class CancellationError extends ExecutionError { + constructor(options: ExecutionErrorOptions = {}) { + const { cause, message } = options + + super({ + cause, + code: "EXEC_CANCELLED", + defaultMessage: "Execution was cancelled", + message, + name: "CancellationError", + }) + } +} + +export class TimeoutError extends ExecutionError { + constructor(options: ExecutionErrorOptions = {}) { + const { cause, message } = options + + super({ + cause, + code: "EXEC_TIMEOUT", + defaultMessage: "Execution timed out", + message, + name: "TimeoutError", + }) + } +} + +export class RetryExhaustedError extends ExecutionError { + constructor(options: ExecutionErrorOptions = {}) { + const { cause, message } = options + + super({ + cause, + code: "EXEC_RETRY_EXHAUSTED", + defaultMessage: "Retry attempts exhausted", + message, + name: "RetryExhaustedError", + }) + } +} + +export class UnhandledException extends ExecutionError { + constructor(options: ExecutionErrorOptions = {}) { + const { cause, message } = options + + super({ + cause, + code: "EXEC_UNHANDLED_EXCEPTION", + defaultMessage: "Unhandled exception", + message, + name: "UnhandledException", + }) + } +} + +export class Panic extends ExecutionError { + constructor(options: ExecutionErrorOptions = {}) { + const { cause, message } = options + + super({ + cause, + code: "EXEC_PANIC", + defaultMessage: "Panic: catch handler failed", + message, + name: "Panic", + }) + } +} diff --git a/src/lib/gen.ts b/src/lib/gen.ts new file mode 100644 index 0000000..efa8319 --- /dev/null +++ b/src/lib/gen.ts @@ -0,0 +1,3 @@ +export function executeGen(): never { + throw new Error("gen is not implemented yet") +} diff --git a/src/lib/retry.ts b/src/lib/retry.ts new file mode 100644 index 0000000..db421a3 --- /dev/null +++ b/src/lib/retry.ts @@ -0,0 +1,21 @@ +import type { RetryPolicy } from "./types" + +export function normalizeRetryPolicy(policy: number | RetryPolicy): RetryPolicy { + if (typeof policy === "number") { + return { + backoff: "linear", + delayMs: 0, + limit: policy, + } + } + + return { + backoff: policy.backoff ?? "linear", + delayMs: policy.delayMs ?? 0, + ...policy, + } +} + +export function retryOptions(policy: RetryPolicy): RetryPolicy { + return normalizeRetryPolicy(policy) +} diff --git a/src/lib/runner.ts b/src/lib/runner.ts new file mode 100644 index 0000000..55ca09e --- /dev/null +++ b/src/lib/runner.ts @@ -0,0 +1,31 @@ +import type { BuilderConfig, RunInput, RunTryFn, RunWithCatchOptions } from "./types" +import { createContext } from "./context" +import { Panic, UnhandledException } from "./errors" + +export function executeRun(config: BuilderConfig, input: RunTryFn): T | UnhandledException +export function executeRun(config: BuilderConfig, input: RunWithCatchOptions): T | E +export function executeRun( + config: BuilderConfig, + input: RunInput +): T | E | UnhandledException +export function executeRun(config: BuilderConfig, input: RunInput) { + const ctx = createContext(config) + + if (typeof input === "function") { + try { + return input(ctx) + } catch (error) { + return new UnhandledException({ cause: error }) + } + } + + try { + return input.try(ctx) + } catch (error) { + try { + return input.catch(error) + } catch (catchError) { + throw new Panic({ cause: catchError }) + } + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..cb06a9e --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,53 @@ +export type MaybePromise = T | Promise + +export type ErrorCode = + | "EXEC_CANCELLED" + | "EXEC_TIMEOUT" + | "EXEC_RETRY_EXHAUSTED" + | "EXEC_UNHANDLED_EXCEPTION" + | "EXEC_PANIC" + +export interface RetryInfo { + attempt: number + limit: number +} + +export interface TryCtx { + signal?: AbortSignal + retry: RetryInfo +} + +export type RunTryFn = (ctx: TryCtx) => T +export type RunCatchFn = (error: unknown) => E + +export interface RunWithCatchOptions { + try: RunTryFn + catch: RunCatchFn +} + +export type RunInput = RunTryFn | RunWithCatchOptions + +export interface RetryPolicy { + limit: number + delayMs?: number + backoff?: "linear" | "exponential" + maxDelayMs?: number + jitter?: boolean + shouldRetry?: (error: unknown, ctx: TryCtx) => boolean +} + +export interface TimeoutOptions { + ms: number + scope: "total" +} + +export type WrapFn = (ctx: TryCtx, next: RunTryFn) => unknown + +export interface BuilderConfig { + retry?: RetryPolicy + timeout?: TimeoutOptions + signal?: AbortSignal + wraps?: WrapFn[] +} + +export type TaskMap = Record unknown>