@multiplier-labs/stepflow 0.3.1 → 0.3.4

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 (112) hide show
  1. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
  2. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
  3. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
  4. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
  5. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  6. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
  7. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  8. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  9. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
  10. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  11. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
  12. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  13. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  14. package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
  15. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  16. package/.github/CODEOWNERS +15 -0
  17. package/.github/dependabot.yml +23 -0
  18. package/.github/workflows/ci.yml +38 -0
  19. package/.github/workflows/publish.yml +17 -5
  20. package/.github/workflows/security.yml +29 -0
  21. package/CHANGELOG.md +34 -0
  22. package/README.md +24 -0
  23. package/SECURITY.md +40 -0
  24. package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
  25. package/dist/chunk-5XJBAPXF.js.map +1 -0
  26. package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
  27. package/dist/chunk-FMPJBK2N.js.map +1 -0
  28. package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
  29. package/dist/chunk-JPXPB7U5.js.map +1 -0
  30. package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
  31. package/dist/chunk-U3PCVK6O.js.map +1 -0
  32. package/dist/events/index.d.ts +60 -7
  33. package/dist/events/index.js +1 -1
  34. package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
  35. package/dist/index.d.ts +15 -23
  36. package/dist/index.js +784 -547
  37. package/dist/index.js.map +1 -1
  38. package/dist/scheduler/index.d.ts +4 -4
  39. package/dist/scheduler/index.js +2 -2
  40. package/dist/storage/index.d.ts +322 -56
  41. package/dist/storage/index.js +4 -2
  42. package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
  43. package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
  44. package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
  45. package/docs/stepflow-api-reference.md +5 -4
  46. package/package.json +12 -8
  47. package/src/core/engine/concurrency.ts +229 -0
  48. package/src/core/engine/lifecycle.ts +176 -0
  49. package/src/core/engine.test.ts +536 -522
  50. package/src/core/engine.ts +116 -235
  51. package/src/core/index.ts +3 -3
  52. package/src/core/orchestrator/persistence.ts +165 -0
  53. package/src/core/orchestrator/plan-resolution.ts +140 -0
  54. package/src/core/orchestrator/state-transitions.ts +109 -0
  55. package/src/core/orchestrator/step-runner.ts +427 -0
  56. package/src/core/orchestrator.ts +57 -395
  57. package/src/core/types.ts +7 -29
  58. package/src/events/index.ts +4 -4
  59. package/src/events/memory.test.ts +39 -41
  60. package/src/events/memory.ts +7 -10
  61. package/src/events/socketio.test.ts +112 -162
  62. package/src/events/socketio.ts +20 -27
  63. package/src/events/types.ts +16 -19
  64. package/src/events/webhook.test.ts +489 -235
  65. package/src/events/webhook.ts +355 -139
  66. package/src/index.ts +19 -19
  67. package/src/planning/evaluate-condition.test.ts +153 -0
  68. package/src/planning/evaluate-condition.ts +105 -0
  69. package/src/planning/index.ts +6 -3
  70. package/src/planning/planner.ts +45 -152
  71. package/src/planning/planning.test.ts +303 -366
  72. package/src/planning/registry.test.ts +204 -251
  73. package/src/planning/registry.ts +20 -106
  74. package/src/planning/types.ts +23 -27
  75. package/src/scheduler/cron.test.ts +149 -150
  76. package/src/scheduler/cron.ts +58 -69
  77. package/src/scheduler/index.ts +4 -14
  78. package/src/scheduler/postgres-persistence.ts +89 -105
  79. package/src/scheduler/sqlite-persistence.test.ts +96 -96
  80. package/src/scheduler/sqlite-persistence.ts +40 -22
  81. package/src/scheduler/types.ts +4 -9
  82. package/src/storage/index.ts +5 -5
  83. package/src/storage/memory.test.ts +133 -183
  84. package/src/storage/memory.ts +21 -42
  85. package/src/storage/postgres-core.ts +550 -0
  86. package/src/storage/postgres-migration.test.ts +69 -0
  87. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  88. package/src/storage/postgres.ts +222 -937
  89. package/src/storage/sqlite-core.ts +151 -0
  90. package/src/storage/sqlite.test.ts +330 -281
  91. package/src/storage/sqlite.ts +225 -170
  92. package/src/storage/types.ts +33 -58
  93. package/src/utils/errors.test.ts +78 -80
  94. package/src/utils/errors.ts +27 -57
  95. package/src/utils/id.ts +3 -3
  96. package/src/utils/index.ts +6 -4
  97. package/src/utils/logger.test.ts +52 -58
  98. package/src/utils/logger.ts +4 -4
  99. package/src/utils/pinned-fetch.test.ts +84 -0
  100. package/src/utils/pinned-fetch.ts +118 -0
  101. package/src/utils/postgres-deps.ts +20 -4
  102. package/src/utils/retry.test.ts +47 -67
  103. package/src/utils/retry.ts +9 -9
  104. package/src/utils/safe-json.test.ts +166 -0
  105. package/src/utils/safe-json.ts +107 -0
  106. package/src/utils/webhook-signature.test.ts +111 -0
  107. package/src/utils/webhook-signature.ts +74 -0
  108. package/dist/chunk-FQCT3I2A.js.map +0 -1
  109. package/dist/chunk-GXY66V7Q.js.map +0 -1
  110. package/dist/chunk-U5QGN3RT.js.map +0 -1
  111. package/dist/chunk-XCF4PLN7.js.map +0 -1
  112. package/src/scheduler/postgres-persistence.test.ts +0 -906
