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..271b303c
--- /dev/null
+++ b/apps/docs/docs/custom-backends.mdx
@@ -0,0 +1,89 @@
+---
+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 |
+
+## 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/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..bc467bd2 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"
@@ -41,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.*"
],
@@ -69,7 +79,8 @@
},
"peerDependencies": {
"@opentelemetry/api": "1",
- "postgres": "^3.4.9"
+ "postgres": "^3.4.9",
+ "vitest": "^5.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
@@ -77,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";