@redflow/client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/default.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { RedflowClient } from "./client";
2
+ import { createClient } from "./client";
3
+
4
+ let defaultClient: RedflowClient | null = null;
5
+
6
+ export function getDefaultClient(): RedflowClient {
7
+ if (!defaultClient) defaultClient = createClient();
8
+ return defaultClient;
9
+ }
10
+
11
+ export function setDefaultClient(client: RedflowClient): void {
12
+ defaultClient = client;
13
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { defineWorkflow } from "./workflow";
2
+ export type { WorkflowHandler, WorkflowHandlerContext } from "./workflow";
3
+
4
+ export { createClient, defaultPrefix } from "./client";
5
+ export type { RedflowClient, CreateClientOptions, SyncRegistryOptions } from "./client";
6
+
7
+ export { startWorker } from "./worker";
8
+ export type { StartWorkerOptions, WorkerHandle } from "./worker";
9
+
10
+ export { setDefaultClient, getDefaultClient } from "./default";
11
+
12
+ export { getDefaultRegistry, __unstableResetDefaultRegistryForTests } from "./registry";
13
+ export type { WorkflowRegistry, WorkflowDefinition } from "./registry";
14
+
15
+ export * from "./types";
16
+
17
+ export * from "./internal/errors";
@@ -0,0 +1,150 @@
1
+ export class RedflowError extends Error {
2
+ override name = "RedflowError";
3
+ }
4
+
5
+ export class InputValidationError extends RedflowError {
6
+ override name = "InputValidationError";
7
+
8
+ constructor(message: string, public readonly issues?: unknown) {
9
+ super(message);
10
+ }
11
+ }
12
+
13
+ export class UnknownWorkflowError extends RedflowError {
14
+ override name = "UnknownWorkflowError";
15
+
16
+ constructor(public readonly workflowName: string) {
17
+ super(`Unknown workflow: ${workflowName}`);
18
+ }
19
+ }
20
+
21
+ export class CanceledError extends RedflowError {
22
+ override name = "CanceledError";
23
+ constructor(message = "Run canceled") {
24
+ super(message);
25
+ }
26
+ }
27
+
28
+ export class TimeoutError extends RedflowError {
29
+ override name = "TimeoutError";
30
+ constructor(message = "Operation timed out") {
31
+ super(message);
32
+ }
33
+ }
34
+
35
+ export class OutputSerializationError extends RedflowError {
36
+ override name = "OutputSerializationError";
37
+
38
+ constructor(message = "Workflow output is not JSON-serializable", public readonly causeError?: unknown) {
39
+ super(message);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Throw this from workflow code to immediately fail the run without retries.
45
+ * Useful for permanent/deterministic failures (e.g. invalid external data,
46
+ * business-logic violations) where retrying would produce the same result.
47
+ */
48
+ export class NonRetriableError extends RedflowError {
49
+ override name = "NonRetriableError";
50
+ }
51
+
52
+ export type SerializedError = {
53
+ name: string;
54
+ message: string;
55
+ stack?: string;
56
+ code?: string;
57
+ kind?: "canceled" | "timeout" | "validation" | "unknown_workflow" | "serialization" | "non_retriable" | "error";
58
+ };
59
+
60
+ function stringifyUnknownError(err: unknown): string {
61
+ if (typeof err === "string") return err;
62
+ if (typeof err === "bigint") return err.toString();
63
+
64
+ try {
65
+ const json = JSON.stringify(err);
66
+ if (typeof json === "string") return json;
67
+ } catch {
68
+ // Fall through to a safer string conversion.
69
+ }
70
+
71
+ try {
72
+ return String(err);
73
+ } catch {
74
+ return "Unknown error";
75
+ }
76
+ }
77
+
78
+ export function serializeError(err: unknown): SerializedError {
79
+ if (err instanceof InputValidationError) {
80
+ return {
81
+ name: err.name,
82
+ message: err.message,
83
+ stack: err.stack,
84
+ kind: "validation",
85
+ };
86
+ }
87
+
88
+ if (err instanceof UnknownWorkflowError) {
89
+ return {
90
+ name: err.name,
91
+ message: err.message,
92
+ stack: err.stack,
93
+ kind: "unknown_workflow",
94
+ };
95
+ }
96
+
97
+ if (err instanceof CanceledError) {
98
+ return {
99
+ name: err.name,
100
+ message: err.message,
101
+ stack: err.stack,
102
+ kind: "canceled",
103
+ };
104
+ }
105
+
106
+ if (err instanceof TimeoutError) {
107
+ return {
108
+ name: err.name,
109
+ message: err.message,
110
+ stack: err.stack,
111
+ kind: "timeout",
112
+ };
113
+ }
114
+
115
+ if (err instanceof OutputSerializationError) {
116
+ return {
117
+ name: err.name,
118
+ message: err.message,
119
+ stack: err.stack,
120
+ kind: "serialization",
121
+ };
122
+ }
123
+
124
+ if (err instanceof NonRetriableError) {
125
+ return {
126
+ name: err.name,
127
+ message: err.message,
128
+ stack: err.stack,
129
+ kind: "non_retriable",
130
+ };
131
+ }
132
+
133
+ if (err instanceof Error) {
134
+ // Prefer standard-ish fields when present.
135
+ const anyErr = err as any;
136
+ return {
137
+ name: err.name || "Error",
138
+ message: err.message || String(err),
139
+ stack: err.stack,
140
+ code: typeof anyErr.code === "string" ? anyErr.code : undefined,
141
+ kind: "error",
142
+ };
143
+ }
144
+
145
+ return {
146
+ name: "Error",
147
+ message: stringifyUnknownError(err),
148
+ kind: "error",
149
+ };
150
+ }
@@ -0,0 +1,32 @@
1
+ const REDFLOW_UNDEFINED_TOKEN = "__redflow_undefined_raw__:v1";
2
+
3
+ export function safeJsonStringify(value: unknown): string {
4
+ if (value === undefined) {
5
+ return REDFLOW_UNDEFINED_TOKEN;
6
+ }
7
+
8
+ const json = JSON.stringify(value);
9
+ if (json === undefined) {
10
+ throw new Error("Value is not JSON-serializable");
11
+ }
12
+ return json;
13
+ }
14
+
15
+ export function safeJsonParse<T = unknown>(value: string | null): T {
16
+ if (value == null) throw new Error("Expected JSON string, got null");
17
+
18
+ if (value === REDFLOW_UNDEFINED_TOKEN) {
19
+ return undefined as T;
20
+ }
21
+
22
+ return JSON.parse(value) as T;
23
+ }
24
+
25
+ export function safeJsonTryParse<T = unknown>(value: string | null): T | undefined {
26
+ if (value == null) return undefined;
27
+ try {
28
+ return safeJsonParse<T>(value);
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
@@ -0,0 +1,38 @@
1
+ export type KeyPrefix = string;
2
+
3
+ export function withPrefix(prefix: KeyPrefix, suffix: string): string {
4
+ const p = prefix.endsWith(":") ? prefix.slice(0, -1) : prefix;
5
+ const s = suffix.startsWith(":") ? suffix.slice(1) : suffix;
6
+ return `${p}:${s}`;
7
+ }
8
+
9
+ function encodeCompositePart(value: string): string {
10
+ return `${value.length}:${value}`;
11
+ }
12
+
13
+ export const keys = {
14
+ workflows: (prefix: KeyPrefix) => withPrefix(prefix, "workflows"),
15
+ workflow: (prefix: KeyPrefix, workflowName: string) => withPrefix(prefix, `workflow:${workflowName}`),
16
+ // Keep run indexes in a separate namespace to avoid collisions with workflow
17
+ // metadata keys when workflow names contain suffix-like segments (e.g. ":runs").
18
+ workflowRuns: (prefix: KeyPrefix, workflowName: string) => withPrefix(prefix, `workflow-runs:${workflowName}`),
19
+ eventWorkflows: (prefix: KeyPrefix, eventName: string) => withPrefix(prefix, `event:${eventName}:workflows`),
20
+
21
+ runsCreated: (prefix: KeyPrefix) => withPrefix(prefix, "runs:created"),
22
+ runsStatus: (prefix: KeyPrefix, status: string) => withPrefix(prefix, `runs:status:${status}`),
23
+
24
+ run: (prefix: KeyPrefix, runId: string) => withPrefix(prefix, `run:${runId}`),
25
+ runSteps: (prefix: KeyPrefix, runId: string) => withPrefix(prefix, `run:${runId}:steps`),
26
+ runLease: (prefix: KeyPrefix, runId: string) => withPrefix(prefix, `run:${runId}:lease`),
27
+
28
+ queueReady: (prefix: KeyPrefix, queue: string) => withPrefix(prefix, `q:${queue}:ready`),
29
+ queueProcessing: (prefix: KeyPrefix, queue: string) => withPrefix(prefix, `q:${queue}:processing`),
30
+ queueScheduled: (prefix: KeyPrefix, queue: string) => withPrefix(prefix, `q:${queue}:scheduled`),
31
+
32
+ cronDef: (prefix: KeyPrefix) => withPrefix(prefix, "cron:def"),
33
+ cronNext: (prefix: KeyPrefix) => withPrefix(prefix, "cron:next"),
34
+ lockCron: (prefix: KeyPrefix) => withPrefix(prefix, "lock:cron"),
35
+
36
+ idempotency: (prefix: KeyPrefix, workflowName: string, idempotencyKey: string) =>
37
+ withPrefix(prefix, `idempo:${encodeCompositePart(workflowName)}:${encodeCompositePart(idempotencyKey)}`),
38
+ };
@@ -0,0 +1,4 @@
1
+ export function sleep(ms: number): Promise<void> {
2
+ if (ms <= 0) return Promise.resolve();
3
+ return new Promise((resolve) => setTimeout(resolve, ms));
4
+ }
@@ -0,0 +1,3 @@
1
+ export function nowMs(): number {
2
+ return Date.now();
3
+ }
@@ -0,0 +1,55 @@
1
+ import type { ZodTypeAny } from "zod";
2
+ import type { DefineWorkflowOptions, StepApi } from "./types";
3
+
4
+ export type WorkflowHandlerContext<TInput> = {
5
+ input: TInput;
6
+ run: {
7
+ id: string;
8
+ workflow: string;
9
+ queue: string;
10
+ attempt: number;
11
+ maxAttempts: number;
12
+ };
13
+ step: StepApi;
14
+ signal: AbortSignal;
15
+ };
16
+
17
+ export type WorkflowHandler<TInput, TOutput> = (ctx: WorkflowHandlerContext<TInput>) => Promise<TOutput>;
18
+
19
+ export type WorkflowDefinition<TSchema extends ZodTypeAny | undefined = ZodTypeAny | undefined, TOutput = unknown> = {
20
+ options: DefineWorkflowOptions<TSchema>;
21
+ handler: WorkflowHandler<any, TOutput>;
22
+ };
23
+
24
+ export class WorkflowRegistry {
25
+ private readonly workflows = new Map<string, WorkflowDefinition>();
26
+
27
+ register(def: WorkflowDefinition): void {
28
+ // Last one wins. This is helpful for dev/hmr and test isolation.
29
+ this.workflows.set(def.options.name, def);
30
+ }
31
+
32
+ get(name: string): WorkflowDefinition | undefined {
33
+ return this.workflows.get(name);
34
+ }
35
+
36
+ list(): WorkflowDefinition[] {
37
+ return [...this.workflows.values()];
38
+ }
39
+
40
+ clear(): void {
41
+ this.workflows.clear();
42
+ }
43
+ }
44
+
45
+ let defaultRegistry: WorkflowRegistry | null = null;
46
+
47
+ export function getDefaultRegistry(): WorkflowRegistry {
48
+ if (!defaultRegistry) defaultRegistry = new WorkflowRegistry();
49
+ return defaultRegistry;
50
+ }
51
+
52
+ /** @internal */
53
+ export function __unstableResetDefaultRegistryForTests(): void {
54
+ defaultRegistry?.clear();
55
+ }
package/src/types.ts ADDED
@@ -0,0 +1,181 @@
1
+ import type { ZodTypeAny } from "zod";
2
+
3
+ export type RunStatus = "scheduled" | "queued" | "running" | "succeeded" | "failed" | "canceled";
4
+ export type StepStatus = "running" | "succeeded" | "failed";
5
+
6
+ export type WorkflowTriggers = {
7
+ events?: string[];
8
+ cron?: CronTrigger[];
9
+ };
10
+
11
+ export type CronTrigger = {
12
+ /** Standard cron expression with seconds support (6-field) via croner. */
13
+ expression: string;
14
+ timezone?: string;
15
+ input?: unknown;
16
+ /** Optional stable id; otherwise derived from expression+input. */
17
+ id?: string;
18
+ };
19
+
20
+ export type WorkflowRetries = {
21
+ /** Total attempts including the first one. Default: 1 (no retries). */
22
+ maxAttempts?: number;
23
+ };
24
+
25
+ export type OnFailureContext = {
26
+ error: unknown;
27
+ input: unknown;
28
+ run: {
29
+ id: string;
30
+ workflow: string;
31
+ queue: string;
32
+ attempt: number;
33
+ maxAttempts: number;
34
+ };
35
+ };
36
+
37
+ export type DefineWorkflowOptions<TSchema extends ZodTypeAny | undefined = ZodTypeAny | undefined> = {
38
+ name: string;
39
+ queue?: string;
40
+ schema?: TSchema;
41
+ triggers?: WorkflowTriggers;
42
+ retries?: WorkflowRetries;
43
+ /** Called when the run reaches terminal failure (retries exhausted or non-retriable error). Not called on cancellation. */
44
+ onFailure?: (ctx: OnFailureContext) => void | Promise<void>;
45
+ };
46
+
47
+ export type StepRunOptions = {
48
+ name: string;
49
+ timeoutMs?: number;
50
+ };
51
+
52
+ export type StepRunWorkflowOptions = StepRunOptions & {
53
+ run?: Omit<RunOptions, "idempotencyKey" | "__maxAttemptsOverride">;
54
+ result?: {
55
+ timeoutMs?: number;
56
+ pollMs?: number;
57
+ };
58
+ /**
59
+ * Optional override for child-run idempotency.
60
+ * By default, redflow derives a stable key from parent run id + step name + child workflow name.
61
+ */
62
+ idempotencyKey?: string;
63
+ };
64
+
65
+ export type StepEmitEventOptions = StepRunOptions & {
66
+ event: string;
67
+ emit?: Omit<EmitEventOptions, "idempotencyKey">;
68
+ /**
69
+ * Optional override for emit idempotency.
70
+ * By default, redflow derives a stable key from parent run id + step name + event name.
71
+ */
72
+ idempotencyKey?: string;
73
+ };
74
+
75
+ export type StepScheduleEventOptions = StepRunOptions & {
76
+ event: string;
77
+ schedule: Omit<ScheduleEventOptions, "idempotencyKey">;
78
+ /**
79
+ * Optional override for schedule idempotency.
80
+ * By default, redflow derives a stable key from parent run id + step name + event name.
81
+ */
82
+ idempotencyKey?: string;
83
+ };
84
+
85
+ export type RunOptions = {
86
+ availableAt?: Date;
87
+ idempotencyKey?: string;
88
+ /** TTL for the idempotency key in seconds. Default: 7 days (604 800s). */
89
+ idempotencyTtl?: number;
90
+ queueOverride?: string;
91
+ /** @internal Runtime hint used by defineWorkflow(). */
92
+ __maxAttemptsOverride?: number;
93
+ };
94
+
95
+ export type EmitEventOptions = {
96
+ idempotencyKey?: string;
97
+ /** TTL for the idempotency key in seconds. Default: 7 days (604 800s). */
98
+ idempotencyTtl?: number;
99
+ };
100
+
101
+ export type ScheduleEventOptions = EmitEventOptions & {
102
+ availableAt: Date;
103
+ };
104
+
105
+ export type RunHandle<TOutput = unknown> = {
106
+ id: string;
107
+ getState(): Promise<RunState | null>;
108
+ result(options?: { timeoutMs?: number; pollMs?: number }): Promise<TOutput>;
109
+ };
110
+
111
+ export type WorkflowLike<TInput = unknown, TOutput = unknown> = {
112
+ name: string;
113
+ run(input: TInput, options?: RunOptions): Promise<RunHandle<TOutput>>;
114
+ };
115
+
116
+ export type StepApi = {
117
+ run<T>(options: StepRunOptions, fn: (ctx: { signal: AbortSignal }) => Promise<T>): Promise<T>;
118
+ runWorkflow<TInput, TOutput>(
119
+ options: StepRunWorkflowOptions,
120
+ workflow: WorkflowLike<TInput, TOutput>,
121
+ input: TInput,
122
+ ): Promise<TOutput>;
123
+ emitEvent(options: StepEmitEventOptions, payload: unknown): Promise<string[]>;
124
+ scheduleEvent(options: StepScheduleEventOptions, payload: unknown): Promise<string[]>;
125
+ };
126
+
127
+ export type RunState = {
128
+ id: string;
129
+ workflow: string;
130
+ queue: string;
131
+ status: RunStatus;
132
+ input: unknown;
133
+ output?: unknown;
134
+ error?: unknown;
135
+ attempt: number;
136
+ maxAttempts: number;
137
+ createdAt: number;
138
+ availableAt?: number;
139
+ startedAt?: number;
140
+ finishedAt?: number;
141
+ cancelRequestedAt?: number;
142
+ cancelReason?: string;
143
+ };
144
+
145
+ export type StepState = {
146
+ name: string;
147
+ status: StepStatus;
148
+ startedAt?: number;
149
+ finishedAt?: number;
150
+ output?: unknown;
151
+ error?: unknown;
152
+ };
153
+
154
+ export type ListRunsParams = {
155
+ status?: RunStatus;
156
+ workflow?: string;
157
+ offset?: number;
158
+ limit?: number;
159
+ };
160
+
161
+ export type ListedRun = {
162
+ id: string;
163
+ workflow: string;
164
+ queue: string;
165
+ status: RunStatus;
166
+ error?: unknown;
167
+ createdAt: number;
168
+ availableAt?: number;
169
+ startedAt?: number;
170
+ finishedAt?: number;
171
+ attempt: number;
172
+ maxAttempts: number;
173
+ };
174
+
175
+ export type WorkflowMeta = {
176
+ name: string;
177
+ queue: string;
178
+ triggers?: WorkflowTriggers;
179
+ retries?: WorkflowRetries;
180
+ updatedAt: number;
181
+ };