@@ -1,98 +1,75 @@
1
1
  /**
2
2
  * PostgreSQL storage adapter using Kysely.
3
3
  *
4
- * Provides durable persistence for workflow runs, steps, and events
5
- * with support for distributed deployments and connection pooling.
4
+ * Provides durable persistence for workflow runs, steps, and events with
5
+ * support for distributed deployments and connection pooling.
6
+ *
7
+ * ## Module layout
8
+ *
9
+ * - `PostgresStorageAdapter` (this file) owns the `pg.Pool` / Kysely
10
+ * instance, runs schema migrations on `initialize()`, and implements all
11
+ * non-CRUD methods: connection lifecycle (`close`, `transaction`),
12
+ * workflow-level operations (`dequeueRun`, `cleanupStaleRuns`,
13
+ * `markRunsAsFailed`, `getStats`, `deleteRun`, `deleteOldRuns`,
14
+ * `getInterruptedRuns`, `getLastCompletedStep`), and the
15
+ * `stepflow_step_results` accessors.
16
+ *
17
+ * - `PostgresTransactionAdapter` (this file) is a thin shell used by
18
+ * `PostgresStorageAdapter.transaction()`. It binds a transaction-scoped
19
+ * Kysely query builder; everything else comes from the shared core.
20
+ * Exported only for regression tests.
21
+ *
22
+ * - `PostgresStorageCore` (./postgres-core.ts) holds the shared CRUD
23
+ * methods (`createRun`, `getRun`, `updateRun`, `listRuns`, `createStep`,
24
+ * `getStep`, `updateStep`, `getStepsForRun`, `saveEvent`,
25
+ * `getEventsForRun`), the row mappers, and the JSON-parse helpers. Both
26
+ * classes here extend it.
6
27
  */
7
28
 
8
- import type { Kysely as KyselyType } from "kysely";
9
- import type { Pool, PoolConfig } from "pg";
10
- import { loadPostgresDeps } from "../utils/postgres-deps.js";
29
+ import type { Kysely as KyselyType } from 'kysely';
30
+ import type { Pool, PoolConfig } from 'pg';
31
+ import type { Logger } from '../core/types.js';
32
+ import { loadPostgresDeps } from '../utils/postgres-deps.js';
11
33
 
