@multiplier-labs/stepflow 0.3.1

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.
Files changed (76) hide show
  1. package/.github/workflows/publish.yml +30 -0
  2. package/README.md +408 -0
  3. package/dist/chunk-FQCT3I2A.js +1512 -0
  4. package/dist/chunk-FQCT3I2A.js.map +1 -0
  5. package/dist/chunk-GXY66V7Q.js +111 -0
  6. package/dist/chunk-GXY66V7Q.js.map +1 -0
  7. package/dist/chunk-U5QGN3RT.js +715 -0
  8. package/dist/chunk-U5QGN3RT.js.map +1 -0
  9. package/dist/chunk-XCF4PLN7.js +575 -0
  10. package/dist/chunk-XCF4PLN7.js.map +1 -0
  11. package/dist/events/index.d.ts +346 -0
  12. package/dist/events/index.js +11 -0
  13. package/dist/events/index.js.map +1 -0
  14. package/dist/index-CPfGsmrp.d.ts +567 -0
  15. package/dist/index.d.ts +748 -0
  16. package/dist/index.js +1707 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/scheduler/index.d.ts +6 -0
  19. package/dist/scheduler/index.js +12 -0
  20. package/dist/scheduler/index.js.map +1 -0
  21. package/dist/storage/index.d.ts +402 -0
  22. package/dist/storage/index.js +13 -0
  23. package/dist/storage/index.js.map +1 -0
  24. package/dist/types-BVShq7zT.d.ts +150 -0
  25. package/dist/types-BlwFEGzj.d.ts +276 -0
  26. package/dist/types-DzkEqnQJ.d.ts +75 -0
  27. package/docs/github-packages-setup.md +97 -0
  28. package/docs/stepflow-api-reference.md +1662 -0
  29. package/package.json +85 -0
  30. package/src/core/engine.test.ts +1421 -0
  31. package/src/core/engine.ts +717 -0
  32. package/src/core/index.ts +7 -0
  33. package/src/core/orchestrator.ts +590 -0
  34. package/src/core/types.ts +242 -0
  35. package/src/events/index.ts +8 -0
  36. package/src/events/memory.test.ts +109 -0
  37. package/src/events/memory.ts +95 -0
  38. package/src/events/socketio.test.ts +428 -0
  39. package/src/events/socketio.ts +269 -0
  40. package/src/events/types.ts +121 -0
  41. package/src/events/webhook.test.ts +695 -0
  42. package/src/events/webhook.ts +591 -0
  43. package/src/index.ts +270 -0
  44. package/src/planning/index.ts +52 -0
  45. package/src/planning/planner.ts +601 -0
  46. package/src/planning/planning.test.ts +790 -0
  47. package/src/planning/registry.test.ts +445 -0
  48. package/src/planning/registry.ts +369 -0
  49. package/src/planning/types.ts +570 -0
  50. package/src/scheduler/cron.test.ts +480 -0
  51. package/src/scheduler/cron.ts +449 -0
  52. package/src/scheduler/index.ts +18 -0
  53. package/src/scheduler/postgres-persistence.test.ts +906 -0
  54. package/src/scheduler/postgres-persistence.ts +546 -0
  55. package/src/scheduler/sqlite-persistence.test.ts +235 -0
  56. package/src/scheduler/sqlite-persistence.ts +268 -0
  57. package/src/scheduler/types.ts +78 -0
  58. package/src/storage/index.ts +27 -0
  59. package/src/storage/memory.test.ts +399 -0
  60. package/src/storage/memory.ts +248 -0
  61. package/src/storage/postgres.ts +1550 -0
  62. package/src/storage/sqlite.test.ts +593 -0
  63. package/src/storage/sqlite.ts +662 -0
  64. package/src/storage/types.ts +368 -0
  65. package/src/utils/errors.test.ts +152 -0
  66. package/src/utils/errors.ts +191 -0
  67. package/src/utils/id.ts +21 -0
  68. package/src/utils/index.ts +8 -0
  69. package/src/utils/logger.test.ts +127 -0
  70. package/src/utils/logger.ts +96 -0
  71. package/src/utils/postgres-deps.ts +63 -0
  72. package/src/utils/retry.test.ts +197 -0
  73. package/src/utils/retry.ts +121 -0
  74. package/tsconfig.json +21 -0
  75. package/tsup.config.ts +14 -0
  76. package/vitest.config.ts +9 -0
