@strav/durable 0.4.30 → 1.0.0-alpha.8

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/package.json CHANGED
@@ -1,49 +1,30 @@
1
1
  {
2
2
  "name": "@strav/durable",
3
- "version": "0.4.30",
3
+ "version": "1.0.0-alpha.8",
4
+ "description": "Strav durable execution — crash-resumable sequential workflows on top of @strav/queue + Postgres. V1: sequential .step() with retries + saga compensation. V2 adds parallel/route/loop/sleep/waitForSignal.",
4
5
  "type": "module",
5
- "description": "Durable, crash-resumable workflow execution for the Strav framework",
6
- "license": "MIT",
7
- "keywords": [
8
- "bun",
9
- "framework",
10
- "typescript",
11
- "strav",
12
- "durable",
13
- "workflow",
14
- "orchestration"
15
- ],
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
16
8
  "exports": {
17
- ".": "./src/index.ts",
18
- "./engine": "./src/engine/index.ts",
19
- "./engine/*": "./src/engine/*.ts",
20
- "./models": "./src/models/index.ts",
21
- "./models/*": "./src/models/*.ts",
22
- "./providers": "./src/providers/index.ts",
23
- "./providers/*": "./src/providers/*.ts",
24
- "./*": "./src/*.ts"
9
+ ".": "./src/index.ts"
25
10
  },
26
11
  "files": [
27
- "src/",
28
- "package.json",
29
- "tsconfig.json",
30
- "CHANGELOG.md"
12
+ "src",
13
+ "README.md"
31
14
  ],
32
- "peerDependencies": {
33
- "@strav/kernel": "0.4.30",
34
- "@strav/database": "0.4.30"
15
+ "engines": {
16
+ "bun": ">=1.3.14"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
35
20
  },
36
21
  "dependencies": {
37
- "@strav/queue": "0.4.30",
38
- "@strav/machine": "0.4.30",
39
- "@strav/workflow": "0.4.30",
40
- "luxon": "^3.7.2"
22
+ "@strav/kernel": "1.0.0-alpha.8",
23
+ "@strav/database": "1.0.0-alpha.8",
24
+ "@strav/queue": "1.0.0-alpha.8"
41
25
  },
42
- "devDependencies": {
43
- "@types/luxon": "^3.7.1"
26
+ "peerDependencies": {
27
+ "@types/bun": ">=1.3.14"
44
28
  },
45
- "scripts": {
46
- "test": "bun test tests/",
47
- "typecheck": "tsc --noEmit"
48
- }
29
+ "devDependencies": null
49
30
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `defineDurable(name, fn)` — declaration-style factory mirroring
3
+ * `defineSchema(...)` / `defineMachine(...)` / `defineWorkflow(...)`.
4
+ *
5
+ * ```ts
6
+ * export const milestone = defineDurable('milestone', (w) =>
7
+ * w.step('discover', async (ctx) => discover(ctx.input))
8
+ * .step('plan', async (ctx) => plan(ctx.results.discover))
9
+ * .step('ship', async (ctx) => ship(ctx.results.plan), {
10
+ * compensate: async (ctx) => rollbackShip(ctx.results.ship),
11
+ * })
12
+ * )
13
+ * ```
14
+ *
15
+ * The returned `DurableWorkflow` is what apps register on the runner.
16
+ * Apps that prefer a more imperative shape can `new DurableWorkflow()`
17
+ * directly — the factory is sugar.
18
+ */
19
+
20
+ import { DurableWorkflow } from './durable_workflow.ts'
21
+
22
+ export function defineDurable(
23
+ name: string,
24
+ build: (workflow: DurableWorkflow) => DurableWorkflow,
25
+ ): DurableWorkflow {
26
+ const workflow = new DurableWorkflow(name)
27
+ build(workflow)
28
+ return workflow
29
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `DurableAdvanceJob` — queue Job that moves a durable run forward
3
+ * by one step.
4
+ *
5
+ * Payload is just `{ runId }` — the runner reads everything else off
6
+ * the run row + the registry. Keeping the payload minimal protects
7
+ * against schema drift between the dispatcher and the worker: a
8
+ * worker on an older deploy can still process jobs queued by the
9
+ * latest deploy as long as the registry shape matches.
10
+ *
11
+ * `maxAttempts = 1` because retry semantics live INSIDE the runner
12
+ * (per-step retries with configurable backoff), not at the Job
13
+ * layer. If the runner throws here it means the engine itself
14
+ * failed — those should land in the queue's dead-letter via the
15
+ * standard Worker pipeline, not get silently retried.
16
+ */
17
+
18
+ import { inject } from '@strav/kernel'
19
+ import { Job, type JobContext } from '@strav/queue'
20
+ import { DurableRunner } from './durable_runner.ts'
21
+
22
+ export interface DurableAdvancePayload {
23
+ runId: string
24
+ }
25
+
26
+ @inject()
27
+ export class DurableAdvanceJob extends Job<DurableAdvancePayload> {
28
+ static override readonly jobName = 'durable.advance'
29
+ static override readonly maxAttempts = 1
30
+
31
+ constructor(private readonly runner: DurableRunner) {
32
+ super()
33
+ }
34
+
35
+ async handle(ctx: JobContext<DurableAdvancePayload>): Promise<void> {
36
+ await this.runner.advance(ctx.payload.runId)
37
+ }
38
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * `DurableCompensateJob` — queue Job that runs saga compensation for
3
+ * a terminally-failed run.
4
+ *
5
+ * Mirrors `DurableAdvanceJob`'s shape — minimal payload, runner owns
6
+ * the state. Same rationale for `maxAttempts = 1`: the runner
7
+ * iterates over compensators internally and swallows their
8
+ * individual failures so the rollback finishes; if the runner
9
+ * itself throws (e.g. a DB connection error mid-walk), the queue's
10
+ * dead-letter is the right place for it.
11
+ */
12
+
13
+ import { inject } from '@strav/kernel'
14
+ import { Job, type JobContext } from '@strav/queue'
15
+ import { DurableRunner } from './durable_runner.ts'
16
+
17
+ export interface DurableCompensatePayload {
18
+ runId: string
19
+ }
20
+
21
+ @inject()
22
+ export class DurableCompensateJob extends Job<DurableCompensatePayload> {
23
+ static override readonly jobName = 'durable.compensate'
24
+ static override readonly maxAttempts = 1
25
+
26
+ constructor(private readonly runner: DurableRunner) {
27
+ super()
28
+ }
29
+
30
+ async handle(ctx: JobContext<DurableCompensatePayload>): Promise<void> {
31
+ await this.runner.compensate(ctx.payload.runId)
32
+ }
33
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Typed StravError subclasses for durable execution.
3
+ *
4
+ * `DurableError` is the base — generic infrastructure failure. The
5
+ * two more-specific subclasses cover the common "the caller asked
6
+ * for something that doesn't exist" cases.
7
+ */
8
+
9
+ import { StravError } from '@strav/kernel'
10
+
11
+ export class DurableError extends StravError {
12
+ constructor(
13
+ message: string,
14
+ options: { context?: Record<string, unknown>; cause?: unknown } = {},
15
+ ) {
16
+ super(message, { code: 'durable.error', status: 500 }, options)
17
+ }
18
+ }
19
+
20
+ export class RunNotFoundError extends StravError {
21
+ constructor(runId: string) {
22
+ super(
23
+ `Durable run "${runId}" not found.`,
24
+ { code: 'durable.run-not-found', status: 404 },
25
+ { context: { runId } },
26
+ )
27
+ }
28
+ }
29
+
30
+ export class WorkflowNotRegisteredError extends StravError {
31
+ constructor(name: string, known: readonly string[]) {
32
+ super(
33
+ `Durable workflow "${name}" is not registered. Known: ${known.length === 0 ? '(none)' : known.join(', ')}.`,
34
+ { code: 'durable.workflow-not-registered', status: 500 },
35
+ { context: { name, known: [...known] } },
36
+ )
37
+ }
38
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `DurableProvider` — wires the durable runtime into the container.
3
+ *
4
+ * `register()`:
5
+ * - Binds `WorkflowRegistry` as a singleton — apps wire workflows
6
+ * into it from their own provider's `boot()`.
7
+ * - Binds `DurableRunner` as a singleton; resolves the Postgres
8
+ * pool, the `Queue` driver (passed via constructor options), the
9
+ * registry, and (optionally) the `Logger`.
10
+ *
11
+ * `boot()`:
12
+ * - Registers both schemas on the app's `SchemaRegistry` so they
13
+ * show up in `db:migrate:generate` output.
14
+ * - Eagerly resolves the runner so a misconfigured app fails at
15
+ * boot, not on the first start() call.
16
+ *
17
+ * The provider does NOT auto-discover workflows — apps register them
18
+ * explicitly. The `discover` slice lands when an app needs it.
19
+ *
20
+ * `@strav/queue` doesn't ship a `QueueProvider` — apps bind their
21
+ * queue driver (typically `DatabaseQueue` in production, `SyncQueue`
22
+ * in tests) in their own provider's `register()`. DurableProvider's
23
+ * constructor takes the queue class so the runtime knows which
24
+ * binding to resolve.
25
+ */
26
+
27
+ import { type Application, LogManager, ServiceProvider } from '@strav/kernel'
28
+ import { PostgresDatabase, SchemaRegistry } from '@strav/database'
29
+ import type { JobClass, Queue } from '@strav/queue'
30
+ import { DurableAdvanceJob } from './durable_advance_job.ts'
31
+ import { DurableCompensateJob } from './durable_compensate_job.ts'
32
+ import { workflowJournalSchema } from './journal_schema.ts'
33
+ import { workflowRunsSchema } from './runs_schema.ts'
34
+ import { DurableRunner } from './durable_runner.ts'
35
+ import { WorkflowRegistry } from './workflow_registry.ts'
36
+
37
+ export interface DurableProviderOptions {
38
+ /**
39
+ * Concrete `Queue` driver class. Apps bind one (typically
40
+ * `DatabaseQueue` for production, `SyncQueue` for tests) in their
41
+ * own provider, then pass the class here so `DurableRunner` can
42
+ * resolve it from the container.
43
+ */
44
+ // biome-ignore lint/suspicious/noExplicitAny: container constructor accepts any[]
45
+ queue: new (...args: any[]) => Queue
46
+ /**
47
+ * Optional Job class overrides. Defaults to the shipped
48
+ * `DurableAdvanceJob` / `DurableCompensateJob`. Apps that subclass
49
+ * the Jobs (custom logging, custom dead-letter routing) pass their
50
+ * subclass here.
51
+ */
52
+ advanceJob?: JobClass
53
+ compensateJob?: JobClass
54
+ }
55
+
56
+ export class DurableProvider extends ServiceProvider {
57
+ override readonly name = 'durable'
58
+ override readonly dependencies = ['database']
59
+
60
+ constructor(private readonly options: DurableProviderOptions) {
61
+ super()
62
+ }
63
+
64
+ override register(app: Application): void {
65
+ app.singleton(WorkflowRegistry, () => new WorkflowRegistry())
66
+ app.singleton(DurableRunner, (c) => {
67
+ const runnerOptions: ConstructorParameters<typeof DurableRunner>[0] = {
68
+ db: c.resolve(PostgresDatabase),
69
+ queue: c.resolve(this.options.queue),
70
+ registry: c.resolve(WorkflowRegistry),
71
+ advanceJob: this.options.advanceJob ?? DurableAdvanceJob,
72
+ compensateJob: this.options.compensateJob ?? DurableCompensateJob,
73
+ }
74
+ if (c.has(LogManager)) {
75
+ runnerOptions.logger = c.resolve(LogManager).channel('durable')
76
+ }
77
+ if (c.has(SchemaRegistry)) runnerOptions.schemas = c.resolve(SchemaRegistry)
78
+ return new DurableRunner(runnerOptions)
79
+ })
80
+ }
81
+
82
+ override boot(app: Application): void {
83
+ if (app.has(SchemaRegistry)) {
84
+ const registry = app.resolve(SchemaRegistry)
85
+ if (!registry.has(workflowRunsSchema.name)) registry.register(workflowRunsSchema)
86
+ if (!registry.has(workflowJournalSchema.name)) registry.register(workflowJournalSchema)
87
+ }
88
+ // Eager-resolve so misconfiguration fails at boot.
89
+ app.resolve(DurableRunner)
90
+ }
91
+ }