12
34
  // Lazy-loaded dependencies - populated by loadPostgresDeps() during initialize()
13
35
  let Kysely: any;
14
36
  let PostgresDialect: any;
15
37
  let sql: any;
16
38
  let pgModule: any;
17
- import { generateId } from "../utils/id.js";
39
+ import { generateId } from '../utils/id.js';
18
40
  import type {
19
41
  StorageAdapter,
20
42
  WorkflowRunRecord,
21
43
  WorkflowRunStepRecord,
22
- WorkflowEventRecord,
23
- ListRunsOptions,
24
- ListEventsOptions,
25
- PaginatedResult,
26
- CreateRunInput,
27
- UpdateRunInput,
28
44
  StepResult,
29
- ExtendedWorkflowRunRecord,
30
- ExtendedRunStatus,
31
45
  ExtendedStepStatus,
32
- } from "./types.js";
33
- import type { RunStatus, StepStatus, WorkflowError } from "../core/types.js";
34
- import { sanitizeErrorForStorage } from "../utils/logger.js";
46
+ } from './types.js';
47
+ import {
48
+ PostgresStorageCore,
49
+ mapRunRow,
50
+ mapStepRow,
51
+ safeParseOptionalField,
52
+ type CoreSchemaQueryBuilder,
53
+ type PostgresCoreDatabase,
54
+ type SchemaQueryBuilder,
55
+ type WorkflowRunsTable,
56
+ type WorkflowRunStepsTable,
57
+ } from './postgres-core.js';
35
58
 
36
59
  /** Strip `stack` from a generic error record before persistence. */
37
- function stripStack<T extends Record<string, unknown>>(
38
- error: T,
39
- ): Omit<T, "stack"> {
60
+ function stripStack<T extends Record<string, unknown>>(error: T): Omit<T, 'stack'> {
40
61
  const { stack: _stack, ...rest } = error;
41
- return rest as Omit<T, "stack">;
62
+ return rest as Omit<T, 'stack'>;
42
63
  }
43
64
 
44
65
  // ============================================================================
45
66
  // Database Types (Kysely schema)
46
67
  // ============================================================================
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
- }
68
+ //
69
+ // The `runs`, `workflow_run_steps`, and `workflow_events` table shapes live
70
+ // in `./postgres-core.ts` so the shared CRUD core can target them without
71
+ // re-declaration. The full `StepflowDatabase` schema below adds the
72
+ // adapter-only `stepflow_step_results` table (extended storage).
96
73
 
97
74
  /** Kysely row type for the stepflow_step_results table (extended storage). */
98
75
  interface StepflowStepResultsTable {
@@ -113,26 +90,7 @@ interface StepflowStepResultsTable {
113
90
  completed_at: Date | null;
114
91
  }
115
92
 
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;
93
+ interface StepflowDatabase extends PostgresCoreDatabase {
136
94
  stepflow_step_results: StepflowStepResultsTable;
137
95
  }
138
96
 
@@ -172,6 +130,13 @@ export interface PostgresStorageConfig {
172
130
  * @default true
173
131
  */
174
132
  autoMigrate?: boolean;
133
+
134
+ /**
135
+ * Optional structured logger used to surface JSON corruption events.
136
+ * Without one, parse failures still increment the corruption counter
137
+ * exposed via `getJsonParseCorruptionCount()` but produce no log output.
138
+ */
139
+ logger?: Logger;
175
140
  }
176
141
 
177
142
  // ============================================================================
@@ -216,39 +181,59 @@ export interface PostgresStorageConfig {
216
181
  * const storage = new PostgresStorageAdapter({ pool });
217
182
  * ```
218
183
  */
219
- export class PostgresStorageAdapter implements StorageAdapter {
184
+ export class PostgresStorageAdapter extends PostgresStorageCore {
220
185
  private db!: KyselyType<StepflowDatabase>;
221
186
  private pool!: Pool;
222
187
  private ownsPool = false;
223
- private schema: string;
224
188
  private autoMigrate: boolean;
225
189
  private initialized = false;
226
190
  private config: PostgresStorageConfig;
227
191
 
228
192
  constructor(config: PostgresStorageConfig) {
229
- this.schema = config.schema ?? "public";
230
- if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(this.schema)) {
193
+ const schema = config.schema ?? 'public';
194
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(schema)) {
231
195
  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.`,
196
+ `Invalid schema name "${schema}". Schema must start with a letter or underscore, ` +
197
+ `contain only alphanumeric characters and underscores, and be at most 63 characters.`
234
198
  );
235
199
  }
