@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 +18 -37
- package/src/define_durable.ts +29 -0
- package/src/durable_advance_job.ts +38 -0
- package/src/durable_compensate_job.ts +33 -0
- package/src/durable_error.ts +38 -0
- package/src/durable_provider.ts +91 -0
- package/src/durable_runner.ts +395 -0
- package/src/durable_workflow.ts +97 -0
- package/src/index.ts +25 -26
- package/src/journal_schema.ts +53 -0
- package/src/runs_schema.ts +38 -0
- package/src/types.ts +58 -198
- package/src/workflow_registry.ts +49 -0
- package/CHANGELOG.md +0 -26
- package/src/builder.ts +0 -158
- package/src/config.ts +0 -36
- package/src/durable.ts +0 -268
- package/src/engine/advance_handler.ts +0 -154
- package/src/engine/compensate_handler.ts +0 -70
- package/src/engine/compensation_driver.ts +0 -61
- package/src/engine/context.ts +0 -36
- package/src/engine/enqueue.ts +0 -62
- package/src/engine/finalize.ts +0 -111
- package/src/engine/index.ts +0 -20
- package/src/engine/run_store.ts +0 -42
- package/src/engine/step_driver.ts +0 -291
- package/src/engine/suspended_run.ts +0 -24
- package/src/errors.ts +0 -21
- package/src/helpers.ts +0 -16
- package/src/models/index.ts +0 -3
- package/src/models/journal.ts +0 -54
- package/src/models/run_machine.ts +0 -39
- package/src/models/workflow_run.ts +0 -36
- package/src/providers/durable_provider.ts +0 -31
- package/src/providers/index.ts +0 -2
- package/src/registry.ts +0 -35
- package/src/schema.ts +0 -70
- package/src/util.ts +0 -25
- package/tsconfig.json +0 -5
package/package.json
CHANGED
|
@@ -1,49 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strav/durable",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"
|
|
6
|
-
"
|
|
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
|
-
"
|
|
29
|
-
"tsconfig.json",
|
|
30
|
-
"CHANGELOG.md"
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
31
14
|
],
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.3.14"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
35
20
|
},
|
|
36
21
|
"dependencies": {
|
|
37
|
-
"@strav/
|
|
38
|
-
"@strav/
|
|
39
|
-
"@strav/
|
|
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
|
-
"
|
|
43
|
-
"@types/
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@types/bun": ">=1.3.14"
|
|
44
28
|
},
|
|
45
|
-
"
|
|
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
|
+
}
|