@multiplier-labs/stepflow 0.3.1
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/.github/workflows/publish.yml +30 -0
- package/README.md +408 -0
- package/dist/chunk-FQCT3I2A.js +1512 -0
- package/dist/chunk-FQCT3I2A.js.map +1 -0
- package/dist/chunk-GXY66V7Q.js +111 -0
- package/dist/chunk-GXY66V7Q.js.map +1 -0
- package/dist/chunk-U5QGN3RT.js +715 -0
- package/dist/chunk-U5QGN3RT.js.map +1 -0
- package/dist/chunk-XCF4PLN7.js +575 -0
- package/dist/chunk-XCF4PLN7.js.map +1 -0
- package/dist/events/index.d.ts +346 -0
- package/dist/events/index.js +11 -0
- package/dist/events/index.js.map +1 -0
- package/dist/index-CPfGsmrp.d.ts +567 -0
- package/dist/index.d.ts +748 -0
- package/dist/index.js +1707 -0
- package/dist/index.js.map +1 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.js +12 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/storage/index.d.ts +402 -0
- package/dist/storage/index.js +13 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/types-BVShq7zT.d.ts +150 -0
- package/dist/types-BlwFEGzj.d.ts +276 -0
- package/dist/types-DzkEqnQJ.d.ts +75 -0
- package/docs/github-packages-setup.md +97 -0
- package/docs/stepflow-api-reference.md +1662 -0
- package/package.json +85 -0
- package/src/core/engine.test.ts +1421 -0
- package/src/core/engine.ts +717 -0
- package/src/core/index.ts +7 -0
- package/src/core/orchestrator.ts +590 -0
- package/src/core/types.ts +242 -0
- package/src/events/index.ts +8 -0
- package/src/events/memory.test.ts +109 -0
- package/src/events/memory.ts +95 -0
- package/src/events/socketio.test.ts +428 -0
- package/src/events/socketio.ts +269 -0
- package/src/events/types.ts +121 -0
- package/src/events/webhook.test.ts +695 -0
- package/src/events/webhook.ts +591 -0
- package/src/index.ts +270 -0
- package/src/planning/index.ts +52 -0
- package/src/planning/planner.ts +601 -0
- package/src/planning/planning.test.ts +790 -0
- package/src/planning/registry.test.ts +445 -0
- package/src/planning/registry.ts +369 -0
- package/src/planning/types.ts +570 -0
- package/src/scheduler/cron.test.ts +480 -0
- package/src/scheduler/cron.ts +449 -0
- package/src/scheduler/index.ts +18 -0
- package/src/scheduler/postgres-persistence.test.ts +906 -0
- package/src/scheduler/postgres-persistence.ts +546 -0
- package/src/scheduler/sqlite-persistence.test.ts +235 -0
- package/src/scheduler/sqlite-persistence.ts +268 -0
- package/src/scheduler/types.ts +78 -0
- package/src/storage/index.ts +27 -0
- package/src/storage/memory.test.ts +399 -0
- package/src/storage/memory.ts +248 -0
- package/src/storage/postgres.ts +1550 -0
- package/src/storage/sqlite.test.ts +593 -0
- package/src/storage/sqlite.ts +662 -0
- package/src/storage/types.ts +368 -0
- package/src/utils/errors.test.ts +152 -0
- package/src/utils/errors.ts +191 -0
- package/src/utils/id.ts +21 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.test.ts +127 -0
- package/src/utils/logger.ts +96 -0
- package/src/utils/postgres-deps.ts +63 -0
- package/src/utils/retry.test.ts +197 -0
- package/src/utils/retry.ts +121 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite storage adapter using better-sqlite3.
|
|
3
|
+
*
|
|
4
|
+
* Provides durable persistence for workflow runs, steps, and events.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type Database from "better-sqlite3";
|
|
8
|
+
import { generateId } from "../utils/id";
|
|
9
|
+
import type {
|
|
10
|
+
StorageAdapter,
|
|
11
|
+
WorkflowRunRecord,
|
|
12
|
+
WorkflowRunStepRecord,
|
|
13
|
+
WorkflowEventRecord,
|
|
14
|
+
ListRunsOptions,
|
|
15
|
+
ListEventsOptions,
|
|
16
|
+
PaginatedResult,
|
|
17
|
+
} from "./types";
|
|
18
|
+
import type { RunStatus, StepStatus, WorkflowError } from "../core/types";
|
|
19
|
+
import { sanitizeErrorForStorage } from "../utils/logger";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Schema SQL
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* SQL statements to create the workflow tables.
|
|
27
|
+
* These create the workflow tables in your database.
|
|
28
|
+
*/
|
|
29
|
+
const CREATE_TABLES_SQL = `
|
|
30
|
+
-- Workflow runs table
|
|
31
|
+
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
kind TEXT NOT NULL,
|
|
34
|
+
status TEXT NOT NULL,
|
|
35
|
+
parent_run_id TEXT,
|
|
36
|
+
input_json TEXT NOT NULL,
|
|
37
|
+
metadata_json TEXT NOT NULL,
|
|
38
|
+
context_json TEXT NOT NULL,
|
|
39
|
+
error_json TEXT,
|
|
40
|
+
created_at TEXT NOT NULL,
|
|
41
|
+
started_at TEXT,
|
|
42
|
+
finished_at TEXT
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_kind_status ON workflow_runs(kind, status);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent ON workflow_runs(parent_run_id);
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_created ON workflow_runs(created_at);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
49
|
+
|
|
50
|
+
-- Workflow run steps table
|
|
51
|
+
CREATE TABLE IF NOT EXISTS workflow_run_steps (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
run_id TEXT NOT NULL,
|
|
54
|
+
step_key TEXT NOT NULL,
|
|
55
|
+
step_name TEXT,
|
|
56
|
+
status TEXT NOT NULL,
|
|
57
|
+
attempt INTEGER NOT NULL DEFAULT 1,
|
|
58
|
+
result_json TEXT,
|
|
59
|
+
error_json TEXT,
|
|
60
|
+
started_at TEXT,
|
|
61
|
+
finished_at TEXT,
|
|
62
|
+
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_run_steps_run ON workflow_run_steps(run_id);
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_run_steps_run_key ON workflow_run_steps(run_id, step_key);
|
|
67
|
+
|
|
68
|
+
-- Workflow events table
|
|
69
|
+
CREATE TABLE IF NOT EXISTS workflow_events (
|
|
70
|
+
id TEXT PRIMARY KEY,
|
|
71
|
+
run_id TEXT NOT NULL,
|
|
72
|
+
step_key TEXT,
|
|
73
|
+
event_type TEXT NOT NULL,
|
|
74
|
+
level TEXT NOT NULL,
|
|
75
|
+
payload_json TEXT,
|
|
76
|
+
timestamp TEXT NOT NULL,
|
|
77
|
+
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_events_run ON workflow_events(run_id);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_ts ON workflow_events(run_id, timestamp);
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Configuration
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Configuration options for the SQLite storage adapter.
|
|
90
|
+
*/
|
|
91
|
+
export interface SQLiteStorageConfig {
|
|
92
|
+
/**
|
|
93
|
+
* The better-sqlite3 database instance.
|
|
94
|
+
* Can be an in-memory database (`:memory:`) or a file path.
|
|
95
|
+
*/
|
|
96
|
+
db: Database.Database;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether to automatically create tables on initialization.
|
|
100
|
+
* Default: true
|
|
101
|
+
*/
|
|
102
|
+
autoCreateTables?: boolean;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @deprecated This option is not currently supported and will be ignored.
|
|
106
|
+
* Table names are always prefixed with 'workflow_'.
|
|
107
|
+
*/
|
|
108
|
+
tablePrefix?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// SQLite Storage Adapter
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* SQLite implementation of StorageAdapter using better-sqlite3.
|
|
117
|
+
*
|
|
118
|
+
* Features:
|
|
119
|
+
* - Synchronous operations (better-sqlite3 is sync by design)
|
|
120
|
+
* - Automatic table creation
|
|
121
|
+
* - Transaction support
|
|
122
|
+
* - Customizable table prefix
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```typescript
|
|
126
|
+
* import Database from 'better-sqlite3';
|
|
127
|
+
* import { SQLiteStorageAdapter } from 'stepflow/storage';
|
|
128
|
+
*
|
|
129
|
+
* const db = new Database('./workflows.db');
|
|
130
|
+
* const storage = new SQLiteStorageAdapter({ db });
|
|
131
|
+
*
|
|
132
|
+
* const engine = new WorkflowEngine({ storage });
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export class SQLiteStorageAdapter implements StorageAdapter {
|
|
136
|
+
private db: Database.Database;
|
|
137
|
+
private prefix: string;
|
|
138
|
+
|
|
139
|
+
// Prepared statements (cached for performance)
|
|
140
|
+
private stmts: {
|
|
141
|
+
insertRun: Database.Statement;
|
|
142
|
+
getRun: Database.Statement;
|
|
143
|
+
updateRun: Database.Statement;
|
|
144
|
+
listRuns: Database.Statement;
|
|
145
|
+
countRuns: Database.Statement;
|
|
146
|
+
insertStep: Database.Statement;
|
|
147
|
+
getStep: Database.Statement;
|
|
148
|
+
updateStep: Database.Statement;
|
|
149
|
+
getStepsForRun: Database.Statement;
|
|
150
|
+
insertEvent: Database.Statement;
|
|
151
|
+
getEventsForRun: Database.Statement;
|
|
152
|
+
deleteOldRuns: Database.Statement;
|
|
153
|
+
deleteStepsForRuns: Database.Statement;
|
|
154
|
+
deleteEventsForRuns: Database.Statement;
|
|
155
|
+
getInterruptedRuns: Database.Statement;
|
|
156
|
+
getLastCompletedStep: Database.Statement;
|
|
157
|
+
} | null = null;
|
|
158
|
+
|
|
159
|
+
constructor(config: SQLiteStorageConfig) {
|
|
160
|
+
this.db = config.db;
|
|
161
|
+
this.prefix = config.tablePrefix ?? "workflow";
|
|
162
|
+
|
|
163
|
+
// Enable foreign keys
|
|
164
|
+
this.db.pragma("foreign_keys = ON");
|
|
165
|
+
|
|
166
|
+
// Create tables if needed
|
|
167
|
+
if (config.autoCreateTables !== false) {
|
|
168
|
+
this.createTables();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Prepare statements
|
|
172
|
+
this.prepareStatements();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create the workflow tables if they don't exist.
|
|
177
|
+
*/
|
|
178
|
+
private createTables(): void {
|
|
179
|
+
this.db.exec(CREATE_TABLES_SQL);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Prepare all SQL statements for better performance.
|
|
184
|
+
*/
|
|
185
|
+
private prepareStatements(): void {
|
|
186
|
+
this.stmts = {
|
|
187
|
+
insertRun: this.db.prepare(`
|
|
188
|
+
INSERT INTO workflow_runs (id, kind, status, parent_run_id, input_json, metadata_json, context_json, error_json, created_at, started_at, finished_at)
|
|
189
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
190
|
+
`),
|
|
191
|
+
getRun: this.db.prepare(`
|
|
192
|
+
SELECT * FROM workflow_runs WHERE id = ?
|
|
193
|
+
`),
|
|
194
|
+
updateRun: this.db.prepare(`
|
|
195
|
+
UPDATE workflow_runs
|
|
196
|
+
SET status = COALESCE(?, status),
|
|
197
|
+
context_json = COALESCE(?, context_json),
|
|
198
|
+
error_json = COALESCE(?, error_json),
|
|
199
|
+
started_at = COALESCE(?, started_at),
|
|
200
|
+
finished_at = COALESCE(?, finished_at)
|
|
201
|
+
WHERE id = ?
|
|
202
|
+
`),
|
|
203
|
+
// json_each() is a SQLite table-valued function that expands a JSON array into rows.
|
|
204
|
+
// We use it here to support filtering by multiple statuses in a single prepared statement:
|
|
205
|
+
// the caller passes a JSON array like '["running","failed"]', and json_each unpacks it
|
|
206
|
+
// so the IN clause can match against each value. When status is NULL, the entire
|
|
207
|
+
// condition is bypassed via the (? IS NULL OR ...) guard.
|
|
208
|
+
listRuns: this.db.prepare(`
|
|
209
|
+
SELECT * FROM workflow_runs
|
|
210
|
+
WHERE (? IS NULL OR kind = ?)
|
|
211
|
+
AND (? IS NULL OR status IN (SELECT value FROM json_each(?)))
|
|
212
|
+
AND (? IS NULL OR parent_run_id = ?)
|
|
213
|
+
ORDER BY created_at DESC
|
|
214
|
+
LIMIT ? OFFSET ?
|
|
215
|
+
`),
|
|
216
|
+
countRuns: this.db.prepare(`
|
|
217
|
+
SELECT COUNT(*) as count FROM workflow_runs
|
|
218
|
+
WHERE (? IS NULL OR kind = ?)
|
|
219
|
+
AND (? IS NULL OR status IN (SELECT value FROM json_each(?)))
|
|
220
|
+
AND (? IS NULL OR parent_run_id = ?)
|
|
221
|
+
`),
|
|
222
|
+
insertStep: this.db.prepare(`
|
|
223
|
+
INSERT INTO workflow_run_steps (id, run_id, step_key, step_name, status, attempt, result_json, error_json, started_at, finished_at)
|
|
224
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
225
|
+
`),
|
|
226
|
+
getStep: this.db.prepare(`
|
|
227
|
+
SELECT * FROM workflow_run_steps WHERE id = ?
|
|
228
|
+
`),
|
|
229
|
+
updateStep: this.db.prepare(`
|
|
230
|
+
UPDATE workflow_run_steps
|
|
231
|
+
SET status = COALESCE(?, status),
|
|
232
|
+
attempt = COALESCE(?, attempt),
|
|
233
|
+
result_json = COALESCE(?, result_json),
|
|
234
|
+
error_json = COALESCE(?, error_json),
|
|
235
|
+
finished_at = COALESCE(?, finished_at)
|
|
236
|
+
WHERE id = ?
|
|
237
|
+
`),
|
|
238
|
+
getStepsForRun: this.db.prepare(`
|
|
239
|
+
SELECT * FROM workflow_run_steps WHERE run_id = ? ORDER BY started_at ASC
|
|
240
|
+
`),
|
|
241
|
+
insertEvent: this.db.prepare(`
|
|
242
|
+
INSERT INTO workflow_events (id, run_id, step_key, event_type, level, payload_json, timestamp)
|
|
243
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
244
|
+
`),
|
|
245
|
+
getEventsForRun: this.db.prepare(`
|
|
246
|
+
SELECT * FROM workflow_events
|
|
247
|
+
WHERE run_id = ?
|
|
248
|
+
AND (? IS NULL OR step_key = ?)
|
|
249
|
+
AND (? IS NULL OR level = ?)
|
|
250
|
+
ORDER BY timestamp ASC
|
|
251
|
+
LIMIT ? OFFSET ?
|
|
252
|
+
`),
|
|
253
|
+
deleteOldRuns: this.db.prepare(`
|
|
254
|
+
DELETE FROM workflow_runs WHERE created_at < ?
|
|
255
|
+
`),
|
|
256
|
+
deleteStepsForRuns: this.db.prepare(`
|
|
257
|
+
DELETE FROM workflow_run_steps WHERE run_id IN (SELECT id FROM workflow_runs WHERE created_at < ?)
|
|
258
|
+
`),
|
|
259
|
+
deleteEventsForRuns: this.db.prepare(`
|
|
260
|
+
DELETE FROM workflow_events WHERE run_id IN (SELECT id FROM workflow_runs WHERE created_at < ?)
|
|
261
|
+
`),
|
|
262
|
+
getInterruptedRuns: this.db.prepare(`
|
|
263
|
+
SELECT * FROM workflow_runs
|
|
264
|
+
WHERE status IN ('queued', 'running')
|
|
265
|
+
ORDER BY created_at ASC
|
|
266
|
+
`),
|
|
267
|
+
getLastCompletedStep: this.db.prepare(`
|
|
268
|
+
SELECT * FROM workflow_run_steps
|
|
269
|
+
WHERE run_id = ? AND status = 'succeeded'
|
|
270
|
+
ORDER BY finished_at DESC
|
|
271
|
+
LIMIT 1
|
|
272
|
+
`),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ============================================================================
|
|
277
|
+
// Run Operations
|
|
278
|
+
// ============================================================================
|
|
279
|
+
|
|
280
|
+
async createRun(
|
|
281
|
+
run: Omit<WorkflowRunRecord, "id" | "createdAt">,
|
|
282
|
+
): Promise<WorkflowRunRecord> {
|
|
283
|
+
const id = generateId();
|
|
284
|
+
const createdAt = new Date();
|
|
285
|
+
|
|
286
|
+
this.stmts!.insertRun.run(
|
|
287
|
+
id,
|
|
288
|
+
run.kind,
|
|
289
|
+
run.status,
|
|
290
|
+
run.parentRunId ?? null,
|
|
291
|
+
JSON.stringify(run.input),
|
|
292
|
+
JSON.stringify(run.metadata),
|
|
293
|
+
JSON.stringify(run.context),
|
|
294
|
+
run.error ? JSON.stringify(sanitizeErrorForStorage(run.error)) : null,
|
|
295
|
+
createdAt.toISOString(),
|
|
296
|
+
run.startedAt?.toISOString() ?? null,
|
|
297
|
+
run.finishedAt?.toISOString() ?? null,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
...run,
|
|
302
|
+
id,
|
|
303
|
+
createdAt,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async getRun(runId: string): Promise<WorkflowRunRecord | null> {
|
|
308
|
+
const row = this.stmts!.getRun.get(runId) as SQLiteRunRow | undefined;
|
|
309
|
+
return row ? this.mapRunRow(row) : null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async updateRun(
|
|
313
|
+
runId: string,
|
|
314
|
+
updates: Partial<WorkflowRunRecord>,
|
|
315
|
+
): Promise<void> {
|
|
316
|
+
this.stmts!.updateRun.run(
|
|
317
|
+
updates.status ?? null,
|
|
318
|
+
updates.context ? JSON.stringify(updates.context) : null,
|
|
319
|
+
updates.error
|
|
320
|
+
? JSON.stringify(sanitizeErrorForStorage(updates.error))
|
|
321
|
+
: null,
|
|
322
|
+
updates.startedAt?.toISOString() ?? null,
|
|
323
|
+
updates.finishedAt?.toISOString() ?? null,
|
|
324
|
+
runId,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async listRuns(
|
|
329
|
+
options: ListRunsOptions = {},
|
|
330
|
+
): Promise<PaginatedResult<WorkflowRunRecord>> {
|
|
331
|
+
const limit = options.limit ?? 50;
|
|
332
|
+
const offset = options.offset ?? 0;
|
|
333
|
+
const kind = options.kind ?? null;
|
|
334
|
+
const status = options.status ?? null;
|
|
335
|
+
const statusJson = status
|
|
336
|
+
? JSON.stringify(Array.isArray(status) ? status : [status])
|
|
337
|
+
: null;
|
|
338
|
+
const parentRunId = options.parentRunId ?? null;
|
|
339
|
+
|
|
340
|
+
const rows = this.stmts!.listRuns.all(
|
|
341
|
+
kind,
|
|
342
|
+
kind,
|
|
343
|
+
statusJson,
|
|
344
|
+
statusJson,
|
|
345
|
+
parentRunId,
|
|
346
|
+
parentRunId,
|
|
347
|
+
limit,
|
|
348
|
+
offset,
|
|
349
|
+
) as SQLiteRunRow[];
|
|
350
|
+
|
|
351
|
+
const countResult = this.stmts!.countRuns.get(
|
|
352
|
+
kind,
|
|
353
|
+
kind,
|
|
354
|
+
statusJson,
|
|
355
|
+
statusJson,
|
|
356
|
+
parentRunId,
|
|
357
|
+
parentRunId,
|
|
358
|
+
) as { count: number };
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
items: rows.map((row) => this.mapRunRow(row)),
|
|
362
|
+
total: countResult.count,
|
|
363
|
+
limit,
|
|
364
|
+
offset,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ============================================================================
|
|
369
|
+
// Step Operations
|
|
370
|
+
// ============================================================================
|
|
371
|
+
|
|
372
|
+
async createStep(
|
|
373
|
+
step: Omit<WorkflowRunStepRecord, "id">,
|
|
374
|
+
): Promise<WorkflowRunStepRecord> {
|
|
375
|
+
const id = generateId();
|
|
376
|
+
|
|
377
|
+
this.stmts!.insertStep.run(
|
|
378
|
+
id,
|
|
379
|
+
step.runId,
|
|
380
|
+
step.stepKey,
|
|
381
|
+
step.stepName,
|
|
382
|
+
step.status,
|
|
383
|
+
step.attempt,
|
|
384
|
+
step.result !== undefined ? JSON.stringify(step.result) : null,
|
|
385
|
+
step.error ? JSON.stringify(sanitizeErrorForStorage(step.error)) : null,
|
|
386
|
+
step.startedAt?.toISOString() ?? null,
|
|
387
|
+
step.finishedAt?.toISOString() ?? null,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
return { ...step, id };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async getStep(stepId: string): Promise<WorkflowRunStepRecord | null> {
|
|
394
|
+
const row = this.stmts!.getStep.get(stepId) as SQLiteStepRow | undefined;
|
|
395
|
+
return row ? this.mapStepRow(row) : null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async updateStep(
|
|
399
|
+
stepId: string,
|
|
400
|
+
updates: Partial<WorkflowRunStepRecord>,
|
|
401
|
+
): Promise<void> {
|
|
402
|
+
this.stmts!.updateStep.run(
|
|
403
|
+
updates.status ?? null,
|
|
404
|
+
updates.attempt ?? null,
|
|
405
|
+
updates.result !== undefined ? JSON.stringify(updates.result) : null,
|
|
406
|
+
updates.error
|
|
407
|
+
? JSON.stringify(sanitizeErrorForStorage(updates.error))
|
|
408
|
+
: null,
|
|
409
|
+
updates.finishedAt?.toISOString() ?? null,
|
|
410
|
+
stepId,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async getStepsForRun(runId: string): Promise<WorkflowRunStepRecord[]> {
|
|
415
|
+
const rows = this.stmts!.getStepsForRun.all(runId) as SQLiteStepRow[];
|
|
416
|
+
return rows.map((row) => this.mapStepRow(row));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ============================================================================
|
|
420
|
+
// Event Operations
|
|
421
|
+
// ============================================================================
|
|
422
|
+
|
|
423
|
+
async saveEvent(event: Omit<WorkflowEventRecord, "id">): Promise<void> {
|
|
424
|
+
const id = generateId();
|
|
425
|
+
|
|
426
|
+
this.stmts!.insertEvent.run(
|
|
427
|
+
id,
|
|
428
|
+
event.runId,
|
|
429
|
+
event.stepKey ?? null,
|
|
430
|
+
event.eventType,
|
|
431
|
+
event.level,
|
|
432
|
+
event.payload !== undefined ? JSON.stringify(event.payload) : null,
|
|
433
|
+
event.timestamp.toISOString(),
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async getEventsForRun(
|
|
438
|
+
runId: string,
|
|
439
|
+
options: ListEventsOptions = {},
|
|
440
|
+
): Promise<WorkflowEventRecord[]> {
|
|
441
|
+
const limit = options.limit ?? 1000;
|
|
442
|
+
const offset = options.offset ?? 0;
|
|
443
|
+
const stepKey = options.stepKey ?? null;
|
|
444
|
+
const level = options.level ?? null;
|
|
445
|
+
|
|
446
|
+
const rows = this.stmts!.getEventsForRun.all(
|
|
447
|
+
runId,
|
|
448
|
+
stepKey,
|
|
449
|
+
stepKey,
|
|
450
|
+
level,
|
|
451
|
+
level,
|
|
452
|
+
limit,
|
|
453
|
+
offset,
|
|
454
|
+
) as SQLiteEventRow[];
|
|
455
|
+
|
|
456
|
+
return rows.map((row) => this.mapEventRow(row));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ============================================================================
|
|
460
|
+
// Transaction Support
|
|
461
|
+
// ============================================================================
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Execute a function within a database transaction (async interface).
|
|
465
|
+
*
|
|
466
|
+
* @deprecated Use `transactionSync()` instead. This method only works when
|
|
467
|
+
* the callback performs purely synchronous operations wrapped in async/await.
|
|
468
|
+
* If the callback awaits real async I/O (network, timers, etc.), it will
|
|
469
|
+
* throw an error. `transactionSync()` makes the synchronous requirement explicit.
|
|
470
|
+
*/
|
|
471
|
+
async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
|
|
472
|
+
let fnPromise!: Promise<T>;
|
|
473
|
+
this.transactionSync(() => {
|
|
474
|
+
fnPromise = fn(this);
|
|
475
|
+
});
|
|
476
|
+
return fnPromise;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Execute a synchronous transaction (preferred for better-sqlite3).
|
|
481
|
+
* Use this when you need transaction guarantees.
|
|
482
|
+
*
|
|
483
|
+
* @example
|
|
484
|
+
* ```typescript
|
|
485
|
+
* storage.transactionSync(() => {
|
|
486
|
+
* // Multiple operations in a single transaction
|
|
487
|
+
* const run = storage.createRunSync(...);
|
|
488
|
+
* storage.updateRunSync(run.id, ...);
|
|
489
|
+
* });
|
|
490
|
+
* ```
|
|
491
|
+
*/
|
|
492
|
+
transactionSync<T>(fn: () => T): T {
|
|
493
|
+
return this.db.transaction(fn)();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ============================================================================
|
|
497
|
+
// Cleanup Operations
|
|
498
|
+
// ============================================================================
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Delete runs older than the specified date.
|
|
502
|
+
* Also deletes associated steps and events (via CASCADE).
|
|
503
|
+
*/
|
|
504
|
+
async deleteOldRuns(olderThan: Date): Promise<number> {
|
|
505
|
+
const isoDate = olderThan.toISOString();
|
|
506
|
+
|
|
507
|
+
// Due to CASCADE, steps and events are deleted automatically
|
|
508
|
+
// But we do it explicitly for databases without CASCADE support
|
|
509
|
+
return this.db.transaction(() => {
|
|
510
|
+
this.stmts!.deleteEventsForRuns.run(isoDate);
|
|
511
|
+
this.stmts!.deleteStepsForRuns.run(isoDate);
|
|
512
|
+
const result = this.stmts!.deleteOldRuns.run(isoDate);
|
|
513
|
+
return result.changes;
|
|
514
|
+
})();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ============================================================================
|
|
518
|
+
// Resume Support
|
|
519
|
+
// ============================================================================
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Get all runs that were interrupted (status is 'running' or 'queued').
|
|
523
|
+
* These runs can potentially be resumed.
|
|
524
|
+
*/
|
|
525
|
+
async getInterruptedRuns(): Promise<WorkflowRunRecord[]> {
|
|
526
|
+
const rows = this.stmts!.getInterruptedRuns.all() as SQLiteRunRow[];
|
|
527
|
+
return rows.map((row) => this.mapRunRow(row));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Get the last completed step for a run.
|
|
532
|
+
* Useful for resuming from a checkpoint.
|
|
533
|
+
*/
|
|
534
|
+
async getLastCompletedStep(
|
|
535
|
+
runId: string,
|
|
536
|
+
): Promise<WorkflowRunStepRecord | null> {
|
|
537
|
+
const row = this.stmts!.getLastCompletedStep.get(runId) as
|
|
538
|
+
| SQLiteStepRow
|
|
539
|
+
| undefined;
|
|
540
|
+
return row ? this.mapStepRow(row) : null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ============================================================================
|
|
544
|
+
// Row Mapping
|
|
545
|
+
// ============================================================================
|
|
546
|
+
|
|
547
|
+
private mapRunRow(row: SQLiteRunRow): WorkflowRunRecord {
|
|
548
|
+
return {
|
|
549
|
+
id: row.id,
|
|
550
|
+
kind: row.kind,
|
|
551
|
+
status: row.status as RunStatus,
|
|
552
|
+
parentRunId: row.parent_run_id ?? undefined,
|
|
553
|
+
input: JSON.parse(row.input_json),
|
|
554
|
+
metadata: JSON.parse(row.metadata_json),
|
|
555
|
+
context: JSON.parse(row.context_json),
|
|
556
|
+
error: row.error_json ? JSON.parse(row.error_json) : undefined,
|
|
557
|
+
createdAt: new Date(row.created_at),
|
|
558
|
+
startedAt: row.started_at ? new Date(row.started_at) : undefined,
|
|
559
|
+
finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private mapStepRow(row: SQLiteStepRow): WorkflowRunStepRecord {
|
|
564
|
+
return {
|
|
565
|
+
id: row.id,
|
|
566
|
+
runId: row.run_id,
|
|
567
|
+
stepKey: row.step_key,
|
|
568
|
+
stepName: row.step_name,
|
|
569
|
+
status: row.status as StepStatus,
|
|
570
|
+
attempt: row.attempt,
|
|
571
|
+
result: row.result_json ? JSON.parse(row.result_json) : undefined,
|
|
572
|
+
error: row.error_json ? JSON.parse(row.error_json) : undefined,
|
|
573
|
+
startedAt: row.started_at ? new Date(row.started_at) : undefined,
|
|
574
|
+
finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private mapEventRow(row: SQLiteEventRow): WorkflowEventRecord {
|
|
579
|
+
return {
|
|
580
|
+
id: row.id,
|
|
581
|
+
runId: row.run_id,
|
|
582
|
+
stepKey: row.step_key ?? undefined,
|
|
583
|
+
eventType: row.event_type,
|
|
584
|
+
level: row.level as "info" | "warn" | "error",
|
|
585
|
+
payload: row.payload_json ? JSON.parse(row.payload_json) : undefined,
|
|
586
|
+
timestamp: new Date(row.timestamp),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ============================================================================
|
|
591
|
+
// Utility Methods
|
|
592
|
+
// ============================================================================
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Close the database connection.
|
|
596
|
+
*/
|
|
597
|
+
close(): void {
|
|
598
|
+
this.db.close();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Get database statistics.
|
|
603
|
+
*/
|
|
604
|
+
getStats(): { runs: number; steps: number; events: number } {
|
|
605
|
+
const runsCount = this.db
|
|
606
|
+
.prepare("SELECT COUNT(*) as count FROM workflow_runs")
|
|
607
|
+
.get() as { count: number };
|
|
608
|
+
const stepsCount = this.db
|
|
609
|
+
.prepare("SELECT COUNT(*) as count FROM workflow_run_steps")
|
|
610
|
+
.get() as { count: number };
|
|
611
|
+
const eventsCount = this.db
|
|
612
|
+
.prepare("SELECT COUNT(*) as count FROM workflow_events")
|
|
613
|
+
.get() as { count: number };
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
runs: runsCount.count,
|
|
617
|
+
steps: stepsCount.count,
|
|
618
|
+
events: eventsCount.count,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ============================================================================
|
|
624
|
+
// SQLite Row Types
|
|
625
|
+
// ============================================================================
|
|
626
|
+
|
|
627
|
+
interface SQLiteRunRow {
|
|
628
|
+
id: string;
|
|
629
|
+
kind: string;
|
|
630
|
+
status: string;
|
|
631
|
+
parent_run_id: string | null;
|
|
632
|
+
input_json: string;
|
|
633
|
+
metadata_json: string;
|
|
634
|
+
context_json: string;
|
|
635
|
+
error_json: string | null;
|
|
636
|
+
created_at: string;
|
|
637
|
+
started_at: string | null;
|
|
638
|
+
finished_at: string | null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
interface SQLiteStepRow {
|
|
642
|
+
id: string;
|
|
643
|
+
run_id: string;
|
|
644
|
+
step_key: string;
|
|
645
|
+
step_name: string;
|
|
646
|
+
status: string;
|
|
647
|
+
attempt: number;
|
|
648
|
+
result_json: string | null;
|
|
649
|
+
error_json: string | null;
|
|
650
|
+
started_at: string | null;
|
|
651
|
+
finished_at: string | null;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
interface SQLiteEventRow {
|
|
655
|
+
id: string;
|
|
656
|
+
run_id: string;
|
|
657
|
+
step_key: string | null;
|
|
658
|
+
event_type: string;
|
|
659
|
+
level: string;
|
|
660
|
+
payload_json: string | null;
|
|
661
|
+
timestamp: string;
|
|
662
|
+
}
|