Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
export type {
DistributedTask,
ExecutionContext,
TaskMetadata,
TaskStatusValue,
} from "./task.js";
export {
TaskMetadataSchema,
TaskStatus,
TaskStatusSchema,
} from "./task.js";

View File

@@ -0,0 +1,80 @@
# `DistributedTask` contract
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DistributedTask<I, O>` is the unit of work the rest of `@suaveplan/queue-distribution` executes. It is deliberately decoupled from `@suaveplan/queue`'s `Job` / state-machine model so the distribution layer can serve queue, batch, RPC, or agent-dispatch consumers via thin adapters. The contract is generic over an input `I` and an output `O` so a caller's static types flow end-to-end through `Worker.executeTask`.
## Features
- `id: string` — caller-assigned identity used in logs, metrics, retry tracking.
- `execute(input, context): Promise<O>` — the work itself. `context` is a caller-defined `ExecutionContext`.
- `TaskStatus` enum (`PENDING | RUNNING | COMPLETED | FAILED | CANCELLED | TIMED_OUT`) + `TaskStatusSchema` for Zod validation.
- `TaskMetadata` shape (`createdAt`, optional `startedAt` / `completedAt`, `attempts`, optional `tags`, indexed extras) + `TaskMetadataSchema`.
- `setStatus` / `getStatus` / `getMetadata` for lifecycle observation by the worker.
## Usage: Basic Example
```ts
import {
type DistributedTask,
TaskStatus,
type TaskStatusValue,
} from "@suaveplan/queue-distribution/task/task";
function makeTask(): DistributedTask<number, string> {
let status: TaskStatusValue = TaskStatus.PENDING;
return {
id: "double-then-stringify",
execute: async (n) => String(n * 2),
getStatus: () => status,
setStatus: (s) => {
status = s;
},
getMetadata: () => ({ createdAt: new Date(), attempts: 0 }),
};
}
```
## Usage: Advanced Example
`DistributedTask` is the seam where consumers bridge their own work model. For a `@suaveplan/queue` consumer, wrap a `Job` so each retry advances `attempts` and propagates failure into the queue's state machine. For RPC, encode the remote-call envelope in `execute`.
```ts
import type { DistributedTask } from "@suaveplan/queue-distribution/task/task";
import { TaskMetadataSchema } from "@suaveplan/queue-distribution/task/task";
class RpcTask<I, O> implements DistributedTask<I, O> {
constructor(readonly id: string, private fetcher: (i: I) => Promise<O>) {}
#status = "PENDING" as const;
#metadata = { createdAt: new Date(), attempts: 0 };
async execute(input: I): Promise<O> {
this.#metadata.attempts += 1;
return this.fetcher(input);
}
getStatus = () => this.#status;
setStatus = (s: typeof this.#status): void => {
this.#status = s;
};
getMetadata = () => {
const parsed = TaskMetadataSchema.parse(this.#metadata);
return parsed;
};
}
```
## API Reference
- `interface DistributedTask<I = unknown, O = unknown>` — fields above.
- `interface ExecutionContext` — opaque marker; callers define the concrete shape.
- `const TaskStatus` — frozen literal map.
- `type TaskStatusValue` — union of `TaskStatus` values.
- `const TaskStatusSchema: z.ZodType<TaskStatusValue>` — Zod enum.
- `interface TaskMetadata` — `createdAt: Date`, `startedAt?: Date`, `completedAt?: Date`, `attempts: number`, `tags?: readonly string[]`, indexed extras.
- `const TaskMetadataSchema: z.ZodType<TaskMetadata>` — Zod object with `passthrough` for extras.
## Implementation Notes
`TaskMetadataSchema` uses `passthrough` because the indexed-extras signature on `TaskMetadata` is intentional: callers can carry arbitrary diagnostic context (queue name, trace id, retry deadline) without ratcheting the schema each time. The schema enforces only the typed fields. `ExecutionContext` is an empty interface — a marker rather than a contract — so callers can pass any object (request scope, tenant id, AbortSignal, dependency container). The distribution layer never inspects the context.

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type DistributedTask,
TaskMetadataSchema,
TaskStatus,
TaskStatusSchema,
type TaskStatusValue,
} from "./task.js";
function makeTask(): DistributedTask<number, string> {
let status: TaskStatusValue = TaskStatus.PENDING;
return {
id: "t-1",
execute: async (input) => String(input),
getStatus: () => status,
setStatus: (s) => {
status = s;
},
getMetadata: () => ({
createdAt: new Date(0),
attempts: 0,
}),
};
}
describe("DistributedTask contract", () => {
it("validates TaskMetadata via TaskMetadataSchema", {
timeout: 5000,
}, () => {
const task = makeTask();
expect(() =>
TaskMetadataSchema.parse(task.getMetadata())
).not.toThrow();
});
it("rejects malformed TaskMetadata via Zod", { timeout: 5000 }, () => {
expect(() =>
TaskMetadataSchema.parse({ createdAt: "not-a-date", attempts: -1 })
).toThrow();
});
it("accepts every TaskStatus value", { timeout: 5000 }, () => {
for (const value of Object.values(TaskStatus)) {
expect(() => TaskStatusSchema.parse(value)).not.toThrow();
}
});
it("rejects unknown TaskStatus values", { timeout: 5000 }, () => {
expect(() => TaskStatusSchema.parse("BANANA")).toThrow();
});
it("setStatus + getStatus round-trip", { timeout: 5000 }, () => {
const task = makeTask();
expect(task.getStatus()).toBe(TaskStatus.PENDING);
task.setStatus(TaskStatus.RUNNING);
expect(task.getStatus()).toBe(TaskStatus.RUNNING);
});
});

View File

@@ -0,0 +1,71 @@
import z from "zod";
/**
* Lifecycle states for a `DistributedTask`. The transitions are linear:
* `PENDING → RUNNING → (COMPLETED | FAILED | CANCELLED | TIMED_OUT)`.
*/
export const TaskStatus = {
PENDING: "PENDING",
RUNNING: "RUNNING",
COMPLETED: "COMPLETED",
FAILED: "FAILED",
CANCELLED: "CANCELLED",
TIMED_OUT: "TIMED_OUT",
} as const;
export type TaskStatusValue = (typeof TaskStatus)[keyof typeof TaskStatus];
/** Zod schema for `TaskStatus`. */
export const TaskStatusSchema = z.enum([
TaskStatus.PENDING,
TaskStatus.RUNNING,
TaskStatus.COMPLETED,
TaskStatus.FAILED,
TaskStatus.CANCELLED,
TaskStatus.TIMED_OUT,
]);
/** Metadata carried with every `DistributedTask`. Indexed extras are allowed. */
export interface TaskMetadata {
createdAt: Date;
startedAt?: Date;
completedAt?: Date;
attempts: number;
tags?: readonly string[];
[key: string]: unknown;
}
/** Zod schema for `TaskMetadata`. */
export const TaskMetadataSchema: z.ZodType<TaskMetadata> = z
.object({
createdAt: z.date(),
startedAt: z.date().optional(),
completedAt: z.date().optional(),
attempts: z.number().int().nonnegative(),
tags: z.array(z.string()).readonly().optional(),
})
.passthrough() as z.ZodType<TaskMetadata>;
/**
* Opaque execution context threaded through `task.execute`. Callers may use
* any object — distribution never inspects it. Use this to thread request
* scope, tenant id, AbortSignal, dependencies, etc. into the unit of work.
*/
// biome-ignore lint/suspicious/noEmptyInterface: opaque marker — see docstring.
export interface ExecutionContext {}
/**
* The unit of work executed by a `Worker`. Generic over input `I` and output
* `O` so callers retain end-to-end typing through `worker.executeTask`.
*
* Tasks are mutable: `setStatus` advances the lifecycle, `getStatus` /
* `getMetadata` observe it. Implementations MUST clamp `attempts` to non-
* negative integers and SHOULD treat `createdAt` as set-once.
*/
export interface DistributedTask<I = unknown, O = unknown> {
readonly id: string;
execute(input: I, context: ExecutionContext): Promise<O>;
getStatus(): TaskStatusValue;
setStatus(status: TaskStatusValue): void;
getMetadata(): TaskMetadata;
}