@multiplier-labs/stepflow 0.3.3 → 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 (69) 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-11_comment-assessment.md +161 -0
  4. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  5. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  6. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  7. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  8. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  9. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  10. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  11. package/.github/workflows/publish.yml +6 -0
  12. package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
  13. package/dist/chunk-5XJBAPXF.js.map +1 -0
  14. package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
  15. package/dist/chunk-FMPJBK2N.js.map +1 -0
  16. package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
  17. package/dist/chunk-JPXPB7U5.js.map +1 -0
  18. package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
  19. package/dist/chunk-U3PCVK6O.js.map +1 -0
  20. package/dist/events/index.d.ts +56 -3
  21. package/dist/events/index.js +1 -1
  22. package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
  23. package/dist/index.d.ts +2 -12
  24. package/dist/index.js +764 -485
  25. package/dist/index.js.map +1 -1
  26. package/dist/scheduler/index.d.ts +1 -1
  27. package/dist/scheduler/index.js +2 -2
  28. package/dist/storage/index.d.ts +313 -47
  29. package/dist/storage/index.js +4 -2
  30. package/docs/stepflow-api-reference.md +3 -1
  31. package/package.json +7 -4
  32. package/src/core/engine/concurrency.ts +229 -0
  33. package/src/core/engine/lifecycle.ts +176 -0
  34. package/src/core/engine.test.ts +70 -0
  35. package/src/core/engine.ts +91 -185
  36. package/src/core/orchestrator/persistence.ts +165 -0
  37. package/src/core/orchestrator/plan-resolution.ts +140 -0
  38. package/src/core/orchestrator/state-transitions.ts +109 -0
  39. package/src/core/orchestrator/step-runner.ts +427 -0
  40. package/src/core/orchestrator.ts +40 -359
  41. package/src/events/webhook.test.ts +292 -0
  42. package/src/events/webhook.ts +320 -77
  43. package/src/planning/evaluate-condition.test.ts +153 -0
  44. package/src/planning/evaluate-condition.ts +105 -0
  45. package/src/planning/planner.ts +1 -83
  46. package/src/planning/registry.ts +3 -85
  47. package/src/scheduler/cron.test.ts +9 -1
  48. package/src/scheduler/cron.ts +26 -2
  49. package/src/scheduler/postgres-persistence.ts +27 -15
  50. package/src/scheduler/sqlite-persistence.ts +28 -3
  51. package/src/storage/postgres-core.ts +550 -0
  52. package/src/storage/postgres-migration.test.ts +69 -0
  53. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  54. package/src/storage/postgres.ts +171 -690
  55. package/src/storage/sqlite-core.ts +151 -0
  56. package/src/storage/sqlite.test.ts +177 -1
  57. package/src/storage/sqlite.ts +183 -93
  58. package/src/utils/index.ts +2 -0
  59. package/src/utils/pinned-fetch.test.ts +84 -0
  60. package/src/utils/pinned-fetch.ts +118 -0
  61. package/src/utils/postgres-deps.ts +16 -0
  62. package/src/utils/safe-json.test.ts +166 -0
  63. package/src/utils/safe-json.ts +107 -0
  64. package/src/utils/webhook-signature.test.ts +111 -0
  65. package/src/utils/webhook-signature.ts +74 -0
  66. package/dist/chunk-4VOKMGP6.js.map +0 -1
  67. package/dist/chunk-GYPNJBDP.js.map +0 -1
  68. package/dist/chunk-KHTUWLVZ.js.map +0 -1
  69. package/dist/chunk-ML35PIHX.js.map +0 -1
@@ -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
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Regression tests for PostgresStorageAdapter migration error handling.
3
+ * See audit 2026-04-27 finding C1: schema migrations must surface failures
4
+ * instead of silently swallowing them.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { PostgresStorageAdapter } from './postgres';
9
+
10
+ interface QueryHandler {
11
+ (text: string, params: unknown[]): Promise<{ command: string; rowCount: number; rows: unknown[] }>;
12
+ }
13
+
14
+ /**
15
+ * Build the smallest pg.Pool-like object Kysely's PostgresDialect needs:
16
+ * `connect()` returning a client with `query()` and `release()`, plus `end()`.
17
+ */
18
+ function buildFakePool(handler: QueryHandler) {
19
+ const client = {
20
+ query: (text: string, params: unknown[] = []) => handler(text, params),
21
+ release: () => {},
22
+ };
23
+ const pool = {
24
+ connect: async () => client,
25
+ end: async () => {},
26
+ on: () => pool,
27
+ off: () => pool,
28
+ };
29
+ return pool;
30
+ }
31
+
32
+ describe('PostgresStorageAdapter — migration error propagation', () => {
33
+ it('rethrows a non-benign migration error with a clear message', async () => {
34
+ const handler: QueryHandler = async (text) => {
35
+ if (/ALTER\s+TABLE/i.test(text)) {
36
+ // Simulate a non-benign migration failure (e.g., permission denied).
37
+ const err: Error & { code?: string } = new Error('permission denied for table runs');
38
+ err.code = '42501'; // insufficient_privilege — not in benign list
39
+ throw err;
40
+ }
41
+ return { command: 'CREATE', rowCount: 0, rows: [] };
42
+ };
43
+
44
+ const adapter = new PostgresStorageAdapter({
45
+ pool: buildFakePool(handler) as unknown as import('pg').Pool,
46
+ });
47
+
48
+ await expect(adapter.initialize()).rejects.toThrow(/Schema migration failed/);
49
+ await expect(adapter.initialize()).rejects.toThrow(/permission denied/);
50
+ });
51
+
52
+ it('tolerates benign duplicate-column errors during idempotent re-runs', async () => {
53
+ const handler: QueryHandler = async (text) => {
54
+ if (/ALTER\s+TABLE/i.test(text)) {
55
+ const err: Error & { code?: string } = new Error('column "priority" of relation "runs" already exists');
56
+ err.code = '42701'; // duplicate_column — benign
57
+ throw err;
58
+ }
59
+ return { command: 'CREATE', rowCount: 0, rows: [] };
60
+ };
61
+
62
+ const adapter = new PostgresStorageAdapter({
63
+ pool: buildFakePool(handler) as unknown as import('pg').Pool,
64
+ });
65
+
66
+ await expect(adapter.initialize()).resolves.toBeUndefined();
67
+ await adapter.close();
68
+ });
69
+ });