@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.
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/CODEOWNERS +15 -0
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/publish.yml +17 -5
- package/.github/workflows/security.yml +29 -0
- package/CHANGELOG.md +34 -0
- package/README.md +24 -0
- package/SECURITY.md +40 -0
- package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +60 -7
- package/dist/events/index.js +1 -1
- package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
- package/dist/index.d.ts +15 -23
- package/dist/index.js +784 -547
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +4 -4
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +322 -56
- package/dist/storage/index.js +4 -2
- package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
- package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
- package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
- package/docs/stepflow-api-reference.md +5 -4
- package/package.json +12 -8
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +536 -522
- package/src/core/engine.ts +116 -235
- package/src/core/index.ts +3 -3
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +57 -395
- package/src/core/types.ts +7 -29
- package/src/events/index.ts +4 -4
- package/src/events/memory.test.ts +39 -41
- package/src/events/memory.ts +7 -10
- package/src/events/socketio.test.ts +112 -162
- package/src/events/socketio.ts +20 -27
- package/src/events/types.ts +16 -19
- package/src/events/webhook.test.ts +489 -235
- package/src/events/webhook.ts +355 -139
- package/src/index.ts +19 -19
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/index.ts +6 -3
- package/src/planning/planner.ts +45 -152
- package/src/planning/planning.test.ts +303 -366
- package/src/planning/registry.test.ts +204 -251
- package/src/planning/registry.ts +20 -106
- package/src/planning/types.ts +23 -27
- package/src/scheduler/cron.test.ts +149 -150
- package/src/scheduler/cron.ts +58 -69
- package/src/scheduler/index.ts +4 -14
- package/src/scheduler/postgres-persistence.ts +89 -105
- package/src/scheduler/sqlite-persistence.test.ts +96 -96
- package/src/scheduler/sqlite-persistence.ts +40 -22
- package/src/scheduler/types.ts +4 -9
- package/src/storage/index.ts +5 -5
- package/src/storage/memory.test.ts +133 -183
- package/src/storage/memory.ts +21 -42
- package/src/storage/postgres-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +222 -937
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +330 -281
- package/src/storage/sqlite.ts +225 -170
- package/src/storage/types.ts +33 -58
- package/src/utils/errors.test.ts +78 -80
- package/src/utils/errors.ts +27 -57
- package/src/utils/id.ts +3 -3
- package/src/utils/index.ts +6 -4
- package/src/utils/logger.test.ts +52 -58
- package/src/utils/logger.ts +4 -4
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +20 -4
- package/src/utils/retry.test.ts +47 -67
- package/src/utils/retry.ts +9 -9
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- package/dist/chunk-FQCT3I2A.js.map +0 -1
- package/dist/chunk-GXY66V7Q.js.map +0 -1
- package/dist/chunk-U5QGN3RT.js.map +0 -1
- package/dist/chunk-XCF4PLN7.js.map +0 -1
- package/src/scheduler/postgres-persistence.test.ts +0 -906
package/src/storage/memory.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
83
|
+
items = items.filter(r => r.parentRunId === options.parentRunId);
|
|
93
84
|
}
|
|
94
85
|
|
|
95
86
|
// Sort
|
|
96
|
-
const orderBy = options.orderBy ??
|
|
97
|
-
const direction = options.orderDirection ??
|
|
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 ===
|
|
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(
|
|
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,
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
}
|