@multiplier-labs/stepflow 0.3.4 → 0.3.6
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.
- package/README.md +9 -0
- package/dist/{chunk-U3PCVK6O.js → chunk-D5HW7VEG.js} +4 -2
- package/dist/chunk-D5HW7VEG.js.map +1 -0
- package/dist/{chunk-5XJBAPXF.js → chunk-JUPZEBR3.js} +3 -1
- package/dist/{chunk-5XJBAPXF.js.map → chunk-JUPZEBR3.js.map} +1 -1
- package/dist/events/index.d.ts +2 -0
- package/dist/events/index.js +1 -1
- package/dist/index.js +2 -2
- package/dist/storage/index.js +1 -1
- package/package.json +23 -12
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +0 -146
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +0 -74
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +0 -161
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +0 -34
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +0 -152
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +0 -34
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +0 -93
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +0 -138
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +0 -121
- package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
- package/.codekin/reports/security/2026-04-11_security-audit.md +0 -297
- package/.github/CODEOWNERS +0 -15
- package/.github/dependabot.yml +0 -23
- package/.github/workflows/ci.yml +0 -38
- package/.github/workflows/publish.yml +0 -42
- package/.github/workflows/security.yml +0 -29
- package/CHANGELOG.md +0 -34
- package/SECURITY.md +0 -40
- package/dist/chunk-U3PCVK6O.js.map +0 -1
- package/docs/github-packages-setup.md +0 -97
- package/docs/stepflow-api-reference.md +0 -1663
- package/src/core/engine/concurrency.ts +0 -229
- package/src/core/engine/lifecycle.ts +0 -176
- package/src/core/engine.test.ts +0 -1435
- package/src/core/engine.ts +0 -598
- package/src/core/index.ts +0 -7
- package/src/core/orchestrator/persistence.ts +0 -165
- package/src/core/orchestrator/plan-resolution.ts +0 -140
- package/src/core/orchestrator/state-transitions.ts +0 -109
- package/src/core/orchestrator/step-runner.ts +0 -427
- package/src/core/orchestrator.ts +0 -252
- package/src/core/types.ts +0 -220
- package/src/events/index.ts +0 -8
- package/src/events/memory.test.ts +0 -107
- package/src/events/memory.ts +0 -92
- package/src/events/socketio.test.ts +0 -378
- package/src/events/socketio.ts +0 -262
- package/src/events/types.ts +0 -118
- package/src/events/webhook.test.ts +0 -949
- package/src/events/webhook.ts +0 -807
- package/src/index.ts +0 -270
- package/src/planning/evaluate-condition.test.ts +0 -153
- package/src/planning/evaluate-condition.ts +0 -105
- package/src/planning/index.ts +0 -55
- package/src/planning/planner.ts +0 -494
- package/src/planning/planning.test.ts +0 -727
- package/src/planning/registry.test.ts +0 -398
- package/src/planning/registry.ts +0 -283
- package/src/planning/types.ts +0 -566
- package/src/scheduler/cron.test.ts +0 -479
- package/src/scheduler/cron.ts +0 -438
- package/src/scheduler/index.ts +0 -8
- package/src/scheduler/postgres-persistence.ts +0 -530
- package/src/scheduler/sqlite-persistence.test.ts +0 -235
- package/src/scheduler/sqlite-persistence.ts +0 -286
- package/src/scheduler/types.ts +0 -73
- package/src/storage/index.ts +0 -27
- package/src/storage/memory.test.ts +0 -349
- package/src/storage/memory.ts +0 -227
- package/src/storage/postgres-core.ts +0 -550
- package/src/storage/postgres-migration.test.ts +0 -69
- package/src/storage/postgres-transaction-pagination.test.ts +0 -107
- package/src/storage/postgres.ts +0 -835
- package/src/storage/sqlite-core.ts +0 -151
- package/src/storage/sqlite.test.ts +0 -642
- package/src/storage/sqlite.ts +0 -717
- package/src/storage/types.ts +0 -343
- package/src/utils/errors.test.ts +0 -150
- package/src/utils/errors.ts +0 -161
- package/src/utils/id.ts +0 -21
- package/src/utils/index.ts +0 -10
- package/src/utils/logger.test.ts +0 -121
- package/src/utils/logger.ts +0 -96
- package/src/utils/pinned-fetch.test.ts +0 -84
- package/src/utils/pinned-fetch.ts +0 -118
- package/src/utils/postgres-deps.ts +0 -79
- package/src/utils/retry.test.ts +0 -177
- package/src/utils/retry.ts +0 -121
- package/src/utils/safe-json.test.ts +0 -166
- package/src/utils/safe-json.ts +0 -107
- package/src/utils/webhook-signature.test.ts +0 -111
- package/src/utils/webhook-signature.ts +0 -74
- package/tsconfig.json +0 -21
- package/tsup.config.ts +0 -14
- package/vitest.config.ts +0 -9
|
@@ -1,550 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,69 +0,0 @@
|
|
|
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
|
-
});
|