200
+ super(schema, config.logger);
236
201
  this.autoMigrate = config.autoMigrate !== false;
237
202
  this.config = config;
238
203
  }
239
204
 
240
205
  /**
241
- * Get a schema-scoped query builder.
242
- * All queries MUST use this instead of this.db directly to respect config.schema.
206
+ * Core-tables query builder consumed by `PostgresStorageCore`. Returns the
207
+ * schema-scoped Kysely view typed against the three core tables, which is
208
+ * what the shared CRUD methods need.
243
209
  */
244
- private get qb() {
210
+ protected override get qb(): CoreSchemaQueryBuilder {
211
+ return this.db.withSchema(this.schema) as unknown as CoreSchemaQueryBuilder;
212
+ }
213
+
214
+ /**
215
+ * Extended query builder for adapter-only tables (currently
216
+ * `stepflow_step_results`). Kept separate from `qb` so the base class stays
217
+ * typed against the core schema.
218
+ */
219
+ private get extQb(): SchemaQueryBuilder<StepflowDatabase> {
245
220
  return this.db.withSchema(this.schema);
246
221
  }
247
222
 
223
+ /** Tag used by shared row mappers in `console.warn` messages. */
224
+ protected override get warnLabel(): string {
225
+ return 'PostgresStorageAdapter';
226
+ }
227
+
228
+ /** Override of `PostgresStorageCore.ensureReady()` — enforces `initialize()`. */
229
+ protected override ensureReady(): void {
230
+ this.ensureInitialized();
231
+ }
232
+
248
233
  private ensureInitialized(): void {
249
234
  if (!this.initialized) {
250
235
  throw new Error(
251
- "PostgresStorageAdapter is not initialized. Call initialize() before using the adapter.",
236
+ 'PostgresStorageAdapter is not initialized. Call initialize() before using the adapter.'
252
237
  );
253
238
  }
254
239
  }
@@ -272,16 +257,14 @@ export class PostgresStorageAdapter implements StorageAdapter {
272
257
  this.pool = this.config.pool;
273
258
  this.ownsPool = false;
274
259
  } else if (this.config.connectionString) {
275
- this.pool = new pgModule.Pool({
276
- connectionString: this.config.connectionString,
277
- });
260
+ this.pool = new pgModule.Pool({ connectionString: this.config.connectionString });
278
261
  this.ownsPool = true;
279
262
  } else if (this.config.poolConfig) {
280
263
  this.pool = new pgModule.Pool(this.config.poolConfig);
281
264
  this.ownsPool = true;
282
265
  } else {
283
266
  throw new Error(
284
- "PostgresStorageConfig must include either pool, connectionString, or poolConfig",
267
+ 'PostgresStorageConfig must include either pool, connectionString, or poolConfig'
285
268
  );
286
269
  }
287
270
 
@@ -312,15 +295,54 @@ export class PostgresStorageAdapter implements StorageAdapter {
312
295
  // Schema Creation
313
296
  // ============================================================================
314
297
 
298
+ /**
299
+ * Run a single migration step. Tolerates only known-benign Postgres errors
300
+ * (duplicate object codes that map to "already applied"), and rethrows
301
+ * everything else with a clear message so partial-state upgrades are not
302
+ * silently swallowed.
303
+ */
304
+ protected async runMigration(label: string, fn: () => Promise<unknown>): Promise<void> {
305
+ try {
306
+ await fn();
307
+ } catch (err) {
308
+ if (PostgresStorageAdapter.isBenignMigrationError(err)) {
309
+ return;
310
+ }
311
+ const message = err instanceof Error ? err.message : String(err);
312
+ throw new Error(`Schema migration failed (${label}): ${message}`, { cause: err });
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Postgres SQLSTATE codes that indicate a migration step has already been
318
+ * applied (object already exists). These are safe to ignore on idempotent
319
+ * re-runs; everything else must propagate.
320
+ *
321
+ * - 42701: duplicate_column
322
+ * - 42P07: duplicate_table
323
+ * - 42P06: duplicate_schema
324
+ * - 42710: duplicate_object (e.g., index, constraint)
325
+ */
326
+ private static readonly BENIGN_MIGRATION_CODES = new Set([
327
+ '42701',
328
+ '42P07',
329
+ '42P06',
330
+ '42710',
331
+ ]);
332
+
333
+ private static isBenignMigrationError(err: unknown): boolean {
334
+ if (!err || typeof err !== 'object') return false;
335
+ const code = (err as { code?: unknown }).code;
336
+ return typeof code === 'string' && PostgresStorageAdapter.BENIGN_MIGRATION_CODES.has(code);
337
+ }
338
+
315
339
  /**
316
340
  * Create the workflow tables if they don't exist.
317
341
  */
318
342
  private async createTables(): Promise<void> {
319
343
  // 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
- );
344
+ if (this.schema !== 'public') {
345
+ await sql`CREATE SCHEMA IF NOT EXISTS ${sql.ref(this.schema)}`.execute(this.db);
324
346
  }
325
347
 
326
348
  // Create runs table with new columns
@@ -347,16 +369,15 @@ export class PostgresStorageAdapter implements StorageAdapter {
347
369
  `.execute(this.db);
348
370
 
349
371
  // 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
- });
372
+ await this.runMigration(
373
+ `add columns to ${this.schema}.runs`,
374
+ () => sql`
375
+ ALTER TABLE ${sql.table(`${this.schema}.runs`)}
376
+ ADD COLUMN IF NOT EXISTS output_json JSONB,
377
+ ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0,
378
+ ADD COLUMN IF NOT EXISTS timeout_ms INTEGER
379
+ `.execute(this.db)
380
+ );
360
381
 
361
382
  // Create indexes for runs
362
383
  await sql`
@@ -473,321 +494,14 @@ export class PostgresStorageAdapter implements StorageAdapter {
473
494
  }
474
495
 
475
496
  // ============================================================================
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
497
+ // Run / Step / Event CRUD
743
498
  // ============================================================================
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
- }
499
+ //
500
+ // The CRUD methods (`createRun`, `getRun`, `updateRun`, `listRuns`,
501
+ // `createStep`, `getStep`, `updateStep`, `getStepsForRun`, `saveEvent`,
502
+ // `getEventsForRun`) are inherited from `PostgresStorageCore`. Each call
503
+ // routes through `this.qb` (overridden above) and `this.ensureReady()`
504
+ // which calls `this.ensureInitialized()`.
791
505
 
792
506
  // ============================================================================
793
507
  // Transaction Support
@@ -800,7 +514,7 @@ export class PostgresStorageAdapter implements StorageAdapter {
800
514
  this.ensureInitialized();
801
515
  return await this.db.transaction().execute(async (trx) => {
802
516
  // Create a transactional adapter wrapper
803
- const txAdapter = new PostgresTransactionAdapter(trx, this.schema);
517
+ const txAdapter = new PostgresTransactionAdapter(trx, this.schema, this.logger);
804
518
  return await fn(txAdapter);
805
519
  });
806
520
  }
@@ -816,8 +530,8 @@ export class PostgresStorageAdapter implements StorageAdapter {
816
530
  async deleteOldRuns(olderThan: Date): Promise<number> {
817
531
  this.ensureInitialized();
818
532
  const result = await this.qb
819
- .deleteFrom("runs")
820
- .where("created_at", "<", olderThan)
533
+ .deleteFrom('runs')
534
+ .where('created_at', '<', olderThan)
821
535
  .executeTakeFirst();
822
536
 
823
537
  return Number(result.numDeletedRows);
@@ -834,33 +548,31 @@ export class PostgresStorageAdapter implements StorageAdapter {
834
548
  async getInterruptedRuns(): Promise<WorkflowRunRecord[]> {
835
549
  this.ensureInitialized();
836
550
  const rows = await this.qb
837
- .selectFrom("runs")
551
+ .selectFrom('runs')
838
552
  .selectAll()
839
- .where("status", "in", ["queued", "running"])
840
- .orderBy("created_at", "asc")
553
+ .where('status', 'in', ['queued', 'running'])
554
+ .orderBy('created_at', 'asc')
841
555
  .execute();
842
556
 
843
- return rows.map((row) => this.mapRunRow(row));
557
+ return rows.map(row => mapRunRow(row as WorkflowRunsTable, this.mapperCtx));
844
558
  }
845
559
 
846
560
  /**
847
561
  * Get the last completed step for a run.
848
562
  * Useful for resuming from a checkpoint.
849
563
  */
850
- async getLastCompletedStep(
851
- runId: string,
852
- ): Promise<WorkflowRunStepRecord | null> {
564
+ async getLastCompletedStep(runId: string): Promise<WorkflowRunStepRecord | null> {
853
565
  this.ensureInitialized();
854
566
  const row = await this.qb
855
- .selectFrom("workflow_run_steps")
567
+ .selectFrom('workflow_run_steps')
856
568
  .selectAll()
857
- .where("run_id", "=", runId)
858
- .where("status", "=", "succeeded")
859
- .orderBy("finished_at", "desc")
569
+ .where('run_id', '=', runId)
570
+ .where('status', '=', 'succeeded')
571
+ .orderBy('finished_at', 'desc')
860
572
  .limit(1)
861
573
  .executeTakeFirst();
862
574
 
863
- return row ? this.mapStepRow(row) : null;
575
+ return row ? mapStepRow(row as WorkflowRunStepsTable, this.mapperCtx) : null;
864
576
  }
865
577
 
866
578
  // ============================================================================
@@ -874,15 +586,12 @@ export class PostgresStorageAdapter implements StorageAdapter {
874
586
  * @param workflowKinds - Optional list of workflow kinds to filter by
875
587
  * @returns The dequeued run, or null if no runs are available
876
588
  */
877
- async dequeueRun(
878
- workflowKinds?: string[],
879
- ): Promise<WorkflowRunRecord | null> {
589
+ async dequeueRun(workflowKinds?: string[]): Promise<WorkflowRunRecord | null> {
880
590
  this.ensureInitialized();
881
591
  // 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``;
592
+ const kindFilter = workflowKinds && workflowKinds.length > 0
593
+ ? sql`AND kind = ANY(${workflowKinds}::text[])`
594
+ : sql``;
886
595
 
887
596
  const result = await sql<WorkflowRunsTable>`
888
597
  UPDATE ${sql.table(`${this.schema}.runs`)}
@@ -898,148 +607,40 @@ export class PostgresStorageAdapter implements StorageAdapter {
898
607
  `.execute(this.db);
899
608
 
900
609
  const row = result.rows[0];
901
- return row ? this.mapRunRow(row) : null;
610
+ return row ? mapRunRow(row, this.mapperCtx) : null;
902
611
  }
903
612
 
904
613
  // ============================================================================
905
- // Row Mapping
614
+ // Row Mapping (adapter-only tables)
906
615
  // ============================================================================
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
- }
616
+ //
617
+ // Mappers and JSON-parse helpers for the three core tables live in
618
+ // `./postgres-core.ts`. The single mapper kept here covers
619
+ // `stepflow_step_results`, the only table outside the core schema.
981
620
 
982
621
  private mapStepResultRow(row: StepflowStepResultsTable): StepResult {
622
+ const ctx = this.mapperCtx;
983
623
  return {
984
624
  id: row.id,
985
625
  runId: row.run_id,
986
626
  stepName: row.step_name,
987
627
  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,
628
+ output: safeParseOptionalField(row.output_json, {
629
+ ...ctx,
630
+ rowId: row.id,
631
+ column: 'output_json',
632
+ }) as Record<string, unknown> | undefined,
633
+ error: safeParseOptionalField(row.error_json, {
634
+ ...ctx,
635
+ rowId: row.id,
636
+ column: 'error_json',
637
+ }) as Record<string, unknown> | undefined,
998
638
  attempt: row.attempt,
999
639
  startedAt: row.started_at ? new Date(row.started_at) : undefined,
1000
640
  completedAt: row.completed_at ? new Date(row.completed_at) : undefined,
1001
641
  };
1002
642
  }
1003
643
 
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
644
  // ============================================================================
1044
645
  // New WorkflowStorage Methods
1045
646
  // ============================================================================
@@ -1050,7 +651,10 @@ export class PostgresStorageAdapter implements StorageAdapter {
1050
651
  */
1051
652
  async deleteRun(id: string): Promise<void> {
1052
653
  this.ensureInitialized();
1053
- await this.qb.deleteFrom("runs").where("id", "=", id).execute();
654
+ await this.qb
655
+ .deleteFrom('runs')
656
+ .where('id', '=', id)
657
+ .execute();
1054
658
  }
1055
659
 
1056
660
  /**
@@ -1112,16 +716,13 @@ export class PostgresStorageAdapter implements StorageAdapter {
1112
716
  /**
1113
717
  * Get a specific step result by run ID and step name.
1114
718
  */
1115
- async getStepResult(
1116
- runId: string,
1117
- stepName: string,
1118
- ): Promise<StepResult | undefined> {
719
+ async getStepResult(runId: string, stepName: string): Promise<StepResult | undefined> {
1119
720
  this.ensureInitialized();
1120
- const row = await this.qb
1121
- .selectFrom("stepflow_step_results")
721
+ const row = await this.extQb
722
+ .selectFrom('stepflow_step_results')
1122
723
  .selectAll()
1123
- .where("run_id", "=", runId)
1124
- .where("step_name", "=", stepName)
724
+ .where('run_id', '=', runId)
725
+ .where('step_name', '=', stepName)
1125
726
  .executeTakeFirst();
1126
727
 
1127
728
  return row ? this.mapStepResultRow(row) : undefined;
@@ -1132,52 +733,46 @@ export class PostgresStorageAdapter implements StorageAdapter {
1132
733
  */
1133
734
  async getStepResults(runId: string): Promise<StepResult[]> {
1134
735
  this.ensureInitialized();
1135
- const rows = await this.qb
1136
- .selectFrom("stepflow_step_results")
736
+ const rows = await this.extQb
737
+ .selectFrom('stepflow_step_results')
1137
738
  .selectAll()
1138
- .where("run_id", "=", runId)
1139
- .orderBy("started_at", "asc")
739
+ .where('run_id', '=', runId)
740
+ .orderBy('started_at', 'asc')
1140
741
  .execute();
1141
742
 
1142
- return rows.map((row) => this.mapStepResultRow(row));
743
+ return rows.map(row => this.mapStepResultRow(row));
1143
744
  }
1144
745
 
1145
746
  /**
1146
747
  * Save or update a step result.
1147
748
  * Uses upsert to handle both new and existing results.
1148
749
  */
1149
- async saveStepResult(
1150
- result: Omit<StepResult, "id"> & { id?: string },
1151
- ): Promise<void> {
750
+ async saveStepResult(result: Omit<StepResult, 'id'> & { id?: string }): Promise<void> {
1152
751
  this.ensureInitialized();
1153
752
  const id = result.id ?? generateId();
1154
753
 
1155
- await this.qb
1156
- .insertInto("stepflow_step_results")
754
+ await this.extQb
755
+ .insertInto('stepflow_step_results')
1157
756
  .values({
1158
757
  id,
1159
758
  run_id: result.runId,
1160
759
  step_name: result.stepName,
1161
760
  status: result.status,
1162
761
  output_json: result.output ? JSON.stringify(result.output) : null,
1163
- error_json: result.error
1164
- ? JSON.stringify(stripStack(result.error))
1165
- : null,
762
+ error_json: result.error ? JSON.stringify(stripStack(result.error)) : null,
1166
763
  attempt: result.attempt,
1167
764
  started_at: result.startedAt ?? null,
1168
765
  completed_at: result.completedAt ?? null,
1169
766
  })
1170
- .onConflict((oc) =>
1171
- oc.columns(["run_id", "step_name"]).doUpdateSet({
767
+ .onConflict(oc =>
768
+ oc.columns(['run_id', 'step_name']).doUpdateSet({
1172
769
  status: result.status,
1173
770
  output_json: result.output ? JSON.stringify(result.output) : null,
1174
- error_json: result.error
1175
- ? JSON.stringify(stripStack(result.error))
1176
- : null,
771
+ error_json: result.error ? JSON.stringify(stripStack(result.error)) : null,
1177
772
  attempt: result.attempt,
1178
773
  started_at: result.startedAt ?? null,
1179
774
  completed_at: result.completedAt ?? null,
1180
- }),
775
+ })
1181
776
  )
1182
777
  .execute();
1183
778
  }
@@ -1191,20 +786,11 @@ export class PostgresStorageAdapter implements StorageAdapter {
1191
786
  */
1192
787
  async getStats(): Promise<{ runs: number; steps: number; events: number }> {
1193
788
  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 }[];
789
+ const [runsCount, stepsCount, eventsCount] = await Promise.all([
790
+ this.qb.selectFrom('runs').select(sql`count(*)`.as('count')).executeTakeFirst(),
791
+ this.qb.selectFrom('workflow_run_steps').select(sql`count(*)`.as('count')).executeTakeFirst(),
792
+ this.qb.selectFrom('workflow_events').select(sql`count(*)`.as('count')).executeTakeFirst(),
793
+ ]) as { count?: string | number }[];
1208
794
 
1209
795
  return {
1210
796
  runs: Number(runsCount?.count ?? 0),
@@ -1220,328 +806,27 @@ export class PostgresStorageAdapter implements StorageAdapter {
1220
806
 
1221
807
  /**
1222
808
  * A StorageAdapter wrapper for use within transactions.
1223
- * This is used internally by PostgresStorageAdapter.transaction().
809
+ *
810
+ * Used internally by `PostgresStorageAdapter.transaction()`. All CRUD methods
811
+ * are inherited from `PostgresStorageCore`; this class only binds the
812
+ * transaction-scoped query builder.
813
+ *
814
+ * Exported for regression testing; not part of the public API.
1224
815
  */
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();
816
+ export class PostgresTransactionAdapter extends PostgresStorageCore {
817
+ private readonly _qb: CoreSchemaQueryBuilder;
1382
818
 
1383
- return { ...step, id };
819
+ constructor(trx: KyselyType<StepflowDatabase>, schema: string, logger?: Logger) {
820
+ super(schema, logger);
821
+ this._qb = trx.withSchema(schema) as unknown as CoreSchemaQueryBuilder;
1384
822
  }
1385
823
 
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;
824
+ protected override get qb(): CoreSchemaQueryBuilder {
825
+ return this._qb;
1394
826
  }
1395
827
 
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
- };
828
+ protected override get warnLabel(): string {
829
+ return 'PostgresTransactionAdapter';
1545
830
  }
1546
831
  }
1547
832