diff --git a/.agent/cherry_candidates.txt b/.agent/cherry_candidates.txt new file mode 100644 index 000000000..5df56d77e --- /dev/null +++ b/.agent/cherry_candidates.txt @@ -0,0 +1,69 @@ ++ 01d1e9983171d8e25984a6ac4ed5a298f784881a Disable web docs test ++ 8cb8edd906136cb044a32330aad6d9b437e5a716 Increase Node.js memory limit for build script ++ cd9d4b4ecbbec587ff015aaea23b20d792d5350e Rename searchUtils.js to searchUtils.mjs and update imports ++ fc869c3b8e3b2737ca69a1f2232f283d39c8c19a Update pnpm-lock.yaml ++ 4ff6fb9f35b1938593d134f7a14cb8c7440ed109 Update build script memory option in package.json ++ bfa58a0632d512e45673bff330dd011f9df46833 Comment out glob import and dynamic MDX loading ++ 2e4fd77c0f4877e84ce6aa82d6fe22242040cb10 Add 'out' directory to ESLint ignore patterns ++ 91695e12cac0196ca774baae0eefe651eeb48fe2 Enable verbatimModuleSyntax ++ d56abd068f5209620558dd6d0f349dc3c812393e Update pnpm-lock.yaml ++ a17ebfa0ea7448344509e3bcc7c59e336f2692d3 Add module-level JSDoc comments to all source files ++ 00a22d62f04c01aef0e09df00a14e746e4d9857e Add test helpers for seeded random generation ++ ca7f45753446ea794167cb5d7a1a187e6c1f874d Refactor test dependencies to use createTestDeps ++ e85390a23a031d32287dd5fd878acbd409b4750e Refactor imports to use 'type' for type-only imports ++ 07005cf4443ac69604d1334f2da839d3cdb372cc Reorder and clean up import statements across modules ++ aa854aad29e855fdb4ce3c6042ae83e025ffe3ea Refactor and flatten schedule module API ++ d669519371da43dde76bdb40f52bfd3e4e18d3e7 Improve formatting and terminology in API reference fixer ++ 10c21286d1f87d170bdf26edbe3b79826337267a Update TypeDoc configuration for improved entry points ++ 7b37a923c301f8d1ec648d5e0cd71f3112f3b3c2 Refactor local-first exports and rename PublicKysely ++ bf6388638d3f1a7897cfb26660f0ecc7ebc28c0a Rename SharedWorker to Worker and update references ++ 2a71c9ba7415874ed6f01ddc30c4324890c9c5f6 Update deps ++ b4cc1f9a6231975e94126c24535bd633a5c88d82 Enhance retry API with typed attempts and onRetry info ++ b3387c5bbe4d30964a89b9a6c4f1f61b47975a28 Rename Semaphore and Mutex interfaces to SemaphoreOld and MutexOld ++ a00d3e398ae2ba9e3ec9a27f8e61e722048a330b Add minPositiveInt constant and refactor usages ++ 4175f2657d89beb48231b6a55d77703fe3e6285d Refactor to use concise arrow function bodies ++ 4f7db540877bd26763b49047b1ab7e58f40522cb Fix typo in clean script for coverage directory ++ 4563c4894e4ef6042dd9bca11c2a4361cfe0270b Improve MDX search error handling and API title formatting ++ 4cd00329a993f412120bd96faaae805b1cf9b44f Add custom TypeDoc plugin for Evolu Type ++ d786ab915c6412ca2082dccfd4dab39c14066c28 Update pnpm-lock.yaml ++ e52efe403982b46bbc9df923c41b1b5506afd30c Refactor 404 pages and remove legacy redirects ++ 50649957460072d25f0b5d16a5051ea218738670 Refactor type assertion tests to use property access ++ 2489f8ae198999ac0bbc5025ffa8231f8cc5b8a7 Promote interface usage for object types and update docs ++ 89833138fc1160bba410b4dc6ad908583c713350 Include 'out' directory in clean script ++ c85732d015ce822622741c11973563f759facdea Add LLM-friendly markdown routes and links for docs, Close #632 ++ b02fefaf56b329e6317cf1d926acf82214b3a1c5 Update pnpm-lock.yaml ++ f16af10273fe6daeefec2271a11b74e916b581cc Add concurrency primitives: Deferred, Gate, Semaphore, Mutex ++ 5c1e129056fe5a42d9faca31de4dff009363484e Generate sections.json and update MDX plugin types ++ e01133a7d3aaf1b92010dec0cc8510acedaa5fb4 Update section heading to 'API overview' ++ 7f7fa4ee3b5ac15959b70417d8f6ffb149aeb8c8 Clarify Array sorting documentation comments ++ 27a50155ae3ab827f2473362c7c62cf8ef6c3062 Update browserslist configuration in package.json ++ 9b381f845f910aac8e79ae1347ca0230a8150182 Update pnpm-lock.yaml ++ d4fcf661595620debda2bd7f07382659b759e0c2 Refactor error and event types to interfaces ++ 63034dcfe0f441bf85e5792407822653e7e0bdf4 Refactor docs and code: clarify composition, update tags ++ f379c4e6849a45d3b81742aebffc3772c13f36e7 Add DistributiveOmit utility type and tests ++ 87780a3ec8a47b99542be71cf895c1359a5c0e51 Rename lazy helpers and update usages to new names ++ fcaf9203cc874a1de4a774ef8eceb33d8d04c55a Add Typedoc link for TypeScript Omit utility type ++ 6fc3bba2b165d82180609bc5c1ee5642ad578f86 Add `todo` development placeholder function ++ 1e095e80e2d56bd267fefcb3887452a2a1f41726 Enforce arrow function style in ESLint config ++ 5751fed8784ab5c42f479f65c84b5c7e5bb77f6b Enable browser test configuration in Vitest ++ f155910c04e56e08bd446ce159c2f6a562f69638 Refactor EvoluSchema to use AnyType ++ ce83b24957096fe5a9e7716efd0dddc133d9846a Add assertType helper for type assertions ++ 0e4c79e234fa403c3d5faeeadecfd2d66806b89b Add ConsoleDep to TestDeps and createTestDeps ++ 972bc6114c7c65c3e787366be4a042312b239a27 Refactor AbortError to use 'cause' and improve Semaphore ++ c78657ce2ff49fac0a83758a184d6fc21cce6e2d Update deps ++ f2a2d4233235ec210cc75c46d3b336fd03069d6a Add FAQ and test for generic interface with Type factory ++ b7e3bf147dfa1995b3f0ebeda181d8aa1b1ecb03 Refactor Runner and Fiber state management and events ++ e356918ebb0b80e9040c7b7a72b3b3835f2d5814 Add isIterable utility and tests ++ 2efb0a80d04f1b951ff83e23717b9d91572e9602 Add numeric literal types Int1To99 and Int1To100 ++ 34bac2f8223e3e9a9df021ea3145d5d101f8d5ac Add isFunction utility and improve getProperty typing ++ 4025cddca26737ddf6a12bd20592411445aa8aae Cache ok() result to avoid repeated allocations ++ a912d90e07c77029b17d044db79c300e6ce61850 Update Iterable doc and add MDN link in typedoc config ++ c0ed4acf12c4dff6ceaeb0f83a547fa3a7cc4e4f Add tests for isOk and isErr utility functions ++ beb6fd0d0a928ea27abbfca4d3f779d76d023897 Remove IntentionalNever type and update casts to never ++ df47475e2f7450200d1752a7b86d88aa56128d9e Add objectFromEntries utility and expand object tests ++ f6eac2eba8f17464424750e539d5534f6ffcdb80 Reorder imports in vitest.config.ts ++ aa6111fbef23d5132b8a792273e98cd6ed9d1231 Implement all() and withConcurrency for Task composition ++ ba4cbe36d45d99de4cff242ef8fa1332c2b8030f Fix typo in expo-sqlite config key in app.json ++ be80a5c885cc3b8427bb3b227b07901481e86850 Remove object enum style guidelines from instructions ++ e013cfd07d4fae324d734feb916e2b21759942a6 Replace createArray and ensureArray with arrayFrom ++ a4656957a5d7fb73c5a1e9c859300b0a85e5577b Add task composition helpers and improve concurrency diff --git a/.agent/phase1_subjects.txt b/.agent/phase1_subjects.txt new file mode 100644 index 000000000..e4f34f272 --- /dev/null +++ b/.agent/phase1_subjects.txt @@ -0,0 +1,17 @@ +fix(common): re-export emptyRows and fix IDE resolution issues +fix(test): update Assert.test.ts to match AbortError schema +fix(react-native): replace remaining constVoid usage +fix(packages): globally replace missing constant utils +fix(nodejs): replace missing constVoid with no-op function +fix(ci): migrate Web Build workflow to Bun +fix(ci): migrate CI workflow to Bun +chore: finalize common-v8 cherry-pick (build fixes, docs, settings) +feat: refactor phase 1 +Add numeric literal types Int1To99 and Int1To100 +fix(Common): Resolve cherry-pick conflicts, types, and formatting for common-v8 +Add task composition helpers and improve concurrency +Fix typo in expo-sqlite config key in app.json +Add Promise.try polyfill to react-expo example +Clarify function overloads guidance in conventions docs +Improve documentation for lazy utility function +Implement all() and withConcurrency for Task composition diff --git a/.changeset/cherry-pick-common-v8.md b/.changeset/cherry-pick-common-v8.md new file mode 100644 index 000000000..e65df32bb --- /dev/null +++ b/.changeset/cherry-pick-common-v8.md @@ -0,0 +1,8 @@ +--- +"@evolu/common": minor +--- + +Added `allSettled`, `forEach`, `any`, and `withConcurrency` to `Task` module. +Updates documentation conventions and improved lazy docs. +Polyfilled `Promise.withResolvers` and `Promise.try` for compatibility. +Fixed build errors related to strict type checking. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7a3cca515..cf9403896 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,11 +20,16 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup - uses: ./.github/actions/setup-node-pnpm-install + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install - name: Install Playwright browsers - run: npx playwright install --with-deps + run: bun x playwright install --with-deps - name: Verify - run: pnpm verify + run: bun run verify diff --git a/.github/workflows/web-build.yaml b/.github/workflows/web-build.yaml index e888dd940..79088dd26 100644 --- a/.github/workflows/web-build.yaml +++ b/.github/workflows/web-build.yaml @@ -26,10 +26,15 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup - uses: ./.github/actions/setup-node-pnpm-install + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install - name: Build Web env: NODE_OPTIONS: "--max_old_space_size=8192" - run: pnpm build && pnpm build:web + run: bun run build && bun run build:web diff --git a/.vscode/settings.json b/.vscode/settings.json index 9d2841ab2..9f086f5cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,37 +1,37 @@ { "typescript.tsdk": "node_modules/typescript/lib", - "editor.defaultFormatter": "biomejs.biome", + // "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, "editor.codeActionsOnSave": { "quickfix.biome": "explicit", "source.organizeImports.biome": "explicit" }, "[json]": { - "editor.defaultFormatter": "biomejs.biome" + // "editor.defaultFormatter": "biomejs.biome" }, "[jsonc]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "vscode.json-language-features" }, "[markdown]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "yzhang.markdown-all-in-one" }, "[yaml]": { "editor.defaultFormatter": "redhat.vscode-yaml" }, "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" + // "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" + // "editor.defaultFormatter": "biomejs.biome" }, "[javascript]": { - "editor.defaultFormatter": "biomejs.biome" + // "editor.defaultFormatter": "biomejs.biome" }, "[javascriptreact]": { - "editor.defaultFormatter": "biomejs.biome" + // "editor.defaultFormatter": "biomejs.biome" }, "[svelte]": { - "editor.defaultFormatter": "svelte.svelte-vscode" + // "editor.defaultFormatter": "svelte.svelte-vscode" }, "files.associations": { "*.mdx": "mdx" @@ -43,5 +43,6 @@ "**/.turbo": true, "**/coverage": true, "**/out": true - } -} + }, + "vitest.disableWorkspaceWarning": true +} \ No newline at end of file diff --git a/README.md b/README.md index c6bba4465..c70b5e9ce 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ Evolu is a local-first platform designed for privacy, ease of use, and no vendor - [Bun](https://bun.sh) (latest) - Node.js >= 22 +## Roadmap + +See [ROADMAP.md](./ROADMAP.md) for current progress and future plans. + ## Development Evolu is a monorepo managed by **Turbo** and **Bun**. We use **Biome** for linting and formatting. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..d9f589097 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,15 @@ +# Roadmap + +## Phase 1: Cherry-pick common-v8 (Completed) +- [x] Integrate `all`, `withConcurrency` (aa6111fb) +- [x] Integrate `allSettled`, `forEach`, `any` (a4656957) +- [x] Documentation updates +- [x] Strict build fixes (Test.ts, Task.test.ts) + +## Phase 2: Refactoring (Upcoming) +- [ ] `arrayFrom` refactor (Commit `e013cfd0`) + - **Breaking Change**: Replace `createArray`/`ensureArray` with `arrayFrom`. + - Requires updates across all packages. + +## Phase 3: Future +- [ ] Further common-v8 synchronizations. diff --git a/apps/web/src/app/(docs)/docs/conventions/page.mdx b/apps/web/src/app/(docs)/docs/conventions/page.mdx index f9470b8d8..f67917664 100644 --- a/apps/web/src/app/(docs)/docs/conventions/page.mdx +++ b/apps/web/src/app/(docs)/docs/conventions/page.mdx @@ -192,9 +192,9 @@ Why arrow functions? - **No hoisting** - Combined with `const`, arrow functions aren't hoisted, which enforces top-down code organization - **Currying** - Arrow functions make currying natural for [dependency injection](/docs/dependency-injection) -**Exception: function overloads.** TypeScript requires the `function` keyword for overloaded signatures: +**Exception: function overloads.** While overloading with arrow functions is possible (using a type with multiple call signatures), it can be hard to type properly because the implementation must satisfy all overloads at once, which TypeScript often can't verify without assertions. Use the `function` keyword instead: -```ts +````ts function mapArray( array: NonEmptyReadonlyArray, mapper: (item: T) => U, @@ -209,7 +209,6 @@ function mapArray( ): ReadonlyArray { return array.map(mapper) as ReadonlyArray; } -``` **In interfaces too.** Use arrow function syntax for interface methods—otherwise ESLint won't allow passing them as references due to JavaScript's `this` binding issues. @@ -225,7 +224,7 @@ interface FooAvoid { bar(value: string): void; baz(): number; } -``` +```` ## Function options diff --git a/examples/react-expo/app.json b/examples/react-expo/app.json index a5898e877..3fe51a196 100644 --- a/examples/react-expo/app.json +++ b/examples/react-expo/app.json @@ -1,57 +1,57 @@ { - "expo": { - "name": "expo-app", - "slug": "expo-app", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "myapp", - "userInterfaceStyle": "automatic", - "newArchEnabled": true, - "ios": { - "supportsTablet": true, - "bundleIdentifier": "com.evolu.expo" - }, - "android": { - "package": "com.evolu.expo", - "adaptiveIcon": { - "foregroundImage": "./assets/images/adaptive-icon.png", - "backgroundColor": "#ffffff" - } - }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/images/favicon.png" - }, - "plugins": [ - "expo-router", - [ - "expo-splash-screen", - { - "image": "./assets/images/splash-icon.png", - "imageWidth": 200, - "resizeMode": "contain", - "backgroundColor": "#ffffff" - } - ], - [ - "expo-secure-store", - { - "configureAndroidBackup": true, - "faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data." - } - ], - [ - "expo-sqlite", - { - "useSQLCipher": true - } - ] - ], - "experiments": { - "typedRoutes": true, - "reactCanary": true - } - } + "expo": { + "name": "expo-app", + "slug": "expo-app", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "myapp", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.evolu.expo" + }, + "android": { + "package": "com.evolu.expo", + "adaptiveIcon": { + "foregroundImage": "./assets/images/adaptive-icon.png", + "backgroundColor": "#ffffff" + } + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff" + } + ], + [ + "expo-secure-store", + { + "configureAndroidBackup": true, + "faceIDPermission": "Allow $(PRODUCT_NAME) to access your Face ID biometric data." + } + ], + [ + "expo-sqlite", + { + "useSQLCipher": true + } + ] + ], + "experiments": { + "typedRoutes": true, + "reactCanary": true + } + } } diff --git a/examples/react-expo/polyfills.ts b/examples/react-expo/polyfills.ts index a1e0b6eef..4170909de 100644 --- a/examples/react-expo/polyfills.ts +++ b/examples/react-expo/polyfills.ts @@ -37,4 +37,13 @@ export const installPolyfills = (): void => { return { promise, resolve, reject }; }; } + + // @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/try + if (typeof Promise.try === "undefined") { + // @ts-expect-error This is OK. + Promise.try = ( + func: (...args: ReadonlyArray) => unknown, + ...args: ReadonlyArray + ): Promise => new Promise((resolve) => resolve(func(...args))); + } }; diff --git a/packages/common/src/Function.ts b/packages/common/src/Function.ts index 7c46a3cd0..8cbba394a 100644 --- a/packages/common/src/Function.ts +++ b/packages/common/src/Function.ts @@ -165,7 +165,22 @@ export function readonly( */ export type Lazy = () => T; -/** Creates a {@link Lazy} from a value. Useful for defining constant thunks. */ +/** + * Creates a {@link Lazy} from a precomputed value. + * + * Use when the value is expensive to compute and want to compute it once at + * definition time rather than on every call. + * + * ### Example + * + * ```ts + * // Computed once at definition, returned on every call + * const getConfig = lazy(parseConfig(rawConfig)); + * + * // vs. computed on every call + * const getConfig = () => parseConfig(rawConfig); + * ``` + */ export const lazy = (value: T): Lazy => () => diff --git a/packages/common/src/Option.ts b/packages/common/src/Option.ts index 5b214069e..7258d30e8 100644 --- a/packages/common/src/Option.ts +++ b/packages/common/src/Option.ts @@ -30,13 +30,13 @@ export type Option = Some | None; /** Present value in an {@link Option}. */ export interface Some { - readonly type: "Some"; - readonly value: T; + readonly type: "Some"; + readonly value: T; } /** Absent value in an {@link Option}. */ export interface None { - readonly type: "None"; + readonly type: "None"; } /** @@ -45,12 +45,12 @@ export interface None { * @group Utilities */ export type InferOption> = - O extends Some ? T : never; + O extends Some ? T : never; /** Creates a {@link Some}. */ export const some = (value: T): Option => ({ - type: "Some", - value, + type: "Some", + value, }); /** Shared {@link None} instance. */ @@ -58,11 +58,11 @@ export const none: None = { type: "None" }; /** Type guard for {@link Some}. */ export const isSome = (option: Option): option is Some => - option.type === "Some"; + option.type === "Some"; /** Type guard for {@link None}. */ export const isNone = (option: Option): option is None => - option.type === "None"; + option.type === "None"; /** * Converts a nullable value to an {@link Option}. @@ -70,5 +70,5 @@ export const isNone = (option: Option): option is None => * `null` and `undefined` become {@link none}. */ export const fromNullable = ( - value: T | null | undefined, + value: T | null | undefined, ): Option> => (value == null ? none : some(value)); diff --git a/packages/common/src/Task.ts b/packages/common/src/Task.ts index 72c9e897d..709876a0e 100644 --- a/packages/common/src/Task.ts +++ b/packages/common/src/Task.ts @@ -1,41 +1,79 @@ /** - * Structured concurrency with fibers, runners, and cancellation. + * JavaScript-native structured concurrency. * * @module */ -import { isNonEmptyArray } from "./Array.js"; -import { assert } from "./Assert.js"; -import type { RandomBytesDep } from "./Crypto.js"; +import { + createArray, + emptyArray, + ensureArray, + isNonEmptyArray, + mapArray, + type NonEmptyReadonlyArray, +} from "./Array.js"; +import { assert, assertType } from "./Assert.js"; +import { createConsole, type Console, type ConsoleDep } from "./Console.js"; +import type { RandomBytes, RandomBytesDep } from "./Crypto.js"; import { createRandomBytes } from "./Crypto.js"; import { eqArrayStrict } from "./Eq.js"; import { lazyTrue, lazyVoid } from "./Function.js"; import { decrement, increment } from "./Number.js"; +import { + emptyRecord, + isFunction, + isIterable, + objectFromEntries, + objectToEntries, +} from "./Object.js"; import type { Random, RandomDep } from "./Random.js"; import { createRandom } from "./Random.js"; -import type { Ref } from "./Ref.js"; -import type { Done, NextResult, Result } from "./Result.js"; +import { type Ref } from "./Ref.js"; +import type { Done, NextResult, Ok, Result } from "./Result.js"; import { err, ok, tryAsync } from "./Result.js"; import type { Schedule, ScheduleStep } from "./Schedule.js"; import { addToSet, deleteFromSet, emptySet } from "./Set.js"; import type { Duration, Time, TimeDep } from "./Time.js"; -import { Millis, createTime, durationToMillis } from "./Time.js"; +import { createTime, durationToMillis, Millis } from "./Time.js"; import type { TracerConfigDep, TracerDep } from "./Tracer.js"; import { - Id, - NonNegativeInt, - PositiveInt, - Unknown, - UnknownResult, - brand, - createId, - minPositiveInt, - typed, - union, - type InferType, - type Typed, + brand, + createId, + Id, + maxPositiveInt, + minPositiveInt, + NonNegativeInt, + object, + PositiveInt, + typed, + union, + Unknown, + UnknownResult, + type InferType, + type Typed, } from "./Type.js"; -import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; +import { + type Awaitable, + type Callback, + type Mutable, + type Predicate, +} from "./Types.js"; + +const withResolvers = (): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} => { + let resolve: (value: T | PromiseLike) => void; + let reject: (reason?: any) => void; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // @ts-expect-error assigned in executor + return { promise, resolve, reject }; +}; /** * JavaScript-native structured concurrency. @@ -51,8 +89,8 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * * Evolu implements structured concurrency with these types: * - * - **{@link Task}** — a function that takes {@link Runner}, optional deps, and - * returns {@link Awaitable} (sync or async) {@link Result} + * - **{@link Task}** — a function that takes {@link Runner} and deps, returning + * {@link Awaitable} (sync or async) {@link Result} * - **{@link Runner}** — runs tasks, creates {@link Fiber}s, monitors and aborts * them * - **{@link Fiber}** — awaitable, abortable/disposable handle to a running task @@ -62,10 +100,14 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * Evolu's structured concurrency core is minimal — one function with a few * flags and helper methods using native APIs. * + * Task's `deps` argument is for dependency injection — dependencies are defined + * as interfaces, wrapped to avoid clashes, and passed to every task + * automatically by the runner. + * * ### Example * * ```ts - * // Pure DI — deps as interface. + * // A dependency — wraps native fetch for testability. * interface NativeFetchDep { * readonly fetch: typeof globalThis.fetch; * } @@ -75,8 +117,8 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * readonly error: unknown; * } * - * // A Task wrapping native fetch. - * const fetchUrl = + * // A Task wrapping native fetch — adds abortability. + * const fetch = * (url: string): Task => * ({ signal }, deps) => * tryAsync( @@ -87,7 +129,7 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * }, * ); * - * // A composition root. + * // In a composition root… * const deps: NativeFetchDep = { * fetch: globalThis.fetch.bind(globalThis), * }; @@ -96,13 +138,13 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * await using run = createRunner(deps); * * // Running a task returns a fiber that can be awaited. - * const result = await run(fetchUrl("/users/123")); + * const result = await run(fetch("/users/123")); * expectTypeOf(result).toEqualTypeOf< * Result * >(); * * // A fiber can also be aborted (or disposed with `using`). - * const fiber = run(fetchUrl("/users/456")); + * const fiber = run(fetch("/users/456")); * fiber.abort(); * * // When this block ends, `await using` disposes run — aborting all fibers. @@ -110,24 +152,31 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * * ## Composition * - * Tasks are lazy — just functions — so they can be wrapped and combined before - * running with helpers like: + * Compose tasks with: * + * - {@link yieldNow} — yield to event loop + * - {@link sleep} — pause execution * - {@link timeout} — time-bounded execution * - {@link retry} — retry with backoff - * - {@link all} — run tasks concurrently, fail fast + * - {@link repeat} — repeat with schedule + * - {@link all} — values, fail fast + * - {@link forEach} — side effects, fail fast + * - {@link allSettled} — results + * - {@link forEachSettled} — ignore results + * - {@link any} — first success wins + * - {@link race} — first to complete wins * - * Composition helpers add typed errors to the error union (e.g., - * {@link TimeoutError}, `RetryError`). These are for business logic — callers - * can pattern match on `error.type` to handle specific failures. + * Sequential by default (except race); use {@link withConcurrency} for parallel. * * Add timeout to prevent hanging: * * ```ts - * const fetchWithTimeout = (url: string) => timeout(fetchUrl(url), "30s"); + * const fetchWithTimeout = (url: string) => timeout(fetch(url), "30s"); * * expectTypeOf(fetchWithTimeout).toEqualTypeOf< - * (url: string) => Task + * ( + * url: string, + * ) => Task * >(); * ``` * @@ -135,42 +184,24 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * * ```ts * const fetchWithRetry = (url: string) => - * retry(fetchWithTimeout(url), take(3)(spaced("100ms"))); + * retry( + * fetchWithTimeout(url), + * // A jittered, capped, limited exponential backoff. + * jitter(1)(maxDelay("20s")(take(2)(exponential("100ms")))), + * ); * - * // RetryError wraps the original error as `cause` when all attempts fail * expectTypeOf(fetchWithRetry).toEqualTypeOf< * ( * url: string, * ) => Task< * Response, - * TimeoutError | FetchError | RetryError - * > - * >(); - * ``` - * - * Control concurrency with semaphore: - * - * ```ts - * const semaphore = createSemaphore(PositiveInt.orThrow(2)); - * - * // Semaphore adds AbortError (semaphore can be disposed) - * const fetchWithPermit = (url: string) => - * semaphore.withPermit(fetchWithRetry(url)); - * - * expectTypeOf(fetchWithPermit).toEqualTypeOf< - * ( - * url: string, - * ) => Task< - * Response, - * | TimeoutError - * | FetchError - * | AbortError - * | RetryError + * FetchError | TimeoutError | RetryError, + * NativeFetchDep * > * >(); * ``` * - * Run composed tasks in parallel: + * Run composed tasks with limited concurrency: * * ```ts * // Create a global root runner at app startup @@ -182,20 +213,18 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * "https://api.example.com/comments", * ]; * - * // Semaphore limits to 2 concurrent requests - * const results = await Promise.all( - * urls.map((url) => run(fetchWithPermit(url))), + * // At most 2 concurrent requests + * const result = await run( + * withConcurrency(2, all(urls.map(fetchWithRetry))), * ); * - * expectTypeOf(results).toEqualTypeOf< - * Array< - * Result< - * Response, - * | AbortError - * | TimeoutError - * | FetchError - * | RetryError - * > + * expectTypeOf(result).toEqualTypeOf< + * Result< + * readonly Response[], + * | TimeoutError + * | FetchError + * | AbortError + * | RetryError * > * >(); * ``` @@ -212,6 +241,8 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * {@link unabortable}, ensuring resources are acquired and released even when * abort is requested. * + * ### Example + * * ```ts * await using stack = run.stack(); * stack.defer(task); @@ -221,10 +252,12 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * * ## Awaitable * + * ```ts + * type Awaitable = T | PromiseLike; + * ``` + * * While {@link Task} returns {@link Awaitable} (allowing sync or async results), - * {@link Runner} is always async. Disposing a runner waits for all child runners - * to complete, which requires `await using`. This is a deliberate design - * choice: + * {@link Runner} is always async. This is a deliberate design choice: * * - **Sync** → {@link Result}, native `using` / `DisposableStack` * - **Async** → {@link Task}, {@link Runner}, {@link Fiber}, `await using` / @@ -232,12 +265,14 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * * Benefits: * - * - **Zero overhead** — sync code stays with zero overhead * - **No API ambiguity** — Task means async, Result means sync + * - **Zero overhead** — sync code stays with zero overhead * - * A unified sync/async API is possible — `isPromiseLike` can detect sync at - * runtime without throwing, and `await using` works with sync disposal, but we - * don't have a use case for that. + * Even though a unified sync/async API is technically possible - with + * `isPromiseLike` detection and two-phase disposal (sync first, async if + * needed, and a flag for callers) - Evolu prefers plain functions for sync code + * because almost anything can be async anyway, and when we need sync, it's for + * simplicity (no dependencies) and performance (zero abstraction). * * ## FAQ * @@ -248,25 +283,24 @@ import { type Awaitable, type Mutable, type Predicate } from "./Types.js"; * write `createRunner(deps)` once and the runner passes `deps` to * every task as a second argument. This removes repetitive plumbing. * - * ### How does it work? - * - * There are two approaches to structured concurrency in TypeScript. The - * program-as-data approach represents computations as data structures that a - * runtime interprets later. Evolu is simpler — tasks are just functions that - * run directly. It's transparent, performant, and easier to debug (native stack - * traces). - * - * @category Core + * @group Core Types */ export type Task = ( run: Runner, deps: D, ) => Awaitable>; +/** + * Shorthand for a {@link Task} with `any` type parameters. + * + * @group Type Utilities + */ +export type AnyTask = Task; + /** * Extracts the value type from a {@link Task}. * - * @category Utilities + * @group Type Utilities */ export type InferTaskOk> = R extends Task ? T : never; @@ -274,7 +308,7 @@ export type InferTaskOk> = /** * Extracts the error type from a {@link Task}. * - * @category Utilities + * @group Type Utilities */ export type InferTaskErr> = R extends Task ? E : never; @@ -282,7 +316,7 @@ export type InferTaskErr> = /** * Extracts the deps type from a {@link Task}. * - * @category Utilities + * @group Type Utilities */ export type InferTaskDeps> = R extends Task ? D : never; @@ -298,14 +332,14 @@ export type InferTaskDeps> = * Use for pull-based protocols like iterators where `Done` signals normal * completion rather than an error. * - * @category Core + * @group Core Types */ export type NextTask = Task>; /** * Extracts the done value type from a {@link NextTask}. * - * @category Utilities + * @group Type Utilities */ export type InferTaskDone> = InferTaskErr extends infer Errors @@ -318,14 +352,14 @@ export type InferTaskDone> = * Error returned when a {@link Task} is aborted via * {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | AbortSignal}. * - * The `reason` field is `unknown` by design — use typed errors for business - * logic. If you need to inspect the reason, use type guards like - * `RaceLostError.is(reason)`. + * The `cause` field is `unknown` by design — use typed errors for business + * logic. If you need to inspect the cause, use type guards like + * `RaceLostError.is(cause)`. * - * @category Core + * @group Core Types */ -export const AbortError = typed("AbortError", { reason: Unknown }); -export type AbortError = typeof AbortError.Type; +export const AbortError = typed("AbortError", { cause: Unknown }); +export interface AbortError extends InferType {} /** * Runs a {@link Task} with @@ -350,7 +384,7 @@ export type AbortError = typeof AbortError.Type; * This makes Runner idiomatic to JavaScript, tiny with minimal overhead, and * easy to debug (native stack traces). * - * @category Core + * @group Core Types * @see {@link createRunner} * @see {@link Task} */ @@ -373,29 +407,17 @@ export interface Runner extends AsyncDisposable { /** * Registers a callback to run when abort is requested. * - * Shorthand for `signal.addEventListener("abort", ..., { once: true })`. - */ - readonly onAbort: (fn: (reason: unknown) => void) => void; - - /** Returns the current {@link RunnerState}. */ - readonly getState: () => RunnerState; - - /** - * Returns the fiber's completion value. + * The callback receives the abort cause (extracted from {@link AbortError}). + * If already aborted, the callback is invoked immediately. For + * {@link unabortable} tasks, the callback is never invoked. * - * `null` while pending. If abort was requested, this is {@link AbortError} - * even if the task completed successfully — see {@link Runner.getOutcome} for - * what the task actually returned. + * Intentionally synchronous — abort is signal propagation, not cleanup. Use + * {@link Runner.defer} for async cleanup that must run regardless of abort. */ - readonly getResult: () => Result | null; + readonly onAbort: (callback: Callback) => void; - /** - * Returns what the task actually returned. - * - * `null` while pending. Unlike {@link Runner.getResult}, not overridden by - * abort. - */ - readonly getOutcome: () => Result | null; + /** Returns the current {@link FiberState}. */ + readonly getState: () => FiberState; /** Returns the current child {@link Fiber}s. */ readonly getChildren: () => ReadonlySet>; @@ -478,7 +500,7 @@ export interface Runner extends AsyncDisposable { * // connection[Symbol.asyncDispose] is now defined * ``` */ - readonly defer: (onDisposeAsync: Task) => AsyncDisposable; + readonly defer: (onDisposeAsync: Task) => AsyncDisposable; /** * Creates an {@link AsyncDisposableStack} bound to the root runner. @@ -493,19 +515,33 @@ export interface Runner extends AsyncDisposable { */ readonly stack: () => AsyncDisposableStack; - /** - * {@link Time}. - * - * Used by {@link sleep}, {@link timeout}. Exposed for custom needs. - */ + /** {@link Time}. */ readonly time: Time; /** - * {@link Random}. + * {@link Console}. + * + * Logging is disabled by default. + * + * ### Example * - * Used by {@link retry} for jitter. Exposed for custom needs. + * ```ts + * run.console.enabled = true; + * ``` */ + readonly console: Console; + + /** {@link Random}. */ readonly random: Random; + + /** {@link RandomBytes}. */ + readonly randomBytes: RandomBytes; + + /** + * @see {@link Concurrency} + * @see {@link withConcurrency} + */ + readonly concurrency: Concurrency; } /** @@ -527,22 +563,22 @@ export interface Runner extends AsyncDisposable { * UI/debugging tools can use this to visually distinguish protected tasks * (e.g., different icon or color) and explain why abort requests are ignored. * - * @category Abort Masking + * @group Abort Masking */ export const AbortMask = brand("AbortMask", NonNegativeInt); export type AbortMask = typeof AbortMask.Type; /** - * The lifecycle state of a {@link Runner}. + * Maximum number of concurrent tasks. * - * - `active` — accepts new tasks - * - `disposing` — no new tasks accepted, waits for in-flight fibers - * - `disposed` — all fibers completed, disposal finished + * Default is 1 (sequential). Use 1-100 as a literal or {@link PositiveInt} for + * larger values. * - * @category Core + * @group Concurrency Primitives + * @see {@link withConcurrency} + * @see {@link createSemaphore} */ -export const RunnerState = union("active", "disposing", "disposed"); -export type RunnerState = typeof RunnerState.Type; +export type Concurrency = PositiveInt; /** * `Fiber` is a handle to a running {@link Task} that can be awaited, aborted, or @@ -563,16 +599,16 @@ export type RunnerState = typeof RunnerState.Type; * * // Auto-abort with `using` * { - * using fiber = run(backgroundSync); + * using background = run(backgroundSync); * await someOtherWork(); - * } // fiber.abort() called automatically here + * } // background.abort() called automatically here * * // Run child tasks in fiber's scope * fiber.run(childTask); * * // Monitor via the Runner * fiber.run.onEvent = (event) => { - * console.log(event); + * // handle event * }; * ``` * @@ -584,38 +620,13 @@ export type RunnerState = typeof RunnerState.Type; * settlement. Do not write code that relies on a specific number of microtask * yields between tasks. Use explicit synchronization primitives instead. * - * @category Core + * @group Core Types */ export class Fiber implements PromiseLike>, Disposable { readonly then: PromiseLike>["then"]; - /** - * Requests abort for this fiber (and any child it started). - * - * ### Example - * - * ```ts - * const fiber = run(fetchData); - * fiber.abort(); - * const result = await fiber; // err(AbortError) - * ``` - * - * When abort is requested, the fiber's result becomes {@link AbortError} even - * if the task completed successfully. This keeps behavior predictable — - * calling `abort()` always yields `AbortError`. - * - * The optional reason is stored in `AbortError.reason`. Since any value can - * be passed, abort reasons are `unknown` — use typed errors for business - * logic. To inspect the reason, use type guards like - * `RaceLostError.is(reason)`. - * - * Abort is idempotent — calling multiple times has no additional effect - * beyond the first call. - */ - readonly abort: (reason?: unknown) => void; - /** * A {@link Runner} whose lifetime is tied to this fiber. * @@ -637,35 +648,42 @@ export class Fiber */ readonly run: Runner; - constructor( - run: Runner, - promise: Promise>, - abort: (reason?: unknown) => void = lazyVoid, - ) { + constructor(run: Runner, promise: Promise>) { this.then = promise.then.bind(promise); - this.abort = abort; this.run = run; } /** - * Returns the fiber's completion value. + * Requests abort for this fiber (and any child it started). + * + * ### Example + * + * ```ts + * const fiber = run(fetchData); + * fiber.abort(); + * const result = await fiber; // err(AbortError) + * ``` * - * `null` while pending. If abort was requested, this is {@link AbortError} - * even if the task completed successfully — see {@link Fiber.getOutcome} for - * what the task actually returned. + * When abort is requested, the fiber's result becomes {@link AbortError} even + * if the task completed successfully. This keeps behavior predictable — + * calling `abort()` always yields `AbortError`. + * + * The optional cause is stored in `AbortError.cause`. Since any value can be + * passed, abort causes are `unknown` — use typed errors for business logic. + * To inspect the cause, use type guards like `RaceLostError.is(cause)`. + * + * Abort is idempotent — calling multiple times has no additional effect + * beyond the first call. */ - getResult(): Result | null { - return this.run.getResult() as Result | null; + abort(cause?: unknown): void { + (this.run as RunnerInternal).requestAbort( + createAbortError(cause), + ); } - /** - * Returns what the task actually returned. - * - * `null` while pending. Unlike {@link Fiber.getResult}, not overridden by - * abort. - */ - getOutcome(): Result | null { - return this.run.getOutcome() as Result | null; + /** Returns the current {@link FiberState}. */ + getState(): FiberState { + return this.run.getState() as FiberState; } [Symbol.dispose](): void { @@ -676,7 +694,7 @@ export class Fiber /** * Extracts the value type from a {@link Fiber}. * - * @category Utilities + * @group Type Utilities */ export type InferFiberOk> = F extends Fiber ? T : never; @@ -684,7 +702,7 @@ export type InferFiberOk> = /** * Extracts the error type from a {@link Fiber}. * - * @category Utilities + * @group Type Utilities */ export type InferFiberErr> = F extends Fiber ? E : never; @@ -692,11 +710,55 @@ export type InferFiberErr> = /** * Extracts the deps type from a {@link Fiber}. * - * @category Utilities + * @group Type Utilities */ export type InferFiberDeps> = F extends Fiber ? D : never; +/** + * The lifecycle state of a {@link Fiber}. + * + * - `running` — task running, no result yet + * - `completing` — waiting for children to complete + * - `completed` — completed with result and outcome + * + * @group Core Types + */ +export type FiberState = + | { readonly type: "running" } + | { readonly type: "completing" } + | { + readonly type: "completed"; + + /** + * The fiber's completion value. + * + * If abort was requested, this is {@link AbortError} even if the task + * completed successfully — see `outcome` for what the task actually + * returned. + */ + readonly result: Result; + + /** + * What the task actually returned. + * + * Unlike `result`, not overridden by abort. + */ + readonly outcome: Result; + }; + +/** + * {@link FiberState} Type. + * + * @group Monitoring + */ +export const FiberSnapshotState = union( + typed("running"), + typed("completing"), + typed("completed", { result: UnknownResult, outcome: UnknownResult }), +); +export type FiberSnapshotState = typeof FiberSnapshotState.Type; + /** * A recursive snapshot of a {@link Runner} tree. * @@ -706,7 +768,7 @@ export type InferFiberDeps> = * computed on demand rather than pushed on every change. Push would require * O(depth) new snapshot objects per mutation. * - * @category Core + * @group Core Types * @see {@link Runner.snapshot} */ export interface FiberSnapshot { @@ -714,24 +776,7 @@ export interface FiberSnapshot { readonly id: Id; /** The current lifecycle state. */ - readonly state: RunnerState; - - /** - * The fiber's completion value. - * - * `null` while pending. If abort was requested, this is {@link AbortError} - * even if the task completed successfully — see {@link FiberSnapshot.outcome} - * for what the task actually returned. - */ - readonly result: Result | null; - - /** - * What the task actually returned. - * - * `null` while pending. Unlike {@link FiberSnapshot.result}, not overridden by - * abort. - */ - readonly outcome: Result | null; + readonly state: FiberSnapshotState; /** Child snapshots in spawn (start) order. */ readonly children: ReadonlyArray; @@ -741,53 +786,16 @@ export interface FiberSnapshot { } /** - * Emitted when a child {@link Fiber} is added to a {@link Runner}. - * - * @category Monitoring - */ -export const RunnerEventChildAdded = typed("childAdded", { - runnerId: Id, - childId: Id, - timestamp: Millis, -}); -export type RunnerEventChildAdded = typeof RunnerEventChildAdded.Type; - -/** - * Emitted when a child {@link Fiber} is removed from a {@link Runner}. - * - * @category Monitoring - */ -export const RunnerEventChildRemoved = typed("childRemoved", { - runnerId: Id, - childId: Id, - timestamp: Millis, -}); -export type RunnerEventChildRemoved = typeof RunnerEventChildRemoved.Type; - -/** - * Emitted when a {@link Runner}'s state changes. + * The event-specific payload of a {@link RunnerEvent}. * - * @category Monitoring + * @group Monitoring */ -export const RunnerEventStateChanged = typed("stateChanged", { - runnerId: Id, - state: RunnerState, - timestamp: Millis, -}); -export type RunnerEventStateChanged = typeof RunnerEventStateChanged.Type; - -/** - * Emitted when a {@link Runner}'s result is set. - * - * @category Monitoring - */ -export const RunnerEventResultSet = typed("resultSet", { - runnerId: Id, - result: UnknownResult, - outcome: UnknownResult, - timestamp: Millis, -}); -export type RunnerEventResultSet = typeof RunnerEventResultSet.Type; +export const RunnerEventData = union( + typed("childAdded", { childId: Id }), + typed("childRemoved", { childId: Id }), + typed("stateChanged", { state: FiberSnapshotState }), +); +export type RunnerEventData = typeof RunnerEventData.Type; /** * Events emitted by a {@link Runner} for monitoring and debugging. @@ -795,15 +803,14 @@ export type RunnerEventResultSet = typeof RunnerEventResultSet.Type; * Events bubble up through parent runners, enabling centralized monitoring at * the root. Use with {@link Runner.onEvent} to track task lifecycle. * - * @category Monitoring + * @group Monitoring */ -export const RunnerEvent = union( - RunnerEventChildAdded, - RunnerEventChildRemoved, - RunnerEventStateChanged, - RunnerEventResultSet, -); -export type RunnerEvent = typeof RunnerEvent.Type; +export const RunnerEvent = object({ + id: Id, + timestamp: Millis, + data: RunnerEventData, +}); +export interface RunnerEvent extends InferType {} /** * Task-aware wrapper around native @@ -832,7 +839,7 @@ export type RunnerEvent = typeof RunnerEvent.Type; * }; // b released, then a released, then analytics sent * ``` * - * @category Resource management + * @group Resource Management */ export class AsyncDisposableStack implements AsyncDisposable { readonly #stack = new globalThis.AsyncDisposableStack(); @@ -869,7 +876,7 @@ export class AsyncDisposableStack implements AsyncDisposable { use(value: T): T; use( acquire: Task, - ): PromiseLike>; + ): PromiseLike>; use( valueOrAcquire: T | Task, ): T | PromiseLike> { @@ -956,7 +963,7 @@ export class AsyncDisposableStack implements AsyncDisposable { /** * Configuration for {@link Runner} behavior. * - * @category Monitoring + * @group Monitoring */ export interface RunnerConfig { /** @@ -972,7 +979,8 @@ export interface RunnerConfigDep { readonly runnerConfig: RunnerConfig; } -export type RunnerDeps = RandomBytesDep & +export type RunnerDeps = ConsoleDep & + RandomBytesDep & RandomDep & TimeDep & Partial & @@ -980,8 +988,9 @@ export type RunnerDeps = RandomBytesDep & Partial; // TODO: const defaultDeps: RunnerDeps = { - random: createRandom(), + console: createConsole(), randomBytes: createRandomBytes(), + random: createRandom(), time: createTime(), }; @@ -991,6 +1000,15 @@ const defaultDeps: RunnerDeps = { * Call once per entry point (main thread, worker, etc.) and dispose on * shutdown. All tasks run as descendants of this root runner. * + * {@link RunnerDeps} provides default dependencies: + * + * - {@link Time} + * - {@link Console} + * - {@link Random} + * - {@link RandomBytes} + * + * Console logging is disabled by default. + * * ### Example * * ```ts @@ -1038,27 +1056,20 @@ const defaultDeps: RunnerDeps = { * // run2(fetchUser("123")); // Error: Property 'config' is missing * ``` * - * @category Core + * @group Creating Runners */ -export function createRunner(): Runner; +export function createRunner(): Runner; export function createRunner(deps: D): Runner; export function createRunner(deps?: D): Runner { - const mergedDeps = { - ...defaultDeps, - ...deps, - } as D; - + const mergedDeps = { ...defaultDeps, ...deps } as D; return createRunnerInternal(mergedDeps)(); } /** Internal Runner properties, hidden from public API via TypeScript types. */ interface RunnerInternal extends Runner { - readonly requestAbort: (abortError: AbortError) => void; + readonly requestAbort: (reason: unknown) => void; readonly requestSignal: AbortSignal; - readonly setResultAndOutcome: ( - result: Result, - outcome: Result, - ) => void; + readonly complete: (result: UnknownResult, outcome: UnknownResult) => void; } const createRunnerInternal = @@ -1066,12 +1077,13 @@ const createRunnerInternal = ( parent?: RunnerInternal, daemon?: RunnerInternal, - behavior?: AbortBehavior, + abortBehavior?: AbortBehavior, + concurrencyBehavior?: Concurrency, ): RunnerInternal => { const parentMask = parent?.abortMask ?? isAbortable; let abortMask: AbortMask; - switch (behavior) { + switch (abortBehavior) { case undefined: abortMask = parentMask; break; @@ -1080,49 +1092,42 @@ const createRunnerInternal = break; default: assert( - behavior <= parentMask, + abortBehavior <= parentMask, "restore used outside its unabortableMask", ); - abortMask = behavior; + abortMask = abortBehavior; } const requestController = new AbortController(); const signalController = new AbortController(); - let state: RunnerState = "active"; + let state: FiberState = running; + let result: UnknownResult | undefined; + let outcome: UnknownResult | undefined; let children: ReadonlySet> = emptySet; - const requestAbort = (abortError: AbortError) => { - requestController.abort(abortError); - if (abortMask === isAbortable) signalController.abort(abortError); + const requestAbort = (reason: unknown) => { + assertType(AbortError, reason); + if (abortMask === isAbortable) signalController.abort(reason); + requestController.abort(reason); }; if (parent) { - const handleParentAbort = () => { - requestAbort(parent.requestSignal.reason as AbortError); - }; + const handleAbort = () => requestAbort(parent.requestSignal.reason); if (parent.requestSignal.aborted) { - handleParentAbort(); + handleAbort(); } else { - parent.requestSignal.addEventListener("abort", handleParentAbort, { + parent.requestSignal.addEventListener("abort", handleAbort, { signal: requestController.signal, }); } } - type RunnerEventPayload = T extends RunnerEvent - ? Omit - : never; - - const emitEvent = (event: RunnerEventPayload) => { + const emitEvent = (data: RunnerEventData) => { if (!deps.runnerConfig?.eventsEnabled.get()) return; - const fullEvent = { - ...event, - runnerId: self.id, - timestamp: deps.time.now(), - }; + const e: RunnerEvent = { id: self.id, timestamp: deps.time.now(), data }; for (let node: Runner | null = self; node; node = node.parent) - node.onEvent?.(fullEvent); + node.onEvent?.(e); }; const run = (task: Task): Fiber => { @@ -1130,29 +1135,29 @@ const createRunnerInternal = self, daemon ?? self, getAbortBehavior(task), + getConcurrencyBehavior(task), ); - if (state !== "active") { - runner.requestAbort(runnerClosingError); - task = () => Promise.resolve(err(runnerClosingError)); + if (state !== running) { + runner.requestAbort(runnerClosingAbortError); + task = () => err(runnerClosingAbortError); } else if ( signalController.signal.aborted && runner.abortMask === isAbortable ) { - const abortError = signalController.signal.reason as AbortError; - runner.requestAbort(abortError); - task = () => Promise.resolve(err(abortError)); + runner.requestAbort(signalController.signal.reason); + task = () => err(signalController.signal.reason); } const promise = new Promise>((resolve) => resolve(task(runner, deps)), ) .then((taskOutcome) => { - const childResult = runner.signal.aborted + const taskResult = runner.signal.aborted ? err(runner.signal.reason) : taskOutcome; - runner.setResultAndOutcome(childResult, taskOutcome); - return childResult; + runner.complete(taskResult, taskOutcome); + return taskResult; }) .finally(runner[Symbol.asyncDispose]) .finally(() => { @@ -1160,9 +1165,7 @@ const createRunnerInternal = emitEvent({ type: "childRemoved", childId: runner.id }); }); - const fiber = new Fiber(runner, promise, (reason?: unknown) => { - runner.requestAbort(createAbortError(reason)); - }); + const fiber = new Fiber(runner, promise); children = addToSet(children, fiber); emitEvent({ type: "childAdded", childId: runner.id }); @@ -1176,29 +1179,31 @@ const createRunnerInternal = const run = self as Mutable>; const id = createId(deps); - let result: Result | null = null; - let outcome: Result | null = null; let snapshot: FiberSnapshot | null = null; - let disposing: Promise | null = null; + let disposingPromise: Promise | null = null; run.id = id; run.parent = parent ?? null; run.signal = signalController.signal; run.abortMask = abortMask; - run.onAbort = (fn: (reason: unknown) => void) => { - signalController.signal.addEventListener( - "abort", - () => { - fn((signalController.signal.reason as AbortError).reason); - }, - { once: true }, - ); + run.onAbort = (callback: (reason: unknown) => void) => { + if (abortMask !== isAbortable) return; + const handleAbort = () => { + assertType(AbortError, signalController.signal.reason); + callback(signalController.signal.reason.cause); + }; + if (signalController.signal.aborted) { + handleAbort(); + return; + } + signalController.signal.addEventListener("abort", handleAbort, { + once: true, + signal: requestController.signal, + }); }; run.getState = () => state; - run.getResult = () => result; - run.getOutcome = () => outcome; run.getChildren = () => children; run.snapshot = () => { @@ -1207,15 +1212,11 @@ const createRunnerInternal = ); if ( snapshot?.state !== state || - snapshot.result !== result || - // No need to check outcome — it's set together with result !eqArrayStrict(snapshot.children, childSnapshots) ) { snapshot = { id, - state, - result, - outcome, + state: state as FiberSnapshotState, children: childSnapshots, abortMask, }; @@ -1225,99 +1226,99 @@ const createRunnerInternal = run.onEvent = undefined; run.daemon = (task: Task) => (daemon ?? self)(task); - run.defer = (task: Task) => ({ + run.defer = (task: Task) => ({ [Symbol.asyncDispose]: () => run.daemon(unabortable(task)).then(lazyVoid), }); run.stack = () => new AsyncDisposableStack(self); run.time = deps.time; + run.console = deps.console; run.random = deps.random; + run.randomBytes = deps.randomBytes; + + run.concurrency = + concurrencyBehavior ?? parent?.concurrency ?? defaultConcurrency; run[Symbol.asyncDispose] = () => { - if (disposing) return disposing; + if (disposingPromise) return disposingPromise; - state = "disposing"; + state = { type: "completing" }; emitEvent({ type: "stateChanged", state }); - requestAbort(runnerClosingError); + requestAbort(runnerClosingAbortError); - disposing = Promise.allSettled(children) + disposingPromise = Promise.allSettled(children) .then(lazyVoid) .finally(() => { - state = "disposed"; + [result, outcome] = [result ?? ok(), outcome ?? ok()]; + state = { type: "completed", result, outcome }; emitEvent({ type: "stateChanged", state }); }); - return disposing; + return disposingPromise; }; - // Internal properties (hidden from public Runner type) + // Internal run.requestAbort = requestAbort; run.requestSignal = requestController.signal; - run.setResultAndOutcome = ( - fiberResult: Result, + run.complete = ( + taskResult: Result, taskOutcome: Result, ) => { - result = fiberResult; + result = taskResult; outcome = taskOutcome; - emitEvent({ - type: "resultSet", - result: fiberResult, - outcome: taskOutcome, - }); }; } return self; }; -/** Mask value indicating the task is abortable. */ -const isAbortable = AbortMask.orThrow(0); +const running: FiberState = { type: "running" }; /** - * Error used as {@link AbortError} reason when a {@link Runner} is disposed. + * Error used as {@link AbortError} cause when a {@link Runner} is disposed. * - * @category Core + * @group Creating Runners */ export const RunnerClosingError = typed("RunnerClosingError"); -export type RunnerClosingError = typeof RunnerClosingError.Type; - -const createAbortError = (reason: unknown): AbortError => ({ - type: "AbortError", - reason, -}); +export interface RunnerClosingError + extends InferType {} /** - * The {@link AbortError} used when a {@link Runner} is disposed. + * The {@link RunnerClosingError} used when a {@link Runner} is disposed. * - * Tasks run on a disposing or disposed runner receive this error. + * Tasks run on a disposing or disposed runner receive this error as + * {@link AbortError} cause. * - * @category Core + * @group Creating Runners */ -export const runnerClosingError = createAbortError({ +export const runnerClosingError: RunnerClosingError = { type: "RunnerClosingError", -} satisfies RunnerClosingError); +}; -type AbortBehavior = "unabortable" | AbortMask; +const createAbortError = (cause: unknown): AbortError => ({ + type: "AbortError", + cause, +}); -interface MaybeAbortBehavior { - [_abortBehavior]?: AbortBehavior; -} +const runnerClosingAbortError: AbortError = + createAbortError(runnerClosingError); + +const isAbortable = AbortMask.orThrow(0 as any); +type AbortBehavior = "unabortable" | AbortMask; +const abortBehaviorSymbol = Symbol("evolu.Task.abortBehavior"); const getAbortBehavior = ( task: Task, -): AbortBehavior | undefined => (task as MaybeAbortBehavior)[_abortBehavior]; +): AbortBehavior | undefined => (task as never)[abortBehaviorSymbol]; const abortBehavior = (behavior: AbortBehavior) => - (task: Task): Task => { - const wrapper: Task = (run) => run(task); - (wrapper as MaybeAbortBehavior)[_abortBehavior] = behavior; - return wrapper; - }; - -const _abortBehavior = Symbol("evolu.Task.abortBehavior"); + (task: Task): Task => + Object.assign((run: Runner) => run(task), { + [abortBehaviorSymbol]: behavior, + }); /** * Makes a {@link Task} unabortable. @@ -1331,7 +1332,7 @@ const _abortBehavior = Symbol("evolu.Task.abortBehavior"); * await using run = createRunner(); * * const events: Array = []; - * const canComplete = Promise.withResolvers(); + * const canComplete = withResolvers(); * let signalAbortedInAnalytics = true; * * // Simulate async analytics API (abortable by default) @@ -1363,7 +1364,7 @@ const _abortBehavior = Symbol("evolu.Task.abortBehavior"); * expect(result).toEqual(ok()); * ``` * - * @category Abort Masking + * @group Abort Masking */ export const unabortable = abortBehavior("unabortable"); @@ -1376,7 +1377,7 @@ export const unabortable = abortBehavior("unabortable"); * - Tasks run inside `unabortableMask` are unabortable by default * - Tasks wrapped with `restore()` restore the previous abortability * - * @category Abort Masking + * @group Abort Masking */ export const unabortableMask = ( fn: ( @@ -1387,6 +1388,70 @@ export const unabortableMask = ( fn(abortBehavior(AbortMask.orThrow(decrement(run.abortMask))))(run, deps), ); +const defaultConcurrency: Concurrency = 1 as Concurrency; + +const concurrencyBehaviorSymbol = Symbol("evolu.Task.concurrencyBehavior"); + +const getConcurrencyBehavior = ( + task: Task, +): Concurrency | undefined => (task as never)[concurrencyBehaviorSymbol]; + +/** + * Sets the {@link Concurrency} level for a {@link Task}. + * + * By default, tasks run sequentially (one at a time) to encourage thinking + * about concurrency explicitly — unlike `Promise.all` which runs everything in + * parallel. + * + * For tuple-based calls like `all([taskA, taskB, taskC])` with a known small + * number of tasks, use `withConcurrency` without a number for unlimited + * parallelism. For arrays of unknown length, always specify a concurrency + * limit. + * + * Concurrency is inherited by child tasks and can be overridden at any level. + * Composition helpers should respect inherited concurrency — they should not + * override it with a fixed number unless semantically required (like + * {@link race} which needs unlimited concurrency). If a helper has a recommended + * concurrency, it should expose it as a const for users to apply with + * `withConcurrency`. + * + * ### Example + * + * ```ts + * // Tuple with known tasks — unlimited concurrency is fine + * const result = await run(withConcurrency(all([fetchA, fetchB, fetchC]))); + * + * // Array of unknown length — always limit concurrency + * const processAll = withConcurrency(10, all(items.map(processItem))); + * + * // Inherited concurrency — inner all() uses parent's limit + * const pipeline = withConcurrency(5, async (run) => { + * const users = await run(all(userIds.map(fetchUser))); // uses 5 + * if (!users.ok) return users; + * return run(all(users.value.map(enrichUser))); // also uses 5 + * }); + * ``` + * + * @group Composition + */ +export function withConcurrency( + concurrency: Concurrency, + task: Task, +): Task; +export function withConcurrency( + task: Task, +): Task; +export function withConcurrency( + concurrencyOrTask: Concurrency | Task, + taskOrFallback?: Task, +): Task { + const isTask = isFunction(concurrencyOrTask); + const task = isTask ? concurrencyOrTask : taskOrFallback!; + return Object.assign((run: Runner) => run(task), { + [concurrencyBehaviorSymbol]: isTask ? maxPositiveInt : concurrencyOrTask, + }); +} + /** * Yields execution to allow other work to proceed. * @@ -1439,14 +1504,14 @@ export const unabortableMask = ( * }; * ``` * - * @category Composition + * @group Composition * @see https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield * @see https://web.dev/articles/optimize-long-tasks */ export const yieldNow: Task = () => tryAsync( () => yieldImpl(), // TODO: yieldImpl(run.signal) - (error): AbortError => ({ type: "AbortError", reason: error }), + (cause): AbortError => createAbortError(cause), ); const yieldImpl: () => Promise = @@ -1470,7 +1535,7 @@ const yieldImpl: () => Promise = * }; * ``` * - * @category Composition + * @group Composition */ export const sleep = (duration: Duration): Task => @@ -1480,9 +1545,9 @@ export const sleep = resolve(ok()); }, durationToMillis(duration)); - run.onAbort((reason) => { + run.onAbort((cause) => { run.time.clearTimeout(id); - resolve(err({ type: "AbortError", reason })); + resolve(err(createAbortError(cause))); }); }); @@ -1492,26 +1557,25 @@ export const sleep = * Like * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race | Promise.race}, * the first task to complete (whether success or failure) wins. All other tasks - * are aborted. + * are aborted. Use {@link any} if you need the first task to succeed instead. * - * Unlike `Promise.race`, this requires a non-empty array instead of an - * `Iterable`. This enables compile-time enforcement that at least one task is - * provided — `Promise.race([])` hangs forever, which is rarely intended. For - * tasks from an `Iterable`, spread and use {@link isNonEmptyArray}: + * Requires a non-empty array — racing zero tasks has no meaningful result + * (there's no "first to complete" without participants). This is enforced at + * compile time for non-empty tuple types. For other arrays, guard with + * {@link isNonEmptyArray}: * * ```ts - * const tasksArray = [...tasksIterable]; - * if (isNonEmptyArray(tasksArray)) { - * await run(race(tasksArray)); + * if (isNonEmptyArray(tasks)) { + * await run(race(tasks)); * } * ``` * * ### Example * * ```ts - * const fast: Task = async () => ok("fast"); - * const slow: Task = async () => { - * await new Promise((r) => setTimeout(r, 10)); + * const fast: Task = () => ok("fast"); + * const slow: Task = async (run) => { + * await run(sleep("10ms")); * return ok("slow"); * }; * @@ -1519,51 +1583,48 @@ export const sleep = * const result = await run(race([fast, slow])); // ok("fast") * ``` * - * @category Composition + * Always runs with unlimited concurrency — a sequential race makes no sense + * since the first task would always "win". + * + * @group Composition */ -export const race = - < - T extends readonly [ - Task, - ...ReadonlyArray>, - ], - >( - tasks: T, - { - abortReason = raceLostError, - }: { - /** Abort reason for losing tasks. Defaults to {@link raceLostError}. */ - abortReason?: unknown; - } = {}, - ): Task< - InferTaskOk, - InferTaskErr, - InferTaskDeps - > => - async (run) => { - const fibers = tasks.map(run.daemon); - const abortPending = (reason: unknown) => { - for (const fiber of fibers) - if (fiber.getResult() === null) fiber.abort(reason); - return ok(); - }; - run.onAbort(abortPending); - await using _ = run.defer(() => abortPending(abortReason)); - return await Promise.race(fibers); - }; - +export const race = < + T extends readonly [ + Task, + ...ReadonlyArray>, + ], +>( + tasks: T, + { + abortCause = raceLostError, + }: { + /** Abort cause for losing tasks. Defaults to {@link raceLostError}. */ + abortCause?: unknown; + } = {}, +): Task< + InferTaskOk, + InferTaskErr, + InferTaskDeps +> => + withConcurrency( + concurrent(tasks, { + stopOn: "first", + collect: false, + abortCause, + }), + ); /** * Abort reason for tasks that lose a {@link race}. * - * @category Composition + * @group Composition */ export const RaceLostError = typed("RaceLostError"); -export type RaceLostError = typeof RaceLostError.Type; +export interface RaceLostError extends InferType {} /** * {@link RaceLostError} used as abort reason in {@link race}. * - * @category Composition + * @group Composition */ export const raceLostError: RaceLostError = { type: "RaceLostError" }; @@ -1584,19 +1645,19 @@ export const raceLostError: RaceLostError = { type: "RaceLostError" }; * } * ``` * - * @category Composition + * @group Composition */ export const timeout = ( task: Task, duration: Duration, { - abortReason = timeoutError, + abortCause = timeoutError, }: { /** - * Abort reason for the task when timeout fires. Defaults to + * Abort cause for the task when timeout fires. Defaults to * {@link timeoutError}. */ - abortReason?: unknown; + abortCause?: unknown; } = {}, ): Task => race( @@ -1606,29 +1667,29 @@ export const timeout = ( await run(sleep(duration)); return err(timeoutError); }, - ] as const, - { abortReason }, + ], + { abortCause }, ); /** * Typed error returned by {@link timeout} when a task exceeds its time limit. * - * @category Composition + * @group Composition */ export const TimeoutError = typed("TimeoutError"); -export type TimeoutError = typeof TimeoutError.Type; +export interface TimeoutError extends InferType {} /** * {@link TimeoutError} used as abort reason in {@link timeout}. * - * @category Composition + * @group Composition */ export const timeoutError: TimeoutError = { type: "TimeoutError" }; /** * Options for {@link retry}. * - * @category Composition + * @group Composition */ export interface RetryOptions { /** Predicate to determine if error is retryable. Defaults to all errors. */ @@ -1644,7 +1705,7 @@ export interface RetryOptions { /** * Info passed to {@link retry} {@link RetryOptions.onRetry} callback. * - * @category Composition + * @group Composition */ export interface RetryAttempt extends ScheduleStep { readonly error: E; @@ -1653,7 +1714,7 @@ export interface RetryAttempt extends ScheduleStep { /** * Error returned when all retry attempts are exhausted. * - * @category Composition + * @group Composition */ export interface RetryError extends Typed<"RetryError"> { /** The error from the final attempt. */ @@ -1695,7 +1756,7 @@ export interface RetryError extends Typed<"RetryError"> { * } * ``` * - * @category Composition + * @group Composition */ export const retry = ( @@ -1756,7 +1817,7 @@ export const retry = /** * Options for {@link repeat}. * - * @category Composition + * @group Composition */ export interface RepeatOptions { /** Predicate to determine if value is repeatable. Defaults to all values. */ @@ -1771,7 +1832,7 @@ export interface RepeatOptions { /** * Info passed to {@link repeat} {@link RepeatOptions.onRepeat} callback. * - * @category Composition + * @group Composition */ export interface RepeatAttempt extends ScheduleStep { readonly value: T; @@ -1815,7 +1876,7 @@ export interface RepeatAttempt extends ScheduleStep { * } * ``` * - * @category Composition + * @group Composition */ export const repeat = ( @@ -1862,25 +1923,22 @@ export const repeat = * A value that can be resolved later. * * Similar to - * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers | Promise.withResolvers}, + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers | withResolvers}, * but integrated with {@link Task} and {@link Runner} for cancellation support. * * Use for bridging callback-based APIs or coordinating between tasks. * - * Disposing aborts all waiting tasks with an {@link AbortError} whose cause is - * {@link deferredDisposedError}. + * Disposing aborts all waiting tasks with an {@link AbortError} with + * {@link deferredDisposedError} reason. * - * @category Concurrency + * @group Concurrency Primitives * @see {@link createDeferred} */ export interface Deferred extends Disposable { /** A {@link Task} that waits until {@link Deferred.resolve} is called. */ readonly task: Task; - /** - * Resolves the value. Returns `true` on first call, `false` if already - * resolved. - */ + /** Resolves the value. Returns `true` once, then `false`. */ readonly resolve: (result: Result) => boolean; } @@ -1901,7 +1959,7 @@ export interface Deferred extends Disposable { * const result = await fiber; // ok("value") * ``` * - * @category Concurrency + * @group Concurrency Primitives */ export const createDeferred = (): Deferred => { let resolved: Result | null = null; @@ -1931,8 +1989,8 @@ export const createDeferred = (): Deferred => { resolvers.add(resolve); - run.onAbort((reason) => { - resolve(err(createAbortError(reason))); + run.onAbort((cause) => { + resolve(err(createAbortError(cause))); }); }); }, @@ -1948,18 +2006,16 @@ export const createDeferred = (): Deferred => { /** * Abort reason used when a {@link Deferred} is disposed. * - * @category Concurrency + * @group Concurrency Primitives */ export const DeferredDisposedError = typed("DeferredDisposedError"); -// zkusit, nejak to slo, kde to bylo? -// export type DeferredDisposedError = typeof DeferredDisposedError.Type; export interface DeferredDisposedError extends InferType {} /** * {@link DeferredDisposedError} used as abort reason in {@link createDeferred}. * - * @category Concurrency + * @group Concurrency Primitives */ export const deferredDisposedError: DeferredDisposedError = { type: "DeferredDisposedError", @@ -1976,7 +2032,7 @@ export const deferredDisposedError: DeferredDisposedError = { * * Disposing aborts all waiting tasks with {@link deferredDisposedError}. * - * @category Concurrency + * @group Concurrency Primitives * @see {@link createGate} */ export interface Gate extends Disposable { @@ -2012,7 +2068,7 @@ export interface Gate extends Disposable { * }; * ``` * - * @category Concurrency + * @group Concurrency Primitives */ export const createGate = (): Gate => { let isOpen = false; @@ -2054,7 +2110,7 @@ export const createGate = (): Gate => { * For mutual exclusion (limiting to exactly one {@link Task}), use {@link Mutex} * instead. * - * @category Concurrency + * @group Concurrency Primitives */ export interface Semaphore extends Disposable { /** @@ -2070,46 +2126,67 @@ export interface Semaphore extends Disposable { /** * Creates a {@link Semaphore} that limits concurrent {@link Task}s. * - * @category Concurrency + * ### Example + * + * ```ts + * await using run = createRunner(); + * run.console.enabled = true; + * + * const semaphore = createSemaphore(PositiveInt.orThrow(2)); + * + * const fetchUser = + * (id: string): Task => + * async (run) => { + * run.console.log("[demo]", "start", id); + * const slept = await run(sleep("10ms")); + * if (!slept.ok) return slept; + * run.console.log("[demo]", "end", id); + * return ok(`user:${id}`); + * }; + * + * const fetchWithPermit = (id: string) => + * semaphore.withPermit(fetchUser(id)); + * + * await Promise.all([ + * run(fetchWithPermit("1")), + * run(fetchWithPermit("2")), + * run(fetchWithPermit("3")), + * ]); + * + * // [demo] start 1 + * // [demo] start 2 + * // [demo] end 1 + * // [demo] start 3 + * // [demo] end 2 + * // [demo] end 3 + * ``` + * + * @group Concurrency Primitives */ -export const createSemaphore = (maxConcurrent: PositiveInt): Semaphore => { - let disposed = false; - let availablePermits: number = maxConcurrent; - const queue = new Set<(result: Result) => void>(); - const runningFibers = new Set>(); - - const abortResult = (reason: unknown): Result => - err(createAbortError(reason)); - - const release = (): void => { - const next = queue.values().next(); - if (!next.done) { - queue.delete(next.value); - next.value(ok()); - return; - } - availablePermits += 1; - }; +export const createSemaphore = (permits: Concurrency): Semaphore => { + const fibers = new Set(); + const queue = new Set>>(); - const abortWaiting = (reason: unknown): void => { - for (const resolve of queue) resolve(abortResult(reason)); - queue.clear(); - }; + let availablePermits: number = permits; + let disposed = false; return { withPermit: (task: Task): Task => async (run) => { - if (disposed) return abortResult(semaphoreDisposedError); - if (run.signal.aborted) return err(run.signal.reason as AbortError); + assert( + availablePermits === 0 || queue.size === 0, + "Semaphore invariant violated: queue must be empty when permits are available.", + ); + if (disposed) return err(semaphoreDisposedAbortError); if (availablePermits === 0) { const acquired = await new Promise>( (resolve) => { queue.add(resolve); - run.onAbort((reason) => { + run.onAbort((cause) => { queue.delete(resolve); - resolve(abortResult(reason)); + resolve(err(createAbortError(cause))); }); }, ); @@ -2118,14 +2195,26 @@ export const createSemaphore = (maxConcurrent: PositiveInt): Semaphore => { availablePermits -= 1; } - const fiber = run(task); - runningFibers.add(fiber); - + let fiber: Fiber | null = null; try { + fiber = run(task); + fibers.add(fiber); return await fiber; } finally { - runningFibers.delete(fiber); - release(); + if (fiber) fibers.delete(fiber); + + const next = queue.values().next(); + if (!next.done) { + queue.delete(next.value); + next.value(ok()); + } else { + availablePermits += 1; + } + + assert( + availablePermits === 0 || queue.size === 0, + "Queue must be empty when permits are available.", + ); } }, @@ -2133,11 +2222,14 @@ export const createSemaphore = (maxConcurrent: PositiveInt): Semaphore => { if (disposed) return; disposed = true; - for (const fiber of runningFibers) { + for (const fiber of fibers) { fiber.abort(semaphoreDisposedError); } - abortWaiting(semaphoreDisposedError); + for (const resolve of queue) { + resolve(err(semaphoreDisposedAbortError)); + } + queue.clear(); }, }; }; @@ -2145,26 +2237,31 @@ export const createSemaphore = (maxConcurrent: PositiveInt): Semaphore => { /** * Abort reason used when a {@link Semaphore} is disposed. * - * @category Concurrency + * @group Concurrency Primitives */ export const SemaphoreDisposedError = typed("SemaphoreDisposedError"); -export type SemaphoreDisposedError = typeof SemaphoreDisposedError.Type; +export interface SemaphoreDisposedError + extends InferType {} /** * {@link SemaphoreDisposedError} used as abort reason in {@link createSemaphore}. * - * @category Concurrency + * @group Concurrency Primitives */ export const semaphoreDisposedError: SemaphoreDisposedError = { type: "SemaphoreDisposedError", }; +const semaphoreDisposedAbortError: AbortError = createAbortError( + semaphoreDisposedError, +); + /** * A mutex (mutual exclusion) that ensures only one {@link Task} runs at a time. * * This is a specialized version of a {@link Semaphore} with a permit count of 1. * - * @category Concurrency + * @group Concurrency Primitives */ export interface Mutex extends Disposable { /** @@ -2176,7 +2273,7 @@ export interface Mutex extends Disposable { readonly withLock: (task: Task) => Task; } -/** @category Concurrency */ +/** @group Concurrency Primitives */ export const createMutex = (): Mutex => { const semaphore = createSemaphore(minPositiveInt); @@ -2186,19 +2283,560 @@ export const createMutex = (): Mutex => { }; }; -/** @category Composition */ -export const all = (): never => { - throw new Error("TODO: later"); +/** + * Runs multiple {@link Task}s until the first error. + * + * Sequential (one by one) by default. Use {@link withConcurrency} to run tasks + * concurrently. If any task fails, remaining tasks are aborted. + * + * Supports arrays, tuples, and structs. + * + * ### Array (returns readonly array) + * + * ```ts + * const result = await run(all(urls.map(fetch))); + * if (!result.ok) return result; + * // result.value: ReadonlyArray + * ``` + * + * ### Tuple (preserves types) + * + * ```ts + * const result = await run( + * all([taskReturningNumber, taskReturningString] as const), + * ); + * if (!result.ok) return result; + * const [a, b] = result.value; // a: number, b: string + * ``` + * + * ### Struct (returns object with same keys) + * + * ```ts + * const result = await run(all({ user: fetchUser, posts: fetchPosts })); + * if (!result.ok) return result; + * const { user, posts } = result.value; + * ``` + * + * ### Concurrency + * + * ```ts + * // Unlimited concurrency + * run(withConcurrency(all(tasks))); + * + * // Limited concurrency — at most 2 tasks run at a time + * run(withConcurrency(2, all(tasks))); + * ``` + * + * @group Composition + */ +export function all< + const T extends readonly [AnyTask, ...ReadonlyArray], +>( + tasks: T, +): Task< + { [K in keyof T]: InferTaskOk }, + InferTaskErr, + InferTaskDeps +>; + +export function all>>( + tasks: T, +): Task< + { [P in keyof T]: InferTaskOk }, + [keyof T] extends [never] ? never : InferTaskErr, + [keyof T] extends [never] ? unknown : InferTaskDeps +>; + +export function all( + tasks: NonEmptyReadonlyArray>, +): Task, E, D>; + +export function all( + tasks: Iterable>, +): Task, E, D>; + +export function all( + tasks: Iterable> | Readonly>, +): Task { + return collect("all", tasks); +} + +/** + * Abort cause used by {@link all} when aborting remaining tasks. + * + * Used when a task fails and other tasks need to be aborted. + * + * @group Composition + */ +export const AllAbortError = typed("AllAbortError"); +export interface AllAbortError extends InferType {} + +/** + * {@link AllAbortError} used as abort cause in {@link all}. + * + * @group Composition + */ +export const allAbortError: AllAbortError = { type: "AllAbortError" }; + +/** + * Runs multiple {@link Task}s and collects all results. + * + * Like + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled | Promise.allSettled}, + * all tasks run to completion regardless of individual failures. Returns an + * array of {@link Result}s preserving the original order. + * + * Sequential by default. Use {@link withConcurrency} for parallel execution. + * + * ### Example + * + * ```ts + * const results = await run( + * allSettled([fetchUser, fetchPosts, fetchComments]), + * ); + * if (!results.ok) return results; // Only AbortError + * + * for (const result of results.value) { + * if (result.ok) { + * console.log("Success:", result.value); + * } else { + * console.log("Failed:", result.error); + * } + * } + * ``` + * + * @group Composition + */ +export function allSettled< + const T extends readonly [AnyTask, ...ReadonlyArray], +>( + tasks: T, +): Task< + { + [K in keyof T]: Result, InferTaskErr | AbortError>; + }, + never, + InferTaskDeps +>; + +export function allSettled>>( + tasks: T, +): Task< + { + [P in keyof T]: Result, InferTaskErr | AbortError>; + }, + never, + [keyof T] extends [never] ? unknown : InferTaskDeps +>; + +export function allSettled( + tasks: NonEmptyReadonlyArray>, +): Task>, never, D>; + +export function allSettled( + tasks: Iterable>, +): Task>, never, D>; + +export function allSettled( + tasks: Iterable> | Readonly>, +): Task { + return collect("allSettled", tasks); +} + +/** + * Abort cause used by {@link allSettled} when aborted externally. + * + * @group Composition + */ +export const AllSettledAbortError = typed("AllSettledAbortError"); +export interface AllSettledAbortError + extends InferType {} + +/** + * {@link AllSettledAbortError} used as abort cause in {@link allSettled}. + * + * @group Composition + */ +export const allSettledAbortError: AllSettledAbortError = { + type: "AllSettledAbortError", }; -// TODO: Implement `all` and `any` with concurrency option: -// all(tasks) // Unbounded concurrent (like Promise.all) -// all(tasks, { concurrency: 5 }) // Max 5 at a time (uses Semaphore) -// all(tasks, { concurrency: 1 }) // Sequential -// any(tasks) // Unbounded concurrent (like Promise.any) -// any(tasks, { concurrency: 1 }) // Sequential fallback ("first" pattern) -// Both accept Iterable - lazy iteration useful for bounded concurrency. -// race stays array-only and unbounded (bounded race doesn't make sense). +/** + * Runs multiple {@link Task}s for side effects only. + * + * Like {@link all}, stops on first error and aborts remaining tasks. Unlike + * {@link all}, doesn't collect results — use for fire-and-forget operations. + * + * Tasks must return `void` to enforce side-effect-only usage. If you need + * return values, use {@link all} instead. + * + * Sequential by default. Use {@link withConcurrency} for parallel execution. + * + * ### Example + * + * ```ts + * // Send notifications sequentially + * const result = await run(forEach(users.map(sendNotification))); + * if (!result.ok) return result; + * + * // Send notifications concurrently (at most 5 at a time) + * const result = await run( + * withConcurrency(5, forEach(users.map(sendNotification))), + * ); + * ``` + * + * @group Composition + */ +export const forEach = ( + tasks: Iterable>, +): Task => + concurrent(tasks, { + stopOn: "error", + collect: false, + abortCause: forEachAbortError, + }); + +/** + * Runs multiple {@link Task}s for side effects and ignores their results. + * + * All tasks run to completion (unless aborted). Unlike {@link forEach}, this + * does not stop on the first error. + * + * Returns `ok()` unless aborted externally. + * + * @group Composition + */ +export const forEachSettled = ( + tasks: Iterable>, +): Task => + concurrent(tasks, { + stopOn: null, + collect: false, + abortCause: forEachSettledAbortError, + }); + +/** + * Abort cause used by {@link forEach} when aborting remaining tasks. + * + * @group Composition + */ +export const ForEachAbortError = typed("ForEachAbortError"); +export interface ForEachAbortError + extends InferType {} + +/** + * {@link ForEachAbortError} used as abort cause in {@link forEach}. + * + * @group Composition + */ +export const forEachAbortError: ForEachAbortError = { + type: "ForEachAbortError", +}; + +/** + * Abort cause used by {@link forEachSettled}. + * + * Used only when remaining tasks must be aborted (for example because the + * caller aborted the parent fiber). + * + * @group Composition + */ +export const ForEachSettledAbortError = typed("ForEachSettledAbortError"); +export interface ForEachSettledAbortError + extends InferType {} + +/** + * {@link ForEachSettledAbortError} used as abort cause in {@link forEachSettled}. + * + * @group Composition + */ +export const forEachSettledAbortError: ForEachSettledAbortError = { + type: "ForEachSettledAbortError", +}; + +/** Shared implementation for {@link all} and {@link allSettled}. */ +function collect( + type: "all", + tasks: Iterable> | Readonly>, +): Task; + +function collect( + type: "allSettled", + tasks: Iterable> | Readonly>, +): Task; + +function collect( + type: "all" | "allSettled", + tasks: Iterable> | Readonly>, +): Task { + const stopOn = (type === "all" ? "error" : null) as StopOn; + const abortCause = type === "all" ? allAbortError : allSettledAbortError; + + if (isIterable(tasks)) { + return concurrent(tasks, { stopOn, collect: true, abortCause }); + } + + const entries = objectToEntries(tasks); + if (!isNonEmptyArray(entries)) return () => ok(emptyRecord); + const taskArray = mapArray(entries, ([, task]) => task); + + return async (run) => { + const result = await run( + concurrent(taskArray, { stopOn, collect: true, abortCause }), + ); + if (!result.ok) return result; + return ok( + objectFromEntries(entries.map(([key], i) => [key, result.value[i]])), + ); + }; +} + +/** + * When to stop processing tasks in {@link concurrent}. + * + * - `"first"` — stop on first result (success or error), used by {@link race} + * - `"error"` — stop on first error, used by {@link all} and {@link forEach} + * - `"success"` — stop on first success, used by {@link any} + * - `null` — never stop early, used by {@link allSettled} and + * {@link forEachSettled} + */ +type StopOn = "first" | "error" | "success"; + +/** + * Runs tasks concurrently. + * + * Implemented as a worker pool respecting {@link Runner.concurrency} — spawns + * only as many workers as allowed, avoiding idle fibers waiting for permits. + * + * Workers run as daemons so callers don't block on unabortable tasks. When + * abort is requested, concurrent returns immediately. Structured concurrency is + * preserved because the root {@link Runner} still waits for all daemons. + * + * The `stopOn` option determines when to stop: + * + * - `"first"` — stop on any result + * - `"error"` — stop on first error + * - `"success"` — stop on first success + * - `null` — never stop early + */ +function concurrent( + tasks: Iterable>, + options: { + stopOn: StopOn; + collect: true; + abortCause: unknown; + }, +): Task, E, D>; + +function concurrent( + tasks: Iterable>, + options: { + stopOn: StopOn; + collect: false; + abortCause: unknown; + allFailed?: AnyAllFailed; + }, +): Task; + +function concurrent( + tasks: Iterable>, + options: { + stopOn: null; + collect: true; + abortCause: unknown; + }, +): Task>, never, D>; + +function concurrent( + tasks: Iterable>, + options: { + stopOn: null; + collect: false; + abortCause: unknown; + }, +): Task; + +function concurrent( + tasksIterable: Iterable, + { + stopOn = null, + collect, + abortCause, + allFailed, + }: { + stopOn?: StopOn | null; + collect: boolean; + abortCause: unknown; + allFailed?: AnyAllFailed; + }, +): Task | T | void, E> { + const tasks = ensureArray(tasksIterable); + const { length } = tasks; + if (length === 0) return () => ok(emptyArray); + + return async (run) => { + const results = collect ? new Array(length) : null; + const aborted = withResolvers(); + const stopSignal = stopOn ? withResolvers() : null; + + let index = 0; + let stopped = null as Result | null; + let lastResult = null as Result | null; + let lastIndexResult = null as Result | null; + + const worker: Task = async (run) => { + while (index < length && (stopOn ? !stopped : true)) { + const i = index++; + + const result = (await run(tasks[i])) as Result; + lastResult = result; + if (i === length - 1) lastIndexResult = result; + + if (!stopOn) { + if (results) results[i] = result; + continue; + } + + const stop = + stopOn === "first" || + (stopOn === "error" && !result.ok) || + (stopOn === "success" && result.ok); + + if (!stop) { + if (results) results[i] = (result as Ok).value; + continue; + } + + if (!stopped) { + stopped = result; + abortWorkers( + !result.ok && AbortError.is(result.error) + ? result.error.cause + : abortCause, + ); + stopSignal?.resolve(); + } + break; + } + return ok(); + }; + + let workersAborted = false; + + const abortWorkers = (cause: unknown) => { + if (workersAborted) return; + workersAborted = true; + for (const worker of workers) worker.abort(cause); + }; + + const workerCount = Math.min(run.concurrency, length); + const workers = createArray(workerCount, () => run.daemon(worker)); + + await using _ = run.defer(() => { + abortWorkers(abortCause); + return ok(); + }); + + run.onAbort((cause) => { + abortWorkers(cause); + aborted.resolve(); + }); + + const waitFor = [Promise.all(workers), aborted.promise]; + if (stopSignal) waitFor.push(stopSignal.promise); + await Promise.race(waitFor); + + if (run.signal.aborted) { + assertType(AbortError, run.signal.reason); + return err(run.signal.reason); + } + + if (!stopOn) return results ? ok(results) : ok(); + if (stopped) return stopped; + if (results) return ok(results); + + return allFailed === "completion" ? lastResult! : lastIndexResult!; + }; +} + +/** + * Returns the first {@link Task} that succeeds. + * + * Like + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any | Promise.any}, + * the first task to succeed wins. All other tasks are aborted. If all tasks + * fail, returns the last error (by input order). + * + * Sequential by default. Use {@link withConcurrency} for parallel execution. + * + * Think of it like `Array.prototype.some()` — it stops on the first success. + * This is in contrast to {@link race}, which returns the first task to complete + * (whether success or failure). + * + * ### Example + * + * ```ts + * // Try multiple endpoints concurrently, first success wins + * const result = await run( + * withConcurrency( + * any([fetchFromPrimary, fetchFromSecondary, fetchFromTertiary]), + * ), + * ); + * ``` + * + * @group Composition + */ +export const any = ( + tasks: NonEmptyReadonlyArray>, + { + allFailed = "input", + }: { + /** How to choose an error if all tasks fail. */ + allFailed?: AnyAllFailed; + } = {}, +): Task => + concurrent(tasks, { + stopOn: "success", + collect: false, + abortCause: anyAbortError, + allFailed, + }); + +/** + * Tie-breaker for {@link any} when all tasks fail. + * + * Used only when no task succeeds. + * + * - `"input"` returns the error from the last task in the input array. This is + * stable under concurrency and generally produces deterministic tests. + * - `"completion"` returns the error from the task that finished last. This + * reflects timing but can vary across runs when task timing varies. + * + * ### Example + * + * ```ts + * await using run = createRunner(); + * const result = await run( + * withConcurrency(any([a, b, c], { allFailed: "completion" })), + * ); + * ``` + */ +export type AnyAllFailed = "input" | "completion"; + +/** + * Abort cause used by {@link any} when aborting remaining tasks. + * + * @group Composition + */ +export const AnyAbortError = typed("AnyAbortError"); +export interface AnyAbortError extends InferType {} + +/** + * {@link AnyAbortError} used as abort cause in {@link any}. + * + * @group Composition + */ +export const anyAbortError: AnyAbortError = { type: "AnyAbortError" }; // TODO: Implement `fetch` - Task wrapper around globalThis.fetch. // Add once retry is implemented to show composition (fetch + timeout + retry). diff --git a/packages/common/src/Test.ts b/packages/common/src/Test.ts index 5e38ee7b0..e8d6bb2b8 100644 --- a/packages/common/src/Test.ts +++ b/packages/common/src/Test.ts @@ -4,18 +4,23 @@ * @module */ +import { testCreateConsole, type ConsoleDep } from "./Console.js"; import { testCreateRandomBytes, type RandomBytesDep } from "./Crypto.js"; import { - testCreateRandom, - testCreateRandomLib, - type RandomDep, - type RandomLibDep, + testCreateRandom, + testCreateRandomLib, + type RandomDep, + type RandomLibDep, } from "./Random.js"; import { createRunner, type Runner, type RunnerConfigDep } from "./Task.js"; import { testCreateTime, type TimeDep } from "./Time.js"; /** Test deps created by {@link createTestDeps}. */ -export type TestDeps = RandomDep & RandomLibDep & RandomBytesDep & TimeDep; +export type TestDeps = RandomDep & + RandomLibDep & + RandomBytesDep & + TimeDep & + ConsoleDep; /** * Creates test deps for proper isolation. @@ -43,7 +48,8 @@ export const createTestDeps = (options?: { const randomLib = testCreateRandomLib(seed); const randomBytes = testCreateRandomBytes({ randomLib }); const time = testCreateTime(); - return { random, randomLib, randomBytes, time }; + const console = testCreateConsole(); + return { random, randomLib, randomBytes, time, console }; }; /** @@ -81,5 +87,5 @@ export function createTestRunner( deps?: Partial & Partial & Omit, ): Runner { const defaults = createTestDeps(); - return createRunner({ ...defaults, ...deps } as D); + return createRunner({ ...defaults, ...deps } as any as D); } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index e023beb4d..4a08afcdd 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -52,36 +52,43 @@ export * from "./Worker.js"; export type { EvoluError } from "./local-first/Error.js"; export { createEvolu } from "./local-first/Evolu.js"; export type { - Evolu, - EvoluConfig, - EvoluDeps, - UnuseOwner, + Evolu, + EvoluConfig, + EvoluDeps, + UnuseOwner } from "./local-first/Evolu.js"; export * as kysely from "./local-first/Kysely.js"; export * from "./local-first/LocalAuth.js"; export * from "./local-first/Owner.js"; -export type { InferRow, Query, QueryRows, Row } from "./local-first/Query.js"; +export { + emptyRows, + type InferRow, + type Query, + type QueryRows, + type Row +} from "./local-first/Query.js"; export type { EvoluSchema } from "./local-first/Schema.js"; export type { - NetworkError, - PaymentRequiredError, - ServerError, - SyncOwner, - SyncState, - SyncStateInitial, - SyncStateIsNotSynced, - SyncStateIsSynced, - SyncStateIsSyncing, + NetworkError, + PaymentRequiredError, + ServerError, + SyncOwner, + SyncState, + SyncStateInitial, + SyncStateIsNotSynced, + SyncStateIsSynced, + SyncStateIsSyncing } from "./local-first/Sync.js"; export { - Timestamp, - timestampBytesToTimestamp, - timestampToTimestampBytes, + Timestamp, + timestampBytesToTimestamp, + timestampToTimestampBytes } from "./local-first/Timestamp.js"; export type { - TimestampBytes, - TimestampCounterOverflowError, - TimestampDriftError, - TimestampError, - TimestampTimeOutOfRangeError, + TimestampBytes, + TimestampCounterOverflowError, + TimestampDriftError, + TimestampError, + TimestampTimeOutOfRangeError } from "./local-first/Timestamp.js"; + diff --git a/packages/common/test/Assert.test.ts b/packages/common/test/Assert.test.ts index 3497c4467..7d58b3610 100644 --- a/packages/common/test/Assert.test.ts +++ b/packages/common/test/Assert.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "vitest"; import { - assert, - assertNonEmptyArray, - assertNonEmptyReadonlyArray, - assertType, + assert, + assertNonEmptyArray, + assertNonEmptyReadonlyArray, + assertType, } from "../src/Assert.js"; import { AbortError } from "../src/Task.js"; @@ -52,7 +52,7 @@ test("assertNonEmptyReadonlyArray", () => { }); test("assertType", () => { - assertType(AbortError, { type: "AbortError", reason: "timeout" }); + assertType(AbortError, { type: "AbortError", cause: "timeout" }); expect(() => { assertType(AbortError, { type: "Other" }); diff --git a/packages/common/test/Option.test.ts b/packages/common/test/Option.test.ts index e0a9c4a3d..e96da4278 100644 --- a/packages/common/test/Option.test.ts +++ b/packages/common/test/Option.test.ts @@ -1,108 +1,108 @@ import { describe, expect, expectTypeOf, test } from "vitest"; import { - fromNullable, - type InferOption, - isNone, - isSome, - none, - type None, - type Option, - some, - type Some, + fromNullable, + type InferOption, + isNone, + isSome, + none, + type None, + type Option, + some, + type Some, } from "../src/Option.js"; test("some creates Some", () => { - const option = some(42); - expect(isSome(option)).toBe(true); - if (isSome(option)) { - expect(option.value).toBe(42); - expectTypeOf(option).toEqualTypeOf>(); - } + const option = some(42); + expect(isSome(option)).toBe(true); + if (isSome(option)) { + expect(option.value).toBe(42); + expectTypeOf(option).toEqualTypeOf>(); + } }); test("none is None", () => { - expect(isNone(none)).toBe(true); - expectTypeOf(none).toEqualTypeOf(); + expect(isNone(none)).toBe(true); + expectTypeOf(none).toEqualTypeOf(); }); test("isSome narrows type", () => { - const option: Option = some("test"); - if (isSome(option)) { - expectTypeOf(option).toEqualTypeOf>(); - expectTypeOf(option.value).toEqualTypeOf(); - } + const option: Option = some("test"); + if (isSome(option)) { + expectTypeOf(option).toEqualTypeOf>(); + expectTypeOf(option.value).toEqualTypeOf(); + } }); test("isNone narrows type", () => { - const option: Option = none; - if (isNone(option)) { - expectTypeOf(option).toEqualTypeOf(); - } + const option: Option = none; + if (isNone(option)) { + expectTypeOf(option).toEqualTypeOf(); + } }); test("fromNullable maps null and undefined to none", () => { - expect(isNone(fromNullable(null))).toBe(true); - expect(isNone(fromNullable(undefined))).toBe(true); - expectTypeOf(fromNullable(null)).toEqualTypeOf>(); + expect(isNone(fromNullable(null))).toBe(true); + expect(isNone(fromNullable(undefined))).toBe(true); + expectTypeOf(fromNullable(null)).toEqualTypeOf>(); }); test("fromNullable maps values to some", () => { - const option = fromNullable("value"); - expect(isSome(option)).toBe(true); - if (isSome(option)) { - expect(option.value).toBe("value"); - } - expectTypeOf(option).toEqualTypeOf>(); + const option = fromNullable("value"); + expect(isSome(option)).toBe(true); + if (isSome(option)) { + expect(option.value).toBe("value"); + } + expectTypeOf(option).toEqualTypeOf>(); }); test("fromNullable strips null and undefined from type", () => { - const value: string | null | undefined = "test"; - const option = fromNullable(value); - expectTypeOf(option).toEqualTypeOf>(); + const value: string | null | undefined = "test"; + const option = fromNullable(value); + expectTypeOf(option).toEqualTypeOf>(); }); describe("InferOption", () => { - test("extracts value type from Option", () => { - type MyOption = Option; - expectTypeOf>().toEqualTypeOf(); - }); - - test("extracts value type from Some", () => { - type MySome = Some; - expectTypeOf>().toEqualTypeOf(); - }); - - test("returns never for None", () => { - expectTypeOf>().toEqualTypeOf(); - }); - - test("works at runtime", () => { - type MyOption = Option; - const value: InferOption = "hello"; - expect(value).toBe("hello"); - }); + test("extracts value type from Option", () => { + type MyOption = Option; + expectTypeOf>().toEqualTypeOf(); + }); + + test("extracts value type from Some", () => { + type MySome = Some; + expectTypeOf>().toEqualTypeOf(); + }); + + test("returns never for None", () => { + expectTypeOf>().toEqualTypeOf(); + }); + + test("works at runtime", () => { + type MyOption = Option; + const value: InferOption = "hello"; + expect(value).toBe("hello"); + }); }); describe("examples", () => { - test("cache that can store null and undefined", () => { - const cache = new Map>(); - - const get = (key: string): Option => cache.get(key) ?? none; - - cache.set("a", some(null)); - cache.set("b", some(undefined)); - - expect(isSome(get("a"))).toBe(true); - expect(isSome(get("b"))).toBe(true); - expect(isNone(get("c"))).toBe(true); - - const a = get("a"); - if (isSome(a)) { - expect(a.value).toBe(null); - } - const b = get("b"); - if (isSome(b)) { - expect(b.value).toBe(undefined); - } - }); + test("cache that can store null and undefined", () => { + const cache = new Map>(); + + const get = (key: string): Option => cache.get(key) ?? none; + + cache.set("a", some(null)); + cache.set("b", some(undefined)); + + expect(isSome(get("a"))).toBe(true); + expect(isSome(get("b"))).toBe(true); + expect(isNone(get("c"))).toBe(true); + + const a = get("a"); + if (isSome(a)) { + expect(a.value).toBe(null); + } + const b = get("b"); + if (isSome(b)) { + expect(b.value).toBe(undefined); + } + }); }); diff --git a/packages/common/test/Task.test.ts b/packages/common/test/Task.test.ts index 707913afd..5ba18810f 100644 --- a/packages/common/test/Task.test.ts +++ b/packages/common/test/Task.test.ts @@ -1,63 +1,102 @@ import { assert, describe, expect, expectTypeOf, test } from "vitest"; -import { isNonEmptyArray } from "../src/Array.js"; -import { exhaustiveCheck } from "../src/Function.js"; +import { + emptyArray, + isNonEmptyArray, + type NonEmptyReadonlyArray, +} from "../src/Array.js"; +import { testCreateConsole } from "../src/Console.js"; +import { exhaustiveCheck, lazyVoid } from "../src/Function.js"; +import { testCreateRandom } from "../src/Random.js"; import { createRef } from "../src/Ref.js"; import type { Done, Result } from "../src/Result.js"; import { done, err, ok, tryAsync } from "../src/Result.js"; import { - exponential, - fixed, - spaced, - take, - whileScheduleInput, + exponential, + fixed, + spaced, + take, + whileScheduleInput, } from "../src/Schedule.js"; import type { - Fiber, - InferFiberErr, - InferFiberOk, - InferTaskDone, - InferTaskErr, - InferTaskOk, - NextTask, - Runner, - RunnerConfigDep, - RunnerDeps, - Task, + Fiber, + FiberState, + InferFiberErr, + InferFiberOk, + InferTaskDone, + InferTaskErr, + InferTaskOk, + NextTask, + RetryError, + Runner, + RunnerConfigDep, + RunnerDeps, + Task, } from "../src/Task.js"; import { - AbortError, - AsyncDisposableStack, - createDeferred, - createGate, - createMutex, - createRunner, - createSemaphore, - deferredDisposedError, - DeferredDisposedError, - race, - RaceLostError, - repeat, - retry, - runnerClosingError, - RunnerEvent, - RunnerState, - semaphoreDisposedError, - sleep, - timeout, - TimeoutError, - unabortable, - unabortableMask, - yieldNow, + AbortError, + AllAbortError, + AllSettledAbortError, + AnyAbortError, + AsyncDisposableStack, + DeferredDisposedError, + ForEachAbortError, + ForEachSettledAbortError, + RaceLostError, + RunnerEvent, + TimeoutError, + all, + allSettled, + any, + createDeferred, + createGate, + createMutex, + createRunner, + createSemaphore, + deferredDisposedError, + forEach, + forEachSettled, + race, + repeat, + retry, + runnerClosingError, + sleep, + timeout, + unabortable, + unabortableMask, + withConcurrency, + yieldNow, } from "../src/Task.js"; import { createTestDeps, createTestRunner } from "../src/Test.js"; -import { createTime, Millis, msLongTask, testCreateTime } from "../src/Time.js"; +import { Millis, createTime, msLongTask, testCreateTime } from "../src/Time.js"; import type { Typed } from "../src/Type.js"; -import { Id, minPositiveInt, PositiveInt } from "../src/Type.js"; +import { Id, PositiveInt, minPositiveInt } from "../src/Type.js"; const eventsEnabled: RunnerConfigDep = { runnerConfig: { eventsEnabled: createRef(true) }, }; +const withResolvers = (): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} => { + let resolve: (value: T | PromiseLike) => void; + let reject: (reason?: any) => void; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // @ts-expect-error assigned in executor + return { promise, resolve, reject }; +}; + +type PromiseWithResolvers = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +}; + interface MyError extends Typed<"MyError"> {} describe("Task", () => { @@ -167,7 +206,7 @@ describe("Runner", () => { test("executes task and returns result", async () => { await using run = createRunner(); - const task = () => ok("hello"); + const task: Task = () => ok("hello"); const result = await run(task); @@ -211,7 +250,7 @@ describe("Runner", () => { }); }); - describe("time", () => { + describe("deps", () => { test("exposes injected time", async () => { const time = testCreateTime(); await using run = createTestRunner({ time }); @@ -219,17 +258,25 @@ describe("Runner", () => { expect(run.time).toBe(time); }); - test("child runners inherit time from parent", async () => { - await using run = createRunner(); + test("exposes injected console", async () => { + const console = testCreateConsole(); + await using run = createTestRunner({ console }); + + expect(run.console).toBe(console); + }); - let childTime: typeof run.time | null = null; + test("exposes injected random", async () => { + const random = testCreateRandom(); + await using run = createTestRunner({ random }); - await run((childRun) => { - childTime = childRun.time; - return ok(); - }); + expect(run.random).toBe(random); + }); - expect(childTime).toBe(run.time); + test("exposes injected randomBytes", async () => { + const deps = createTestDeps(); + await using run = createTestRunner(deps); + + expect(run.randomBytes).toBe(deps.randomBytes); }); }); @@ -239,7 +286,7 @@ describe("Runner", () => { await using run = createTestRunner({ ...deps, ...eventsEnabled }); const events: Array = []; - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); run.onEvent = (event) => { events.push(event); @@ -247,20 +294,23 @@ describe("Runner", () => { const fiber = run(() => taskComplete.promise); - const childAddedEvents = events.filter((e) => e.type === "childAdded"); + const childAddedEvents = events.filter( + (e) => e.data.type === "childAdded", + ); expect(childAddedEvents.length).toBe(1); - expect(childAddedEvents[0].runnerId).toBe(run.id); - expect(childAddedEvents[0].childId).toBe(fiber.run.id); + expect(childAddedEvents[0].id).toBe(run.id); + assert(childAddedEvents[0].data.type === "childAdded"); + expect(childAddedEvents[0].data.childId).toBe(fiber.run.id); taskComplete.resolve(ok()); await fiber; }); - test("emits resultSet and childRemoved when child completes", async () => { + test("emits completing, completed, childRemoved when child completes", async () => { await using run = createTestRunner(eventsEnabled); const events: Array = []; - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); const fiber = run(() => taskComplete.promise); @@ -271,15 +321,25 @@ describe("Runner", () => { taskComplete.resolve(ok()); await fiber; - // Assert exact ordering: resultSet must come before childRemoved - expect(events.map((e) => e.type)).toMatchInlineSnapshot(` - [ - "resultSet", - "stateChanged", - "stateChanged", - "childRemoved", - ] - `); + expect(events.map((e) => e.data.type)).toEqual([ + "stateChanged", + "stateChanged", + "childRemoved", + ]); + + const [completing, completed, childRemoved] = events; + + assert(completing.data.type === "stateChanged"); + expect(completing.data.state.type).toBe("completing"); + + assert(completed.data.type === "stateChanged"); + expect(completed.data.state.type).toBe("completed"); + assert(completed.data.state.type === "completed"); + expect(completed.data.state.result).toEqual(ok()); + expect(completed.data.state.outcome).toEqual(ok()); + + assert(childRemoved.data.type === "childRemoved"); + expect(childRemoved.data.childId).toBe(fiber.run.id); }); test("bubbles up through parent chain", async () => { @@ -291,7 +351,7 @@ describe("Runner", () => { events.push({ level: "root", event }); }; - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); const fiber = run(async (parentRun) => { parentRun.onEvent = (event) => { @@ -304,7 +364,7 @@ describe("Runner", () => { }; // Start a grandchild - const grandchildComplete = Promise.withResolvers>(); + const grandchildComplete = withResolvers>(); const grandchild = childRun(() => grandchildComplete.promise); grandchildComplete.resolve(ok()); await grandchild; @@ -361,7 +421,7 @@ describe("Runner", () => { test("returns new reference when children change", async () => { await using run = createRunner(); - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); const task = () => taskComplete.promise; @@ -385,8 +445,8 @@ describe("Runner", () => { test("preserves child snapshot references when sibling changes", async () => { await using run = createRunner(); - const task1Complete = Promise.withResolvers>(); - const task2Complete = Promise.withResolvers>(); + const task1Complete = withResolvers>(); + const task2Complete = withResolvers>(); const fiber1 = run(() => task1Complete.promise); const fiber2 = run(() => task2Complete.promise); @@ -419,7 +479,7 @@ describe("Runner", () => { // Start 5 concurrent fibers const fibers = Array.from({ length: 5 }, () => { - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); taskCompletes.push(taskComplete); return run(() => taskComplete.promise); }); @@ -484,8 +544,8 @@ describe("Runner", () => { await using run = createRunner(); const events: Array = []; - const taskStarted = Promise.withResolvers(); - const canComplete = Promise.withResolvers(); + const taskStarted = withResolvers(); + const canComplete = withResolvers(); const cleanup = () => { events.push("cleanup"); @@ -510,7 +570,7 @@ describe("Runner", () => { const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); expect(events).toEqual(["work started", "cleanup"]); }); }); @@ -526,7 +586,7 @@ describe("Runner", () => { (id: string): Task => async ({ signal }) => { const taskComplete = - Promise.withResolvers>(); + withResolvers>(); const timeout = setTimeout(() => { results.push(`${id} completed`); @@ -554,17 +614,17 @@ describe("Runner", () => { expect(results).toEqual(["task1 aborted", "task2 aborted"]); }); - test("transitions active → disposing → disposed", async () => { + test("transitions running → completing → completed", async () => { const run = createRunner(); - expectTypeOf(run.getState()).toEqualTypeOf(); - expect(run.getState()).toBe("active"); + expectTypeOf(run.getState()).toEqualTypeOf(); + expect(run.getState().type).toBe("running"); - const taskStarted = Promise.withResolvers(); - const taskCanFinish = Promise.withResolvers(); + const taskStarted = withResolvers(); + const taskCanFinish = withResolvers(); - let stateInAbortHandler: RunnerState | null = null; - let stateAfterAwait: RunnerState | null = null; + let stateInAbortHandler: FiberState | undefined; + let stateAfterAwait: FiberState | undefined; const task: Task = async (run) => { run.signal.addEventListener("abort", () => { @@ -580,14 +640,27 @@ describe("Runner", () => { await taskStarted.promise; const disposePromise = run[Symbol.asyncDispose](); - expect(run.getState()).toBe("disposing"); + expect(run.getState().type).toBe("completing"); taskCanFinish.resolve(); await disposePromise; - expect(stateInAbortHandler).toBe("disposing"); - expect(stateAfterAwait).toBe("disposing"); - expect(run.getState()).toBe("disposed"); + expect(stateInAbortHandler!.type).toBe("completing"); + expect(stateAfterAwait!.type).toBe("completing"); + expect(run.getState().type).toBe("completed"); + }); + + test("defaults completed result and outcome to ok", async () => { + const run = createRunner(); + + await run[Symbol.asyncDispose](); + + const state = run.getState(); + expect(state).toEqual({ + type: "completed", + result: ok(), + outcome: ok(), + }); }); test("is idempotent", async () => { @@ -599,11 +672,11 @@ describe("Runner", () => { expect(promise1).toBe(promise2); }); - test("does not run new tasks when disposing", async () => { + test("does not run new tasks when completing", async () => { const run = createRunner(); run[Symbol.asyncDispose](); - expect(run.getState()).toBe("disposing"); + expect(run.getState().type).toBe("completing"); let regularRan = false; let unabortableRan = false; @@ -634,21 +707,21 @@ describe("Runner", () => { expect(unabortableRan).toBe(false); expect(unabortableMaskRan).toBe(false); - expect(regularFiber.run.getState()).toBe("disposed"); - expect(unabortableFiber.run.getState()).toBe("disposed"); - expect(unabortableMaskFiber.run.getState()).toBe("disposed"); + expect(regularFiber.run.getState().type).toBe("completed"); + expect(unabortableFiber.run.getState().type).toBe("completed"); + expect(unabortableMaskFiber.run.getState().type).toBe("completed"); - const expected = err(runnerClosingError); + const expected = err({ type: "AbortError", cause: runnerClosingError }); expect(regularResult).toEqual(expected); expect(unabortableResult).toEqual(expected); expect(unabortableMaskResult).toEqual(expected); }); - test("does not run new tasks when disposed", async () => { + test("does not run new tasks when completed", async () => { const run = createRunner(); await run[Symbol.asyncDispose](); - expect(run.getState()).toBe("disposed"); + expect(run.getState().type).toBe("completed"); let regularRan = false; let unabortableRan = false; @@ -679,11 +752,11 @@ describe("Runner", () => { expect(unabortableRan).toBe(false); expect(unabortableMaskRan).toBe(false); - expect(regularFiber.run.getState()).toBe("disposed"); - expect(unabortableFiber.run.getState()).toBe("disposed"); - expect(unabortableMaskFiber.run.getState()).toBe("disposed"); + expect(regularFiber.run.getState().type).toBe("completed"); + expect(unabortableFiber.run.getState().type).toBe("completed"); + expect(unabortableMaskFiber.run.getState().type).toBe("completed"); - const expected = err(runnerClosingError); + const expected = err({ type: "AbortError", cause: runnerClosingError }); expect(regularResult).toEqual(expected); expect(unabortableResult).toEqual(expected); expect(unabortableMaskResult).toEqual(expected); @@ -694,8 +767,8 @@ describe("Runner", () => { test("passes the abort reason directly, not wrapped in AbortError", async () => { await using run = createRunner(); - const receivedReason = Promise.withResolvers(); - const taskStarted = Promise.withResolvers(); + const receivedReason = withResolvers(); + const taskStarted = withResolvers(); const fiber = run(async (childRun) => { childRun.onAbort((reason) => { @@ -719,8 +792,8 @@ describe("Runner", () => { test("receives undefined when aborted without reason", async () => { await using run = createRunner(); - const receivedReason = Promise.withResolvers(); - const taskStarted = Promise.withResolvers(); + const receivedReason = withResolvers(); + const taskStarted = withResolvers(); const fiber = run(async (childRun) => { childRun.onAbort((reason) => { @@ -740,6 +813,127 @@ describe("Runner", () => { expect(reason).toBeUndefined(); }); + + test("invokes callback immediately when already aborted", async () => { + await using run = createRunner(); + + const receivedReason = withResolvers(); + const allowRegister = withResolvers(); + + const fiber = run(async (childRun) => { + await allowRegister.promise; + childRun.onAbort((reason) => { + receivedReason.resolve(reason); + }); + return ok(); + }); + + fiber.abort("late-reason"); + allowRegister.resolve(); + + const reason = await receivedReason.promise; + expect(reason).toBe("late-reason"); + }); + + test.sequential("removes listener via signal option for cleanup", async () => { + // This test verifies that onAbort uses `signal: requestController.signal` + // for listener cleanup. Per spec, when the cleanup signal aborts, the + // listener is removed. We capture the cleanup signal and verify it's + // aborted after disposal. + + await using run = createRunner(); + + let cleanupSignal: AbortSignal | null = null; + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalAddEventListener = AbortSignal.prototype.addEventListener; + + let childSignal: AbortSignal | null = null; + + AbortSignal.prototype.addEventListener = function ( + ...args: Parameters + ) { + const [type, , options] = args; + if ( + type === "abort" && + this === childSignal && + options && + typeof options === "object" && + options.signal + ) { + cleanupSignal = options.signal; + } + originalAddEventListener.apply(this, args); + }; + + try { + await run((childRun) => { + childSignal = childRun.signal; + childRun.onAbort(lazyVoid); + return ok(); + }); + + // Cleanup signal should exist and be aborted after disposal + expect(cleanupSignal).not.toBeNull(); + expect(cleanupSignal!.aborted).toBe(true); + } finally { + AbortSignal.prototype.addEventListener = originalAddEventListener; + } + }); + + test.sequential("removes parent abort listener via signal option for cleanup", async () => { + // This test verifies that child runners use `signal: requestController.signal` + // for parent abort listener cleanup. When a child completes, the listener + // on parent.requestSignal should be removed automatically. + + await using run = createRunner(); + + let cleanupSignal: AbortSignal | null = null; + // eslint-disable-next-line @typescript-eslint/unbound-method + const originalAddEventListener = AbortSignal.prototype.addEventListener; + + // We need to capture the parent's requestSignal to identify the right listener + let parentRequestSignal: AbortSignal | null = null; + + AbortSignal.prototype.addEventListener = function ( + ...args: Parameters + ) { + const [type, , options] = args; + // The parent abort listener is registered on parent.requestSignal + if ( + type === "abort" && + this === parentRequestSignal && + options && + typeof options === "object" && + options.signal + ) { + cleanupSignal = options.signal; + } + originalAddEventListener.apply(this, args); + }; + + try { + // First, we need to get access to the parent's internal requestSignal + // We do this by spawning a child that captures it + await run((childRun) => { + // The child registers a listener on parent.requestSignal + // We can identify it by checking what signal addEventListener is called on + // The parent's requestSignal is internal, but we can use a trick: + // spawn another child and that child will register on childRun's requestSignal + parentRequestSignal = ( + childRun as never as { requestSignal: AbortSignal } + ).requestSignal; + + const childFiber = childRun(() => ok(42)); + return childFiber; + }); + + // Cleanup signal should exist and be aborted after child disposal + expect(cleanupSignal).not.toBeNull(); + expect(cleanupSignal!.aborted).toBe(true); + } finally { + AbortSignal.prototype.addEventListener = originalAddEventListener; + } + }); }); }); @@ -750,7 +944,7 @@ describe("Fiber", () => { const task: Task = () => Promise.resolve(ok(42)); const fiber = run(task); - expectTypeOf(fiber).toEqualTypeOf>(); + expectTypeOf(fiber).toEqualTypeOf>(); const result = await fiber; @@ -764,8 +958,7 @@ describe("Fiber", () => { let taskRan = false; let signalAbortedBeforeInnerRun = false; - let innerFiberState: string | null = null; - let innerFiberResult: Result | null = null; + let innerFiberState: FiberState | undefined; const fiber = run(async (run) => { await Promise.resolve(); @@ -778,8 +971,7 @@ describe("Fiber", () => { await innerFiber; - innerFiberState = innerFiber.run.getState(); - innerFiberResult = innerFiber.run.getResult(); + innerFiberState = innerFiber.getState(); return ok(); }); @@ -789,11 +981,11 @@ describe("Fiber", () => { expect(signalAbortedBeforeInnerRun).toBe(true); expect(taskRan).toBe(false); - expect(innerFiberState).toBe("disposed"); - expect(innerFiberResult).toEqual( - err({ type: "AbortError", reason: "stop" }), + assert(innerFiberState?.type === "completed"); + expect(innerFiberState.result).toEqual( + err({ type: "AbortError", cause: "stop" }), ); - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); }); test("during run signals abort via AbortSignal", async () => { @@ -802,7 +994,7 @@ describe("Fiber", () => { let signalAbortedInHandler = false; const task: Task = async ({ signal }) => { - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); const timeout = setTimeout(() => { taskComplete.resolve(ok()); @@ -830,7 +1022,7 @@ describe("Fiber", () => { expect(result).toEqual( err({ type: "AbortError", - reason: "test abort", + cause: "test abort", }), ); }); @@ -847,7 +1039,7 @@ describe("Fiber", () => { readonly error: unknown; } - const errorCapture = Promise.withResolvers(); + const errorCapture = withResolvers(); const fetchTask = (url: string): Task => @@ -869,13 +1061,13 @@ describe("Fiber", () => { expect(await fiber).toEqual( err({ type: "AbortError", - reason: "cancelled", + cause: "cancelled", }), ); expect(await errorCapture.promise).toEqual({ type: "AbortError", - reason: "cancelled", + cause: "cancelled", }); }); }); @@ -885,7 +1077,7 @@ describe("Fiber", () => { await using run = createRunner(); const task: Task = async ({ signal }) => { - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); const timeout = setTimeout(() => { taskComplete.resolve(ok()); @@ -912,60 +1104,60 @@ describe("Fiber", () => { expect(await fiber).toEqual( err({ type: "AbortError", - reason: undefined, + cause: undefined, }), ); }); }); - test("getResult returns null while pending, Result after completion", async () => { + test("getState returns running while running, completed with result after completion", async () => { await using run = createRunner(); - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); const fiber = run(() => taskComplete.promise); - expect(fiber.getResult()).toBeNull(); + expect(fiber.getState().type).toBe("running"); taskComplete.resolve(ok(42)); await fiber; - expectTypeOf(fiber.getResult()).toEqualTypeOf | null>(); - expect(fiber.getResult()).toEqual(ok(42)); + const state = fiber.getState(); + expectTypeOf(state).toEqualTypeOf>(); + assert(state.type === "completed"); + expect(state.result).toEqual(ok(42)); }); - test("getOutcome equals getResult when not aborted", async () => { + test("completed state outcome equals result when not aborted", async () => { await using run = createRunner(); - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); const fiber = run(() => taskComplete.promise); - expect(fiber.getOutcome()).toBeNull(); - expect(fiber.getResult()).toBeNull(); + expect(fiber.getState().type).toBe("running"); taskComplete.resolve(ok(42)); await fiber; - expect(fiber.getOutcome()).toEqual(fiber.getResult()); + const state = fiber.getState(); + assert(state.type === "completed"); + expect(state.outcome).toEqual(state.result); }); - test("getOutcome preserves original result when aborted", async () => { + test("completed state outcome preserves original result when aborted", async () => { await using run = createRunner(); const fiber = run(() => ok("data")); fiber.abort("stop"); await fiber; - // getResult returns AbortError - expect(fiber.getResult()).toEqual( - err({ type: "AbortError", reason: "stop" }), - ); - // getOutcome preserves what the task actually returned - expect(fiber.getOutcome()).toEqual(ok("data")); + const state = fiber.getState(); + assert(state.type === "completed"); + // result returns AbortError + expect(state.result).toEqual(err({ type: "AbortError", cause: "stop" })); + // outcome preserves what the task actually returned + expect(state.outcome).toEqual(ok("data")); }); describe("run", () => { @@ -995,24 +1187,26 @@ describe("Fiber", () => { expect(parentFiberId).not.toBe(childFiberId); }); - test("snapshot returns null result while pending, Result after completion", async () => { + test("snapshot returns running state while running, completed with result after completion", async () => { await using run = createRunner(); - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); const fiber = run(() => taskComplete.promise); - expect(fiber.run.snapshot().result).toBeNull(); + expect(fiber.run.snapshot().state.type).toBe("running"); taskComplete.resolve(ok(42)); await fiber; - expect(fiber.run.snapshot().result).toEqual(ok(42)); + const snapshotState = fiber.run.snapshot().state; + assert(snapshotState.type === "completed"); + expect(snapshotState.result).toEqual(ok(42)); }); }); describe("daemon", () => { test("called directly on root runner", async () => { const events: Array = []; - const daemonCanComplete = Promise.withResolvers(); + const daemonCanComplete = withResolvers(); await using run = createRunner(); @@ -1036,7 +1230,7 @@ describe("Fiber", () => { test("outlives parent task", async () => { const events: Array = []; - const daemonCanComplete = Promise.withResolvers(); + const daemonCanComplete = withResolvers(); let daemonFiber: Fiber; await using run = createRunner(); @@ -1082,7 +1276,7 @@ describe("Fiber", () => { const daemonTask: Task = async ({ signal }) => { events.push("daemon started"); - const taskComplete = Promise.withResolvers>(); + const taskComplete = withResolvers>(); const timeout = setTimeout(() => { events.push("daemon completed"); @@ -1118,7 +1312,7 @@ describe("Fiber", () => { test("from nested task runs on root runner", async () => { const events: Array = []; - const daemonCanComplete = Promise.withResolvers(); + const daemonCanComplete = withResolvers(); let daemonFiber: Fiber; await using run = createRunner(); @@ -1225,15 +1419,17 @@ describe("unabortable", () => { // Inner unabortable task completed successfully expect(innerResult).toEqual(ok()); // But outer abortable task was aborted - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); // Outcome preserves what the task actually returned - expect(fiber.getOutcome()).toEqual(innerResult); + const state = fiber.getState(); + assert(state.type === "completed"); + expect(state.outcome).toEqual(innerResult); }); test("with abort during run masks signal and completes", async () => { await using run = createRunner(); - const canComplete = Promise.withResolvers(); + const canComplete = withResolvers(); let signalAbortedAtStart = true; let signalAbortedAfterAbort = true; @@ -1318,18 +1514,18 @@ describe("unabortableMask", () => { // acquire and release ran, use was skipped (abortable sees the abort) expect(events).toEqual(["acquire", "release"]); // Outer fiber result is AbortError because outer task was aborted - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); }); test("with abort during run masks signal, skips abortable", async () => { await using run = createRunner(); const events: Array = []; - const acquireStarted = Promise.withResolvers(); - const canContinue = Promise.withResolvers(); + const acquireStarted = withResolvers(); + const canContinue = withResolvers(); let signalAbortedAtStart = true; let signalAbortedAfterAwait = true; - let signalAbortedInUnmaskedTask = true; + let signalAbortedInMaskedTask = true; const task = unabortableMask((restore) => async (run) => { signalAbortedAtStart = run.signal.aborted; @@ -1341,8 +1537,8 @@ describe("unabortableMask", () => { // Regular task runs because it inherits the abort mask await run(({ signal }) => { - signalAbortedInUnmaskedTask = signal.aborted; - events.push("unmasked task"); + signalAbortedInMaskedTask = signal.aborted; + events.push("masked task"); return ok(); }); @@ -1367,8 +1563,8 @@ describe("unabortableMask", () => { expect(signalAbortedAtStart).toBe(false); expect(signalAbortedAfterAwait).toBe(false); - expect(signalAbortedInUnmaskedTask).toBe(false); - expect(events).toEqual(["acquire", "unmasked task", "release"]); + expect(signalAbortedInMaskedTask).toBe(false); + expect(events).toEqual(["acquire", "masked task", "release"]); expect(result).toEqual(ok()); }); @@ -1376,8 +1572,8 @@ describe("unabortableMask", () => { await using run = createRunner(); const events: Array = []; - const innerStarted = Promise.withResolvers(); - const canContinue = Promise.withResolvers(); + const innerStarted = withResolvers(); + const canContinue = withResolvers(); const task = unabortableMask((restore1) => async (run) => { // mask = 1 @@ -1556,8 +1752,8 @@ describe("AsyncDisposableStack", () => { await using run = createRunner(); const events: Array = []; - const taskStarted = Promise.withResolvers(); - const canComplete = Promise.withResolvers(); + const taskStarted = withResolvers(); + const canComplete = withResolvers(); const task: Task = async (run) => { await using stack = run.stack(); @@ -1581,7 +1777,7 @@ describe("AsyncDisposableStack", () => { const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); expect(events).toEqual(["work started", "cleanup"]); }); }); @@ -1754,7 +1950,7 @@ describe("AsyncDisposableStack", () => { await using run = createRunner(); const events: Array = []; - const canComplete = Promise.withResolvers(); + const canComplete = withResolvers(); const slowAcquire: Task = async ({ signal }) => { events.push(`acquire started, aborted: ${signal.aborted}`); @@ -1786,7 +1982,7 @@ describe("AsyncDisposableStack", () => { const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); expect(events).toEqual([ "acquire started, aborted: false", "acquire completed, aborted: false", @@ -1886,7 +2082,7 @@ describe("AsyncDisposableStack", () => { await using run = createRunner(); let childRunner: Runner | null = null; - let stateWhileWorking: RunnerState | null = null; + let stateWhileWorking: FiberState | null = null; const task: Task = async (run) => { await using stack = run.stack(); @@ -1903,8 +2099,8 @@ describe("AsyncDisposableStack", () => { const result = await run(task); expect(result).toEqual(ok()); - expect(stateWhileWorking).toBe("active"); - expect(childRunner!.getState()).toBe("disposed"); + expect(stateWhileWorking!.type).toBe("running"); + expect(childRunner!.getState().type).toBe("completed"); }); test("accepts moved native stack", async () => { @@ -1977,8 +2173,8 @@ describe("AsyncDisposableStack", () => { await using run = createRunner(); const events: Array = []; - const taskStarted = Promise.withResolvers(); - const canComplete = Promise.withResolvers(); + const taskStarted = withResolvers(); + const canComplete = withResolvers(); const task: Task = async (run) => { await using stack = run.stack(); @@ -2008,7 +2204,7 @@ describe("AsyncDisposableStack", () => { const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); expect(events).toEqual(["work started", "h1 released"]); }); @@ -2096,7 +2292,7 @@ describe("AsyncDisposableStack", () => { await using run = createRunner(); const events: Array = []; - const canContinue = Promise.withResolvers(); + const canContinue = withResolvers(); // Abortable factory - if aborted, acquired resources are cleaned up const createBundle: Task< @@ -2134,7 +2330,7 @@ describe("AsyncDisposableStack", () => { const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "cancelled" })); + expect(result).toEqual(err({ type: "AbortError", cause: "cancelled" })); // 'a' was acquired then cleaned up when scope exited expect(events).toEqual(["a acquired", "a released"]); }); @@ -2162,15 +2358,12 @@ describe("AsyncDisposableStack", () => { }); }; - // Run factory - task scope ends after this const bundle = await run(createBundle); expect(bundle.ok).toBe(true); if (!bundle.ok) throw new Error("unreachable"); events.push("using bundle after factory ended"); - // Factory task scope is dead, but cleanup should still work - // because defer() uses daemon (root scope) await bundle.value[Symbol.asyncDispose](); expect(events).toEqual([ @@ -2214,15 +2407,12 @@ describe("AsyncDisposableStack", () => { }); }; - // Run factory - task scope ends after this const bundle = await run(createBundle); expect(bundle.ok).toBe(true); if (!bundle.ok) throw new Error("unreachable"); events.push(`using ${bundle.value.handle.id} after factory ended`); - // Factory task scope is dead, but disposal should still work - // because adopt() uses daemon (root scope) await bundle.value[Symbol.asyncDispose](); expect(events).toEqual([ @@ -2281,8 +2471,8 @@ describe("AsyncDisposableStack", () => { await using run = createRunner(); const events: Array = []; - const workStarted = Promise.withResolvers(); - const canComplete = Promise.withResolvers(); + const workStarted = withResolvers(); + const canComplete = withResolvers(); const cleanupHelper = () => { events.push("cleanup helper ran"); @@ -2321,7 +2511,7 @@ describe("AsyncDisposableStack", () => { const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); expect(events).toEqual([ "acquired", "work started", @@ -2410,9 +2600,11 @@ describe("sleep", () => { const result = await fiber; const elapsed = Date.now() - start; - expect(result).toEqual(err({ type: "AbortError", reason: "cancelled" })); - expect(fiber.getOutcome()).toEqual( - err({ type: "AbortError", reason: "cancelled" }), + expect(result).toEqual(err({ type: "AbortError", cause: "cancelled" })); + const state = fiber.getState(); + assert(state.type === "completed"); + expect(state.outcome).toEqual( + err({ type: "AbortError", cause: "cancelled" }), ); expect(elapsed).toBeLessThan(50); }); @@ -2422,7 +2614,7 @@ describe("race", () => { test("returns first task to succeed and aborts others", async () => { await using run = createRunner(); - const slowObservedAbort = Promise.withResolvers(); + const slowObservedAbort = withResolvers(); const fast = () => ok("fast"); const slow = async ({ signal }: { signal: AbortSignal }) => { @@ -2438,13 +2630,13 @@ describe("race", () => { const slowAbortReason = await slowObservedAbort.promise; assert(AbortError.is(slowAbortReason)); - expect(RaceLostError.is(slowAbortReason.reason)).toBe(true); + expect(RaceLostError.is(slowAbortReason.cause)).toBe(true); }); test("returns first task to fail and aborts others", async () => { await using run = createRunner(); - const slowObservedAbort = Promise.withResolvers(); + const slowObservedAbort = withResolvers(); interface FastError extends Typed<"FastError"> {} @@ -2464,13 +2656,13 @@ describe("race", () => { const slowAbortReason = await slowObservedAbort.promise; assert(AbortError.is(slowAbortReason)); - expect(RaceLostError.is(slowAbortReason.reason)).toBe(true); + expect(RaceLostError.is(slowAbortReason.cause)).toBe(true); }); test("aborts others when one throws", async () => { await using run = createRunner(); - const slowObservedAbort = Promise.withResolvers(); + const slowObservedAbort = withResolvers(); const throwing = () => { throw new Error("boom"); @@ -2485,7 +2677,7 @@ describe("race", () => { const slowAbortReason = await slowObservedAbort.promise; assert(AbortError.is(slowAbortReason)); - expect(RaceLostError.is(slowAbortReason.reason)).toBe(true); + expect(RaceLostError.is(slowAbortReason.cause)).toBe(true); }); test("infers union of Ok and Err types from heterogeneous tasks", async () => { @@ -2554,8 +2746,8 @@ describe("race", () => { test("propagates external abort to all raced tasks", async () => { await using run = createRunner(); - const task1ObservedAbort = Promise.withResolvers(); - const task2ObservedAbort = Promise.withResolvers(); + const task1ObservedAbort = withResolvers(); + const task2ObservedAbort = withResolvers(); const task1: Task = async ({ signal }) => { await Promise.resolve(); @@ -2577,7 +2769,7 @@ describe("race", () => { const result = await fiber; expect(result).toEqual( - err({ type: "AbortError", reason: "external abort" }), + err({ type: "AbortError", cause: "external abort" }), ); // Both tasks should have observed the abort @@ -2586,14 +2778,14 @@ describe("race", () => { assert(AbortError.is(task1Reason)); assert(AbortError.is(task2Reason)); - expect(task1Reason.reason).toBe("external abort"); - expect(task2Reason.reason).toBe("external abort"); + expect(task1Reason.cause).toBe("external abort"); + expect(task2Reason.cause).toBe("external abort"); }); test("uses custom abortReason for losing tasks", async () => { await using run = createRunner(); - const slowObservedAbort = Promise.withResolvers(); + const slowObservedAbort = withResolvers(); const fast = () => ok("fast"); const slow = async ({ signal }: { signal: AbortSignal }) => { @@ -2603,13 +2795,13 @@ describe("race", () => { }; const customReason = { type: "CustomAbort", message: "you lost" }; - const result = await run(race([fast, slow], { abortReason: customReason })); + const result = await run(race([fast, slow], { abortCause: customReason })); expect(result).toEqual(ok("fast")); const slowAbortReason = await slowObservedAbort.promise; assert(AbortError.is(slowAbortReason)); - expect(slowAbortReason.reason).toEqual(customReason); + expect(slowAbortReason.cause).toEqual(customReason); }); }); @@ -2645,7 +2837,7 @@ describe("timeout", () => { const time = testCreateTime(); await using run = createTestRunner({ time }); - const abortReasonCapture = Promise.withResolvers(); + const abortReasonCapture = withResolvers(); const slow: Task = async ({ onAbort }) => { onAbort((reason) => { @@ -2671,7 +2863,7 @@ describe("timeout", () => { await using run = createTestRunner({ time }); const customReason = { type: "CustomTimeout" }; - const abortReasonCapture = Promise.withResolvers(); + const abortReasonCapture = withResolvers(); const slow: Task = async ({ onAbort }) => { onAbort((reason) => { @@ -2682,7 +2874,7 @@ describe("timeout", () => { return ok(); }; - const fiber = run(timeout(slow, "10ms", { abortReason: customReason })); + const fiber = run(timeout(slow, "10ms", { abortCause: customReason })); time.advance("10ms"); await fiber; @@ -2696,7 +2888,7 @@ describe("timeout", () => { await using run = createTestRunner({ time }); let taskCompleted = false; - const completionCapture = Promise.withResolvers(); + const completionCapture = withResolvers(); const slow: Task = unabortable(async (run) => { const result = await run(sleep("100ms")); @@ -2858,7 +3050,7 @@ describe("retry", () => { let attempts = 0; const task = () => { attempts++; - return err({ type: "AbortError", reason: "test" }); + return err({ type: "AbortError", cause: "test" }); }; const result = await run(retry(task, take(3)(spaced("1ms")))); @@ -2873,7 +3065,7 @@ describe("retry", () => { test("propagates abort to running task", async () => { await using run = createRunner(); - const taskStarted = Promise.withResolvers(); + const taskStarted = withResolvers(); const task: Task = async (run) => { taskStarted.resolve(); @@ -2943,6 +3135,31 @@ describe("retry", () => { } expect(attempts).toBe(2); }); + + test("abort during retry delay returns AbortError", async () => { + await using run = createRunner(); + + let attempts = 0; + const task: Task = () => { + attempts++; + return err({ type: "MyError" }); + }; + + // Use a long delay so we can abort during it + const fiber = run(retry(task, take(3)(spaced("1s")))); + + // Wait for first attempt to complete and delay to start + await run(sleep("10ms")); + expect(attempts).toBe(1); + + fiber.abort(); + + const result = await fiber; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(AbortError.is(result.error)).toBe(true); + } + }); }); describe("repeat", () => { @@ -3076,7 +3293,7 @@ describe("repeat", () => { expect(count).toBe(1); }); - test("uses forever schedule when unbounded", async () => { + test("uses forever schedule when unlimited", async () => { await using run = createRunner(); let count = 0; @@ -3138,7 +3355,7 @@ describe("repeat", () => { const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); expect(count).toBe(1); }); @@ -3277,7 +3494,7 @@ describe("DI", () => { test("createRunner without args returns Runner", async () => { await using run = createRunner(); - expectTypeOf(run).toEqualTypeOf>(); + expectTypeOf(run).toEqualTypeOf(); }); test("runner rejects task with missing deps", async () => { @@ -3452,6 +3669,199 @@ describe("DI", () => { }); describe("concurrency", () => { + describe("withConcurrency", () => { + test("defaults to max concurrency when passed only a task", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const createTask = + (id: number): Task => + async () => { + events.push(`start ${id}`); + await canFinish.promise; + events.push(`end ${id}`); + return ok(id); + }; + + const fiber = run( + withConcurrency(all([createTask(1), createTask(2), createTask(3)])), + ); + + expect(events).toEqual(["start 1", "start 2", "start 3"]); + + canFinish.resolve(); + const result = await fiber; + + expect(result).toEqual(ok([1, 2, 3])); + }); + + test("inherits concurrency", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const createTask = + (id: number): Task => + async () => { + events.push(`start ${id}`); + await canFinish.promise; + events.push(`end ${id}`); + return ok(id); + }; + + const fiber = run( + withConcurrency(2 as PositiveInt, (run) => + run( + all([createTask(1), createTask(2), createTask(3), createTask(4)]), + ), + ), + ); + + // Only 2 tasks should start (inherited) + await Promise.resolve(); + expect(events).toEqual(["start 1", "start 2"]); + + canFinish.resolve(); + const result = await fiber; + + expect(result).toEqual(ok([1, 2, 3, 4])); + }); + + test("nested withConcurrency overrides parent", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const createTask = + (id: number): Task => + async () => { + events.push(`start ${id}`); + await canFinish.promise; + events.push(`end ${id}`); + return ok(id); + }; + + const fiber = run( + withConcurrency(5 as PositiveInt, (run) => + run( + withConcurrency(1 as PositiveInt, (run) => + run(all([createTask(1), createTask(2), createTask(3)])), + ), + ), + ), + ); + + // Only 1 task should start (inner concurrency overrides) + await Promise.resolve(); + await Promise.resolve(); + expect(events).toEqual(["start 1"]); + + canFinish.resolve(); + const result = await fiber; + + expect(result).toEqual(ok([1, 2, 3])); + }); + + test("default concurrency is sequential", async () => { + await using run = createRunner(); + + const events: Array = []; + + const createTask = + (id: number): Task => + () => { + events.push(`start ${id}`); + events.push(`end ${id}`); + return ok(id); + }; + + // Default is sequential, tasks run one at a time + const result = await run( + all([createTask(1), createTask(2), createTask(3)]), + ); + + // Sequential: 1 starts and finishes, then 2, then 3 + expect(events).toEqual([ + "start 1", + "end 1", + "start 2", + "end 2", + "start 3", + "end 3", + ]); + expect(result).toEqual(ok([1, 2, 3])); + }); + + test("abort propagates to all tasks", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const createTask = + (id: number): Task => + async ({ onAbort }) => { + events.push(`start ${id}`); + onAbort(() => events.push(`abort ${id}`)); + await canFinish.promise; + events.push(`end ${id}`); + return ok(id); + }; + + const fiber = run( + withConcurrency(all([createTask(1), createTask(2), createTask(3)])), + ); + + await Promise.resolve(); + expect(events).toEqual(["start 1", "start 2", "start 3"]); + + fiber.abort(); + canFinish.resolve(); + const result = await fiber; + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(AbortError.is(result.error)).toBe(true); + } + expect(events).toContain("abort 1"); + expect(events).toContain("abort 2"); + expect(events).toContain("abort 3"); + }); + + test("unabortable task does not block error from returning", async () => { + // Not using `await using` because disposal waits for all fibers to complete, + // including the unabortable task (10s). We want to verify all() returns + // promptly on error without blocking on unabortable tasks. + const run = createRunner(); + + let unabortableCompleted = false; + + const failingTask: Task = () => + err({ type: "MyError" }); + + const unabortableTask = unabortable(async (run) => { + await run(sleep("10s")); + unabortableCompleted = true; + return ok("done"); + }); + + const start = Date.now(); + const result = await run( + withConcurrency(all([unabortableTask, failingTask])), + ); + const elapsed = Date.now() - start; + + // all returns promptly with error, doesn't wait for unabortable task + expect(result).toEqual(err({ type: "MyError" })); + expect(elapsed).toBeLessThan(50); + expect(unabortableCompleted).toBe(false); + }); + }); + describe("Deferred", () => { test("resolves with ok", async () => { await using run = createRunner(); @@ -3486,7 +3896,7 @@ describe("concurrency", () => { fiber.abort("stop"); const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); }); test("resolve returns true only on first call", () => { @@ -3526,7 +3936,7 @@ describe("concurrency", () => { // fiber2 should get AbortError const result2 = await fiber2; expect(result2).toEqual( - err({ type: "AbortError", reason: "stop fiber2" }), + err({ type: "AbortError", cause: "stop fiber2" }), ); // fiber1 and fiber3 should still be pending, resolve them @@ -3555,6 +3965,18 @@ describe("concurrency", () => { expect(result1).toEqual(err(deferredDisposedError)); expect(result2).toEqual(err(deferredDisposedError)); }); + + test("task returns immediately when already resolved", async () => { + await using run = createRunner(); + + const { task, resolve } = createDeferred(); + + // Resolve before running the task + resolve(ok("pre-resolved")); + + const result = await run(task); + expect(result).toEqual(ok("pre-resolved")); + }); }); describe("Gate", () => { @@ -3669,27 +4091,7 @@ describe("concurrency", () => { fiber.abort("cancelled"); const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "cancelled" })); - }); - - test("open is idempotent", () => { - const gate = createGate(); - - gate.open(); - gate.open(); - gate.open(); - - expect(gate.isOpen()).toBe(true); - }); - - test("close is idempotent", () => { - const gate = createGate(); - - gate.close(); - gate.close(); - gate.close(); - - expect(gate.isOpen()).toBe(false); + expect(result).toEqual(err({ type: "AbortError", cause: "cancelled" })); }); test("preserves deps type", () => { @@ -3731,6 +4133,42 @@ describe("concurrency", () => { expect(gate.isOpen()).toBe(false); }); + test("open is idempotent", () => { + const gate = createGate(); + + gate.open(); + expect(gate.isOpen()).toBe(true); + + gate.open(); + gate.open(); + expect(gate.isOpen()).toBe(true); + }); + + test("close is idempotent", () => { + const gate = createGate(); + + // Already closed, close again + gate.close(); + gate.close(); + expect(gate.isOpen()).toBe(false); + + gate.open(); + gate.close(); + gate.close(); + expect(gate.isOpen()).toBe(false); + }); + + test("open and close are no-op after dispose", () => { + const gate = createGate(); + + gate[Symbol.dispose](); + + // Should not throw or change state + gate.open(); + gate.close(); + expect(gate.isOpen()).toBe(false); + }); + test("wait returns DeferredDisposedError after dispose", async () => { await using run = createRunner(); @@ -3742,191 +4180,423 @@ describe("concurrency", () => { expect(result).toEqual(err(deferredDisposedError)); }); }); + describe("Semaphore", () => { - test("limits concurrent tasks", async () => { + test("runs a task", async () => { await using run = createRunner(); - const semaphore = createSemaphore(PositiveInt.orThrow(2)); + const semaphore = createSemaphore(1 as PositiveInt); + + const result = await run(semaphore.withPermit(() => ok("ran"))); + + expect(result).toEqual(ok("ran")); + }); + + test("limits concurrent tasks to permit count", async () => { + await using run = createRunner(); + + const semaphore = createSemaphore(2 as PositiveInt); const events: Array = []; - const started = Array.from({ length: 3 }, () => - Promise.withResolvers(), - ); - const finish = Array.from({ length: 3 }, () => - Promise.withResolvers(), - ); + const task1Started = withResolvers(); + const task2Started = withResolvers(); + const canFinish = withResolvers(); - const task = - (id: number): Task => + const createTask = + (id: number, started: () => void): Task => async () => { events.push(`start ${id}`); - started[id].resolve(); - await finish[id].promise; + started(); + await canFinish.promise; events.push(`end ${id}`); return ok(); }; - const fibers = [0, 1, 2].map((id) => run(semaphore.withPermit(task(id)))); + // Start 3 tasks with 2 permits + const fiber1 = run( + semaphore.withPermit(createTask(1, task1Started.resolve)), + ); + const fiber2 = run( + semaphore.withPermit(createTask(2, task2Started.resolve)), + ); - await Promise.all([started[0].promise, started[1].promise]); - expect(events).toEqual(["start 0", "start 1"]); + await task1Started.promise; + await task2Started.promise; + + // Third task should be queued + const task3Started = withResolvers(); + const fiber3 = run( + semaphore.withPermit(createTask(3, task3Started.resolve)), + ); - finish[0].resolve(); - await fibers[0]; + await Promise.resolve(); + expect(events).toEqual(["start 1", "start 2"]); - await started[2].promise; - const indexEnd0 = events.indexOf("end 0"); - const indexStart2 = events.indexOf("start 2"); - expect(indexStart2).toBeGreaterThan(indexEnd0); + // Let task 1 finish + canFinish.resolve(); + await fiber1; - finish[1].resolve(); - finish[2].resolve(); - await Promise.all(fibers); + // Now task 3 should start + await task3Started.promise; + expect(events).toContain("start 3"); - semaphore[Symbol.dispose](); + await Promise.all([fiber2, fiber3]); }); - test("releases permit on task error", async () => { + test("queues tasks when permits exhausted", async () => { await using run = createRunner(); - const semaphore = createSemaphore(minPositiveInt); - const started = Promise.withResolvers(); - const waitingStarted = Promise.withResolvers(); - const releaseFirst = Promise.withResolvers(); + const semaphore = createSemaphore(1 as PositiveInt); + const events: Array = []; + + const task1Started = withResolvers(); + const task1CanFinish = withResolvers(); - const failingTask: Task = async () => { - started.resolve(); - await releaseFirst.promise; - return err({ type: "Fail" }); - }; + const fiber1 = run( + semaphore.withPermit(async () => { + events.push("start 1"); + task1Started.resolve(); + await task1CanFinish.promise; + events.push("end 1"); + return ok(); + }), + ); - const waitingTask: Task = () => { - waitingStarted.resolve(); - return ok(); - }; + await task1Started.promise; + + // Second task should wait + const fiber2 = run( + semaphore.withPermit(() => { + events.push("task 2 ran"); + return ok(); + }), + ); + + await Promise.resolve(); + expect(events).toEqual(["start 1"]); + + // Let first task finish + task1CanFinish.resolve(); + await fiber1; + await fiber2; + + expect(events).toEqual(["start 1", "end 1", "task 2 ran"]); + }); - const failingFiber = run(semaphore.withPermit(failingTask)); - await started.promise; + test("returns task result", async () => { + await using run = createRunner(); - const waitingFiber = run(semaphore.withPermit(waitingTask)); - releaseFirst.resolve(); + const semaphore = createSemaphore(1 as PositiveInt); - const failingResult = await failingFiber; - expect(failingResult).toEqual(err({ type: "Fail" })); + const okResult = await run(semaphore.withPermit(() => ok(42))); + const errResult = await run( + semaphore.withPermit(() => err({ type: "MyError" })), + ); - const waitingResult = await waitingFiber; - expect(waitingResult).toEqual(ok()); - await waitingStarted.promise; + expect(okResult).toEqual(ok(42)); + expect(errResult).toEqual(err({ type: "MyError" })); + }); - semaphore[Symbol.dispose](); + test("releases permit when task succeeds", async () => { + await using run = createRunner(); + + const semaphore = createSemaphore(1 as PositiveInt); + + // First task succeeds + await run(semaphore.withPermit(() => ok("first"))); + + // Second task should run immediately (permit released) + let secondRan = false; + await run( + semaphore.withPermit(() => { + secondRan = true; + return ok(); + }), + ); + + expect(secondRan).toBe(true); + }); + + test("releases permit when task fails", async () => { + await using run = createRunner(); + + const semaphore = createSemaphore(1 as PositiveInt); + + // First task fails + await run(semaphore.withPermit(() => err({ type: "MyError" }))); + + // Second task should run immediately (permit released) + let secondRan = false; + await run( + semaphore.withPermit(() => { + secondRan = true; + return ok(); + }), + ); + + expect(secondRan).toBe(true); }); - test("runs waiters in FIFO order", async () => { + test("abort while waiting removes from queue", async () => { await using run = createRunner(); - const semaphore = createSemaphore(minPositiveInt); + const semaphore = createSemaphore(1 as PositiveInt); const events: Array = []; - const holderRelease = Promise.withResolvers(); - const holder: Task = async () => { - events.push("start holder"); - await holderRelease.promise; - events.push("end holder"); - return ok(); - }; + const task1Started = withResolvers(); + const task1CanFinish = withResolvers(); - const waitingTask = - (id: number): Task => - () => { - events.push(`start ${id}`); + const fiber1 = run( + semaphore.withPermit(async () => { + events.push("start 1"); + task1Started.resolve(); + await task1CanFinish.promise; + events.push("end 1"); return ok(); - }; + }), + ); - const holderFiber = run(semaphore.withPermit(holder)); - await Promise.resolve(); + await task1Started.promise; + + // Second task waits + const fiber2 = run( + semaphore.withPermit(() => { + events.push("task 2 ran"); + return ok(); + }), + ); + + // Abort the waiting task + fiber2.abort("cancelled"); - const firstWaiter = run(semaphore.withPermit(waitingTask(1))); - const secondWaiter = run(semaphore.withPermit(waitingTask(2))); + const result2 = await fiber2; + expect(result2).toEqual(err({ type: "AbortError", cause: "cancelled" })); + + // Third task should proceed when permit is released + const fiber3 = run( + semaphore.withPermit(() => { + events.push("task 3 ran"); + return ok(); + }), + ); + + task1CanFinish.resolve(); + await fiber1; + await fiber3; + + expect(events).toEqual(["start 1", "end 1", "task 3 ran"]); + }); + + test("abort while running aborts task", async () => { + await using run = createRunner(); + + const semaphore = createSemaphore(1 as PositiveInt); + let abortReceived = false; + + const fiber = run( + semaphore.withPermit( + ({ signal }) => + new Promise>((resolve) => { + signal.addEventListener("abort", () => { + abortReceived = true; + resolve(err({ type: "AbortError", cause: signal.reason })); + }); + }), + ), + ); + + fiber.abort("stop"); + const result = await fiber; + + expect(abortReceived).toBe(true); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); + }); - holderRelease.resolve(); - await holderFiber; + test("dispose aborts running tasks", async () => { + await using run = createRunner(); - await firstWaiter; - await secondWaiter; + const semaphore = createSemaphore(1 as PositiveInt); + const events: Array = []; - const start1 = events.indexOf("start 1"); - const start2 = events.indexOf("start 2"); - expect(start1).toBeGreaterThan(events.indexOf("end holder")); - expect(start1).toBeLessThan(start2); + const taskStarted = withResolvers(); + + const fiber = run( + semaphore.withPermit(({ signal }) => { + events.push("task started"); + taskStarted.resolve(); + return new Promise>((resolve) => { + signal.addEventListener("abort", () => { + events.push("task aborted"); + resolve(err({ type: "AbortError", cause: signal.reason })); + }); + }); + }), + ); + await taskStarted.promise; semaphore[Symbol.dispose](); + + const result = await fiber; + + expect(events).toEqual(["task started", "task aborted"]); + expect(result).toEqual( + err({ + type: "AbortError", + cause: { type: "SemaphoreDisposedError" }, + }), + ); }); - test("dispose aborts waiting and running tasks", async () => { + test("dispose aborts waiting tasks", async () => { await using run = createRunner(); - const semaphore = createSemaphore(minPositiveInt); - const runningStarted = Promise.withResolvers(); - const runningCanFinish = Promise.withResolvers(); - const runningAbortReason = Promise.withResolvers(); + const semaphore = createSemaphore(1 as PositiveInt); - const runningTask: Task = async ({ onAbort }) => { - onAbort((reason) => { - runningAbortReason.resolve(reason); - runningCanFinish.resolve(); - }); + const task1Started = withResolvers(); - runningStarted.resolve(); - await runningCanFinish.promise; - return ok(); - }; + // Hold the permit with a task that listens for abort + const fiber1 = run( + semaphore.withPermit(({ signal }) => { + task1Started.resolve(); + return new Promise>((resolve) => { + signal.addEventListener("abort", () => { + resolve(err({ type: "AbortError", cause: signal.reason })); + }); + }); + }), + ); + + await task1Started.promise; + + // Waiting task + const fiber2 = run(semaphore.withPermit(() => ok("should not run"))); + + semaphore[Symbol.dispose](); + + const [result1, result2] = await Promise.all([fiber1, fiber2]); + + // Running task was aborted + expect(result1).toEqual( + err({ + type: "AbortError", + cause: { type: "SemaphoreDisposedError" }, + }), + ); + + // Waiting task was aborted + expect(result2).toEqual( + err({ + type: "AbortError", + cause: { type: "SemaphoreDisposedError" }, + }), + ); + }); + + test("acquire after dispose returns SemaphoreDisposedError", async () => { + await using run = createRunner(); - const waitingTask: Task = () => ok(); + const semaphore = createSemaphore(1 as PositiveInt); + semaphore[Symbol.dispose](); + + const result = await run( + semaphore.withPermit(() => ok("should not run")), + ); - const runningFiber = run(semaphore.withPermit(runningTask)); - await runningStarted.promise; + expect(result).toEqual( + err({ + type: "AbortError", + cause: { type: "SemaphoreDisposedError" }, + }), + ); + }); - const waitingFiber = run(semaphore.withPermit(waitingTask)); + test("dispose is idempotent", () => { + const semaphore = createSemaphore(1 as PositiveInt); semaphore[Symbol.dispose](); + semaphore[Symbol.dispose](); + semaphore[Symbol.dispose](); + + // Should not throw + }); + + test("preserves FIFO order for queued tasks", async () => { + await using run = createRunner(); + + const semaphore = createSemaphore(1 as PositiveInt); + const events: Array = []; + + const task1Started = withResolvers(); + const task1CanFinish = withResolvers(); + + // Hold the permit + const fiber1 = run( + semaphore.withPermit(async () => { + task1Started.resolve(); + await task1CanFinish.promise; + events.push("task 1"); + return ok(); + }), + ); - const runningResult = await runningFiber; - const waitingResult = await waitingFiber; + await task1Started.promise; - expect(runningResult).toEqual( - err({ type: "AbortError", reason: semaphoreDisposedError }), + // Queue tasks in order + const fiber2 = run( + semaphore.withPermit(() => { + events.push("task 2"); + return ok(); + }), + ); + const fiber3 = run( + semaphore.withPermit(() => { + events.push("task 3"); + return ok(); + }), ); - expect(waitingResult).toEqual( - err({ type: "AbortError", reason: semaphoreDisposedError }), + const fiber4 = run( + semaphore.withPermit(() => { + events.push("task 4"); + return ok(); + }), ); - const reason = await runningAbortReason.promise; - expect(reason).toEqual(semaphoreDisposedError); + task1CanFinish.resolve(); + await Promise.all([fiber1, fiber2, fiber3, fiber4]); + + expect(events).toEqual(["task 1", "task 2", "task 3", "task 4"]); }); - test("aborted runner does not hang waiting for permit", async () => { + test("multiple permits allow concurrent execution", async () => { await using run = createRunner(); - const semaphore = createSemaphore(minPositiveInt); - const hold = Promise.withResolvers(); + const semaphore = createSemaphore(3 as PositiveInt); + let concurrent = 0; + let maxConcurrent = 0; + + const taskFinished = withResolvers(); + let finishedCount = 0; - const holder: Task = async () => { - await hold.promise; + const createTask = (): Task => async () => { + concurrent += 1; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await Promise.resolve(); + concurrent -= 1; + finishedCount += 1; + if (finishedCount === 5) taskFinished.resolve(); return ok(); }; - const holderFiber = run(semaphore.withPermit(holder)); - - const waiterFiber = run(semaphore.withPermit(() => ok())); - waiterFiber.abort("stop"); + // Run 5 tasks with 3 permits + run(semaphore.withPermit(createTask())); + run(semaphore.withPermit(createTask())); + run(semaphore.withPermit(createTask())); + run(semaphore.withPermit(createTask())); + run(semaphore.withPermit(createTask())); - const waiterResult = await waiterFiber; - expect(waiterResult).toEqual(err({ type: "AbortError", reason: "stop" })); + await taskFinished.promise; - hold.resolve(); - await holderFiber; - - semaphore[Symbol.dispose](); + expect(maxConcurrent).toBe(3); }); }); @@ -3937,9 +4607,9 @@ describe("concurrency", () => { const mutex = createMutex(); const events: Array = []; - const firstStarted = Promise.withResolvers(); - const firstFinish = Promise.withResolvers(); - const secondStarted = Promise.withResolvers(); + const firstStarted = withResolvers(); + const firstFinish = withResolvers(); + const secondStarted = withResolvers(); const firstTask: Task = async () => { events.push("start 1"); @@ -3976,7 +4646,1111 @@ describe("concurrency", () => { }); }); -describe("examples TODO: review", () => { +describe("all", () => { + test("runs tasks sequentially by default", async () => { + await using run = createRunner(); + + const events: Array = []; + + const createTask = + (id: number): Task => + async () => { + events.push(`start ${id}`); + await Promise.resolve(); + events.push(`end ${id}`); + return ok(id); + }; + + const result = await run( + all([createTask(1), createTask(2), createTask(3)]), + ); + + expect(result).toEqual(ok([1, 2, 3])); + expect(events).toEqual([ + "start 1", + "end 1", + "start 2", + "end 2", + "start 3", + "end 3", + ]); + }); + + test("returns empty array for empty input", async () => { + await using run = createRunner(); + + const emptyTasks: Array> = []; + const result = await run(all(emptyTasks)); + + expect(result).toEqual(ok(emptyArray)); + }); + + test("fails fast on first error", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFail = withResolvers(); + + const slowTask: Task = async ({ signal }) => { + events.push("slow start"); + // Wait until aborted or a very long time + await new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + if (!signal.aborted) { + events.push("slow end"); + } + return ok("slow"); + }; + + const failingTask: Task = async () => { + events.push("fail start"); + await canFail.promise; + events.push("fail end"); + return err({ type: "MyError" }); + }; + + const fiber = run(withConcurrency(all([slowTask, failingTask]))); + + expect(events).toEqual(["slow start", "fail start"]); + + // Let failing task fail + canFail.resolve(); + + const result = await fiber; + + expect(result).toEqual(err({ type: "MyError" })); + // Slow task was aborted (no "slow end") + expect(events).not.toContain("slow end"); + }); + + test("aborts others when a task throws", async () => { + await using run = createRunner(); + + const slowObservedAbort = withResolvers(); + + const slowTask: Task = async (run) => { + await new Promise((resolve) => { + run.onAbort(() => resolve()); + }); + slowObservedAbort.resolve(run.signal.reason); + return ok(); + }; + + const throwingTask: Task = () => { + throw new Error("boom"); + }; + + await expect( + run(withConcurrency(all([slowTask, throwingTask]))), + ).rejects.toThrow("boom"); + + const slowAbortReason = await slowObservedAbort.promise; + assert(AbortError.is(slowAbortReason)); + expect(AllAbortError.is(slowAbortReason.cause)).toBe(true); + }); + + test("propagates abort cause to other tasks", async () => { + await using run = createRunner(); + + const abortCause = { type: "TestAbort" }; + const causes: Array = []; + + const waitForAbort: Task = (run) => + new Promise((resolve) => { + run.onAbort((cause) => { + causes.push(cause); + resolve(ok()); + }); + }); + + const abortingTask: Task = () => + err({ type: "AbortError", cause: abortCause }); + + const fiber = run( + withConcurrency(3 as PositiveInt, all([waitForAbort, abortingTask, waitForAbort])), + ); + + const result = await fiber; + + expect(result).toEqual(err({ type: "AbortError", cause: abortCause })); + expect(causes).toEqual([abortCause, abortCause]); + }); + + test("limits concurrency with explicit number", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const createTask = + (id: number): Task => + async () => { + events.push(`start ${id}`); + await canFinish.promise; + events.push(`end ${id}`); + return ok(id); + }; + + const fiber = run( + withConcurrency( + 2 as PositiveInt, + all([createTask(1), createTask(2), createTask(3), createTask(4)]), + ), + ); + + // Only 2 tasks should start + expect(events).toEqual(["start 1", "start 2"]); + + canFinish.resolve(); + const result = await fiber; + + expect(result).toEqual(ok([1, 2, 3, 4])); + expect(events).toContain("start 3"); + expect(events).toContain("start 4"); + }); + + test("supports struct input and returns object with same keys", async () => { + await using run = createRunner(); + + const taskA: Task = () => ok(42); + const taskB: Task = () => ok("hello"); + const taskC: Task = () => ok(true); + + const result = await run(all({ a: taskA, b: taskB, c: taskC })); + + expect(result).toEqual(ok({ a: 42, b: "hello", c: true })); + }); + + test("struct preserves types", async () => { + await using run = createRunner(); + + const struct = { + num: (() => ok(42)) as Task, + str: (() => ok("hello")) as Task, + }; + + const result = await run(all(struct)); + if (result.ok) { + expectTypeOf(result.value.num).toEqualTypeOf(); + expectTypeOf(result.value.str).toEqualTypeOf(); + } + }); + + test("struct fails fast on first error", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFail = withResolvers(); + + const goodTask: Task = async ({ signal }) => { + events.push("good start"); + await new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + return ok("good"); + }; + + const badTask: Task = async () => { + events.push("bad start"); + await canFail.promise; + return err({ type: "MyError" }); + }; + + const fiber = run(withConcurrency(all({ good: goodTask, bad: badTask }))); + + expect(events).toEqual(["good start", "bad start"]); + + canFail.resolve(); + const result = await fiber; + + expect(result).toEqual(err({ type: "MyError" })); + }); + + test("struct returns empty object for empty input", async () => { + await using run = createRunner(); + + const result = await run(all({})); + + expect(result).toEqual(ok({})); + }); + + test("struct respects withConcurrency", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const createTask = + (id: string): Task => + async () => { + events.push(`start ${id}`); + await canFinish.promise; + events.push(`end ${id}`); + return ok(id); + }; + + const fiber = run( + withConcurrency( + minPositiveInt, + all({ a: createTask("a"), b: createTask("b"), c: createTask("c") }), + ), + ); + + // Sequential: only one at a time + expect(events).toEqual(["start a"]); + + canFinish.resolve(); + const result = await fiber; + + expect(result).toEqual(ok({ a: "a", b: "b", c: "c" })); + }); + + test("tuple preserves types", async () => { + await using run = createRunner(); + + const result = await run( + all([() => ok(42), () => ok("hello"), () => ok(true)]), + ); + + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf< + readonly [number, string, boolean] + >(); + } + }); + + test("struct preserves readonly properties", async () => { + await using run = createRunner(); + + const readonlyStruct = { + num: (() => ok(42)) as Task, + str: (() => ok("hello")) as Task, + } as const; + + const result = await run(all(readonlyStruct)); + + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf<{ + readonly num: number; + readonly str: string; + }>(); + } + }); + + test("non-empty arrays preserve types", async () => { + await using run = createRunner(); + + const tasks: NonEmptyReadonlyArray> = [() => ok(1)]; + const result = await run(all(tasks)); + + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf>(); + } + }); + + test("returns promptly on external abort even when blocked", async () => { + // Not using `await using` because disposal waits for all fibers to complete, + // including the unabortable task (10s). + const run = createRunner(); + + let unabortableCompleted = false; + + const unabortableTask = unabortable(async (run) => { + await run(sleep("10s")); + unabortableCompleted = true; + return ok(); + }); + + const abortableWaitsForAbort: Task = async (run) => { + await new Promise((resolve) => { + run.onAbort(() => resolve()); + }); + return ok(); + }; + + // Run sequentially (default concurrency=1): all() is effectively blocked on + // the first task. Even then, external abort should make it return promptly. + const fiber = run(all([unabortableTask, abortableWaitsForAbort])); + + await Promise.resolve(); + fiber.abort("cancelled"); + + const result = await Promise.race([ + Promise.resolve(fiber), + new Promise<"timeout">((resolve) => { + setTimeout(() => resolve("timeout"), 100); + }), + ]); + + expect(result).not.toBe("timeout"); + + const taskResult = result as Result, AbortError>; + expect(taskResult).toEqual(err({ type: "AbortError", cause: "cancelled" })); + expect(unabortableCompleted).toBe(false); + }); +}); + +describe("allSettled", () => { + test("runs tasks sequentially by default", async () => { + await using run = createRunner(); + + const events: Array = []; + + const createTask = + (id: number): Task => + async () => { + events.push(`start ${id}`); + await Promise.resolve(); + events.push(`end ${id}`); + return ok(id); + }; + + const result = await run( + allSettled([createTask(1), createTask(2), createTask(3)]), + ); + + expect(result).toEqual(ok([ok(1), ok(2), ok(3)])); + expect(events).toEqual([ + "start 1", + "end 1", + "start 2", + "end 2", + "start 3", + "end 3", + ]); + }); + + test("runs all tasks even when some fail", async () => { + await using run = createRunner(); + + const events: Array = []; + + const successTask = + (id: number): Task => + () => { + events.push(`success ${id}`); + return ok(id); + }; + + const failTask = + (id: number): Task => + () => { + events.push(`fail ${id}`); + return err({ type: "MyError" }); + }; + + const result = await run( + allSettled([successTask(1), failTask(2), successTask(3)]), + ); + + expect(result).toEqual(ok([ok(1), err({ type: "MyError" }), ok(3)])); + expect(events).toEqual(["success 1", "fail 2", "success 3"]); + }); + + test("non-empty arrays preserve types", async () => { + await using run = createRunner(); + + const tasks: NonEmptyReadonlyArray> = [() => ok(1)]; + const result = await run(allSettled(tasks)); + + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf< + NonEmptyReadonlyArray> + >(); + } + }); + + test("returns empty array for empty input", async () => { + await using run = createRunner(); + + const emptyTasks: Array> = []; + const result = await run(allSettled(emptyTasks)); + + expect(result).toEqual(ok(emptyArray)); + }); + + test("supports struct input", async () => { + await using run = createRunner(); + + const taskA: Task = () => ok(42); + const taskB: Task = () => err({ type: "MyError" }); + const taskC: Task = () => ok(true); + + const result = await run(allSettled({ a: taskA, b: taskB, c: taskC })); + + expect(result).toEqual( + ok({ + a: ok(42), + b: err({ type: "MyError" }), + c: ok(true), + }), + ); + }); + + test("struct returns empty object for empty input", async () => { + await using run = createRunner(); + + const result = await run(allSettled({})); + + expect(result).toEqual(ok({})); + }); + + test("struct preserves types", async () => { + await using run = createRunner(); + + const struct = { + num: (() => ok(42)) as Task, + str: (() => ok("hello")) as Task, + }; + + const result = await run(allSettled(struct)); + if (result.ok) { + expectTypeOf(result.value.num).toEqualTypeOf< + Result + >(); + expectTypeOf(result.value.str).toEqualTypeOf< + Result + >(); + } + }); + + test("struct preserves readonly properties", async () => { + await using run = createRunner(); + + const readonlyStruct = { + num: (() => ok(42)) as Task, + str: (() => ok("hello")) as Task, + } as const; + + const result = await run(allSettled(readonlyStruct)); + + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf<{ + readonly num: Result; + readonly str: Result; + }>(); + } + }); + + test("struct respects withConcurrency", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const createTask = + (id: string): Task => + async () => { + events.push(`start ${id}`); + await canFinish.promise; + events.push(`end ${id}`); + return ok(id); + }; + + const fiber = run( + withConcurrency( + minPositiveInt, + allSettled({ + a: createTask("a"), + b: createTask("b"), + c: createTask("c"), + }), + ), + ); + + // Sequential: only one at a time + expect(events).toEqual(["start a"]); + + canFinish.resolve(); + const result = await fiber; + + expect(result).toEqual( + ok({ + a: ok("a"), + b: ok("b"), + c: ok("c"), + }), + ); + }); + + test("respects withConcurrency", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const createTask = + (id: number): Task => + async () => { + events.push(`start ${id}`); + await canFinish.promise; + events.push(`end ${id}`); + return ok(id); + }; + + const fiber = run( + withConcurrency( + 2 as PositiveInt, + allSettled([createTask(1), createTask(2), createTask(3)]), + ), + ); + + // Only 2 tasks should start + expect(events).toEqual(["start 1", "start 2"]); + + canFinish.resolve(); + const result = await fiber; + + expect(result).toEqual(ok([ok(1), ok(2), ok(3)])); + }); + + test("tuple preserves types", async () => { + await using run = createRunner(); + + const result = await run( + allSettled([ + () => ok(42), + () => err({ type: "MyError" } as MyError), + () => ok("hello"), + ]), + ); + + if (result.ok) { + expectTypeOf(result.value).toEqualTypeOf< + readonly [ + Result, + Result, + Result, + ] + >(); + } + }); + + test("aborts others when a task throws", async () => { + await using run = createRunner(); + + const slowObservedAbort = withResolvers(); + + const slowTask: Task = async (run) => { + await new Promise((resolve) => { + run.onAbort(() => resolve()); + }); + slowObservedAbort.resolve(run.signal.reason); + return ok(); + }; + + const throwingTask: Task = () => { + throw new Error("boom"); + }; + + await expect( + run(withConcurrency(allSettled([slowTask, throwingTask]))), + ).rejects.toThrow("boom"); + + const slowAbortReason = await slowObservedAbort.promise; + assert(AbortError.is(slowAbortReason)); + expect(AllSettledAbortError.is(slowAbortReason.cause)).toBe(true); + }); +}); + +describe("forEach", () => { + test("runs tasks sequentially by default", async () => { + await using run = createRunner(); + + const events: Array = []; + const createTask = + (id: number): Task => + async () => { + events.push(`start ${id}`); + await Promise.resolve(); + events.push(`end ${id}`); + return ok(); + }; + + const result = await run(forEach([createTask(1), createTask(2)])); + + expect(result).toEqual(ok()); + expect(events).toEqual(["start 1", "end 1", "start 2", "end 2"]); + }); + + test("fails fast on first error", async () => { + await using run = createRunner(); + + const events: Array = []; + const tasks: NonEmptyReadonlyArray> = [ + () => { + events.push("task 1"); + return ok(); + }, + () => { + events.push("task 2"); + return err({ type: "MyError" }); + }, + () => { + events.push("task 3"); + return ok(); + }, + ]; + + const result = await run(forEach(tasks)); + + expect(result).toEqual(err({ type: "MyError" })); + expect(events).toEqual(["task 1", "task 2"]); + }); + + test("respects withConcurrency", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const createTask = + (id: number): Task => + async () => { + events.push(`start ${id}`); + await canFinish.promise; + events.push(`end ${id}`); + return ok(); + }; + + const fiber = run( + withConcurrency( + 2 as PositiveInt, + forEach([createTask(1), createTask(2), createTask(3)]), + ), + ); + + // Only 2 tasks should start + expect(events).toEqual(["start 1", "start 2"]); + + canFinish.resolve(); + const result = await fiber; + + expect(result).toEqual(ok()); + }); + + test("aborts others when a task fails", async () => { + await using run = createRunner(); + + const slowObservedAbort = withResolvers(); + + const slowTask: Task = async (run) => { + await new Promise((resolve) => { + run.onAbort(() => resolve()); + }); + slowObservedAbort.resolve(run.signal.reason); + return ok(); + }; + + const failingTask: Task = () => err({ type: "MyError" }); + + const result = await run(withConcurrency(forEach([slowTask, failingTask]))); + + expect(result).toEqual(err({ type: "MyError" })); + + const slowAbortReason = await slowObservedAbort.promise; + assert(AbortError.is(slowAbortReason)); + expect(ForEachAbortError.is(slowAbortReason.cause)).toBe(true); + }); + + test("aborts others when a task throws", async () => { + await using run = createRunner(); + + const slowObservedAbort = withResolvers(); + + const slowTask: Task = async (run) => { + await new Promise((resolve) => { + run.onAbort(() => resolve()); + }); + slowObservedAbort.resolve(run.signal.reason); + return ok(); + }; + + const throwingTask: Task = () => { + throw new Error("boom"); + }; + + await expect( + run(withConcurrency(forEach([slowTask, throwingTask]))), + ).rejects.toThrow("boom"); + + const slowAbortReason = await slowObservedAbort.promise; + assert(AbortError.is(slowAbortReason)); + expect(ForEachAbortError.is(slowAbortReason.cause)).toBe(true); + }); +}); + +describe("forEachSettled", () => { + test("runs all tasks even if some fail", async () => { + await using run = createRunner(); + + const events: Array = []; + const tasks: NonEmptyReadonlyArray> = [ + () => { + events.push("task 1"); + return ok(); + }, + () => { + events.push("task 2"); + return err({ type: "MyError" }); + }, + () => { + events.push("task 3"); + return ok(); + }, + ]; + + const result = await run(forEachSettled(tasks)); + + expect(result).toEqual(ok()); + expect(events).toEqual(["task 1", "task 2", "task 3"]); + }); + + test("aborts others when a task throws", async () => { + await using run = createRunner(); + + const slowObservedAbort = withResolvers(); + + const slowTask: Task = async (run) => { + await new Promise((resolve) => { + run.onAbort(() => resolve()); + }); + slowObservedAbort.resolve(run.signal.reason); + return ok(); + }; + + const throwingTask: Task = () => { + throw new Error("boom"); + }; + + await expect( + run(withConcurrency(forEachSettled([slowTask, throwingTask]))), + ).rejects.toThrow("boom"); + + const slowAbortReason = await slowObservedAbort.promise; + assert(AbortError.is(slowAbortReason)); + expect(ForEachSettledAbortError.is(slowAbortReason.cause)).toBe(true); + }); +}); + +describe("any", () => { + test("returns first success", async () => { + await using run = createRunner(); + + const result = await run(any([() => ok(1), () => ok(2), () => ok(3)])); + + expect(result).toEqual(ok(1)); + }); + + test("returns first success with concurrent execution", async () => { + await using run = createRunner(); + + const events: Array = []; + const canFinish = withResolvers(); + + const slow: Task = async () => { + events.push("slow start"); + await canFinish.promise; + events.push("slow end"); + return ok("slow"); + }; + + const fast: Task = () => { + events.push("fast"); + return ok("fast"); + }; + + const fiber = run(withConcurrency(any([slow, fast]))); + await Promise.resolve(); + canFinish.resolve(); + + const result = await fiber; + expect(result).toEqual(ok("fast")); + }); + + test("returns last error when all fail", async () => { + await using run = createRunner(); + + interface MyError { + readonly type: "MyError"; + readonly value: number; + } + + const result = await run( + any([ + () => err({ type: "MyError", value: 1 }), + () => err({ type: "MyError", value: 2 }), + () => err({ type: "MyError", value: 3 }), + ]), + ); + + expect(result).toEqual(err({ type: "MyError", value: 3 })); + }); + + test("returns last error when all fail with concurrent execution", async () => { + await using run = createRunner(); + + const events: Array = []; + + interface MyError { + readonly type: "MyError"; + readonly value: number; + } + + const createFailingTask = + (id: number): Task => + () => { + events.push(`task ${id}`); + return err({ type: "MyError", value: id }); + }; + + const result = await run( + withConcurrency( + any([createFailingTask(1), createFailingTask(2), createFailingTask(3)]), + ), + ); + + // With unlimited concurrency, all tasks run. Last in input order determines error. + // Since they're sync, order is deterministic: 1, 2, 3 + expect(events).toEqual(["task 1", "task 2", "task 3"]); + expect(result).toEqual(err({ type: "MyError", value: 3 })); + }); + + test("returns last error by input order, not completion order", async () => { + await using run = createRunner(); + + interface MyError { + readonly type: "MyError"; + readonly id: "slow" | "fast"; + } + + const canFinish = withResolvers(); + + const slow: Task = async () => { + await canFinish.promise; + return err({ type: "MyError", id: "slow" }); + }; + + const fast: Task = () => + err({ type: "MyError", id: "fast" }); + + const fiber = run(withConcurrency(any([slow, fast]))); + await Promise.resolve(); + canFinish.resolve(); + + const result = await fiber; + expect(result).toEqual(err({ type: "MyError", id: "fast" })); + }); + + test("can return last error by completion order", async () => { + await using run = createRunner(); + + interface MyError { + readonly type: "MyError"; + readonly id: "slow" | "fast"; + } + + const canFinish = withResolvers(); + + const slow: Task = async () => { + await canFinish.promise; + return err({ type: "MyError", id: "slow" }); + }; + + const fast: Task = () => + err({ type: "MyError", id: "fast" }); + + const fiber = run( + withConcurrency(any([slow, fast], { allFailed: "completion" })), + ); + await Promise.resolve(); + canFinish.resolve(); + + const result = await fiber; + expect(result).toEqual(err({ type: "MyError", id: "slow" })); + }); + + test("aborts others when first succeeds", async () => { + await using run = createRunner(); + + const slowAbortReason = withResolvers(); + + const slow: Task = async (run) => { + await new Promise((resolve) => { + run.onAbort((cause) => { + slowAbortReason.resolve(cause); + resolve(); + }); + }); + return ok("slow"); + }; + + const fast: Task = () => ok("fast"); + + const result = await run(withConcurrency(any([slow, fast]))); + + expect(result).toEqual(ok("fast")); + const cause = await slowAbortReason.promise; + expect(AnyAbortError.is(cause)).toBe(true); + }); + + test("skips failures until success", async () => { + await using run = createRunner(); + + const events: Array = []; + + interface MyError { + readonly type: "MyError"; + readonly value: number; + } + + const result = await run( + any([ + () => { + events.push("fail 1"); + return err({ type: "MyError", value: 1 }); + }, + () => { + events.push("fail 2"); + return err({ type: "MyError", value: 2 }); + }, + () => { + events.push("success"); + return ok("winner"); + }, + () => { + events.push("never runs"); + return ok("too late"); + }, + ]), + ); + + expect(result).toEqual(ok("winner")); + expect(events).toEqual(["fail 1", "fail 2", "success"]); + }); +}); + +describe("examples TODO", () => { + describe.skip("composition types from JSDoc", () => { + // These tests verify the types shown in Task.ts JSDoc examples are accurate. + // No runtime behavior - just type-level assertions. + + interface FetchError extends Typed<"FetchError"> { + readonly error: unknown; + } + + interface NativeFetchDep { + readonly fetch: typeof globalThis.fetch; + } + + // Simulated fetch task - typed but never executed + const fetch = ( + _url: string, + ): Task => { + throw new Error("Not implemented - type test only"); + }; + + test("timeout adds TimeoutError to error union", () => { + const fetchWithTimeout = (url: string) => timeout(fetch(url), "30s"); + + expectTypeOf(fetchWithTimeout).toEqualTypeOf< + ( + url: string, + ) => Task + >(); + }); + + test("retry adds RetryError to error union", async () => { + const fetchWithTimeout = (url: string) => timeout(fetch(url), "30s"); + + const fetchWithRetry = (url: string) => + retry(fetchWithTimeout(url), take(2)(exponential("100ms"))); + + type Expected = ( + url: string, + ) => Task< + Response, + FetchError | TimeoutError | RetryError, + NativeFetchDep + >; + + expectTypeOf(fetchWithRetry).toEqualTypeOf(); + + const deps: RunnerDeps & NativeFetchDep = { + ...createTestDeps(), + console: testCreateConsole(), + fetch: globalThis.fetch, + }; + + await using run = createRunner(deps); + + const urls = [ + "https://api.example.com/users", + "https://api.example.com/posts", + "https://api.example.com/comments", + ]; + + // At most 2 concurrent requests + const _result = await run( + withConcurrency(2 as PositiveInt, all(urls.map(fetchWithRetry))), + ); + }); + + test("all with NonEmptyReadonlyArray returns NonEmptyReadonlyArray", () => { + // Create tasks array typed as NonEmptyReadonlyArray + const tasks: NonEmptyReadonlyArray> = [ + () => ok("a"), + ]; + + const _composed = all(tasks); + + type ActualResultType = Awaited>; + + const _result = null as unknown as ActualResultType; + + expectTypeOf(_result).toEqualTypeOf< + Result, MyError | AbortError> + >(); + }); + }); + + describe("createSemaphore", () => { + test("limits concurrency with sleep helper", async () => { + await using run = createRunner(); + // run.console.enabled = true; + + const semaphore = createSemaphore(2 as PositiveInt); + + const fetchUser = + (id: string): Task => + async (run) => { + run.console.log("[demo]", "start", id); + const slept = await run(sleep("5ms")); + if (!slept.ok) return slept; + run.console.log("[demo]", "end", id); + return ok(`user:${id}`); + }; + + const fetchWithPermit = (id: string) => + semaphore.withPermit(fetchUser(id)); + + await Promise.all([ + run(fetchWithPermit("1")), + run(fetchWithPermit("2")), + run(fetchWithPermit("3")), + ]); + }); + }); + describe("yieldNow", () => { test("keeps UI responsive when processing large arrays", async () => { await using run = createRunner(); @@ -4048,8 +5822,10 @@ describe("examples TODO: review", () => { fiber.abort("stop"); const result = await fiber; - expect(result).toEqual(err({ type: "AbortError", reason: "stop" })); - expect(fiber.getOutcome()).toEqual(ok("data")); + expect(result).toEqual(err({ type: "AbortError", cause: "stop" })); + const state = fiber.getState(); + assert(state.type === "completed"); + expect(state.outcome).toEqual(ok("data")); }); test("unabortable preserves result and outcome", async () => { @@ -4060,7 +5836,9 @@ describe("examples TODO: review", () => { const result = await fiber; expect(result).toEqual(ok("data")); - expect(fiber.getOutcome()).toEqual(ok("data")); + const state = fiber.getState(); + assert(state.type === "completed"); + expect(state.outcome).toEqual(ok("data")); }); }); @@ -4069,7 +5847,7 @@ describe("examples TODO: review", () => { await using run = createRunner(); const events: Array = []; - const canComplete = Promise.withResolvers(); + const canComplete = withResolvers(); let signalAbortedInAnalytics = true; // Simulate async analytics API (abortable by default) diff --git a/packages/common/test/local-first/Protocol.test.ts b/packages/common/test/local-first/Protocol.test.ts index ee4c75772..184862601 100644 --- a/packages/common/test/local-first/Protocol.test.ts +++ b/packages/common/test/local-first/Protocol.test.ts @@ -3,76 +3,76 @@ import * as fc from "fast-check"; import { assert, beforeAll, describe, expect, it, test } from "vitest"; import { createBuffer } from "../../src/Buffer.js"; import { lazyFalse, lazyTrue } from "../../src/Function.js"; +import { err, getOrThrow, ok } from "../../src/Result.js"; +import { SqliteValue } from "../../src/Sqlite.js"; +import type { TestDeps } from "../../src/Test.js"; +import { createTestDeps } from "../../src/Test.js"; +import { + NonNegativeInt, + PositiveInt, + createId, + dateToDateIso, +} from "../../src/Type.js"; import type { NonEmptyReadonlyArray } from "../../src/index.js"; -import { assertNonEmptyArray, EncryptionKey } from "../../src/index.js"; +import { EncryptionKey, assertNonEmptyArray } from "../../src/index.js"; import type { TimestampsRangeWithTimestampsBuffer } from "../../src/local-first/Protocol.js"; import { - applyProtocolMessageAsClient, - applyProtocolMessageAsRelay, - createProtocolMessageBuffer, - createProtocolMessageForSync, - createProtocolMessageFromCrdtMessages, - createTimestampsBuffer, - decodeFlags, - decodeLength, - decodeNodeId, - decodeNonNegativeInt, - decodeNumber, - decodeRle, - decodeSqliteValue, - decodeString, - decryptAndDecodeDbChange, - defaultProtocolMessageRangesMaxSize, - encodeAndEncryptDbChange, - encodeFlags, - encodeLength, - encodeNodeId, - encodeNonNegativeInt, - encodeNumber, - encodeSqliteValue, - encodeString, - MessageType, - ProtocolMessageMaxSize, - ProtocolMessageRangesMaxSize, - ProtocolValueType, - protocolVersion, - SubscriptionFlags, + MessageType, + ProtocolMessageMaxSize, + ProtocolMessageRangesMaxSize, + ProtocolValueType, + SubscriptionFlags, + applyProtocolMessageAsClient, + applyProtocolMessageAsRelay, + createProtocolMessageBuffer, + createProtocolMessageForSync, + createProtocolMessageFromCrdtMessages, + createTimestampsBuffer, + decodeFlags, + decodeLength, + decodeNodeId, + decodeNonNegativeInt, + decodeNumber, + decodeRle, + decodeSqliteValue, + decodeString, + decryptAndDecodeDbChange, + defaultProtocolMessageRangesMaxSize, + encodeAndEncryptDbChange, + encodeFlags, + encodeLength, + encodeNodeId, + encodeNonNegativeInt, + encodeNumber, + encodeSqliteValue, + encodeString, + protocolVersion, } from "../../src/local-first/Protocol.js"; import type { - CrdtMessage, - EncryptedCrdtMessage, - EncryptedDbChange, - Storage, - StorageDep, + CrdtMessage, + EncryptedCrdtMessage, + EncryptedDbChange, + Storage, + StorageDep, } from "../../src/local-first/Storage.js"; import { - DbChange, - InfiniteUpperBound, - RangeType, - timestampBytesToFingerprint, + DbChange, + InfiniteUpperBound, + RangeType, + timestampBytesToFingerprint, } from "../../src/local-first/Storage.js"; import { - createInitialTimestamp, - timestampBytesToTimestamp, - timestampToTimestampBytes, + createInitialTimestamp, + timestampBytesToTimestamp, + timestampToTimestampBytes, } from "../../src/local-first/Timestamp.js"; -import { err, getOrThrow, ok } from "../../src/Result.js"; -import { SqliteValue } from "../../src/Sqlite.js"; -import type { TestDeps } from "../../src/Test.js"; -import { createTestDeps } from "../../src/Test.js"; -import { - createId, - dateToDateIso, - NonNegativeInt, - PositiveInt, -} from "../../src/Type.js"; import { testCreateRelayStorageAndSqliteDeps } from "../_deps.js"; import { - maxTimestamp, - testOwner, - testOwnerIdBytes, - testTimestampsAsc, - testTimestampsRandom, + maxTimestamp, + testOwner, + testOwnerIdBytes, + testTimestampsAsc, + testTimestampsRandom, } from "./_fixtures.js"; beforeAll(async () => { @@ -1230,8 +1230,8 @@ describe("E2E sync", () => { assertNonEmptyArray(firstHalf); assertNonEmptyArray(secondHalf); - await clientStorage.writeMessages(testOwnerIdBytes, firstHalf); - await relayStorage.writeMessages(testOwnerIdBytes, secondHalf); + await clientStorage.writeMessages(testOwnerIdBytes, firstHalf as any); + await relayStorage.writeMessages(testOwnerIdBytes, secondHalf as any); const syncSteps = await reconcile(clientStorage, relayStorage); expect(syncSteps).toMatchInlineSnapshot(` @@ -1260,8 +1260,8 @@ describe("E2E sync", () => { assertNonEmptyArray(firstHalf); assertNonEmptyArray(secondHalf); - await clientStorage.writeMessages(testOwnerIdBytes, firstHalf); - await relayStorage.writeMessages(testOwnerIdBytes, secondHalf); + await clientStorage.writeMessages(testOwnerIdBytes, firstHalf as any); + await relayStorage.writeMessages(testOwnerIdBytes, secondHalf as any); const syncSteps = await reconcile( clientStorage, diff --git a/packages/nodejs/src/BetterSqliteDriver.ts b/packages/nodejs/src/BetterSqliteDriver.ts index a9fc12318..41676eaf3 100644 --- a/packages/nodejs/src/BetterSqliteDriver.ts +++ b/packages/nodejs/src/BetterSqliteDriver.ts @@ -1,9 +1,8 @@ import { - constVoid, - createPreparedStatementsCache, - type CreateSqliteDriver, - type SqliteDriver, - type SqliteRow, + createPreparedStatementsCache, + type CreateSqliteDriver, + type SqliteDriver, + type SqliteRow, } from "@evolu/common"; import BetterSQLite, { type Statement } from "better-sqlite3"; @@ -16,7 +15,7 @@ export const createBetterSqliteDriver: CreateSqliteDriver = (name, options) => { (sql) => db.prepare(sql), // Not needed. // https://github.com/WiseLibs/better-sqlite3/blob/master/docs/api.md#class-statement - constVoid, + () => {}, ); const driver: SqliteDriver = { diff --git a/packages/react-native/src/sqlite-drivers/createOpSqliteDriver.ts b/packages/react-native/src/sqlite-drivers/createOpSqliteDriver.ts index 6d56b87d2..8e0de9b10 100644 --- a/packages/react-native/src/sqlite-drivers/createOpSqliteDriver.ts +++ b/packages/react-native/src/sqlite-drivers/createOpSqliteDriver.ts @@ -1,11 +1,10 @@ import { - bytesToHex, - constVoid, - createPreparedStatementsCache, - type CreateSqliteDriver, - isSqlMutation, - type SqliteDriver, - type SqliteRow, + bytesToHex, + createPreparedStatementsCache, + isSqlMutation, + type CreateSqliteDriver, + type SqliteDriver, + type SqliteRow, } from "@evolu/common"; import { open, type PreparedStatement } from "@op-engineering/op-sqlite"; @@ -26,7 +25,7 @@ export const createOpSqliteDriver: CreateSqliteDriver = (name, options) => { const cache = createPreparedStatementsCache( (sql) => db.prepareStatement(sql), // op-sqlite doesn't have API for that - constVoid, + () => {}, ); const driver: SqliteDriver = { diff --git a/packages/react/src/useEvoluError.ts b/packages/react/src/useEvoluError.ts index 574dc3e1c..d375828a6 100644 --- a/packages/react/src/useEvoluError.ts +++ b/packages/react/src/useEvoluError.ts @@ -1,7 +1,9 @@ -import { constNull, type EvoluError } from "@evolu/common"; +import { type EvoluError } from "@evolu/common"; import { useSyncExternalStore } from "react"; import { useEvolu } from "./useEvolu.js"; +const constNull = () => null; + /** Subscribe to {@link EvoluError} changes. */ export const useEvoluError = (): EvoluError | null => { const evolu = useEvolu(); diff --git a/packages/react/src/useIsSsr.ts b/packages/react/src/useIsSsr.ts index b865abb1b..a5f22fd1e 100644 --- a/packages/react/src/useIsSsr.ts +++ b/packages/react/src/useIsSsr.ts @@ -1,6 +1,8 @@ -import { constFalse, constTrue } from "@evolu/common"; import { useSyncExternalStore } from "react"; +const constFalse = () => false; +const constTrue = () => true; + // eslint-disable-next-line @typescript-eslint/no-empty-function const emptySubscribe = () => () => {}; diff --git a/packages/react/src/useQuerySubscription.ts b/packages/react/src/useQuerySubscription.ts index ff23f3369..e87e2f71b 100644 --- a/packages/react/src/useQuerySubscription.ts +++ b/packages/react/src/useQuerySubscription.ts @@ -1,13 +1,15 @@ -import { constVoid } from "@evolu/common"; + import { - type Query, - type QueryRows, - type Row, - emptyRows, -} from "@evolu/common/local-first"; + emptyRows, + type Query, + type QueryRows, + type Row, +} from "@evolu/common"; import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { useEvolu } from "./useEvolu.js"; +const constVoid = () => {}; + /** Subscribe to {@link Query} {@link QueryRows} changes. */ export const useQuerySubscription = ( query: Query,