From 2b639ef0b0f7038bea2b8ca607fd2f6ee2e81263 Mon Sep 17 00:00:00 2001 From: Clifton Cunningham Date: Tue, 13 Oct 2026 05:48:57 +0100 Subject: [PATCH 1/2] feat(openworkflow): export the backend API as openworkflow/backend Add a documented subpath for people writing their own `Backend`. It exports the `Backend` interface and its param and result types, plus the helpers the built-in backends share for the rules that don't depend on storage: retry on failure, rerun, cancel conflicts, schedule occurrences, pagination, and row and error checks. A custom backend that uses them behaves the same way as the SQLite and Postgres backends. `openworkflow/internal` stays as it is for the CLI and dashboard. The new docs page marks the backend API as experimental before 1.0, so backends should pin the OpenWorkflow versions they support. --- apps/docs/docs.json | 7 ++- apps/docs/docs/custom-backends.mdx | 68 ++++++++++++++++++++++++++++++ packages/openworkflow/backend.ts | 34 +++++++++++++++ packages/openworkflow/package.json | 4 ++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 apps/docs/docs/custom-backends.mdx create mode 100644 packages/openworkflow/backend.ts diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 99864f05..9018df6d 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -74,7 +74,12 @@ }, { "group": "Backends", - "pages": ["docs/postgres", "docs/sqlite", "docs/namespaces"] + "pages": [ + "docs/postgres", + "docs/sqlite", + "docs/namespaces", + "docs/custom-backends" + ] }, { "group": "Reference", diff --git a/apps/docs/docs/custom-backends.mdx b/apps/docs/docs/custom-backends.mdx new file mode 100644 index 00000000..8907187c --- /dev/null +++ b/apps/docs/docs/custom-backends.mdx @@ -0,0 +1,68 @@ +--- +title: Custom Backends +description: Implement the Backend interface to store workflow state in your own database or driver, and check it with the backend test suite +--- + +OpenWorkflow ships with [PostgreSQL](/docs/postgres) and [SQLite](/docs/sqlite) +backends. To store workflow state somewhere else, implement the `Backend` +interface from `openworkflow/backend` and pass it to the client and worker like +any other backend: + +```ts +import { MyBackend } from "./my-backend"; +import { OpenWorkflow } from "openworkflow"; + +const backend = new MyBackend(); +const ow = new OpenWorkflow({ backend }); +``` + + + The backend API is experimental. It can change in a minor release before 1.0 + (for example, when a feature adds new backend methods), so pin the + `openworkflow` versions your backend supports, such as `>=0.11.0 <0.12.0`. + + +## The Backend Interface + +`openworkflow/backend` exports the `Backend` interface and the types its methods +take and return: + +```ts +import type { + Backend, + ClaimWorkflowRunParams, + WorkflowRun, +} from "openworkflow/backend"; + +export class MyBackend implements Backend { + async claimWorkflowRun( + params: ClaimWorkflowRunParams, + ): Promise { + // ... + } + + // ... +} +``` + +A backend owns its connections and its schema. `stop()` is called when the +client or worker shuts down. + +## Shared Helpers + +The built-in backends keep the rules that don't depend on storage in shared +helpers. Custom backends should use the same helpers, so they behave the same +way: + +| Helper | Use | +| ---------------------------------- | --------------------------------------------------------------------------- | +| `computeFailedWorkflowRunUpdate` | Decide whether a failed run is retried, and when, or fails | +| `prepareWorkflowRerun` | Validate a rerun and build the new run's parameters from the source run | +| `resolveCancelWorkflowRunConflict` | Choose the result when a cancel matched no row (already canceled, or final) | +| `nextScheduleOccurrence` | Compute a schedule's next run time from its cron and timezone | +| `isTerminalStatus` | Check whether a run status is final | +| `decodeListCursor` | Decode the `after` / `before` cursor of a list call | +| `buildPaginatedResponse` | Build a list response, with cursors, from `limit + 1` fetched rows | +| `toWorkflowRunCounts` | Turn `GROUP BY "status"` rows into counts for every status | +| `requireRow` | Throw `Failed to ` when a mutation matched no row | +| `wrapError` | Wrap a driver error with a message, keeping the cause | diff --git a/packages/openworkflow/backend.ts b/packages/openworkflow/backend.ts new file mode 100644 index 00000000..655dc549 --- /dev/null +++ b/packages/openworkflow/backend.ts @@ -0,0 +1,34 @@ +// Backend contract and the helpers the built-in backends share, for authors of +// custom backends. Experimental: may change in a minor release before 1.0. + +// contract +export * from "./core/backend.js"; +export type { SerializedError } from "./core/error.js"; +export type { JsonValue } from "./core/json.js"; +export type { + WorkflowSchedule, + WorkflowScheduleDefinition, +} from "./core/schedule.js"; +export type { + StepAttempt, + StepAttemptContext, + StepAttemptStatus, + StepKind, +} from "./core/step-attempt.js"; +export type { WorkflowRun, WorkflowRunStatus } from "./core/workflow-run.js"; + +// helpers +export type { Cursor } from "./core/cursor.js"; +export { + buildPaginatedResponse, + DEFAULT_PAGINATION_PAGE_SIZE, + decodeListCursor, +} from "./core/cursor.js"; +export { requireRow, wrapError } from "./core/error.js"; +export { prepareWorkflowRerun } from "./core/rerun.js"; +export { nextScheduleOccurrence } from "./core/schedule.js"; +export { computeFailedWorkflowRunUpdate } from "./core/workflow-definition.js"; +export { + isTerminalStatus, + resolveCancelWorkflowRunConflict, +} from "./core/workflow-run.js"; diff --git a/packages/openworkflow/package.json b/packages/openworkflow/package.json index 0882f504..8865082f 100644 --- a/packages/openworkflow/package.json +++ b/packages/openworkflow/package.json @@ -30,6 +30,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./backend": { + "types": "./dist/backend.d.ts", + "default": "./dist/backend.js" + }, "./internal": { "types": "./dist/internal.d.ts", "default": "./dist/internal.js" From b26c2dcc8b9c82e9f161e01d204e55a3205b2d06 Mon Sep 17 00:00:00 2001 From: Clifton Cunningham Date: Tue, 13 Oct 2026 05:49:05 +0100 Subject: [PATCH 2/2] feat(openworkflow): export the backend test suite as openworkflow/testing Publish `testBackend()`, the suite the SQLite and Postgres backends run, so people can check a custom `Backend` against the same tests. Vitest becomes an optional peer dependency. --- apps/docs/docs/custom-backends.mdx | 21 +++++++++++++++++++++ package-lock.json | 6 +++++- packages/openworkflow/package.json | 12 +++++++++++- packages/openworkflow/testing.ts | 2 ++ 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 packages/openworkflow/testing.ts diff --git a/apps/docs/docs/custom-backends.mdx b/apps/docs/docs/custom-backends.mdx index 8907187c..271b303c 100644 --- a/apps/docs/docs/custom-backends.mdx +++ b/apps/docs/docs/custom-backends.mdx @@ -66,3 +66,24 @@ way: | `toWorkflowRunCounts` | Turn `GROUP BY "status"` rows into counts for every status | | `requireRow` | Throw `Failed to ` when a mutation matched no row | | `wrapError` | Wrap a driver error with a message, keeping the cause | + +## Testing Your Backend + +`openworkflow/testing` exports the test suite that the built-in backends run. +It requires [Vitest](https://vitest.dev): + +```ts +import { MyBackend } from "./my-backend"; +import { randomUUID } from "node:crypto"; +import { testBackend } from "openworkflow/testing"; + +testBackend({ + setup: async () => new MyBackend({ namespaceId: randomUUID() }), + teardown: async (backend) => { + await backend.stop(); + }, +}); +``` + +Each `setup()` should return a backend on its own namespace, so tests don't see +each other's runs. Run the suite against each OpenWorkflow version you support. diff --git a/package-lock.json b/package-lock.json index 3eaa8697..e4409075 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15456,7 +15456,8 @@ }, "peerDependencies": { "@opentelemetry/api": "1", - "postgres": "^3.4.9" + "postgres": "^3.4.9", + "vitest": "^5.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -15464,6 +15465,9 @@ }, "postgres": { "optional": true + }, + "vitest": { + "optional": true } } } diff --git a/packages/openworkflow/package.json b/packages/openworkflow/package.json index 8865082f..bc467bd2 100644 --- a/packages/openworkflow/package.json +++ b/packages/openworkflow/package.json @@ -45,12 +45,18 @@ "./sqlite": { "types": "./dist/sqlite.d.ts", "default": "./dist/sqlite.js" + }, + "./testing": { + "types": "./dist/testing.d.ts", + "default": "./dist/testing.js" } }, "files": [ "dist", "!*.test.*", "!*.testsuite.*", + "dist/testing/backend.testsuite.*", + "dist/testing/schedules.testsuite.*", "!*.tsbuildinfo", "!vitest.global-setup.*" ], @@ -73,7 +79,8 @@ }, "peerDependencies": { "@opentelemetry/api": "1", - "postgres": "^3.4.9" + "postgres": "^3.4.9", + "vitest": "^5.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -81,6 +88,9 @@ }, "postgres": { "optional": true + }, + "vitest": { + "optional": true } }, "engines": { diff --git a/packages/openworkflow/testing.ts b/packages/openworkflow/testing.ts new file mode 100644 index 00000000..fc38fec6 --- /dev/null +++ b/packages/openworkflow/testing.ts @@ -0,0 +1,2 @@ +export type { TestBackendOptions } from "./testing/backend.testsuite.js"; +export { testBackend } from "./testing/backend.testsuite.js";