@strav/durable 0.4.27

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.
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Brain composition point — duck-typed, with no `@strav/brain` dependency.
3
+ *
4
+ * `@strav/brain`'s `AgentRunner.run()` returns either an `AgentResult` or a
5
+ * `SuspendedRun` ({ status: 'suspended', pendingToolCalls, state }). When a
6
+ * durable `.step` handler returns a `SuspendedRun`, the engine suspends the
7
+ * whole run and journals the snapshot; resuming the run re-enters the handler
8
+ * so it can call `runner.resume(...)`. The per-agent suspend nests inside the
9
+ * per-workflow suspend.
10
+ */
11
+
12
+ /** A structural view of a `@strav/brain` `SuspendedRun`. */
13
+ export interface SuspendedRunLike {
14
+ status: 'suspended'
15
+ state: unknown
16
+ pendingToolCalls: unknown
17
+ }
18
+
19
+ /** Structurally detect a brain `SuspendedRun` without importing `@strav/brain`. */
20
+ export function isSuspendedRun(value: unknown): value is SuspendedRunLike {
21
+ if (typeof value !== 'object' || value === null) return false
22
+ const v = value as Record<string, unknown>
23
+ return v.status === 'suspended' && 'state' in v && 'pendingToolCalls' in v
24
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { StravError } from '@strav/kernel'
2
+
3
+ /** Base error for the durable execution engine. */
4
+ export class DurableError extends StravError {}
5
+
6
+ /** Thrown when a run id does not exist. */
7
+ export class RunNotFoundError extends DurableError {
8
+ constructor(public readonly runId: number) {
9
+ super(`Durable run ${runId} not found.`)
10
+ }
11
+ }
12
+
13
+ /** Thrown when a workflow name has no registered definition in this process. */
14
+ export class WorkflowNotRegisteredError extends DurableError {
15
+ constructor(public readonly workflowName: string) {
16
+ super(
17
+ `Durable workflow "${workflowName}" is not registered. ` +
18
+ `Import the module that defines it before starting or advancing a run.`
19
+ )
20
+ }
21
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { DurableWorkflow } from './builder.ts'
2
+ import { registry } from './registry.ts'
3
+
4
+ /**
5
+ * Create a durable workflow and register it under `name`.
6
+ *
7
+ * @example
8
+ * export const milestone = durable('milestone')
9
+ * .step('discover', async (ctx) => discover(ctx.input))
10
+ * .step('ship', async (ctx) => ship(ctx))
11
+ */
12
+ export function durable(name: string): DurableWorkflow {
13
+ const workflow = new DurableWorkflow(name)
14
+ registry.register(workflow)
15
+ return workflow
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ export { durable } from './helpers.ts'
2
+ export { DurableWorkflow } from './builder.ts'
3
+ export { Durable } from './durable.ts'
4
+ export { registry } from './registry.ts'
5
+ export { WorkflowRun } from './models/workflow_run.ts'
6
+ export { runMachine } from './models/run_machine.ts'
7
+ export { default as DurableProvider } from './providers/durable_provider.ts'
8
+ export { ensureTables } from './schema.ts'
9
+ export {
10
+ DurableError,
11
+ RunNotFoundError,
12
+ WorkflowNotRegisteredError,
13
+ } from './errors.ts'
14
+ export { WorkflowError, CompensationError } from '@strav/workflow'
15
+ export type {
16
+ DurableContext,
17
+ DurableStep,
18
+ DurableStepHandler,
19
+ DurableLoopHandler,
20
+ DurableRouteResolver,
21
+ DurableParallelEntry,
22
+ DurableCompensator,
23
+ DurableStepOptions,
24
+ DurableLoopOptions,
25
+ SequentialStep,
26
+ ParallelStep,
27
+ RouteStep,
28
+ LoopStep,
29
+ SleepStep,
30
+ SignalStep,
31
+ ChildStep,
32
+ RunStatus,
33
+ JournalStatus,
34
+ StartResult,
35
+ ResumeResult,
36
+ RunStatusSnapshot,
37
+ } from './types.ts'
@@ -0,0 +1,3 @@
1
+ export { WorkflowRun } from './workflow_run.ts'
2
+ export { runMachine } from './run_machine.ts'
3
+ export { loadJournal, writeJournal } from './journal.ts'
@@ -0,0 +1,54 @@
1
+ import { sql } from '@strav/database'
2
+ import type { JournalRecord, JournalWrite } from '../types.ts'
3
+ import { parseJson } from '../util.ts'
4
+
5
+ /**
6
+ * Load every journal entry for a run, keyed by `step_id`. Used (unlocked) at
7
+ * the start of an advance to short-circuit completed steps and sub-units.
8
+ */
9
+ export async function loadJournal(runId: number): Promise<Map<string, JournalRecord>> {
10
+ const rows = (await sql`
11
+ SELECT "step_id", "status", "result", "error", "attempt"
12
+ FROM "_strav_workflow_journal"
13
+ WHERE "run_id" = ${runId}
14
+ `) as Record<string, unknown>[]
15
+
16
+ const map = new Map<string, JournalRecord>()
17
+ for (const r of rows) {
18
+ map.set(r.step_id as string, {
19
+ stepId: r.step_id as string,
20
+ status: r.status as JournalRecord['status'],
21
+ result: parseJson(r.result),
22
+ error: (r.error as string | null) ?? null,
23
+ attempt: Number(r.attempt),
24
+ })
25
+ }
26
+ return map
27
+ }
28
+
29
+ /**
30
+ * Append journal entries inside a transaction. `ON CONFLICT DO NOTHING` on
31
+ * `UNIQUE (run_id, step_id)` makes a redelivered step idempotent — the first
32
+ * write wins, later writes for the same `step_id` are no-ops.
33
+ */
34
+ export async function writeJournal(
35
+ trx: { (strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown> },
36
+ runId: number,
37
+ writes: JournalWrite[]
38
+ ): Promise<void> {
39
+ for (const w of writes) {
40
+ await trx`
41
+ INSERT INTO "_strav_workflow_journal"
42
+ ("run_id", "step_id", "status", "result", "error", "attempt")
43
+ VALUES (
44
+ ${runId},
45
+ ${w.stepId},
46
+ ${w.status},
47
+ ${w.result === undefined ? null : JSON.stringify(w.result)},
48
+ ${w.error ?? null},
49
+ ${w.attempt}
50
+ )
51
+ ON CONFLICT ("run_id", "step_id") DO NOTHING
52
+ `
53
+ }
54
+ }
@@ -0,0 +1,39 @@
1
+ import { defineMachine } from '@strav/machine'
2
+
3
+ /**
4
+ * The run-status lifecycle, modeled with `@strav/machine`.
5
+ *
6
+ * pending → running ⇄ suspended
7
+ * running → completed
8
+ * running/suspended → compensating → failed
9
+ * any non-terminal → canceled
10
+ *
11
+ * The engine writes status columns via raw SQL inside its atomic, locked
12
+ * transactions; this machine is the single declarative source of truth for
13
+ * which transitions are legal and powers the `WorkflowRun` `stateful()` model.
14
+ */
15
+ export const runMachine = defineMachine({
16
+ field: 'status',
17
+ initial: 'pending',
18
+ states: [
19
+ 'pending',
20
+ 'running',
21
+ 'suspended',
22
+ 'compensating',
23
+ 'completed',
24
+ 'failed',
25
+ 'canceled',
26
+ ],
27
+ transitions: {
28
+ begin: { from: 'pending', to: 'running' },
29
+ suspend: { from: 'running', to: 'suspended' },
30
+ wake: { from: 'suspended', to: 'running' },
31
+ complete: { from: 'running', to: 'completed' },
32
+ rollback: { from: ['running', 'suspended'], to: 'compensating' },
33
+ fail: { from: ['running', 'suspended', 'compensating'], to: 'failed' },
34
+ cancel: {
35
+ from: ['pending', 'running', 'suspended', 'compensating'],
36
+ to: 'canceled',
37
+ },
38
+ },
39
+ })
@@ -0,0 +1,36 @@
1
+ import type { DateTime } from 'luxon'
2
+ import { BaseModel, cast } from '@strav/database'
3
+ import { stateful } from '@strav/machine'
4
+ import type { Machine } from '@strav/machine'
5
+ import { runMachine } from './run_machine.ts'
6
+
7
+ /**
8
+ * ORM model over `_strav_workflow_runs` — the durable, queryable record of a
9
+ * workflow run (Albastr's "pollable open-Turn live record").
10
+ *
11
+ * Mixed with `stateful()` so a run's status is a first-class state machine:
12
+ * `run.is('running')`, `run.availableTransitions()`, `WorkflowRun.inState(...)`.
13
+ * The engine hot path writes status via raw SQL for atomicity; this model is
14
+ * for reads and for application code that wants to poll or query runs.
15
+ */
16
+ export class WorkflowRun extends stateful(BaseModel, runMachine as Machine) {
17
+ static override get tableName(): string {
18
+ return '_strav_workflow_runs'
19
+ }
20
+
21
+ declare id: number
22
+ declare workflowName: string
23
+ @cast('json') declare input: Record<string, unknown>
24
+ declare status: string
25
+ @cast('json') declare state: Record<string, unknown>
26
+ declare currentStep: number
27
+ declare compensationCursor: number | null
28
+ declare parentRunId: number | null
29
+ declare parentStepId: string | null
30
+ declare awaitingSignal: string | null
31
+ declare wakeAt: DateTime | null
32
+ declare error: string | null
33
+ @cast('json') declare result: Record<string, unknown> | null
34
+ declare createdAt: DateTime
35
+ declare updatedAt: DateTime
36
+ }
@@ -0,0 +1,31 @@
1
+ import { ServiceProvider } from '@strav/kernel'
2
+ import type { Application } from '@strav/kernel'
3
+ import { Durable } from '../durable.ts'
4
+
5
+ export interface DurableProviderOptions {
6
+ /** Whether to auto-create the durable engine tables. Default: `true`. */
7
+ ensureTables?: boolean
8
+ }
9
+
10
+ /**
11
+ * Registers the durable execution engine.
12
+ *
13
+ * On boot it creates the engine tables and registers the `durable:advance` /
14
+ * `durable:compensate` queue handlers. Run a `@strav/queue` `Worker` on the
15
+ * durable queue (default name `'durable'`) to actually process runs.
16
+ */
17
+ export default class DurableProvider extends ServiceProvider {
18
+ readonly name = 'durable'
19
+ override readonly dependencies = ['database', 'queue']
20
+
21
+ constructor(private readonly options?: DurableProviderOptions) {
22
+ super()
23
+ }
24
+
25
+ override async boot(_app: Application): Promise<void> {
26
+ if (this.options?.ensureTables !== false) {
27
+ await Durable.ensureTables()
28
+ }
29
+ Durable.registerHandlers()
30
+ }
31
+ }
@@ -0,0 +1,2 @@
1
+ export { default as DurableProvider } from './durable_provider.ts'
2
+ export type { DurableProviderOptions } from './durable_provider.ts'
@@ -0,0 +1,35 @@
1
+ import type { DurableWorkflow } from './builder.ts'
2
+ import { WorkflowNotRegisteredError } from './errors.ts'
3
+
4
+ /**
5
+ * Process-wide registry of durable workflow definitions.
6
+ *
7
+ * A `durable:advance` queue job carries only a workflow *name* (not a
8
+ * closure), so the worker process must be able to look the definition up
9
+ * by name. Importing the module that calls `durable(...)` registers it.
10
+ */
11
+ const workflows = new Map<string, DurableWorkflow>()
12
+
13
+ export const registry = {
14
+ /** Register (or replace) a workflow definition by name. */
15
+ register(workflow: DurableWorkflow): void {
16
+ workflows.set(workflow.name, workflow)
17
+ },
18
+
19
+ /** Look up a workflow definition, throwing if it is not registered. */
20
+ get(name: string): DurableWorkflow {
21
+ const wf = workflows.get(name)
22
+ if (!wf) throw new WorkflowNotRegisteredError(name)
23
+ return wf
24
+ },
25
+
26
+ /** Whether a workflow name is registered. */
27
+ has(name: string): boolean {
28
+ return workflows.has(name)
29
+ },
30
+
31
+ /** Clear all registrations. For testing only. */
32
+ reset(): void {
33
+ workflows.clear()
34
+ },
35
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { sql } from '@strav/database'
2
+
3
+ /**
4
+ * Create the durable engine's two tables if they do not exist.
5
+ *
6
+ * Follows the `@strav/queue` precedent — inline, idempotent DDL rather than
7
+ * a migration file (apps own their migrations directory). Safe to call on
8
+ * every boot. Post-1.0 schema changes must be additive
9
+ * (`ALTER TABLE ... ADD COLUMN IF NOT EXISTS`).
10
+ *
11
+ * - `_strav_workflow_runs` — the durable record of a run; the pollable row.
12
+ * - `_strav_workflow_journal` — the per-step checkpoint log;
13
+ * `UNIQUE (run_id, step_id)` is what makes redelivery idempotent.
14
+ */
15
+ export async function ensureTables(): Promise<void> {
16
+ await sql`
17
+ CREATE TABLE IF NOT EXISTS "_strav_workflow_runs" (
18
+ "id" BIGSERIAL PRIMARY KEY,
19
+ "workflow_name" VARCHAR(255) NOT NULL,
20
+ "input" JSONB NOT NULL DEFAULT '{}',
21
+ "status" VARCHAR(32) NOT NULL DEFAULT 'pending',
22
+ "state" JSONB NOT NULL DEFAULT '{}',
23
+ "current_step" INT NOT NULL DEFAULT 0,
24
+ "compensation_cursor" INT,
25
+ "parent_run_id" BIGINT REFERENCES "_strav_workflow_runs"("id") ON DELETE CASCADE,
26
+ "parent_step_id" VARCHAR(512),
27
+ "awaiting_signal" VARCHAR(255),
28
+ "wake_at" TIMESTAMPTZ,
29
+ "error" TEXT,
30
+ "result" JSONB,
31
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
32
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
33
+ )
34
+ `
35
+
36
+ await sql`
37
+ CREATE INDEX IF NOT EXISTS "idx_strav_wf_runs_status"
38
+ ON "_strav_workflow_runs" ("status")
39
+ `
40
+
41
+ await sql`
42
+ CREATE INDEX IF NOT EXISTS "idx_strav_wf_runs_parent"
43
+ ON "_strav_workflow_runs" ("parent_run_id")
44
+ `
45
+
46
+ await sql`
47
+ CREATE TABLE IF NOT EXISTS "_strav_workflow_journal" (
48
+ "id" BIGSERIAL PRIMARY KEY,
49
+ "run_id" BIGINT NOT NULL REFERENCES "_strav_workflow_runs"("id") ON DELETE CASCADE,
50
+ "step_id" VARCHAR(512) NOT NULL,
51
+ "status" VARCHAR(16) NOT NULL DEFAULT 'completed',
52
+ "result" JSONB,
53
+ "error" TEXT,
54
+ "attempt" INT NOT NULL DEFAULT 1,
55
+ "completed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
56
+ UNIQUE ("run_id", "step_id")
57
+ )
58
+ `
59
+
60
+ await sql`
61
+ CREATE INDEX IF NOT EXISTS "idx_strav_wf_journal_run"
62
+ ON "_strav_workflow_journal" ("run_id")
63
+ `
64
+ }
65
+
66
+ /** Drop the durable engine's tables. For testing only. */
67
+ export async function dropTables(): Promise<void> {
68
+ await sql`DROP TABLE IF EXISTS "_strav_workflow_journal"`
69
+ await sql`DROP TABLE IF EXISTS "_strav_workflow_runs"`
70
+ }
package/src/types.ts ADDED
@@ -0,0 +1,216 @@
1
+ import type { WorkflowContext } from '@strav/workflow'
2
+
3
+ // ── Run + journal status ────────────────────────────────────────────────────
4
+
5
+ /** Lifecycle status of a durable workflow run. */
6
+ export type RunStatus =
7
+ | 'pending'
8
+ | 'running'
9
+ | 'suspended'
10
+ | 'compensating'
11
+ | 'completed'
12
+ | 'failed'
13
+ | 'canceled'
14
+
15
+ /** Status of a single journal entry. */
16
+ export type JournalStatus = 'completed' | 'failed'
17
+
18
+ // ── Durable context ─────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Context passed to durable step handlers. Extends the `@strav/workflow`
22
+ * context (`input` + `results`) with durable-only accessors, so a plain
23
+ * `@strav/workflow` handler is copy-paste portable into a durable workflow.
24
+ */
25
+ export interface DurableContext extends WorkflowContext {
26
+ /** The durable run id — stable across crashes/restarts. */
27
+ runId: number
28
+ /** The current retry attempt for the executing step (1-based). */
29
+ attempt: number
30
+ /** The name of the step currently executing. */
31
+ stepName: string
32
+ /** Read a completed step (or signal) result by name. */
33
+ signal<T = unknown>(name: string): T | undefined
34
+ /**
35
+ * The payload passed to the most recent `Durable.resume()` for this run.
36
+ * Used by a brain-agent step to resume a journaled `SuspendedRun`.
37
+ */
38
+ resumeData<T = unknown>(): T | undefined
39
+ }
40
+
41
+ // ── Handler signatures ──────────────────────────────────────────────────────
42
+
43
+ /** Handler for sequential, parallel, and route steps. */
44
+ export type DurableStepHandler = (ctx: DurableContext) => Promise<unknown>
45
+
46
+ /** Handler for loop steps — receives the iteration input + context. */
47
+ export type DurableLoopHandler = (input: unknown, ctx: DurableContext) => Promise<unknown>
48
+
49
+ /** Route resolver — returns the branch key to dispatch to. */
50
+ export type DurableRouteResolver = (ctx: DurableContext) => string | Promise<string>
51
+
52
+ /** Compensation handler for saga-style rollback. */
53
+ export type DurableCompensator = (ctx: DurableContext) => Promise<void>
54
+
55
+ /** A single entry in a parallel step. */
56
+ export interface DurableParallelEntry {
57
+ name: string
58
+ handler: DurableStepHandler
59
+ compensate?: DurableCompensator
60
+ }
61
+
62
+ // ── Step options ────────────────────────────────────────────────────────────
63
+
64
+ export interface DurableStepOptions {
65
+ /** Saga compensation — run in reverse order if a later step fails. */
66
+ compensate?: DurableCompensator
67
+ /** Max total attempts before the step is declared failed. Default 3. */
68
+ maxRetries?: number
69
+ /** Backoff strategy between retries. Default 'exponential'. */
70
+ retryBackoff?: 'exponential' | 'linear'
71
+ }
72
+
73
+ export interface DurableLoopOptions {
74
+ maxIterations: number
75
+ until?: (result: unknown, iteration: number) => boolean
76
+ feedback?: (result: unknown) => unknown
77
+ mapInput?: (ctx: DurableContext) => unknown
78
+ maxRetries?: number
79
+ retryBackoff?: 'exponential' | 'linear'
80
+ }
81
+
82
+ // ── Step definitions ────────────────────────────────────────────────────────
83
+
84
+ export interface SequentialStep {
85
+ type: 'step'
86
+ name: string
87
+ handler: DurableStepHandler
88
+ compensate?: DurableCompensator
89
+ maxRetries: number
90
+ retryBackoff: 'exponential' | 'linear'
91
+ }
92
+
93
+ export interface ParallelStep {
94
+ type: 'parallel'
95
+ name: string
96
+ entries: DurableParallelEntry[]
97
+ maxRetries: number
98
+ retryBackoff: 'exponential' | 'linear'
99
+ }
100
+
101
+ export interface RouteStep {
102
+ type: 'route'
103
+ name: string
104
+ resolver: DurableRouteResolver
105
+ branches: Record<string, DurableStepHandler>
106
+ maxRetries: number
107
+ retryBackoff: 'exponential' | 'linear'
108
+ }
109
+
110
+ export interface LoopStep {
111
+ type: 'loop'
112
+ name: string
113
+ handler: DurableLoopHandler
114
+ maxIterations: number
115
+ until?: (result: unknown, iteration: number) => boolean
116
+ feedback?: (result: unknown) => unknown
117
+ mapInput?: (ctx: DurableContext) => unknown
118
+ maxRetries: number
119
+ retryBackoff: 'exponential' | 'linear'
120
+ }
121
+
122
+ /** Durable timer — suspends the run and resumes after `duration`. */
123
+ export interface SleepStep {
124
+ type: 'sleep'
125
+ name: string
126
+ duration: number | Date
127
+ }
128
+
129
+ /** Human-in-the-loop pause — suspends until `Durable.resume()` delivers the signal. */
130
+ export interface SignalStep {
131
+ type: 'signal'
132
+ name: string
133
+ signal: string
134
+ timeout?: number
135
+ }
136
+
137
+ /** Spawn an independently durable child workflow and wait for it. */
138
+ export interface ChildStep {
139
+ type: 'child'
140
+ name: string
141
+ childName: string
142
+ mapInput?: (ctx: DurableContext) => Record<string, unknown>
143
+ }
144
+
145
+ export type DurableStep =
146
+ | SequentialStep
147
+ | ParallelStep
148
+ | RouteStep
149
+ | LoopStep
150
+ | SleepStep
151
+ | SignalStep
152
+ | ChildStep
153
+
154
+ // ── Public result shapes ────────────────────────────────────────────────────
155
+
156
+ export interface StartResult {
157
+ runId: number
158
+ status: RunStatus
159
+ }
160
+
161
+ export interface ResumeResult {
162
+ /** Whether the signal was accepted (run suspended on a matching signal). */
163
+ accepted: boolean
164
+ status: RunStatus
165
+ }
166
+
167
+ export interface RunStatusSnapshot {
168
+ runId: number
169
+ workflowName: string
170
+ status: RunStatus
171
+ currentStep: number
172
+ totalSteps: number
173
+ awaitingSignal: string | null
174
+ wakeAt: string | null
175
+ results: Record<string, unknown>
176
+ error: string | null
177
+ parentRunId: number | null
178
+ children: RunStatusSnapshot[]
179
+ }
180
+
181
+ // ── Internal records ────────────────────────────────────────────────────────
182
+
183
+ /** A hydrated `_strav_workflow_runs` row (snake_case columns → camelCase). */
184
+ export interface RunRow {
185
+ id: number
186
+ workflowName: string
187
+ input: Record<string, unknown>
188
+ status: RunStatus
189
+ state: Record<string, unknown>
190
+ currentStep: number
191
+ compensationCursor: number | null
192
+ parentRunId: number | null
193
+ parentStepId: string | null
194
+ awaitingSignal: string | null
195
+ wakeAt: Date | null
196
+ error: string | null
197
+ result: Record<string, unknown> | null
198
+ }
199
+
200
+ /** A hydrated `_strav_workflow_journal` row. */
201
+ export interface JournalRecord {
202
+ stepId: string
203
+ status: JournalStatus
204
+ result: unknown
205
+ error: string | null
206
+ attempt: number
207
+ }
208
+
209
+ /** A pending journal write produced by the step driver. */
210
+ export interface JournalWrite {
211
+ stepId: string
212
+ status: JournalStatus
213
+ result?: unknown
214
+ error?: string
215
+ attempt: number
216
+ }
package/src/util.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Parse a JSONB column value. Bun's SQL driver may return a JSONB column as
3
+ * either an already-parsed object or a raw string depending on context — this
4
+ * normalizes both. `null`/`undefined` pass through unchanged.
5
+ */
6
+ export function parseJson<T = unknown>(value: unknown): T {
7
+ if (value == null) return value as T
8
+ if (typeof value === 'string') {
9
+ try {
10
+ return JSON.parse(value) as T
11
+ } catch {
12
+ return value as T
13
+ }
14
+ }
15
+ return value as T
16
+ }
17
+
18
+ /** Compute the retry backoff delay (ms) for a given attempt number. */
19
+ export function backoffDelay(
20
+ attempt: number,
21
+ strategy: 'exponential' | 'linear'
22
+ ): number {
23
+ if (strategy === 'linear') return attempt * 5_000
24
+ return Math.pow(2, attempt) * 1_000 + Math.random() * 1_000
25
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"],
4
+ "exclude": ["node_modules", "tests"]
5
+ }