diff --git a/.changeset/document-task-results.md b/.changeset/document-task-results.md new file mode 100644 index 0000000..c28b3e7 --- /dev/null +++ b/.changeset/document-task-results.md @@ -0,0 +1,9 @@ +--- +'@robinw151/resolver': patch +--- + +Document how a task's result is derived from what its function returns. For an `Observable` the +first emitted value becomes the result and the subscription is closed afterwards, so later emissions +are never observed and sources that honor unsubscription are cancelled. The `Task` type, +`register()` and the README now describe this, including how to use `last()` or `toArray()` when a +different value is needed, and that both only emit once the source completes. diff --git a/README.md b/README.md index 6150b3e..104466c 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,54 @@ const resolver = new Resolver() ); ``` +## Task Results + +Every task produces exactly one result. A task function may return a plain value, a `Promise` or an `Observable`: + +```typescript +import { of } from 'rxjs'; + +const resolver = new Resolver() + .register({ id: 'value', fn: () => 1 }) + .register({ id: 'promise', fn: () => Promise.resolve(2) }) + .register({ id: 'observable', fn: () => of(3) }); +``` + +For an `Observable` the **first emitted value** becomes the task's result. The subscription is closed right after that value, so later emissions are never observed, and sources that honor unsubscription are cancelled: + +```typescript +// Only the first value is used, the subscription is closed afterwards +fn: () => of(1, 2, 3); // { data: 1 } + +// Observed as events, an HttpClient request emits `HttpEventType.Sent` first, +// so the task resolves with that event and the request is cancelled +fn: () => http.get('/user', { observe: 'events', reportProgress: true }); +``` + +Pipe the source when a different value is needed: + +```typescript +import { last, toArray } from 'rxjs'; + +fn: () => of(1, 2, 3).pipe(last()); // { data: 3 } +fn: () => of(1, 2, 3).pipe(toArray()); // { data: [1, 2, 3] } +``` + +Keep in mind that `last()` and `toArray()` only emit once the source completes. Applying either to a source that never completes leaves the task, and therefore the whole resolution, pending indefinitely. The default behavior has no such risk, which is why an infinite source such as `interval(1000)` resolves with its first value instead of hanging. + +A source that completes without emitting any value cannot produce a result. Such a task resolves with an [`EmptyTaskError`](#error-handling) instead of blocking the resolution: + +```typescript +import { EMPTY, lastValueFrom } from 'rxjs'; +import { EmptyTaskError, isError, Resolver } from '@robinw151/resolver'; + +const result = await lastValueFrom(new Resolver().register({ id: 'empty', fn: () => EMPTY }).resolve()); + +if (isError(result.tasks.empty)) { + console.log(result.tasks.empty.error instanceof EmptyTaskError); // true +} +``` + ## Error Handling Tasks can return either successful data or errors. The resolver handles both cases gracefully: @@ -165,6 +213,8 @@ Tasks can return either successful data or errors. The resolver handles both cas - Successful tasks return `{ data: TResult }` - Failed tasks return `{ error: unknown }` +A task whose `Observable` completes without emitting a value fails with an `EmptyTaskError`, which is exported from the package and carries the `taskId` of the task that produced it. + ## Global Arguments The resolver supports global arguments that are passed to all task functions during execution. This is useful for sharing configuration, API keys, or other context across all tasks. diff --git a/src/resolver.interface.ts b/src/resolver.interface.ts index 2e362a7..635c146 100644 --- a/src/resolver.interface.ts +++ b/src/resolver.interface.ts @@ -24,8 +24,43 @@ export type ResolverResultWithLoadingState = Observable< >; // Task +/** + * A single unit of work that can be registered with a resolver. + * + * Every task produces exactly one result. How that result is obtained depends on what `fn` + * returns, but the outcome is always a single `TaskResult`, either `{ data }` or `{ error }`. + */ export interface Task { + /** + * Unique identifier of the task within a resolver. + */ readonly id: TId; + + /** + * The function that performs the work of the task. + * + * It receives the results of the task's dependencies as its first argument and the resolver's + * global arguments as its second argument, and may return a plain value, a `Promise` or an + * `Observable`. + * + * For an `Observable` the **first emitted value** becomes the task's result and the subscription + * is then closed, so later emissions are never observed and sources that honor unsubscription + * are cancelled. A source that completes without emitting resolves the task with an + * `EmptyTaskError`. + * + * Pipe the source when a different value is needed, for example `last()` to wait for the final + * value of a completing source, or `toArray()` to collect every value: + * + * ```typescript + * fn: () => of(1, 2, 3); // { data: 1 } + * fn: () => of(1, 2, 3).pipe(last()); // { data: 3 } + * fn: () => of(1, 2, 3).pipe(toArray()); // { data: [1, 2, 3] } + * ``` + * + * Note that `last()` and `toArray()` only emit once the source completes. Applying either to a + * source that does not complete leaves the task, and therefore the whole resolution, pending + * indefinitely. + */ fn: (args: TArgs, globalArgs: TGlobalArgs) => TResult | Promise | Observable; } diff --git a/src/resolver.ts b/src/resolver.ts index e67c2d3..1eefc8b 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -143,7 +143,12 @@ export class Resolver { * * @param task - The task configuration object containing: * - `id`: Unique identifier for the task - * - `fn`: Function that executes the task, receiving resolved dependencies and global args + * - `fn`: Function that executes the task, receiving resolved dependencies and global args. + * It may return a plain value, a `Promise` or an `Observable`. For an `Observable` the first + * emitted value becomes the task's result and the subscription is then closed, so later + * emissions are never observed and sources that honor unsubscription are cancelled. Pipe the + * source with `last()` or `toArray()` when a different value is needed, keeping in mind that + * both only emit once the source completes. * @param dependencies - Optional array of task IDs that this task depends on. These tasks * must be registered before this task and will be resolved before this task executes. *