@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
@@ -6,7 +6,7 @@
6
6
  * all state in-process with no persistence or multi-process safety guarantees.
7
7
  */
8
8
 
9
- import { generateId } from "../utils/id";
9
+ import { generateId } from '../utils/id';
10
10
  import type {
11
11
  StorageAdapter,
12
12
  WorkflowRunRecord,
@@ -15,7 +15,7 @@ import type {
15
15
  ListRunsOptions,
16
16
  ListEventsOptions,
17
17
  PaginatedResult,
18
- } from "./types";
18
+ } from './types';
19
19
 
20
20
  /**
21
21
  * In-memory implementation of StorageAdapter.
@@ -40,9 +40,7 @@ export class MemoryStorageAdapter implements StorageAdapter {
40
40
  // ============================================================================
41
41
 
42
42
  /** Create and persist a new workflow run record. */
43
- async createRun(
44
- run: Omit<WorkflowRunRecord, "id" | "createdAt">,
45
- ): Promise<WorkflowRunRecord> {
43
+ async createRun(run: Omit<WorkflowRunRecord, 'id' | 'createdAt'>): Promise<WorkflowRunRecord> {
46
44
  const record: WorkflowRunRecord = {
47
45
  ...run,
48
46
  id: generateId(),
@@ -58,10 +56,7 @@ export class MemoryStorageAdapter implements StorageAdapter {
58
56
  }
59
57
 
60
58
  /** Apply partial updates to an existing workflow run. No-op if the run does not exist. */
61
- async updateRun(
62
- runId: string,
63
- updates: Partial<WorkflowRunRecord>,
64
- ): Promise<void> {
59
+ async updateRun(runId: string, updates: Partial<WorkflowRunRecord>): Promise<void> {
65
60
  const run = this.runs.get(runId);
66
61
  if (run) {
67
62
  Object.assign(run, updates);
@@ -69,36 +64,32 @@ export class MemoryStorageAdapter implements StorageAdapter {
69
64
  }
70
65
 
71
66
  /** List workflow runs with optional filtering, sorting, and pagination. */
72
- async listRuns(
73
- options: ListRunsOptions = {},
74
- ): Promise<PaginatedResult<WorkflowRunRecord>> {
67
+ async listRuns(options: ListRunsOptions = {}): Promise<PaginatedResult<WorkflowRunRecord>> {
75
68
  let items = Array.from(this.runs.values());
76
69
 
77
70
  // Filter by kind
78
71
  if (options.kind) {
79
- items = items.filter((r) => r.kind === options.kind);
72
+ items = items.filter(r => r.kind === options.kind);
80
73
  }
81
74
 
82
75
  // Filter by status
83
76
  if (options.status) {
84
- const statuses = Array.isArray(options.status)
85
- ? options.status
86
- : [options.status];
87
- items = items.filter((r) => statuses.includes(r.status));
77
+ const statuses = Array.isArray(options.status) ? options.status : [options.status];
78
+ items = items.filter(r => statuses.includes(r.status));
88
79
  }
89
80
 
90
81
  // Filter by parentRunId
91
82
  if (options.parentRunId !== undefined) {
92
- items = items.filter((r) => r.parentRunId === options.parentRunId);
83
+ items = items.filter(r => r.parentRunId === options.parentRunId);
93
84
  }
94
85
 
95
86
  // Sort
96
- const orderBy = options.orderBy ?? "createdAt";
97
- const direction = options.orderDirection ?? "desc";
87
+ const orderBy = options.orderBy ?? 'createdAt';
88
+ const direction = options.orderDirection ?? 'desc';
98
89
  items.sort((a, b) => {
99
90
  const aVal = a[orderBy]?.getTime() ?? 0;
100
91
  const bVal = b[orderBy]?.getTime() ?? 0;
101
- return direction === "asc" ? aVal - bVal : bVal - aVal;
92
+ return direction === 'asc' ? aVal - bVal : bVal - aVal;
102
93
  });
103
94
 
104
95
  const total = items.length;
@@ -115,9 +106,7 @@ export class MemoryStorageAdapter implements StorageAdapter {
115
106
  // ============================================================================
116
107
 
117
108
  /** Create and persist a new step execution record. */
118
- async createStep(
119
- step: Omit<WorkflowRunStepRecord, "id">,
120
- ): Promise<WorkflowRunStepRecord> {
109
+ async createStep(step: Omit<WorkflowRunStepRecord, 'id'>): Promise<WorkflowRunStepRecord> {
121
110
  const record: WorkflowRunStepRecord = {
122
111
  ...step,
123
112
  id: generateId(),
@@ -132,10 +121,7 @@ export class MemoryStorageAdapter implements StorageAdapter {
132
121
  }
133
122
 
134
123
  /** Apply partial updates to an existing step record. No-op if the step does not exist. */
135
- async updateStep(
136
- stepId: string,
137
- updates: Partial<WorkflowRunStepRecord>,
138
- ): Promise<void> {
124
+ async updateStep(stepId: string, updates: Partial<WorkflowRunStepRecord>): Promise<void> {
139
125
  const step = this.steps.get(stepId);
140
126
  if (step) {
141
127
  Object.assign(step, updates);
@@ -145,10 +131,8 @@ export class MemoryStorageAdapter implements StorageAdapter {
145
131
  /** Retrieve all step records for a workflow run, ordered by start time ascending. */
146
132
  async getStepsForRun(runId: string): Promise<WorkflowRunStepRecord[]> {
147
133
  return Array.from(this.steps.values())
148
- .filter((s) => s.runId === runId)
149
- .sort(
150
- (a, b) => (a.startedAt?.getTime() ?? 0) - (b.startedAt?.getTime() ?? 0),
151
- );
134
+ .filter(s => s.runId === runId)
135
+ .sort((a, b) => (a.startedAt?.getTime() ?? 0) - (b.startedAt?.getTime() ?? 0));
152
136
  }
153
137
 
154
138
  // ============================================================================
@@ -156,7 +140,7 @@ export class MemoryStorageAdapter implements StorageAdapter {
156
140
  // ============================================================================
157
141
 
158
142
  /** Persist a workflow event record. */
159
- async saveEvent(event: Omit<WorkflowEventRecord, "id">): Promise<void> {
143
+ async saveEvent(event: Omit<WorkflowEventRecord, 'id'>): Promise<void> {
160
144
  const record: WorkflowEventRecord = {
161
145
  ...event,
162
146
  id: generateId(),
@@ -165,22 +149,17 @@ export class MemoryStorageAdapter implements StorageAdapter {
165
149
  }
166
150
 
167
151
  /** Retrieve events for a workflow run with optional filtering and pagination. */
168
- async getEventsForRun(
169
- runId: string,
170
- options: ListEventsOptions = {},
171
- ): Promise<WorkflowEventRecord[]> {
172
- let items = Array.from(this.events.values()).filter(
173
- (e) => e.runId === runId,
174
- );
152
+ async getEventsForRun(runId: string, options: ListEventsOptions = {}): Promise<WorkflowEventRecord[]> {
153
+ let items = Array.from(this.events.values()).filter(e => e.runId === runId);
175
154
 
176
155
  // Filter by step
177
156
  if (options.stepKey) {
178
- items = items.filter((e) => e.stepKey === options.stepKey);
157
+ items = items.filter(e => e.stepKey === options.stepKey);
179
158
  }
180
159
 
181
160
  // Filter by level
182
161
  if (options.level) {
183
- items = items.filter((e) => e.level === options.level);
162
+ items = items.filter(e => e.level === options.level);
184
163
  }
185
164
 
186
165
  // Sort by timestamp
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Shared core for the PostgreSQL storage adapters.
3
+ *
4
+ * `PostgresStorageAdapter` (the public, pool-owning adapter) and
5
+ * `PostgresTransactionAdapter` (the in-transaction wrapper used by
6
+ * `PostgresStorageAdapter.transaction()`) both implement the same CRUD subset
7
+ * of `StorageAdapter`. Before this refactor they each carried their own copy
8
+ * of every CRUD method, every row mapper, and every JSON-parse helper. The
9
+ * only meaningful difference between them is which Kysely query builder they
10
+ * target — an instance-bound one (`db.withSchema(schema)`) versus a
11
+ * transaction-bound one (`trx.withSchema(schema)`).
12
+ *
13
+ * This module collapses that duplication:
14
+ *
15
+ * - Pure helpers (`safeJsonParse`, `safeParseField`, `safeParseOptionalField`,
16
+ * row mappers, `applyRunsFilters`) live as standalone functions so callers
17
+ * can use them directly without inheritance.
18
+ *
19
+ * - `PostgresStorageCore` is an abstract base class that implements the
20
+ * CRUD subset of `StorageAdapter` against an abstract `qb` accessor and
21
+ * an `ensureReady()` hook. Subclasses supply the schema-scoped query
22
+ * builder (and an optional pre-call guard, e.g.
23
+ * `PostgresStorageAdapter.ensureInitialized()`).
24
+ *
25
+ * No behavior change: every query and every JSON-parse path matches the
26
+ * previous in-line implementations. The C2 transaction-pagination fix
27
+ * (same-connection COUNT(*)) is preserved by the shared `listRuns`
28
+ * implementation. The C1 migration error-propagation fix lives on
29
+ * `PostgresStorageAdapter` and is unaffected by this module.
30
+ */
31
+
32
+ import type { Kysely as KyselyType } from 'kysely';
33
+ import { generateId } from '../utils/id.js';
34
+ import { requirePostgresDeps } from '../utils/postgres-deps.js';
35
+ import { sanitizeErrorForStorage } from '../utils/logger.js';
36
+ import {
37
+ safeParseField as sharedSafeParseField,
38
+ safeParseOptionalField as sharedSafeParseOptionalField,
39
+ type MapperContext,
40
+ type SafeJsonParseContext,
41
+ } from '../utils/safe-json.js';
42
+ import type {
43
+ StorageAdapter,
44
+ WorkflowRunRecord,
45
+ WorkflowRunStepRecord,
46
+ WorkflowEventRecord,
47
+ ListRunsOptions,
48
+ ListEventsOptions,
49
+ PaginatedResult,
50
+ CreateRunInput,
51
+ UpdateRunInput,
52
+ } from './types.js';
53
+ import type { Logger, RunStatus, StepStatus, WorkflowError } from '../core/types.js';
54
+
55
+ /**
56
+ * Re-export the shared `MapperContext` from `src/utils/safe-json.ts` so
57
+ * existing imports of `MapperContext` from this module continue to work.
58
+ */
59
+ export type { MapperContext };
60
+
61
+ // ============================================================================
62
+ // Database row types (re-declared here so postgres-core.ts is self-contained;
63
+ // they mirror the table definitions in postgres.ts)
64
+ // ============================================================================
65
+
66
+ /** Kysely row type for the workflow_runs table. */
67
+ export interface WorkflowRunsTable {
68
+ id: string;
69
+ kind: string;
70
+ status: string;
71
+ parent_run_id: string | null;
72
+ input_json: string;
73
+ metadata_json: string;
74
+ context_json: string;
75
+ output_json: string | null;
76
+ error_json: string | null;
77
+ priority: number;
78
+ timeout_ms: number | null;
79
+ created_at: Date;
80
+ started_at: Date | null;
81
+ finished_at: Date | null;
82
+ }
83
+
84
+ /** Kysely row type for the workflow_run_steps table. */
85
+ export interface WorkflowRunStepsTable {
86
+ id: string;
87
+ run_id: string;
88
+ step_key: string;
89
+ step_name: string;
90
+ status: string;
91
+ attempt: number;
92
+ result_json: string | null;
93
+ error_json: string | null;
94
+ started_at: Date | null;
95
+ finished_at: Date | null;
96
+ }
97
+
98
+ /** Kysely row type for the workflow_events table. */
99
+ export interface WorkflowEventsTable {
100
+ id: string;
101
+ run_id: string;
102
+ step_key: string | null;
103
+ event_type: string;
104
+ level: string;
105
+ payload_json: string | null;
106
+ timestamp: Date;
107
+ }
108
+
109
+ /** Combined Kysely schema covering the tables touched by the shared core. */
110
+ export interface PostgresCoreDatabase {
111
+ runs: WorkflowRunsTable;
112
+ workflow_run_steps: WorkflowRunStepsTable;
113
+ workflow_events: WorkflowEventsTable;
114
+ }
115
+
116
+ /** Schema-scoped query builder produced by `db.withSchema(schemaName)`. */
117
+ export type SchemaQueryBuilder<DB extends PostgresCoreDatabase = PostgresCoreDatabase> =
118
+ ReturnType<KyselyType<DB>['withSchema']>;
119
+
120
+ /** Concrete core-only schema query builder used inside the shared base class. */
121
+ export type CoreSchemaQueryBuilder = SchemaQueryBuilder<PostgresCoreDatabase>;
122
+
123
+ // ============================================================================
124
+ // JSON-parse helpers (pure)
125
+ // ============================================================================
126
+ //
127
+ // Storage adapters supply a `MapperContext` ({ component, logger }); each
128
+ // helper extends it with the row id and column being parsed so a structured
129
+ // log emitted on corruption identifies the bad row. The raw value is never
130
+ // logged — see `src/utils/safe-json.ts`.
131
+
132
+ /** Parse a column value if it's still a JSON string; pass through otherwise. */
133
+ export function safeParseField(
134
+ value: unknown,
135
+ fallback: unknown,
136
+ ctx: SafeJsonParseContext,
137
+ ): unknown {
138
+ return sharedSafeParseField(value, fallback, ctx);
139
+ }
140
+
141
+ /** Same as `safeParseField` but returns `undefined` for falsy/missing values. */
142
+ export function safeParseOptionalField(
143
+ value: unknown,
144
+ ctx: SafeJsonParseContext,
145
+ ): unknown {
146
+ return sharedSafeParseOptionalField(value, ctx);
147
+ }
148
+
149
+ // ============================================================================
150
+ // Row mappers (pure)
151
+ // ============================================================================
152
+
153
+ /** Map a `runs` row to the public `WorkflowRunRecord` shape. */
154
+ export function mapRunRow(row: WorkflowRunsTable, ctx: MapperContext): WorkflowRunRecord {
155
+ const fieldCtx = (column: string): SafeJsonParseContext => ({ ...ctx, rowId: row.id, column });
156
+ return {
157
+ id: row.id,
158
+ kind: row.kind,
159
+ status: row.status as RunStatus,
160
+ parentRunId: row.parent_run_id ?? undefined,
161
+ input: safeParseField(row.input_json, {}, fieldCtx('input_json')) as Record<string, unknown>,
162
+ context: safeParseField(row.context_json, {}, fieldCtx('context_json')) as Record<string, unknown>,
163
+ output: safeParseOptionalField(row.output_json, fieldCtx('output_json')) as Record<string, unknown> | undefined,
164
+ error: safeParseOptionalField(row.error_json, fieldCtx('error_json')) as WorkflowError | undefined,
165
+ metadata: safeParseField(row.metadata_json, {}, fieldCtx('metadata_json')) as Record<string, unknown>,
166
+ priority: row.priority ?? 0,
167
+ timeoutMs: row.timeout_ms ?? undefined,
168
+ createdAt: new Date(row.created_at),
169
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
170
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
171
+ };
172
+ }
173
+
174
+ /** Map a `workflow_run_steps` row to the public `WorkflowRunStepRecord` shape. */
175
+ export function mapStepRow(row: WorkflowRunStepsTable, ctx: MapperContext): WorkflowRunStepRecord {
176
+ const fieldCtx = (column: string): SafeJsonParseContext => ({ ...ctx, rowId: row.id, column });
177
+ return {
178
+ id: row.id,
179
+ runId: row.run_id,
180
+ stepKey: row.step_key,
181
+ stepName: row.step_name,
182
+ status: row.status as StepStatus,
183
+ attempt: row.attempt,
184
+ result: safeParseOptionalField(row.result_json, fieldCtx('result_json')),
185
+ error: safeParseOptionalField(row.error_json, fieldCtx('error_json')) as WorkflowError | undefined,
186
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
187
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
188
+ };
189
+ }
190
+
191
+ /** Map a `workflow_events` row to the public `WorkflowEventRecord` shape. */
192
+ export function mapEventRow(row: WorkflowEventsTable, ctx: MapperContext): WorkflowEventRecord {
193
+ const fieldCtx: SafeJsonParseContext = { ...ctx, rowId: row.id, column: 'payload_json' };
194
+ return {
195
+ id: row.id,
196
+ runId: row.run_id,
197
+ stepKey: row.step_key ?? undefined,
198
+ eventType: row.event_type,
199
+ level: row.level as 'info' | 'warn' | 'error',
200
+ payload: safeParseOptionalField(row.payload_json, fieldCtx),
201
+ timestamp: new Date(row.timestamp),
202
+ };
203
+ }
204
+
205
+ // ============================================================================
206
+ // Filter helpers (pure)
207
+ // ============================================================================
208
+
209
+ /**
210
+ * Apply the common `kind`/`status`/`parentRunId` filters from `ListRunsOptions`
211
+ * to a Kysely select query. Used by both the data and count queries in
212
+ * `listRuns` so they share exactly the same WHERE clause.
213
+ */
214
+ export function applyRunsFilters<T extends { where(col: any, op: any, val: any): T }>(
215
+ query: T,
216
+ options: ListRunsOptions,
217
+ ): T {
218
+ if (options.kind) {
219
+ query = query.where('kind', '=', options.kind);
220
+ }
221
+ if (options.status) {
222
+ const statuses = Array.isArray(options.status) ? options.status : [options.status];
223
+ query = query.where('status', 'in', statuses);
224
+ }
225
+ if (options.parentRunId !== undefined) {
226
+ query = query.where('parent_run_id', '=', options.parentRunId);
227
+ }
228
+ return query;
229
+ }
230
+
231
+ // ============================================================================
232
+ // Shared base class
233
+ // ============================================================================
234
+
235
+ /**
236
+ * Abstract base implementing the CRUD subset of `StorageAdapter` against a
237
+ * schema-scoped Kysely query builder. Subclasses must provide the query
238
+ * builder via `qb` and may override `ensureReady()` to gate calls (the
239
+ * pool-owning adapter uses this to enforce `initialize()` having run).
240
+ *
241
+ * The base class deliberately does **not** own the connection pool, schema
242
+ * migrations, or transaction control — those stay with `PostgresStorageAdapter`.
243
+ *
244
+ * The base class targets `PostgresCoreDatabase` (the three core tables) so
245
+ * Kysely's row-type machinery can fully resolve column types on every query.
246
+ * Subclasses with a wider schema (e.g. `PostgresStorageAdapter` with
247
+ * `stepflow_step_results`) keep their own typed handle for the extra tables
248
+ * and return a core-typed view here.
249
+ */
250
+ export abstract class PostgresStorageCore implements StorageAdapter {
251
+ protected constructor(
252
+ protected readonly schema: string,
253
+ protected readonly logger?: Logger,
254
+ ) {}
255
+
256
+ /** Schema-scoped query builder. Subclasses return the bound builder. */
257
+ protected abstract get qb(): CoreSchemaQueryBuilder;
258
+
259
+ /** Component label used in corruption warnings; defines the JSON-parse `MapperContext`. */
260
+ protected abstract get warnLabel(): string;
261
+
262
+ /** Mapper context handed to row mappers — pairs the component label with the optional logger. */
263
+ protected get mapperCtx(): MapperContext {
264
+ return { component: this.warnLabel, logger: this.logger };
265
+ }
266
+
267
+ /**
268
+ * Hook called at the start of every CRUD method. The pool-owning adapter
269
+ * overrides this to throw before `initialize()` has run; the transaction
270
+ * wrapper leaves it as a no-op.
271
+ */
272
+ protected ensureReady(): void {}
273
+
274
+ // --------------------------------------------------------------------------
275
+ // Run operations
276
+ // --------------------------------------------------------------------------
277
+
278
+ async createRun(
279
+ run: CreateRunInput | Omit<WorkflowRunRecord, 'id' | 'createdAt'>,
280
+ ): Promise<WorkflowRunRecord> {
281
+ this.ensureReady();
282
+ const id = 'id' in run && run.id ? run.id : generateId();
283
+ const createdAt = new Date();
284
+
285
+ await this.qb
286
+ .insertInto('runs')
287
+ .values({
288
+ id,
289
+ kind: run.kind,
290
+ status: run.status,
291
+ parent_run_id: 'parentRunId' in run ? (run.parentRunId ?? null) : null,
292
+ input_json: JSON.stringify(run.input),
293
+ metadata_json: JSON.stringify(run.metadata ?? {}),
294
+ context_json: JSON.stringify(run.context ?? {}),
295
+ output_json: null,
296
+ error_json:
297
+ 'error' in run && run.error
298
+ ? JSON.stringify(sanitizeErrorForStorage(run.error))
299
+ : null,
300
+ priority: 'priority' in run ? (run.priority ?? 0) : 0,
301
+ timeout_ms: 'timeoutMs' in run ? (run.timeoutMs ?? null) : null,
302
+ created_at: createdAt,
303
+ started_at: null,
304
+ finished_at: null,
305
+ })
306
+ .execute();
307
+
308
+ return {
309
+ id,
310
+ kind: run.kind,
311
+ status: run.status as RunStatus,
312
+ parentRunId: 'parentRunId' in run ? run.parentRunId : undefined,
313
+ input: run.input,
314
+ context: run.context ?? {},
315
+ output: undefined,
316
+ error: undefined,
317
+ metadata: run.metadata ?? {},
318
+ priority: 'priority' in run ? (run.priority ?? 0) : 0,
319
+ timeoutMs: 'timeoutMs' in run ? run.timeoutMs : undefined,
320
+ createdAt,
321
+ };
322
+ }
323
+
324
+ async getRun(runId: string): Promise<WorkflowRunRecord | null> {
325
+ this.ensureReady();
326
+ const row = await this.qb
327
+ .selectFrom('runs')
328
+ .selectAll()
329
+ .where('id', '=', runId)
330
+ .executeTakeFirst();
331
+
332
+ return row ? mapRunRow(row as WorkflowRunsTable, this.mapperCtx) : null;
333
+ }
334
+
335
+ async updateRun(
336
+ runId: string,
337
+ updates: UpdateRunInput | Partial<WorkflowRunRecord>,
338
+ ): Promise<void> {
339
+ this.ensureReady();
340
+ const updateData: Partial<WorkflowRunsTable> = {};
341
+
342
+ if (updates.status !== undefined) {
343
+ updateData.status = updates.status;
344
+ }
345
+ if (updates.context !== undefined) {
346
+ updateData.context_json = JSON.stringify(updates.context);
347
+ }
348
+ if ('output' in updates && updates.output !== undefined) {
349
+ updateData.output_json = JSON.stringify(updates.output);
350
+ }
351
+ if (updates.error !== undefined) {
352
+ updateData.error_json = JSON.stringify(
353
+ sanitizeErrorForStorage(updates.error as WorkflowError),
354
+ );
355
+ }
356
+ if (updates.startedAt !== undefined) {
357
+ updateData.started_at = updates.startedAt;
358
+ }
359
+ if (updates.finishedAt !== undefined) {
360
+ updateData.finished_at = updates.finishedAt;
361
+ }
362
+
363
+ if (Object.keys(updateData).length > 0) {
364
+ await this.qb
365
+ .updateTable('runs')
366
+ .set(updateData)
367
+ .where('id', '=', runId)
368
+ .execute();
369
+ }
370
+ }
371
+
372
+ async listRuns(
373
+ options: ListRunsOptions = {},
374
+ ): Promise<PaginatedResult<WorkflowRunRecord>> {
375
+ this.ensureReady();
376
+ const { sql } = requirePostgresDeps();
377
+ const limit = options.limit ?? 50;
378
+ const offset = options.offset ?? 0;
379
+
380
+ // Same-connection COUNT(*) so totals reflect rows visible to this builder
381
+ // (preserves the C2 transaction-pagination fix from PR #47).
382
+ const countQuery = applyRunsFilters(
383
+ this.qb.selectFrom('runs').select(sql<number>`count(*)`.as('count')),
384
+ options,
385
+ );
386
+ const dataQuery = applyRunsFilters(this.qb.selectFrom('runs').selectAll(), options);
387
+
388
+ const orderBy = options.orderBy ?? 'createdAt';
389
+ const orderDirection = options.orderDirection ?? 'desc';
390
+ const orderColumn =
391
+ orderBy === 'createdAt'
392
+ ? 'created_at'
393
+ : orderBy === 'startedAt'
394
+ ? 'started_at'
395
+ : 'finished_at';
396
+
397
+ const countResult = (await countQuery.executeTakeFirst()) as
398
+ | { count?: string | number }
399
+ | undefined;
400
+ const total = Number(countResult?.count ?? 0);
401
+
402
+ const rows = await dataQuery
403
+ .orderBy(orderColumn, orderDirection)
404
+ .limit(limit)
405
+ .offset(offset)
406
+ .execute();
407
+
408
+ return {
409
+ items: rows.map((row) => mapRunRow(row as WorkflowRunsTable, this.mapperCtx)),
410
+ total,
411
+ };
412
+ }
413
+
414
+ // --------------------------------------------------------------------------
415
+ // Step operations
416
+ // --------------------------------------------------------------------------
417
+
418
+ async createStep(
419
+ step: Omit<WorkflowRunStepRecord, 'id'>,
420
+ ): Promise<WorkflowRunStepRecord> {
421
+ this.ensureReady();
422
+ const id = generateId();
423
+
424
+ await this.qb
425
+ .insertInto('workflow_run_steps')
426
+ .values({
427
+ id,
428
+ run_id: step.runId,
429
+ step_key: step.stepKey,
430
+ step_name: step.stepName,
431
+ status: step.status,
432
+ attempt: step.attempt,
433
+ result_json: step.result !== undefined ? JSON.stringify(step.result) : null,
434
+ error_json: step.error
435
+ ? JSON.stringify(sanitizeErrorForStorage(step.error))
436
+ : null,
437
+ started_at: step.startedAt ?? null,
438
+ finished_at: step.finishedAt ?? null,
439
+ })
440
+ .execute();
441
+
442
+ return { ...step, id };
443
+ }
444
+
445
+ async getStep(stepId: string): Promise<WorkflowRunStepRecord | null> {
446
+ this.ensureReady();
447
+ const row = await this.qb
448
+ .selectFrom('workflow_run_steps')
449
+ .selectAll()
450
+ .where('id', '=', stepId)
451
+ .executeTakeFirst();
452
+
453
+ return row ? mapStepRow(row as WorkflowRunStepsTable, this.mapperCtx) : null;
454
+ }
455
+
456
+ async updateStep(
457
+ stepId: string,
458
+ updates: Partial<WorkflowRunStepRecord>,
459
+ ): Promise<void> {
460
+ this.ensureReady();
461
+ const updateData: Partial<WorkflowRunStepsTable> = {};
462
+
463
+ if (updates.status !== undefined) {
464
+ updateData.status = updates.status;
465
+ }
466
+ if (updates.attempt !== undefined) {
467
+ updateData.attempt = updates.attempt;
468
+ }
469
+ if (updates.result !== undefined) {
470
+ updateData.result_json = JSON.stringify(updates.result);
471
+ }
472
+ if (updates.error !== undefined) {
473
+ updateData.error_json = JSON.stringify(
474
+ sanitizeErrorForStorage(updates.error as WorkflowError),
475
+ );
476
+ }
477
+ if (updates.finishedAt !== undefined) {
478
+ updateData.finished_at = updates.finishedAt;
479
+ }
480
+
481
+ if (Object.keys(updateData).length > 0) {
482
+ await this.qb
483
+ .updateTable('workflow_run_steps')
484
+ .set(updateData)
485
+ .where('id', '=', stepId)
486
+ .execute();
487
+ }
488
+ }
489
+
490
+ async getStepsForRun(runId: string): Promise<WorkflowRunStepRecord[]> {
491
+ this.ensureReady();
492
+ const rows = await this.qb
493
+ .selectFrom('workflow_run_steps')
494
+ .selectAll()
495
+ .where('run_id', '=', runId)
496
+ .orderBy('started_at', 'asc')
497
+ .execute();
498
+
499
+ return rows.map((row) => mapStepRow(row as WorkflowRunStepsTable, this.mapperCtx));
500
+ }
501
+
502
+ // --------------------------------------------------------------------------
503
+ // Event operations
504
+ // --------------------------------------------------------------------------
505
+
506
+ async saveEvent(event: Omit<WorkflowEventRecord, 'id'>): Promise<void> {
507
+ this.ensureReady();
508
+ const id = generateId();
509
+
510
+ await this.qb
511
+ .insertInto('workflow_events')
512
+ .values({
513
+ id,
514
+ run_id: event.runId,
515
+ step_key: event.stepKey ?? null,
516
+ event_type: event.eventType,
517
+ level: event.level,
518
+ payload_json: event.payload !== undefined ? JSON.stringify(event.payload) : null,
519
+ timestamp: event.timestamp,
520
+ })
521
+ .execute();
522
+ }
523
+
524
+ async getEventsForRun(
525
+ runId: string,
526
+ options: ListEventsOptions = {},
527
+ ): Promise<WorkflowEventRecord[]> {
528
+ this.ensureReady();
529
+ const limit = options.limit ?? 1000;
530
+ const offset = options.offset ?? 0;
531
+
532
+ let query = this.qb
533
+ .selectFrom('workflow_events')
534
+ .selectAll()
535
+ .where('run_id', '=', runId);
536
+
537
+ if (options.stepKey) {
538
+ query = query.where('step_key', '=', options.stepKey);
539
+ }
540
+ if (options.level) {
541
+ query = query.where('level', '=', options.level);
542
+ }
543
+
544
+ query = query.orderBy('timestamp', 'asc').limit(limit).offset(offset);
545
+
546
+ const rows = await query.execute();
547
+
548
+ return rows.map((row) => mapEventRow(row as WorkflowEventsTable, this.mapperCtx));
549
+ }
550
+ }