@@ -0,0 +1,1550 @@
1
+ /**
2
+ * PostgreSQL storage adapter using Kysely.
3
+ *
4
+ * Provides durable persistence for workflow runs, steps, and events
5
+ * with support for distributed deployments and connection pooling.
6
+ */
7
+
8
+ import type { Kysely as KyselyType } from "kysely";
9
+ import type { Pool, PoolConfig } from "pg";
10
+ import { loadPostgresDeps } from "../utils/postgres-deps.js";
11
+
12
+ // Lazy-loaded dependencies - populated by loadPostgresDeps() during initialize()
13
+ let Kysely: any;
14
+ let PostgresDialect: any;
15
+ let sql: any;
16
+ let pgModule: any;
17
+ import { generateId } from "../utils/id.js";
18
+ import type {
19
+ StorageAdapter,
20
+ WorkflowRunRecord,
21
+ WorkflowRunStepRecord,
22
+ WorkflowEventRecord,
23
+ ListRunsOptions,
24
+ ListEventsOptions,
25
+ PaginatedResult,
26
+ CreateRunInput,
27
+ UpdateRunInput,
28
+ StepResult,
29
+ ExtendedWorkflowRunRecord,
30
+ ExtendedRunStatus,
31
+ ExtendedStepStatus,
32
+ } from "./types.js";
33
+ import type { RunStatus, StepStatus, WorkflowError } from "../core/types.js";
34
+ import { sanitizeErrorForStorage } from "../utils/logger.js";
35
+
36
+ /** Strip `stack` from a generic error record before persistence. */
37
+ function stripStack<T extends Record<string, unknown>>(
38
+ error: T,
39
+ ): Omit<T, "stack"> {
40
+ const { stack: _stack, ...rest } = error;
41
+ return rest as Omit<T, "stack">;
42
+ }
43
+
44
+ // ============================================================================
45
+ // Database Types (Kysely schema)
46
+ // ============================================================================
47
+
48
+ /** Kysely row type for the workflow_runs table. */
49
+ interface WorkflowRunsTable {
50
+ id: string;
51
+ /** Workflow kind identifier (e.g. 'order-processing'). */
52
+ kind: string;
53
+ /** Current run status (maps to RunStatus). */
54
+ status: string;
55
+ /** Parent run ID for child workflows, null for top-level runs. */
56
+ parent_run_id: string | null;
57
+ /** JSON-serialized input payload. */
58
+ input_json: string;
59
+ /** JSON-serialized arbitrary metadata. */
60
+ metadata_json: string;
61
+ /** JSON-serialized workflow context (accumulated step results). */
62
+ context_json: string;
63
+ /** JSON-serialized output payload, null until run completes. */
64
+ output_json: string | null;
65
+ /** JSON-serialized error object, null unless run failed. */
66
+ error_json: string | null;
67
+ /** Execution priority (lower number = higher precedence). */
68
+ priority: number;
69
+ /** Workflow-level timeout in milliseconds, null for no timeout. */
70
+ timeout_ms: number | null;
71
+ created_at: Date;
72
+ started_at: Date | null;
73
+ finished_at: Date | null;
74
+ }
75
+
76
+ /** Kysely row type for the workflow_run_steps table. */
77
+ interface WorkflowRunStepsTable {
78
+ id: string;
79
+ /** Foreign key to workflow_runs.id. */
80
+ run_id: string;
81
+ /** Unique step identifier within the workflow definition. */
82
+ step_key: string;
83
+ /** Human-readable step name. */
84
+ step_name: string;
85
+ /** Current step status (maps to StepStatus). */
86
+ status: string;
87
+ /** Current retry attempt number (1-based). */
88
+ attempt: number;
89
+ /** JSON-serialized step result, null until step completes. */
90
+ result_json: string | null;
91
+ /** JSON-serialized error object, null unless step failed. */
92
+ error_json: string | null;
93
+ started_at: Date | null;
94
+ finished_at: Date | null;
95
+ }
96
+
97
+ /** Kysely row type for the stepflow_step_results table (extended storage). */
98
+ interface StepflowStepResultsTable {
99
+ id: string;
100
+ /** Foreign key to runs.id. */
101
+ run_id: string;
102
+ /** Step name identifier. */
103
+ step_name: string;
104
+ /** Current step status (maps to ExtendedStepStatus). */
105
+ status: string;
106
+ /** JSON-serialized step output, null until step completes. */
107
+ output_json: string | null;
108
+ /** JSON-serialized error object, null unless step failed. */
109
+ error_json: string | null;
110
+ /** Current retry attempt number (1-based). */
111
+ attempt: number;
112
+ started_at: Date | null;
113
+ completed_at: Date | null;
114
+ }
115
+
116
+ /** Kysely row type for the workflow_events table. */
117
+ interface WorkflowEventsTable {
118
+ id: string;
119
+ /** Foreign key to workflow_runs.id. */
120
+ run_id: string;
121
+ /** Step key that emitted this event, null for run-level events. */
122
+ step_key: string | null;
123
+ /** Event type identifier (e.g. 'run.started', 'step.completed'). */
124
+ event_type: string;
125
+ /** Severity level: 'info', 'warn', or 'error'. */
126
+ level: string;
127
+ /** JSON-serialized event payload, null if no extra data. */
128
+ payload_json: string | null;
129
+ timestamp: Date;
130
+ }
131
+
132
+ interface StepflowDatabase {
133
+ runs: WorkflowRunsTable;
134
+ workflow_run_steps: WorkflowRunStepsTable;
135
+ workflow_events: WorkflowEventsTable;
136
+ stepflow_step_results: StepflowStepResultsTable;
137
+ }
138
+
139
+ // ============================================================================
140
+ // Configuration
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Configuration options for the PostgreSQL storage adapter.
145
+ */
146
+ export interface PostgresStorageConfig {
147
+ /**
148
+ * PostgreSQL connection string.
149
+ * Example: "postgresql://user:pass@localhost:5432/dbname"
150
+ */
151
+ connectionString?: string;
152
+
153
+ /**
154
+ * Existing pg.Pool instance for connection sharing with application.
155
+ * If provided, the adapter will not close this pool on close().
156
+ */
157
+ pool?: Pool;
158
+
159
+ /**
160
+ * Pool configuration options (if not providing pool or connectionString).
161
+ */
162
+ poolConfig?: PoolConfig;
163
+
164
+ /**
165
+ * Schema name for Stepflow tables.
166
+ * @default 'public'
167
+ */
168
+ schema?: string;
169
+
170
+ /**
171
+ * Automatically create tables on initialize().
172
+ * @default true
173
+ */
174
+ autoMigrate?: boolean;
175
+ }
176
+
177
+ // ============================================================================
178
+ // PostgreSQL Storage Adapter
179
+ // ============================================================================
180
+
181
+ /**
182
+ * PostgreSQL implementation of StorageAdapter using Kysely.
183
+ *
184
+ * Features:
185
+ * - Connection pooling (shared or dedicated)
186
+ * - Automatic table creation
187
+ * - Transaction support
188
+ * - Atomic dequeue operations for distributed workers
189
+ * - JSONB storage for flexible data
190
+ *
191
+ * @example
192
+ * ```typescript
193
+ * import { PostgresStorageAdapter } from 'stepflow/storage';
194
+ *
195
+ * const storage = new PostgresStorageAdapter({
196
+ * connectionString: process.env.DATABASE_URL,
197
+ * });
198
+ *
199
+ * await storage.initialize();
200
+ *
201
+ * const engine = new WorkflowEngine({ storage });
202
+ * ```
203
+ *
204
+ * @example Sharing connection pool
205
+ * ```typescript
206
+ * import pg from 'pg';
207
+ * import { PostgresStorageAdapter } from 'stepflow/storage';
208
+ *
209
+ * // Application's existing pool
210
+ * const pool = new pg.Pool({
211
+ * connectionString: process.env.DATABASE_URL,
212
+ * max: 20,
213
+ * });
214
+ *
215
+ * // Share with Stepflow
216
+ * const storage = new PostgresStorageAdapter({ pool });
217
+ * ```
218
+ */
219
+ export class PostgresStorageAdapter implements StorageAdapter {
220
+ private db!: KyselyType<StepflowDatabase>;
221
+ private pool!: Pool;
222
+ private ownsPool = false;
223
+ private schema: string;
224
+ private autoMigrate: boolean;
225
+ private initialized = false;
226
+ private config: PostgresStorageConfig;
227
+
228
+ constructor(config: PostgresStorageConfig) {
229
+ this.schema = config.schema ?? "public";
230
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(this.schema)) {
231
+ throw new Error(
232
+ `Invalid schema name "${this.schema}". Schema must start with a letter or underscore, ` +
233
+ `contain only alphanumeric characters and underscores, and be at most 63 characters.`,
234
+ );
235
+ }
236
+ this.autoMigrate = config.autoMigrate !== false;
237
+ this.config = config;
238
+ }
239
+
240
+ /**
241
+ * Get a schema-scoped query builder.
242
+ * All queries MUST use this instead of this.db directly to respect config.schema.
243
+ */
244
+ private get qb() {
245
+ return this.db.withSchema(this.schema);
246
+ }
247
+
248
+ private ensureInitialized(): void {
249
+ if (!this.initialized) {
250
+ throw new Error(
251
+ "PostgresStorageAdapter is not initialized. Call initialize() before using the adapter.",
252
+ );
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Initialize the storage adapter.
258
+ * Creates tables if autoMigrate is enabled.
259
+ */
260
+ async initialize(): Promise<void> {
261
+ if (this.initialized) {
262
+ return;
263
+ }
264
+
265
+ const deps = await loadPostgresDeps();
266
+ Kysely = deps.Kysely;
267
+ PostgresDialect = deps.PostgresDialect;
268
+ sql = deps.sql;
269
+ pgModule = deps.pgModule;
270
+
271
+ if (this.config.pool) {
272
+ this.pool = this.config.pool;
273
+ this.ownsPool = false;
274
+ } else if (this.config.connectionString) {
275
+ this.pool = new pgModule.Pool({
276
+ connectionString: this.config.connectionString,
277
+ });
278
+ this.ownsPool = true;
279
+ } else if (this.config.poolConfig) {
280
+ this.pool = new pgModule.Pool(this.config.poolConfig);
281
+ this.ownsPool = true;
282
+ } else {
283
+ throw new Error(
284
+ "PostgresStorageConfig must include either pool, connectionString, or poolConfig",
285
+ );
286
+ }
287
+
288
+ this.db = new Kysely({
289
+ dialect: new PostgresDialect({
290
+ pool: this.pool,
291
+ }),
292
+ });
293
+
294
+ if (this.autoMigrate) {
295
+ await this.createTables();
296
+ }
297
+
298
+ this.initialized = true;
299
+ }
300
+
301
+ /**
302
+ * Close the database connection.
303
+ * Only closes the pool if it was created by this adapter.
304
+ */
305
+ async close(): Promise<void> {
306
+ await this.db.destroy();
307
+ // Kysely's destroy() already calls pool.end() via PostgresDialect,
308
+ // so we must NOT call pool.end() again.
309
+ }
310
+
311
+ // ============================================================================
312
+ // Schema Creation
313
+ // ============================================================================
314
+
315
+ /**
316
+ * Create the workflow tables if they don't exist.
317
+ */
318
+ private async createTables(): Promise<void> {
319
+ // Create schema if not public
320
+ if (this.schema !== "public") {
321
+ await sql`CREATE SCHEMA IF NOT EXISTS ${sql.ref(this.schema)}`.execute(
322
+ this.db,
323
+ );
324
+ }
325
+
326
+ // Create runs table with new columns
327
+ await sql`
328
+ CREATE TABLE IF NOT EXISTS ${sql.table(`${this.schema}.runs`)} (
329
+ id TEXT PRIMARY KEY,
330
+ kind TEXT NOT NULL,
331
+ status TEXT NOT NULL,
332
+ parent_run_id TEXT,
333
+ input_json JSONB NOT NULL DEFAULT '{}',
334
+ metadata_json JSONB NOT NULL DEFAULT '{}',
335
+ context_json JSONB NOT NULL DEFAULT '{}',
336
+ output_json JSONB,
337
+ error_json JSONB,
338
+ priority INTEGER NOT NULL DEFAULT 0,
339
+ timeout_ms INTEGER,
340
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
341
+ started_at TIMESTAMPTZ,
342
+ finished_at TIMESTAMPTZ,
343
+ CONSTRAINT runs_status_check CHECK (
344
+ status IN ('pending', 'queued', 'running', 'succeeded', 'failed', 'canceled', 'timeout')
345
+ )
346
+ )
347
+ `.execute(this.db);
348
+
349
+ // Add new columns if they don't exist (for migrations)
350
+ await sql`
351
+ ALTER TABLE ${sql.table(`${this.schema}.runs`)}
352
+ ADD COLUMN IF NOT EXISTS output_json JSONB,
353
+ ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0,
354
+ ADD COLUMN IF NOT EXISTS timeout_ms INTEGER
355
+ `
356
+ .execute(this.db)
357
+ .catch(() => {
358
+ // Ignore if columns already exist or syntax not supported
359
+ });
360
+
361
+ // Create indexes for runs
362
+ await sql`
363
+ CREATE INDEX IF NOT EXISTS idx_runs_kind_status
364
+ ON ${sql.table(`${this.schema}.runs`)} (kind, status)
365
+ `.execute(this.db);
366
+
367
+ await sql`
368
+ CREATE INDEX IF NOT EXISTS idx_runs_parent
369
+ ON ${sql.table(`${this.schema}.runs`)} (parent_run_id)
370
+ `.execute(this.db);
371
+
372
+ await sql`
373
+ CREATE INDEX IF NOT EXISTS idx_runs_created
374
+ ON ${sql.table(`${this.schema}.runs`)} (created_at DESC)
375
+ `.execute(this.db);
376
+
377
+ await sql`
378
+ CREATE INDEX IF NOT EXISTS idx_runs_status
379
+ ON ${sql.table(`${this.schema}.runs`)} (status)
380
+ `.execute(this.db);
381
+
382
+ await sql`
383
+ CREATE INDEX IF NOT EXISTS idx_runs_priority
384
+ ON ${sql.table(`${this.schema}.runs`)} (priority DESC, created_at ASC)
385
+ `.execute(this.db);
386
+
387
+ // Create workflow_run_steps table (legacy)
388
+ await sql`
389
+ CREATE TABLE IF NOT EXISTS ${sql.table(`${this.schema}.workflow_run_steps`)} (
390
+ id TEXT PRIMARY KEY,
391
+ run_id TEXT NOT NULL REFERENCES ${sql.table(`${this.schema}.runs`)} (id) ON DELETE CASCADE,
392
+ step_key TEXT NOT NULL,
393
+ step_name TEXT,
394
+ status TEXT NOT NULL,
395
+ attempt INTEGER NOT NULL DEFAULT 1,
396
+ result_json JSONB,
397
+ error_json JSONB,
398
+ started_at TIMESTAMPTZ,
399
+ finished_at TIMESTAMPTZ,
400
+ CONSTRAINT workflow_run_steps_status_check CHECK (
401
+ status IN ('pending', 'running', 'succeeded', 'failed', 'skipped', 'completed')
402
+ )
403
+ )
404
+ `.execute(this.db);
405
+
406
+ // Create indexes for workflow_run_steps
407
+ await sql`
408
+ CREATE INDEX IF NOT EXISTS idx_workflow_run_steps_run
409
+ ON ${sql.table(`${this.schema}.workflow_run_steps`)} (run_id)
410
+ `.execute(this.db);
411
+
412
+ await sql`
413
+ CREATE INDEX IF NOT EXISTS idx_workflow_run_steps_run_key
414
+ ON ${sql.table(`${this.schema}.workflow_run_steps`)} (run_id, step_key)
415
+ `.execute(this.db);
416
+
417
+ // Create stepflow_step_results table (new)
418
+ await sql`
419
+ CREATE TABLE IF NOT EXISTS ${sql.table(`${this.schema}.stepflow_step_results`)} (
420
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
421
+ run_id TEXT NOT NULL REFERENCES ${sql.table(`${this.schema}.runs`)} (id) ON DELETE CASCADE,
422
+ step_name TEXT NOT NULL,
423
+ status TEXT NOT NULL,
424
+ output_json JSONB,
425
+ error_json JSONB,
426
+ attempt INTEGER NOT NULL DEFAULT 1,
427
+ started_at TIMESTAMPTZ,
428
+ completed_at TIMESTAMPTZ,
429
+ CONSTRAINT stepflow_step_results_status_check CHECK (
430
+ status IN ('pending', 'running', 'completed', 'failed', 'skipped')
431
+ ),
432
+ UNIQUE (run_id, step_name)
433
+ )
434
+ `.execute(this.db);
435
+
436
+ // Create indexes for stepflow_step_results
437
+ await sql`
438
+ CREATE INDEX IF NOT EXISTS idx_stepflow_step_results_run
439
+ ON ${sql.table(`${this.schema}.stepflow_step_results`)} (run_id)
440
+ `.execute(this.db);
441
+
442
+ await sql`
443
+ CREATE INDEX IF NOT EXISTS idx_stepflow_step_results_run_name
444
+ ON ${sql.table(`${this.schema}.stepflow_step_results`)} (run_id, step_name)
445
+ `.execute(this.db);
446
+
447
+ // Create workflow_events table
448
+ await sql`
449
+ CREATE TABLE IF NOT EXISTS ${sql.table(`${this.schema}.workflow_events`)} (
450
+ id TEXT PRIMARY KEY,
451
+ run_id TEXT NOT NULL REFERENCES ${sql.table(`${this.schema}.runs`)} (id) ON DELETE CASCADE,
452
+ step_key TEXT,
453
+ event_type TEXT NOT NULL,
454
+ level TEXT NOT NULL,
455
+ payload_json JSONB,
456
+ timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
457
+ CONSTRAINT workflow_events_level_check CHECK (
458
+ level IN ('info', 'warn', 'error')
459
+ )
460
+ )
461
+ `.execute(this.db);
462
+
463
+ // Create indexes for workflow_events
464
+ await sql`
465
+ CREATE INDEX IF NOT EXISTS idx_workflow_events_run
466
+ ON ${sql.table(`${this.schema}.workflow_events`)} (run_id)
467
+ `.execute(this.db);
468
+
469
+ await sql`
470
+ CREATE INDEX IF NOT EXISTS idx_workflow_events_run_ts
471
+ ON ${sql.table(`${this.schema}.workflow_events`)} (run_id, timestamp)
472
+ `.execute(this.db);
473
+ }
474
+
475
+ // ============================================================================
476
+ // Run Operations
477
+ // ============================================================================
478
+
479
+ /**
480
+ * Create a new workflow run.
481
+ * Supports both legacy and new CreateRunInput interfaces.
482
+ */
483
+ async createRun(
484
+ run: CreateRunInput | Omit<WorkflowRunRecord, "id" | "createdAt">,
485
+ ): Promise<WorkflowRunRecord> {
486
+ this.ensureInitialized();
487
+ const id = "id" in run && run.id ? run.id : generateId();
488
+ const createdAt = new Date();
489
+
490
+ await this.qb
491
+ .insertInto("runs")
492
+ .values({
493
+ id,
494
+ kind: run.kind,
495
+ status: run.status,
496
+ parent_run_id: "parentRunId" in run ? (run.parentRunId ?? null) : null,
497
+ input_json: JSON.stringify(run.input),
498
+ metadata_json: JSON.stringify(run.metadata ?? {}),
499
+ context_json: JSON.stringify(run.context ?? {}), // Default to empty object
500
+ output_json: null,
501
+ error_json:
502
+ "error" in run && run.error
503
+ ? JSON.stringify(sanitizeErrorForStorage(run.error))
504
+ : null,
505
+ priority: "priority" in run ? (run.priority ?? 0) : 0,
506
+ timeout_ms: "timeoutMs" in run ? (run.timeoutMs ?? null) : null,
507
+ created_at: createdAt,
508
+ started_at: null,
509
+ finished_at: null,
510
+ })
511
+ .execute();
512
+
513
+ return {
514
+ id,
515
+ kind: run.kind,
516
+ status: run.status as RunStatus,
517
+ parentRunId: "parentRunId" in run ? run.parentRunId : undefined,
518
+ input: run.input,
519
+ context: run.context ?? {},
520
+ output: undefined,
521
+ error: undefined,
522
+ metadata: run.metadata ?? {},
523
+ priority: "priority" in run ? (run.priority ?? 0) : 0,
524
+ timeoutMs: "timeoutMs" in run ? run.timeoutMs : undefined,
525
+ createdAt,
526
+ };
527
+ }
528
+
529
+ async getRun(runId: string): Promise<WorkflowRunRecord | null> {
530
+ this.ensureInitialized();
531
+ const row = await this.qb
532
+ .selectFrom("runs")
533
+ .selectAll()
534
+ .where("id", "=", runId)
535
+ .executeTakeFirst();
536
+
537
+ return row ? this.mapRunRow(row) : null;
538
+ }
539
+
540
+ /**
541
+ * Update a workflow run.
542
+ * Supports both legacy Partial<WorkflowRunRecord> and new UpdateRunInput interfaces.
543
+ */
544
+ async updateRun(
545
+ runId: string,
546
+ updates: UpdateRunInput | Partial<WorkflowRunRecord>,
547
+ ): Promise<void> {
548
+ this.ensureInitialized();
549
+ const updateData: Partial<WorkflowRunsTable> = {};
550
+
551
+ if (updates.status !== undefined) {
552
+ updateData.status = updates.status;
553
+ }
554
+ if (updates.context !== undefined) {
555
+ updateData.context_json = JSON.stringify(updates.context);
556
+ }
557
+ if ("output" in updates && updates.output !== undefined) {
558
+ updateData.output_json = JSON.stringify(updates.output);
559
+ }
560
+ if (updates.error !== undefined) {
561
+ updateData.error_json = JSON.stringify(
562
+ sanitizeErrorForStorage(updates.error),
563
+ );
564
+ }
565
+ if (updates.startedAt !== undefined) {
566
+ updateData.started_at = updates.startedAt;
567
+ }
568
+ if (updates.finishedAt !== undefined) {
569
+ updateData.finished_at = updates.finishedAt;
570
+ }
571
+
572
+ if (Object.keys(updateData).length > 0) {
573
+ await this.qb
574
+ .updateTable("runs")
575
+ .set(updateData)
576
+ .where("id", "=", runId)
577
+ .execute();
578
+ }
579
+ }
580
+
581
+ /**
582
+ * List workflow runs with filtering and pagination.
583
+ */
584
+ /**
585
+ * Apply common run filters to a Kysely query builder.
586
+ * Used by both the data query and count query in listRuns to avoid duplication.
587
+ */
588
+ private applyRunsFilters<T extends { where(col: any, op: any, val: any): T }>(
589
+ query: T,
590
+ options: ListRunsOptions,
591
+ ): T {
592
+ if (options.kind) {
593
+ query = query.where("kind", "=", options.kind);
594
+ }
595
+ if (options.status) {
596
+ const statuses = Array.isArray(options.status)
597
+ ? options.status
598
+ : [options.status];
599
+ query = query.where("status", "in", statuses);
600
+ }
601
+ if (options.parentRunId !== undefined) {
602
+ query = query.where("parent_run_id", "=", options.parentRunId);
603
+ }
604
+ return query;
605
+ }
606
+
607
+ async listRuns(
608
+ options: ListRunsOptions = {},
609
+ ): Promise<PaginatedResult<WorkflowRunRecord>> {
610
+ this.ensureInitialized();
611
+ const limit = options.limit ?? 50;
612
+ const offset = options.offset ?? 0;
613
+
614
+ let query = this.applyRunsFilters(
615
+ this.qb.selectFrom("runs").selectAll(),
616
+ options,
617
+ );
618
+
619
+ const countQuery = this.applyRunsFilters(
620
+ this.qb.selectFrom("runs").select(sql<number>`count(*)`.as("count")),
621
+ options,
622
+ );
623
+
624
+ const countResult = (await countQuery.executeTakeFirst()) as
625
+ | { count?: string | number }
626
+ | undefined;
627
+ const total = Number(countResult?.count ?? 0);
628
+
629
+ // Order
630
+ const orderBy = options.orderBy ?? "createdAt";
631
+ const orderDirection = options.orderDirection ?? "desc";
632
+ const orderColumn =
633
+ orderBy === "createdAt"
634
+ ? "created_at"
635
+ : orderBy === "startedAt"
636
+ ? "started_at"
637
+ : "finished_at";
638
+
639
+ query = query.orderBy(orderColumn, orderDirection);
640
+
641
+ // Apply pagination
642
+ query = query.limit(limit).offset(offset);
643
+
644
+ const rows = await query.execute();
645
+
646
+ return {
647
+ items: rows.map((row) => this.mapRunRow(row)),
648
+ total,
649
+ };
650
+ }
651
+
652
+ // ============================================================================
653
+ // Step Operations
654
+ // ============================================================================
655
+
656
+ async createStep(
657
+ step: Omit<WorkflowRunStepRecord, "id">,
658
+ ): Promise<WorkflowRunStepRecord> {
659
+ this.ensureInitialized();
660
+ const id = generateId();
661
+
662
+ await this.qb
663
+ .insertInto("workflow_run_steps")
664
+ .values({
665
+ id,
666
+ run_id: step.runId,
667
+ step_key: step.stepKey,
668
+ step_name: step.stepName,
669
+ status: step.status,
670
+ attempt: step.attempt,
671
+ result_json:
672
+ step.result !== undefined ? JSON.stringify(step.result) : null,
673
+ error_json: step.error
674
+ ? JSON.stringify(sanitizeErrorForStorage(step.error))
675
+ : null,
676
+ started_at: step.startedAt ?? null,
677
+ finished_at: step.finishedAt ?? null,
678
+ })
679
+ .execute();
680
+
681
+ return { ...step, id };
682
+ }
683
+
684
+ async getStep(stepId: string): Promise<WorkflowRunStepRecord | null> {
685
+ this.ensureInitialized();
686
+ const row = await this.qb
687
+ .selectFrom("workflow_run_steps")
688
+ .selectAll()
689
+ .where("id", "=", stepId)
690
+ .executeTakeFirst();
691
+
692
+ return row ? this.mapStepRow(row) : null;
693
+ }
694
+
695
+ async updateStep(
696
+ stepId: string,
697
+ updates: Partial<WorkflowRunStepRecord>,
698
+ ): Promise<void> {
699
+ this.ensureInitialized();
700
+ const updateData: Partial<WorkflowRunStepsTable> = {};
701
+
702
+ if (updates.status !== undefined) {
703
+ updateData.status = updates.status;
704
+ }
705
+ if (updates.attempt !== undefined) {
706
+ updateData.attempt = updates.attempt;
707
+ }
708
+ if (updates.result !== undefined) {
709
+ updateData.result_json = JSON.stringify(updates.result);
710
+ }
711
+ if (updates.error !== undefined) {
712
+ updateData.error_json = JSON.stringify(
713
+ sanitizeErrorForStorage(updates.error),
714
+ );
715
+ }
716
+ if (updates.finishedAt !== undefined) {
717
+ updateData.finished_at = updates.finishedAt;
718
+ }
719
+
720
+ if (Object.keys(updateData).length > 0) {
721
+ await this.qb
722
+ .updateTable("workflow_run_steps")
723
+ .set(updateData)
724
+ .where("id", "=", stepId)
725
+ .execute();
726
+ }
727
+ }
728
+
729
+ async getStepsForRun(runId: string): Promise<WorkflowRunStepRecord[]> {
730
+ this.ensureInitialized();
731
+ const rows = await this.qb
732
+ .selectFrom("workflow_run_steps")
733
+ .selectAll()
734
+ .where("run_id", "=", runId)
735
+ .orderBy("started_at", "asc")
736
+ .execute();
737
+
738
+ return rows.map((row) => this.mapStepRow(row));
739
+ }
740
+
741
+ // ============================================================================
742
+ // Event Operations
743
+ // ============================================================================
744
+
745
+ async saveEvent(event: Omit<WorkflowEventRecord, "id">): Promise<void> {
746
+ this.ensureInitialized();
747
+ const id = generateId();
748
+
749
+ await this.qb
750
+ .insertInto("workflow_events")
751
+ .values({
752
+ id,
753
+ run_id: event.runId,
754
+ step_key: event.stepKey ?? null,
755
+ event_type: event.eventType,
756
+ level: event.level,
757
+ payload_json:
758
+ event.payload !== undefined ? JSON.stringify(event.payload) : null,
759
+ timestamp: event.timestamp,
760
+ })
761
+ .execute();
762
+ }
763
+
764
+ async getEventsForRun(
765
+ runId: string,
766
+ options: ListEventsOptions = {},
767
+ ): Promise<WorkflowEventRecord[]> {
768
+ this.ensureInitialized();
769
+ const limit = options.limit ?? 1000;
770
+ const offset = options.offset ?? 0;
771
+
772
+ let query = this.qb
773
+ .selectFrom("workflow_events")
774
+ .selectAll()
775
+ .where("run_id", "=", runId);
776
+
777
+ if (options.stepKey) {
778
+ query = query.where("step_key", "=", options.stepKey);
779
+ }
780
+
781
+ if (options.level) {
782
+ query = query.where("level", "=", options.level);
783
+ }
784
+
785
+ query = query.orderBy("timestamp", "asc").limit(limit).offset(offset);
786
+
787
+ const rows = await query.execute();
788
+
789
+ return rows.map((row) => this.mapEventRow(row));
790
+ }
791
+
792
+ // ============================================================================
793
+ // Transaction Support
794
+ // ============================================================================
795
+
796
+ /**
797
+ * Execute a function within a database transaction.
798
+ */
799
+ async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
800
+ this.ensureInitialized();
801
+ return await this.db.transaction().execute(async (trx) => {
802
+ // Create a transactional adapter wrapper
803
+ const txAdapter = new PostgresTransactionAdapter(trx, this.schema);
804
+ return await fn(txAdapter);
805
+ });
806
+ }
807
+
808
+ // ============================================================================
809
+ // Cleanup Operations
810
+ // ============================================================================
811
+
812
+ /**
813
+ * Delete runs older than the specified date.
814
+ * Also deletes associated steps and events (via CASCADE).
815
+ */
816
+ async deleteOldRuns(olderThan: Date): Promise<number> {
817
+ this.ensureInitialized();
818
+ const result = await this.qb
819
+ .deleteFrom("runs")
820
+ .where("created_at", "<", olderThan)
821
+ .executeTakeFirst();
822
+
823
+ return Number(result.numDeletedRows);
824
+ }
825
+
826
+ // ============================================================================
827
+ // Resume Support
828
+ // ============================================================================
829
+
830
+ /**
831
+ * Get all runs that were interrupted (status is 'running' or 'queued').
832
+ * These runs can potentially be resumed.
833
+ */
834
+ async getInterruptedRuns(): Promise<WorkflowRunRecord[]> {
835
+ this.ensureInitialized();
836
+ const rows = await this.qb
837
+ .selectFrom("runs")
838
+ .selectAll()
839
+ .where("status", "in", ["queued", "running"])
840
+ .orderBy("created_at", "asc")
841
+ .execute();
842
+
843
+ return rows.map((row) => this.mapRunRow(row));
844
+ }
845
+
846
+ /**
847
+ * Get the last completed step for a run.
848
+ * Useful for resuming from a checkpoint.
849
+ */
850
+ async getLastCompletedStep(
851
+ runId: string,
852
+ ): Promise<WorkflowRunStepRecord | null> {
853
+ this.ensureInitialized();
854
+ const row = await this.qb
855
+ .selectFrom("workflow_run_steps")
856
+ .selectAll()
857
+ .where("run_id", "=", runId)
858
+ .where("status", "=", "succeeded")
859
+ .orderBy("finished_at", "desc")
860
+ .limit(1)
861
+ .executeTakeFirst();
862
+
863
+ return row ? this.mapStepRow(row) : null;
864
+ }
865
+
866
+ // ============================================================================
867
+ // Atomic Dequeue (for distributed workers)
868
+ // ============================================================================
869
+
870
+ /**
871
+ * Atomically dequeue a run for processing.
872
+ * Uses FOR UPDATE SKIP LOCKED for safe concurrent access.
873
+ *
874
+ * @param workflowKinds - Optional list of workflow kinds to filter by
875
+ * @returns The dequeued run, or null if no runs are available
876
+ */
877
+ async dequeueRun(
878
+ workflowKinds?: string[],
879
+ ): Promise<WorkflowRunRecord | null> {
880
+ this.ensureInitialized();
881
+ // Use raw SQL for FOR UPDATE SKIP LOCKED
882
+ const kindFilter =
883
+ workflowKinds && workflowKinds.length > 0
884
+ ? sql`AND kind = ANY(${workflowKinds}::text[])`
885
+ : sql``;
886
+
887
+ const result = await sql<WorkflowRunsTable>`
888
+ UPDATE ${sql.table(`${this.schema}.runs`)}
889
+ SET status = 'running', started_at = NOW()
890
+ WHERE id = (
891
+ SELECT id FROM ${sql.table(`${this.schema}.runs`)}
892
+ WHERE status = 'queued' ${kindFilter}
893
+ ORDER BY priority DESC, created_at ASC
894
+ LIMIT 1
895
+ FOR UPDATE SKIP LOCKED
896
+ )
897
+ RETURNING *
898
+ `.execute(this.db);
899
+
900
+ const row = result.rows[0];
901
+ return row ? this.mapRunRow(row) : null;
902
+ }
903
+
904
+ // ============================================================================
905
+ // Row Mapping
906
+ // ============================================================================
907
+
908
+ private mapRunRow(row: WorkflowRunsTable): WorkflowRunRecord {
909
+ return {
910
+ id: row.id,
911
+ kind: row.kind,
912
+ status: row.status as RunStatus,
913
+ parentRunId: row.parent_run_id ?? undefined,
914
+ input:
915
+ typeof row.input_json === "string"
916
+ ? JSON.parse(row.input_json)
917
+ : row.input_json,
918
+ context:
919
+ typeof row.context_json === "string"
920
+ ? JSON.parse(row.context_json)
921
+ : row.context_json,
922
+ output: row.output_json
923
+ ? typeof row.output_json === "string"
924
+ ? JSON.parse(row.output_json)
925
+ : row.output_json
926
+ : undefined,
927
+ error: row.error_json
928
+ ? typeof row.error_json === "string"
929
+ ? JSON.parse(row.error_json)
930
+ : row.error_json
931
+ : undefined,
932
+ metadata:
933
+ typeof row.metadata_json === "string"
934
+ ? JSON.parse(row.metadata_json)
935
+ : row.metadata_json,
936
+ priority: row.priority ?? 0,
937
+ timeoutMs: row.timeout_ms ?? undefined,
938
+ createdAt: new Date(row.created_at),
939
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
940
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
941
+ };
942
+ }
943
+
944
+ /**
945
+ * Map a database row to an extended workflow run record.
946
+ */
947
+ private mapExtendedRunRow(row: WorkflowRunsTable): ExtendedWorkflowRunRecord {
948
+ return {
949
+ id: row.id,
950
+ kind: row.kind,
951
+ status: row.status as ExtendedRunStatus,
952
+ input:
953
+ typeof row.input_json === "string"
954
+ ? JSON.parse(row.input_json)
955
+ : row.input_json,
956
+ metadata:
957
+ typeof row.metadata_json === "string"
958
+ ? JSON.parse(row.metadata_json)
959
+ : row.metadata_json,
960
+ context:
961
+ typeof row.context_json === "string"
962
+ ? JSON.parse(row.context_json)
963
+ : row.context_json,
964
+ output: row.output_json
965
+ ? typeof row.output_json === "string"
966
+ ? JSON.parse(row.output_json)
967
+ : row.output_json
968
+ : undefined,
969
+ error: row.error_json
970
+ ? typeof row.error_json === "string"
971
+ ? JSON.parse(row.error_json)
972
+ : row.error_json
973
+ : undefined,
974
+ priority: row.priority ?? 0,
975
+ timeoutMs: row.timeout_ms ?? undefined,
976
+ createdAt: new Date(row.created_at),
977
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
978
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
979
+ };
980
+ }
981
+
982
+ private mapStepResultRow(row: StepflowStepResultsTable): StepResult {
983
+ return {
984
+ id: row.id,
985
+ runId: row.run_id,
986
+ stepName: row.step_name,
987
+ status: row.status as ExtendedStepStatus,
988
+ output: row.output_json
989
+ ? typeof row.output_json === "string"
990
+ ? JSON.parse(row.output_json)
991
+ : row.output_json
992
+ : undefined,
993
+ error: row.error_json
994
+ ? typeof row.error_json === "string"
995
+ ? JSON.parse(row.error_json)
996
+ : row.error_json
997
+ : undefined,
998
+ attempt: row.attempt,
999
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
1000
+ completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
1001
+ };
1002
+ }
1003
+
1004
+ private mapStepRow(row: WorkflowRunStepsTable): WorkflowRunStepRecord {
1005
+ return {
1006
+ id: row.id,
1007
+ runId: row.run_id,
1008
+ stepKey: row.step_key,
1009
+ stepName: row.step_name,
1010
+ status: row.status as StepStatus,
1011
+ attempt: row.attempt,
1012
+ result: row.result_json
1013
+ ? typeof row.result_json === "string"
1014
+ ? JSON.parse(row.result_json)
1015
+ : row.result_json
1016
+ : undefined,
1017
+ error: row.error_json
1018
+ ? typeof row.error_json === "string"
1019
+ ? JSON.parse(row.error_json)
1020
+ : row.error_json
1021
+ : undefined,
1022
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
1023
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
1024
+ };
1025
+ }
1026
+
1027
+ private mapEventRow(row: WorkflowEventsTable): WorkflowEventRecord {
1028
+ return {
1029
+ id: row.id,
1030
+ runId: row.run_id,
1031
+ stepKey: row.step_key ?? undefined,
1032
+ eventType: row.event_type,
1033
+ level: row.level as "info" | "warn" | "error",
1034
+ payload: row.payload_json
1035
+ ? typeof row.payload_json === "string"
1036
+ ? JSON.parse(row.payload_json)
1037
+ : row.payload_json
1038
+ : undefined,
1039
+ timestamp: new Date(row.timestamp),
1040
+ };
1041
+ }
1042
+
1043
+ // ============================================================================
1044
+ // New WorkflowStorage Methods
1045
+ // ============================================================================
1046
+
1047
+ /**
1048
+ * Delete a workflow run by ID.
1049
+ * Also deletes associated steps and events (via CASCADE).
1050
+ */
1051
+ async deleteRun(id: string): Promise<void> {
1052
+ this.ensureInitialized();
1053
+ await this.qb.deleteFrom("runs").where("id", "=", id).execute();
1054
+ }
1055
+
1056
+ /**
1057
+ * Cleanup stale runs that have exceeded their timeout.
1058
+ * Marks them as 'timeout' status with an appropriate error.
1059
+ *
1060
+ * @param defaultTimeoutMs - Default timeout in ms for runs without explicit timeout (default: 600000 = 10 minutes)
1061
+ * @returns Number of runs marked as timed out
1062
+ */
1063
+ async cleanupStaleRuns(defaultTimeoutMs: number = 600000): Promise<number> {
1064
+ this.ensureInitialized();
1065
+ const result = await sql<{ id: string }>`
1066
+ UPDATE ${sql.table(`${this.schema}.runs`)}
1067
+ SET
1068
+ status = 'timeout',
1069
+ error_json = jsonb_build_object(
1070
+ 'code', 'WORKFLOW_TIMEOUT',
1071
+ 'message', 'Workflow exceeded maximum execution time and was marked as timed out'
1072
+ ),
1073
+ finished_at = NOW()
1074
+ WHERE status = 'running'
1075
+ AND started_at IS NOT NULL
1076
+ AND (
1077
+ (timeout_ms IS NOT NULL AND started_at < NOW() - (timeout_ms || ' milliseconds')::interval)
1078
+ OR
1079
+ (timeout_ms IS NULL AND started_at < NOW() - (${defaultTimeoutMs} || ' milliseconds')::interval)
1080
+ )
1081
+ RETURNING id
1082
+ `.execute(this.db);
1083
+
1084
+ return result.rows.length;
1085
+ }
1086
+
1087
+ /**
1088
+ * Mark multiple runs as failed with a given reason.
1089
+ * Useful for cleanup when a worker shuts down unexpectedly.
1090
+ *
1091
+ * @param runIds - Array of run IDs to mark as failed
1092
+ * @param reason - Reason message for the failure
1093
+ */
1094
+ async markRunsAsFailed(runIds: string[], reason: string): Promise<void> {
1095
+ this.ensureInitialized();
1096
+ if (runIds.length === 0) return;
1097
+
1098
+ await sql`
1099
+ UPDATE ${sql.table(`${this.schema}.runs`)}
1100
+ SET
1101
+ status = 'failed',
1102
+ error_json = jsonb_build_object(
1103
+ 'code', 'WORKER_SHUTDOWN',
1104
+ 'message', ${reason}
1105
+ ),
1106
+ finished_at = NOW()
1107
+ WHERE id = ANY(${runIds})
1108
+ AND status = 'running'
1109
+ `.execute(this.db);
1110
+ }
1111
+
1112
+ /**
1113
+ * Get a specific step result by run ID and step name.
1114
+ */
1115
+ async getStepResult(
1116
+ runId: string,
1117
+ stepName: string,
1118
+ ): Promise<StepResult | undefined> {
1119
+ this.ensureInitialized();
1120
+ const row = await this.qb
1121
+ .selectFrom("stepflow_step_results")
1122
+ .selectAll()
1123
+ .where("run_id", "=", runId)
1124
+ .where("step_name", "=", stepName)
1125
+ .executeTakeFirst();
1126
+
1127
+ return row ? this.mapStepResultRow(row) : undefined;
1128
+ }
1129
+
1130
+ /**
1131
+ * Get all step results for a run.
1132
+ */
1133
+ async getStepResults(runId: string): Promise<StepResult[]> {
1134
+ this.ensureInitialized();
1135
+ const rows = await this.qb
1136
+ .selectFrom("stepflow_step_results")
1137
+ .selectAll()
1138
+ .where("run_id", "=", runId)
1139
+ .orderBy("started_at", "asc")
1140
+ .execute();
1141
+
1142
+ return rows.map((row) => this.mapStepResultRow(row));
1143
+ }
1144
+
1145
+ /**
1146
+ * Save or update a step result.
1147
+ * Uses upsert to handle both new and existing results.
1148
+ */
1149
+ async saveStepResult(
1150
+ result: Omit<StepResult, "id"> & { id?: string },
1151
+ ): Promise<void> {
1152
+ this.ensureInitialized();
1153
+ const id = result.id ?? generateId();
1154
+
1155
+ await this.qb
1156
+ .insertInto("stepflow_step_results")
1157
+ .values({
1158
+ id,
1159
+ run_id: result.runId,
1160
+ step_name: result.stepName,
1161
+ status: result.status,
1162
+ output_json: result.output ? JSON.stringify(result.output) : null,
1163
+ error_json: result.error
1164
+ ? JSON.stringify(stripStack(result.error))
1165
+ : null,
1166
+ attempt: result.attempt,
1167
+ started_at: result.startedAt ?? null,
1168
+ completed_at: result.completedAt ?? null,
1169
+ })
1170
+ .onConflict((oc) =>
1171
+ oc.columns(["run_id", "step_name"]).doUpdateSet({
1172
+ status: result.status,
1173
+ output_json: result.output ? JSON.stringify(result.output) : null,
1174
+ error_json: result.error
1175
+ ? JSON.stringify(stripStack(result.error))
1176
+ : null,
1177
+ attempt: result.attempt,
1178
+ started_at: result.startedAt ?? null,
1179
+ completed_at: result.completedAt ?? null,
1180
+ }),
1181
+ )
1182
+ .execute();
1183
+ }
1184
+
1185
+ // ============================================================================
1186
+ // Utility Methods
1187
+ // ============================================================================
1188
+
1189
+ /**
1190
+ * Get database statistics.
1191
+ */
1192
+ async getStats(): Promise<{ runs: number; steps: number; events: number }> {
1193
+ this.ensureInitialized();
1194
+ const [runsCount, stepsCount, eventsCount] = (await Promise.all([
1195
+ this.qb
1196
+ .selectFrom("runs")
1197
+ .select(sql`count(*)`.as("count"))
1198
+ .executeTakeFirst(),
1199
+ this.qb
1200
+ .selectFrom("workflow_run_steps")
1201
+ .select(sql`count(*)`.as("count"))
1202
+ .executeTakeFirst(),
1203
+ this.qb
1204
+ .selectFrom("workflow_events")
1205
+ .select(sql`count(*)`.as("count"))
1206
+ .executeTakeFirst(),
1207
+ ])) as { count?: string | number }[];
1208
+
1209
+ return {
1210
+ runs: Number(runsCount?.count ?? 0),
1211
+ steps: Number(stepsCount?.count ?? 0),
1212
+ events: Number(eventsCount?.count ?? 0),
1213
+ };
1214
+ }
1215
+ }
1216
+
1217
+ // ============================================================================
1218
+ // Transaction Adapter (wraps Kysely transaction)
1219
+ // ============================================================================
1220
+
1221
+ /**
1222
+ * A StorageAdapter wrapper for use within transactions.
1223
+ * This is used internally by PostgresStorageAdapter.transaction().
1224
+ */
1225
+ class PostgresTransactionAdapter implements StorageAdapter {
1226
+ private qb: ReturnType<KyselyType<StepflowDatabase>["withSchema"]>;
1227
+
1228
+ constructor(
1229
+ private trx: KyselyType<StepflowDatabase>,
1230
+ private schema: string,
1231
+ ) {
1232
+ this.qb = trx.withSchema(schema);
1233
+ }
1234
+
1235
+ async createRun(
1236
+ run: CreateRunInput | Omit<WorkflowRunRecord, "id" | "createdAt">,
1237
+ ): Promise<WorkflowRunRecord> {
1238
+ const id = "id" in run && run.id ? run.id : generateId();
1239
+ const createdAt = new Date();
1240
+
1241
+ await this.qb
1242
+ .insertInto("runs")
1243
+ .values({
1244
+ id,
1245
+ kind: run.kind,
1246
+ status: run.status,
1247
+ parent_run_id: "parentRunId" in run ? (run.parentRunId ?? null) : null,
1248
+ input_json: JSON.stringify(run.input),
1249
+ metadata_json: JSON.stringify(run.metadata ?? {}),
1250
+ context_json: JSON.stringify(run.context ?? {}),
1251
+ output_json: null,
1252
+ error_json:
1253
+ "error" in run && run.error
1254
+ ? JSON.stringify(sanitizeErrorForStorage(run.error))
1255
+ : null,
1256
+ priority: "priority" in run ? (run.priority ?? 0) : 0,
1257
+ timeout_ms: "timeoutMs" in run ? (run.timeoutMs ?? null) : null,
1258
+ created_at: createdAt,
1259
+ started_at: null,
1260
+ finished_at: null,
1261
+ })
1262
+ .execute();
1263
+
1264
+ return {
1265
+ id,
1266
+ kind: run.kind,
1267
+ status: run.status as RunStatus,
1268
+ parentRunId: "parentRunId" in run ? run.parentRunId : undefined,
1269
+ input: run.input,
1270
+ context: run.context ?? {},
1271
+ output: undefined,
1272
+ error: undefined,
1273
+ metadata: run.metadata ?? {},
1274
+ priority: "priority" in run ? (run.priority ?? 0) : 0,
1275
+ timeoutMs: "timeoutMs" in run ? run.timeoutMs : undefined,
1276
+ createdAt,
1277
+ };
1278
+ }
1279
+
1280
+ async getRun(runId: string): Promise<WorkflowRunRecord | null> {
1281
+ const row = await this.qb
1282
+ .selectFrom("runs")
1283
+ .selectAll()
1284
+ .where("id", "=", runId)
1285
+ .executeTakeFirst();
1286
+
1287
+ return row ? this.mapRunRow(row) : null;
1288
+ }
1289
+
1290
+ async updateRun(
1291
+ runId: string,
1292
+ updates: UpdateRunInput | Partial<WorkflowRunRecord>,
1293
+ ): Promise<void> {
1294
+ const updateData: Partial<WorkflowRunsTable> = {};
1295
+
1296
+ if (updates.status !== undefined) updateData.status = updates.status;
1297
+ if (updates.context !== undefined)
1298
+ updateData.context_json = JSON.stringify(updates.context);
1299
+ if ("output" in updates && updates.output !== undefined)
1300
+ updateData.output_json = JSON.stringify(updates.output);
1301
+ if (updates.error !== undefined)
1302
+ updateData.error_json = JSON.stringify(
1303
+ sanitizeErrorForStorage(updates.error as WorkflowError),
1304
+ );
1305
+ if (updates.startedAt !== undefined)
1306
+ updateData.started_at = updates.startedAt;
1307
+ if (updates.finishedAt !== undefined)
1308
+ updateData.finished_at = updates.finishedAt;
1309
+
1310
+ if (Object.keys(updateData).length > 0) {
1311
+ await this.qb
1312
+ .updateTable("runs")
1313
+ .set(updateData)
1314
+ .where("id", "=", runId)
1315
+ .execute();
1316
+ }
1317
+ }
1318
+
1319
+ async listRuns(
1320
+ options: ListRunsOptions = {},
1321
+ ): Promise<PaginatedResult<WorkflowRunRecord>> {
1322
+ const limit = options.limit ?? 50;
1323
+ const offset = options.offset ?? 0;
1324
+
1325
+ let query = this.qb.selectFrom("runs").selectAll();
1326
+
1327
+ if (options.kind) query = query.where("kind", "=", options.kind);
1328
+ if (options.status) {
1329
+ const statuses = Array.isArray(options.status)
1330
+ ? options.status
1331
+ : [options.status];
1332
+ query = query.where("status", "in", statuses);
1333
+ }
1334
+ if (options.parentRunId !== undefined) {
1335
+ query = query.where("parent_run_id", "=", options.parentRunId);
1336
+ }
1337
+
1338
+ const orderColumn =
1339
+ (options.orderBy ?? "createdAt") === "createdAt"
1340
+ ? "created_at"
1341
+ : options.orderBy === "startedAt"
1342
+ ? "started_at"
1343
+ : "finished_at";
1344
+ const orderDirection = options.orderDirection ?? "desc";
1345
+
1346
+ query = query
1347
+ .orderBy(orderColumn, orderDirection)
1348
+ .limit(limit)
1349
+ .offset(offset);
1350
+
1351
+ const rows = await query.execute();
1352
+
1353
+ return {
1354
+ items: rows.map((row) => this.mapRunRow(row)),
1355
+ total: rows.length, // Simplified for transaction context
1356
+ };
1357
+ }
1358
+
1359
+ async createStep(
1360
+ step: Omit<WorkflowRunStepRecord, "id">,
1361
+ ): Promise<WorkflowRunStepRecord> {
1362
+ const id = generateId();
1363
+
1364
+ await this.qb
1365
+ .insertInto("workflow_run_steps")
1366
+ .values({
1367
+ id,
1368
+ run_id: step.runId,
1369
+ step_key: step.stepKey,
1370
+ step_name: step.stepName,
1371
+ status: step.status,
1372
+ attempt: step.attempt,
1373
+ result_json:
1374
+ step.result !== undefined ? JSON.stringify(step.result) : null,
1375
+ error_json: step.error
1376
+ ? JSON.stringify(sanitizeErrorForStorage(step.error))
1377
+ : null,
1378
+ started_at: step.startedAt ?? null,
1379
+ finished_at: step.finishedAt ?? null,
1380
+ })
1381
+ .execute();
1382
+
1383
+ return { ...step, id };
1384
+ }
1385
+
1386
+ async getStep(stepId: string): Promise<WorkflowRunStepRecord | null> {
1387
+ const row = await this.qb
1388
+ .selectFrom("workflow_run_steps")
1389
+ .selectAll()
1390
+ .where("id", "=", stepId)
1391
+ .executeTakeFirst();
1392
+
1393
+ return row ? this.mapStepRow(row) : null;
1394
+ }
1395
+
1396
+ async updateStep(
1397
+ stepId: string,
1398
+ updates: Partial<WorkflowRunStepRecord>,
1399
+ ): Promise<void> {
1400
+ const updateData: Partial<WorkflowRunStepsTable> = {};
1401
+
1402
+ if (updates.status !== undefined) updateData.status = updates.status;
1403
+ if (updates.attempt !== undefined) updateData.attempt = updates.attempt;
1404
+ if (updates.result !== undefined)
1405
+ updateData.result_json = JSON.stringify(updates.result);
1406
+ if (updates.error !== undefined)
1407
+ updateData.error_json = JSON.stringify(
1408
+ sanitizeErrorForStorage(updates.error as WorkflowError),
1409
+ );
1410
+ if (updates.finishedAt !== undefined)
1411
+ updateData.finished_at = updates.finishedAt;
1412
+
1413
+ if (Object.keys(updateData).length > 0) {
1414
+ await this.qb
1415
+ .updateTable("workflow_run_steps")
1416
+ .set(updateData)
1417
+ .where("id", "=", stepId)
1418
+ .execute();
1419
+ }
1420
+ }
1421
+
1422
+ async getStepsForRun(runId: string): Promise<WorkflowRunStepRecord[]> {
1423
+ const rows = await this.qb
1424
+ .selectFrom("workflow_run_steps")
1425
+ .selectAll()
1426
+ .where("run_id", "=", runId)
1427
+ .orderBy("started_at", "asc")
1428
+ .execute();
1429
+
1430
+ return rows.map((row) => this.mapStepRow(row));
1431
+ }
1432
+
1433
+ async saveEvent(event: Omit<WorkflowEventRecord, "id">): Promise<void> {
1434
+ const id = generateId();
1435
+
1436
+ await this.qb
1437
+ .insertInto("workflow_events")
1438
+ .values({
1439
+ id,
1440
+ run_id: event.runId,
1441
+ step_key: event.stepKey ?? null,
1442
+ event_type: event.eventType,
1443
+ level: event.level,
1444
+ payload_json:
1445
+ event.payload !== undefined ? JSON.stringify(event.payload) : null,
1446
+ timestamp: event.timestamp,
1447
+ })
1448
+ .execute();
1449
+ }
1450
+
1451
+ async getEventsForRun(
1452
+ runId: string,
1453
+ options: ListEventsOptions = {},
1454
+ ): Promise<WorkflowEventRecord[]> {
1455
+ let query = this.qb
1456
+ .selectFrom("workflow_events")
1457
+ .selectAll()
1458
+ .where("run_id", "=", runId);
1459
+
1460
+ if (options.stepKey) query = query.where("step_key", "=", options.stepKey);
1461
+ if (options.level) query = query.where("level", "=", options.level);
1462
+
1463
+ const rows = await query
1464
+ .orderBy("timestamp", "asc")
1465
+ .limit(options.limit ?? 1000)
1466
+ .offset(options.offset ?? 0)
1467
+ .execute();
1468
+
1469
+ return rows.map((row) => this.mapEventRow(row));
1470
+ }
1471
+
1472
+ private mapRunRow(row: WorkflowRunsTable): WorkflowRunRecord {
1473
+ return {
1474
+ id: row.id,
1475
+ kind: row.kind,
1476
+ status: row.status as RunStatus,
1477
+ parentRunId: row.parent_run_id ?? undefined,
1478
+ input:
1479
+ typeof row.input_json === "string"
1480
+ ? JSON.parse(row.input_json)
1481
+ : row.input_json,
1482
+ context:
1483
+ typeof row.context_json === "string"
1484
+ ? JSON.parse(row.context_json)
1485
+ : row.context_json,
1486
+ output: row.output_json
1487
+ ? typeof row.output_json === "string"
1488
+ ? JSON.parse(row.output_json)
1489
+ : row.output_json
1490
+ : undefined,
1491
+ error: row.error_json
1492
+ ? typeof row.error_json === "string"
1493
+ ? JSON.parse(row.error_json)
1494
+ : row.error_json
1495
+ : undefined,
1496
+ metadata:
1497
+ typeof row.metadata_json === "string"
1498
+ ? JSON.parse(row.metadata_json)
1499
+ : row.metadata_json,
1500
+ priority: row.priority ?? 0,
1501
+ timeoutMs: row.timeout_ms ?? undefined,
1502
+ createdAt: new Date(row.created_at),
1503
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
1504
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
1505
+ };
1506
+ }
1507
+
1508
+ private mapStepRow(row: WorkflowRunStepsTable): WorkflowRunStepRecord {
1509
+ return {
1510
+ id: row.id,
1511
+ runId: row.run_id,
1512
+ stepKey: row.step_key,
1513
+ stepName: row.step_name,
1514
+ status: row.status as StepStatus,
1515
+ attempt: row.attempt,
1516
+ result: row.result_json
1517
+ ? typeof row.result_json === "string"
1518
+ ? JSON.parse(row.result_json)
1519
+ : row.result_json
1520
+ : undefined,
1521
+ error: row.error_json
1522
+ ? typeof row.error_json === "string"
1523
+ ? JSON.parse(row.error_json)
1524
+ : row.error_json
1525
+ : undefined,
1526
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
1527
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
1528
+ };
1529
+ }
1530
+
1531
+ private mapEventRow(row: WorkflowEventsTable): WorkflowEventRecord {
1532
+ return {
1533
+ id: row.id,
1534
+ runId: row.run_id,
1535
+ stepKey: row.step_key ?? undefined,
1536
+ eventType: row.event_type,
1537
+ level: row.level as "info" | "warn" | "error",
1538
+ payload: row.payload_json
1539
+ ? typeof row.payload_json === "string"
1540
+ ? JSON.parse(row.payload_json)
1541
+ : row.payload_json
1542
+ : undefined,
1543
+ timestamp: new Date(row.timestamp),
1544
+ };
1545
+ }
1546
+ }
1547
+
1548
+ // Convenience aliases for shorter naming
1549
+ export { PostgresStorageAdapter as PostgresStorage };
1550
+ export type { PostgresStorageConfig as PostgresStorageOptions };