@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
package/src/storage/sqlite.ts
DELETED
|
@@ -1,717 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SQLite storage adapter using better-sqlite3.
|
|
3
|
-
*
|
|
4
|
-
* Provides durable persistence for workflow runs, steps, and events.
|
|
5
|
-
*
|
|
6
|
-
* ## Module layout
|
|
7
|
-
*
|
|
8
|
-
* - `SQLiteStorageAdapter` (this file) owns the better-sqlite3 connection,
|
|
9
|
-
* the prepared-statement cache, and every method on `StorageAdapter`. It
|
|
10
|
-
* is the only adapter class — unlike the Postgres adapter there is no
|
|
11
|
-
* transaction-scoped twin, so no abstract CRUD base class is required.
|
|
12
|
-
*
|
|
13
|
-
* - `sqlite-core.ts` holds the pure row-type interfaces and row mappers
|
|
14
|
-
* (`mapRunRow`, `mapStepRow`, `mapEventRow`). The mappers route every
|
|
15
|
-
* JSON-column parse through `safeParseField` / `safeParseOptionalField`
|
|
16
|
-
* in `src/utils/safe-json.ts`, sharing the corruption-counter +
|
|
17
|
-
* structured-warn behavior with the Postgres core (audit M2 / L3,
|
|
18
|
-
* PR #52). The split mirrors `postgres-core.ts` so both adapters
|
|
19
|
-
* expose mappers in the same shape.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import type Database from 'better-sqlite3';
|
|
23
|
-
import { generateId } from '../utils/id';
|
|
24
|
-
import type {
|
|
25
|
-
StorageAdapter,
|
|
26
|
-
WorkflowRunRecord,
|
|
27
|
-
WorkflowRunStepRecord,
|
|
28
|
-
WorkflowEventRecord,
|
|
29
|
-
ListRunsOptions,
|
|
30
|
-
ListEventsOptions,
|
|
31
|
-
PaginatedResult,
|
|
32
|
-
} from './types';
|
|
33
|
-
import type { Logger } from '../core/types';
|
|
34
|
-
import { sanitizeErrorForStorage } from '../utils/logger';
|
|
35
|
-
import type { MapperContext } from '../utils/safe-json';
|
|
36
|
-
import {
|
|
37
|
-
mapRunRow,
|
|
38
|
-
mapStepRow,
|
|
39
|
-
mapEventRow,
|
|
40
|
-
type SQLiteRunRow,
|
|
41
|
-
type SQLiteStepRow,
|
|
42
|
-
type SQLiteEventRow,
|
|
43
|
-
} from './sqlite-core';
|
|
44
|
-
|
|
45
|
-
// ============================================================================
|
|
46
|
-
// Schema SQL
|
|
47
|
-
// ============================================================================
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* SQL statements to create the workflow tables.
|
|
51
|
-
* These create the workflow tables in your database.
|
|
52
|
-
*/
|
|
53
|
-
const CREATE_TABLES_SQL = `
|
|
54
|
-
-- Workflow runs table
|
|
55
|
-
CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
56
|
-
id TEXT PRIMARY KEY,
|
|
57
|
-
kind TEXT NOT NULL,
|
|
58
|
-
status TEXT NOT NULL,
|
|
59
|
-
parent_run_id TEXT,
|
|
60
|
-
input_json TEXT NOT NULL,
|
|
61
|
-
metadata_json TEXT NOT NULL,
|
|
62
|
-
context_json TEXT NOT NULL,
|
|
63
|
-
completed_steps_json TEXT,
|
|
64
|
-
error_json TEXT,
|
|
65
|
-
created_at TEXT NOT NULL,
|
|
66
|
-
started_at TEXT,
|
|
67
|
-
finished_at TEXT
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
CREATE INDEX IF NOT EXISTS idx_workflow_runs_kind_status ON workflow_runs(kind, status);
|
|
71
|
-
CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent ON workflow_runs(parent_run_id);
|
|
72
|
-
CREATE INDEX IF NOT EXISTS idx_workflow_runs_created ON workflow_runs(created_at);
|
|
73
|
-
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
|
74
|
-
|
|
75
|
-
-- Workflow run steps table
|
|
76
|
-
CREATE TABLE IF NOT EXISTS workflow_run_steps (
|
|
77
|
-
id TEXT PRIMARY KEY,
|
|
78
|
-
run_id TEXT NOT NULL,
|
|
79
|
-
step_key TEXT NOT NULL,
|
|
80
|
-
step_name TEXT,
|
|
81
|
-
status TEXT NOT NULL,
|
|
82
|
-
attempt INTEGER NOT NULL DEFAULT 1,
|
|
83
|
-
result_json TEXT,
|
|
84
|
-
error_json TEXT,
|
|
85
|
-
started_at TEXT,
|
|
86
|
-
finished_at TEXT,
|
|
87
|
-
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
CREATE INDEX IF NOT EXISTS idx_workflow_run_steps_run ON workflow_run_steps(run_id);
|
|
91
|
-
CREATE INDEX IF NOT EXISTS idx_workflow_run_steps_run_key ON workflow_run_steps(run_id, step_key);
|
|
92
|
-
|
|
93
|
-
-- Workflow events table
|
|
94
|
-
CREATE TABLE IF NOT EXISTS workflow_events (
|
|
95
|
-
id TEXT PRIMARY KEY,
|
|
96
|
-
run_id TEXT NOT NULL,
|
|
97
|
-
step_key TEXT,
|
|
98
|
-
event_type TEXT NOT NULL,
|
|
99
|
-
level TEXT NOT NULL,
|
|
100
|
-
payload_json TEXT,
|
|
101
|
-
timestamp TEXT NOT NULL,
|
|
102
|
-
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
CREATE INDEX IF NOT EXISTS idx_workflow_events_run ON workflow_events(run_id);
|
|
106
|
-
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_ts ON workflow_events(run_id, timestamp);
|
|
107
|
-
`;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Columns added to {@link CREATE_TABLES_SQL} after the initial schema, expressed
|
|
111
|
-
* as `ALTER TABLE ADD COLUMN` migrations. SQLite's `CREATE TABLE IF NOT EXISTS`
|
|
112
|
-
* does not reconcile column lists against existing tables, so a deploy that ran
|
|
113
|
-
* an older version of this adapter will be missing any column added later. The
|
|
114
|
-
* adapter applies these on startup so existing databases stay usable across
|
|
115
|
-
* upgrades.
|
|
116
|
-
*
|
|
117
|
-
* Append-only: SQLite does not support DROP COLUMN or general ALTER COLUMN, so
|
|
118
|
-
* removing or changing an entry here would break upgrades from older versions.
|
|
119
|
-
* Only add new entries when a column is added to {@link CREATE_TABLES_SQL}.
|
|
120
|
-
*/
|
|
121
|
-
const SCHEMA_MIGRATIONS: ReadonlyArray<{ table: string; column: string; type: string }> = [
|
|
122
|
-
{ table: 'workflow_runs', column: 'completed_steps_json', type: 'TEXT' },
|
|
123
|
-
];
|
|
124
|
-
|
|
125
|
-
// ============================================================================
|
|
126
|
-
// Configuration
|
|
127
|
-
// ============================================================================
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Configuration options for the SQLite storage adapter.
|
|
131
|
-
*/
|
|
132
|
-
export interface SQLiteStorageConfig {
|
|
133
|
-
/**
|
|
134
|
-
* The better-sqlite3 database instance.
|
|
135
|
-
* Can be an in-memory database (`:memory:`) or a file path.
|
|
136
|
-
*/
|
|
137
|
-
db: Database.Database;
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Whether to automatically create tables on initialization.
|
|
141
|
-
* Default: true
|
|
142
|
-
*/
|
|
143
|
-
autoCreateTables?: boolean;
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* @deprecated This option is not currently supported and will be ignored.
|
|
147
|
-
* Table names are always prefixed with 'workflow_'.
|
|
148
|
-
*/
|
|
149
|
-
tablePrefix?: string;
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Optional structured logger used to surface JSON corruption events.
|
|
153
|
-
* Without one, parse failures still increment the corruption counter
|
|
154
|
-
* exposed via `getJsonParseCorruptionCount()` but produce no log output.
|
|
155
|
-
*/
|
|
156
|
-
logger?: Logger;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ============================================================================
|
|
160
|
-
// Helpers
|
|
161
|
-
// ============================================================================
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* better-sqlite3 raises `SqliteError { code: 'SQLITE_ERROR', message:
|
|
165
|
-
* 'duplicate column name: <name>' }` when an `ALTER TABLE ADD COLUMN` targets
|
|
166
|
-
* a column that already exists. Treat that as a benign no-op so concurrent
|
|
167
|
-
* startups (or any race between the {@link runMigrations} existence check and
|
|
168
|
-
* the ALTER) don't crash. All other errors propagate.
|
|
169
|
-
*/
|
|
170
|
-
function isDuplicateColumnError(err: unknown): boolean {
|
|
171
|
-
if (!err || typeof err !== 'object') return false;
|
|
172
|
-
const code = (err as { code?: unknown }).code;
|
|
173
|
-
const message = (err as { message?: unknown }).message;
|
|
174
|
-
return (
|
|
175
|
-
code === 'SQLITE_ERROR' &&
|
|
176
|
-
typeof message === 'string' &&
|
|
177
|
-
message.toLowerCase().includes('duplicate column name')
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Resolve to `true` if `p` settles within a few microtask ticks of being
|
|
183
|
-
* inspected, otherwise `false`. Used by the deprecated async `transaction()`
|
|
184
|
-
* to detect callbacks that suspend on real async I/O — by the time we ask,
|
|
185
|
-
* the synchronous COMMIT has already run, so a still-pending promise means
|
|
186
|
-
* any post-await operations land outside the transaction.
|
|
187
|
-
*/
|
|
188
|
-
async function isPromiseSettled(p: Promise<unknown>): Promise<boolean> {
|
|
189
|
-
let settled = false;
|
|
190
|
-
p.then(
|
|
191
|
-
() => {
|
|
192
|
-
settled = true;
|
|
193
|
-
},
|
|
194
|
-
() => {
|
|
195
|
-
settled = true;
|
|
196
|
-
}
|
|
197
|
-
);
|
|
198
|
-
// Drain a handful of microtask ticks. `await Promise.resolve()` callbacks
|
|
199
|
-
// and other already-resolved continuations finish in 1-2 ticks; anything
|
|
200
|
-
// still pending after this is doing real async I/O (timers, network, etc.).
|
|
201
|
-
for (let i = 0; i < 5 && !settled; i++) {
|
|
202
|
-
await Promise.resolve();
|
|
203
|
-
}
|
|
204
|
-
return settled;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ============================================================================
|
|
208
|
-
// SQLite Storage Adapter
|
|
209
|
-
// ============================================================================
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* SQLite implementation of StorageAdapter using better-sqlite3.
|
|
213
|
-
*
|
|
214
|
-
* Features:
|
|
215
|
-
* - Synchronous operations (better-sqlite3 is sync by design)
|
|
216
|
-
* - Automatic table creation
|
|
217
|
-
* - Transaction support
|
|
218
|
-
* - Customizable table prefix
|
|
219
|
-
*
|
|
220
|
-
* @example
|
|
221
|
-
* ```typescript
|
|
222
|
-
* import Database from 'better-sqlite3';
|
|
223
|
-
* import { SQLiteStorageAdapter } from 'stepflow/storage';
|
|
224
|
-
*
|
|
225
|
-
* const db = new Database('./workflows.db');
|
|
226
|
-
* const storage = new SQLiteStorageAdapter({ db });
|
|
227
|
-
*
|
|
228
|
-
* const engine = new WorkflowEngine({ storage });
|
|
229
|
-
* ```
|
|
230
|
-
*/
|
|
231
|
-
export class SQLiteStorageAdapter implements StorageAdapter {
|
|
232
|
-
private db: Database.Database;
|
|
233
|
-
private prefix: string;
|
|
234
|
-
private logger: Logger | undefined;
|
|
235
|
-
|
|
236
|
-
// Prepared statements (cached for performance)
|
|
237
|
-
private stmts: {
|
|
238
|
-
insertRun: Database.Statement;
|
|
239
|
-
getRun: Database.Statement;
|
|
240
|
-
updateRun: Database.Statement;
|
|
241
|
-
listRuns: Database.Statement;
|
|
242
|
-
countRuns: Database.Statement;
|
|
243
|
-
insertStep: Database.Statement;
|
|
244
|
-
getStep: Database.Statement;
|
|
245
|
-
updateStep: Database.Statement;
|
|
246
|
-
getStepsForRun: Database.Statement;
|
|
247
|
-
insertEvent: Database.Statement;
|
|
248
|
-
getEventsForRun: Database.Statement;
|
|
249
|
-
deleteOldRuns: Database.Statement;
|
|
250
|
-
deleteStepsForRuns: Database.Statement;
|
|
251
|
-
deleteEventsForRuns: Database.Statement;
|
|
252
|
-
getInterruptedRuns: Database.Statement;
|
|
253
|
-
getLastCompletedStep: Database.Statement;
|
|
254
|
-
} | null = null;
|
|
255
|
-
|
|
256
|
-
constructor(config: SQLiteStorageConfig) {
|
|
257
|
-
this.db = config.db;
|
|
258
|
-
this.prefix = config.tablePrefix ?? 'workflow';
|
|
259
|
-
this.logger = config.logger;
|
|
260
|
-
|
|
261
|
-
// Enable foreign keys
|
|
262
|
-
this.db.pragma('foreign_keys = ON');
|
|
263
|
-
|
|
264
|
-
// Create tables if needed
|
|
265
|
-
if (config.autoCreateTables !== false) {
|
|
266
|
-
this.createTables();
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Always reconcile column-level migrations: the prepared statements below
|
|
270
|
-
// reference the latest schema, so an adapter sitting on top of an older
|
|
271
|
-
// table layout (e.g., a database created by a previous version, or a
|
|
272
|
-
// caller-managed schema with `autoCreateTables: false` that has not been
|
|
273
|
-
// hand-upgraded) would crash on the first INSERT/SELECT. Migrations are
|
|
274
|
-
// idempotent and skip tables that don't exist.
|
|
275
|
-
this.runMigrations();
|
|
276
|
-
|
|
277
|
-
// Prepare statements
|
|
278
|
-
this.prepareStatements();
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Create the workflow tables if they don't exist.
|
|
283
|
-
*/
|
|
284
|
-
private createTables(): void {
|
|
285
|
-
this.db.exec(CREATE_TABLES_SQL);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Apply additive schema migrations to existing tables.
|
|
290
|
-
*
|
|
291
|
-
* `CREATE TABLE IF NOT EXISTS` is a no-op when the table already exists, so
|
|
292
|
-
* any column added to {@link CREATE_TABLES_SQL} after the initial schema is
|
|
293
|
-
* silently absent on databases created by older versions of this adapter.
|
|
294
|
-
* Without this step, the first INSERT/SELECT crashes with
|
|
295
|
-
* `SqliteError: table X has no column named Y`. This mirrors the
|
|
296
|
-
* `ALTER TABLE ADD COLUMN IF NOT EXISTS` block in `postgres.ts`'s
|
|
297
|
-
* `createTables()`.
|
|
298
|
-
*
|
|
299
|
-
* For each entry in {@link SCHEMA_MIGRATIONS}: if the table exists and the
|
|
300
|
-
* column does not, run `ALTER TABLE ADD COLUMN`. The ALTER is wrapped in a
|
|
301
|
-
* try/catch that tolerates `duplicate column` errors so concurrent startups
|
|
302
|
-
* (or any race between the existence check and the ALTER) do not crash.
|
|
303
|
-
* Other SQLite errors propagate.
|
|
304
|
-
*/
|
|
305
|
-
private runMigrations(): void {
|
|
306
|
-
for (const { table, column, type } of SCHEMA_MIGRATIONS) {
|
|
307
|
-
const tableInfo = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
|
308
|
-
// PRAGMA table_info on a missing table returns []; CREATE TABLE handles that case.
|
|
309
|
-
if (tableInfo.length === 0) continue;
|
|
310
|
-
if (tableInfo.some((col) => col.name === column)) continue;
|
|
311
|
-
|
|
312
|
-
try {
|
|
313
|
-
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
|
314
|
-
} catch (err) {
|
|
315
|
-
if (isDuplicateColumnError(err)) continue;
|
|
316
|
-
throw err;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Prepare all SQL statements for better performance.
|
|
323
|
-
*/
|
|
324
|
-
private prepareStatements(): void {
|
|
325
|
-
this.stmts = {
|
|
326
|
-
insertRun: this.db.prepare(`
|
|
327
|
-
INSERT INTO workflow_runs (id, kind, status, parent_run_id, input_json, metadata_json, context_json, completed_steps_json, error_json, created_at, started_at, finished_at)
|
|
328
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
329
|
-
`),
|
|
330
|
-
getRun: this.db.prepare(`
|
|
331
|
-
SELECT * FROM workflow_runs WHERE id = ?
|
|
332
|
-
`),
|
|
333
|
-
updateRun: this.db.prepare(`
|
|
334
|
-
UPDATE workflow_runs
|
|
335
|
-
SET status = COALESCE(?, status),
|
|
336
|
-
context_json = COALESCE(?, context_json),
|
|
337
|
-
completed_steps_json = COALESCE(?, completed_steps_json),
|
|
338
|
-
error_json = COALESCE(?, error_json),
|
|
339
|
-
started_at = COALESCE(?, started_at),
|
|
340
|
-
finished_at = COALESCE(?, finished_at)
|
|
341
|
-
WHERE id = ?
|
|
342
|
-
`),
|
|
343
|
-
// json_each() is a SQLite table-valued function that expands a JSON array into rows.
|
|
344
|
-
// We use it here to support filtering by multiple statuses in a single prepared statement:
|
|
345
|
-
// the caller passes a JSON array like '["running","failed"]', and json_each unpacks it
|
|
346
|
-
// so the IN clause can match against each value. When status is NULL, the entire
|
|
347
|
-
// condition is bypassed via the (? IS NULL OR ...) guard.
|
|
348
|
-
listRuns: this.db.prepare(`
|
|
349
|
-
SELECT * FROM workflow_runs
|
|
350
|
-
WHERE (? IS NULL OR kind = ?)
|
|
351
|
-
AND (? IS NULL OR status IN (SELECT value FROM json_each(?)))
|
|
352
|
-
AND (? IS NULL OR parent_run_id = ?)
|
|
353
|
-
ORDER BY created_at DESC
|
|
354
|
-
LIMIT ? OFFSET ?
|
|
355
|
-
`),
|
|
356
|
-
countRuns: this.db.prepare(`
|
|
357
|
-
SELECT COUNT(*) as count FROM workflow_runs
|
|
358
|
-
WHERE (? IS NULL OR kind = ?)
|
|
359
|
-
AND (? IS NULL OR status IN (SELECT value FROM json_each(?)))
|
|
360
|
-
AND (? IS NULL OR parent_run_id = ?)
|
|
361
|
-
`),
|
|
362
|
-
insertStep: this.db.prepare(`
|
|
363
|
-
INSERT INTO workflow_run_steps (id, run_id, step_key, step_name, status, attempt, result_json, error_json, started_at, finished_at)
|
|
364
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
365
|
-
`),
|
|
366
|
-
getStep: this.db.prepare(`
|
|
367
|
-
SELECT * FROM workflow_run_steps WHERE id = ?
|
|
368
|
-
`),
|
|
369
|
-
updateStep: this.db.prepare(`
|
|
370
|
-
UPDATE workflow_run_steps
|
|
371
|
-
SET status = COALESCE(?, status),
|
|
372
|
-
attempt = COALESCE(?, attempt),
|
|
373
|
-
result_json = COALESCE(?, result_json),
|
|
374
|
-
error_json = COALESCE(?, error_json),
|
|
375
|
-
finished_at = COALESCE(?, finished_at)
|
|
376
|
-
WHERE id = ?
|
|
377
|
-
`),
|
|
378
|
-
getStepsForRun: this.db.prepare(`
|
|
379
|
-
SELECT * FROM workflow_run_steps WHERE run_id = ? ORDER BY started_at ASC
|
|
380
|
-
`),
|
|
381
|
-
insertEvent: this.db.prepare(`
|
|
382
|
-
INSERT INTO workflow_events (id, run_id, step_key, event_type, level, payload_json, timestamp)
|
|
383
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
384
|
-
`),
|
|
385
|
-
getEventsForRun: this.db.prepare(`
|
|
386
|
-
SELECT * FROM workflow_events
|
|
387
|
-
WHERE run_id = ?
|
|
388
|
-
AND (? IS NULL OR step_key = ?)
|
|
389
|
-
AND (? IS NULL OR level = ?)
|
|
390
|
-
ORDER BY timestamp ASC
|
|
391
|
-
LIMIT ? OFFSET ?
|
|
392
|
-
`),
|
|
393
|
-
deleteOldRuns: this.db.prepare(`
|
|
394
|
-
DELETE FROM workflow_runs WHERE created_at < ?
|
|
395
|
-
`),
|
|
396
|
-
deleteStepsForRuns: this.db.prepare(`
|
|
397
|
-
DELETE FROM workflow_run_steps WHERE run_id IN (SELECT id FROM workflow_runs WHERE created_at < ?)
|
|
398
|
-
`),
|
|
399
|
-
deleteEventsForRuns: this.db.prepare(`
|
|
400
|
-
DELETE FROM workflow_events WHERE run_id IN (SELECT id FROM workflow_runs WHERE created_at < ?)
|
|
401
|
-
`),
|
|
402
|
-
getInterruptedRuns: this.db.prepare(`
|
|
403
|
-
SELECT * FROM workflow_runs
|
|
404
|
-
WHERE status IN ('queued', 'running')
|
|
405
|
-
ORDER BY created_at ASC
|
|
406
|
-
`),
|
|
407
|
-
getLastCompletedStep: this.db.prepare(`
|
|
408
|
-
SELECT * FROM workflow_run_steps
|
|
409
|
-
WHERE run_id = ? AND status = 'succeeded'
|
|
410
|
-
ORDER BY finished_at DESC
|
|
411
|
-
LIMIT 1
|
|
412
|
-
`),
|
|
413
|
-
};
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// ============================================================================
|
|
417
|
-
// Run Operations
|
|
418
|
-
// ============================================================================
|
|
419
|
-
|
|
420
|
-
async createRun(run: Omit<WorkflowRunRecord, 'id' | 'createdAt'>): Promise<WorkflowRunRecord> {
|
|
421
|
-
const id = generateId();
|
|
422
|
-
const createdAt = new Date();
|
|
423
|
-
|
|
424
|
-
this.stmts!.insertRun.run(
|
|
425
|
-
id,
|
|
426
|
-
run.kind,
|
|
427
|
-
run.status,
|
|
428
|
-
run.parentRunId ?? null,
|
|
429
|
-
JSON.stringify(run.input),
|
|
430
|
-
JSON.stringify(run.metadata),
|
|
431
|
-
JSON.stringify(run.context),
|
|
432
|
-
run.completedSteps ? JSON.stringify(run.completedSteps) : null,
|
|
433
|
-
run.error ? JSON.stringify(sanitizeErrorForStorage(run.error)) : null,
|
|
434
|
-
createdAt.toISOString(),
|
|
435
|
-
run.startedAt?.toISOString() ?? null,
|
|
436
|
-
run.finishedAt?.toISOString() ?? null
|
|
437
|
-
);
|
|
438
|
-
|
|
439
|
-
return {
|
|
440
|
-
...run,
|
|
441
|
-
id,
|
|
442
|
-
createdAt,
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
async getRun(runId: string): Promise<WorkflowRunRecord | null> {
|
|
447
|
-
const row = this.stmts!.getRun.get(runId) as SQLiteRunRow | undefined;
|
|
448
|
-
return row ? mapRunRow(row, this.mapperCtx) : null;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
async updateRun(runId: string, updates: Partial<WorkflowRunRecord>): Promise<void> {
|
|
452
|
-
this.stmts!.updateRun.run(
|
|
453
|
-
updates.status ?? null,
|
|
454
|
-
updates.context ? JSON.stringify(updates.context) : null,
|
|
455
|
-
updates.completedSteps ? JSON.stringify(updates.completedSteps) : null,
|
|
456
|
-
updates.error ? JSON.stringify(sanitizeErrorForStorage(updates.error)) : null,
|
|
457
|
-
updates.startedAt?.toISOString() ?? null,
|
|
458
|
-
updates.finishedAt?.toISOString() ?? null,
|
|
459
|
-
runId
|
|
460
|
-
);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
async listRuns(options: ListRunsOptions = {}): Promise<PaginatedResult<WorkflowRunRecord>> {
|
|
464
|
-
const limit = options.limit ?? 50;
|
|
465
|
-
const offset = options.offset ?? 0;
|
|
466
|
-
const kind = options.kind ?? null;
|
|
467
|
-
const status = options.status ?? null;
|
|
468
|
-
const statusJson = status ? JSON.stringify(Array.isArray(status) ? status : [status]) : null;
|
|
469
|
-
const parentRunId = options.parentRunId ?? null;
|
|
470
|
-
|
|
471
|
-
const rows = this.stmts!.listRuns.all(
|
|
472
|
-
kind, kind,
|
|
473
|
-
statusJson, statusJson,
|
|
474
|
-
parentRunId, parentRunId,
|
|
475
|
-
limit, offset
|
|
476
|
-
) as SQLiteRunRow[];
|
|
477
|
-
|
|
478
|
-
const countResult = this.stmts!.countRuns.get(
|
|
479
|
-
kind, kind,
|
|
480
|
-
statusJson, statusJson,
|
|
481
|
-
parentRunId, parentRunId
|
|
482
|
-
) as { count: number };
|
|
483
|
-
|
|
484
|
-
return {
|
|
485
|
-
items: rows.map(row => mapRunRow(row, this.mapperCtx)),
|
|
486
|
-
total: countResult.count,
|
|
487
|
-
limit,
|
|
488
|
-
offset,
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// ============================================================================
|
|
493
|
-
// Step Operations
|
|
494
|
-
// ============================================================================
|
|
495
|
-
|
|
496
|
-
async createStep(step: Omit<WorkflowRunStepRecord, 'id'>): Promise<WorkflowRunStepRecord> {
|
|
497
|
-
const id = generateId();
|
|
498
|
-
|
|
499
|
-
this.stmts!.insertStep.run(
|
|
500
|
-
id,
|
|
501
|
-
step.runId,
|
|
502
|
-
step.stepKey,
|
|
503
|
-
step.stepName,
|
|
504
|
-
step.status,
|
|
505
|
-
step.attempt,
|
|
506
|
-
step.result !== undefined ? JSON.stringify(step.result) : null,
|
|
507
|
-
step.error ? JSON.stringify(sanitizeErrorForStorage(step.error)) : null,
|
|
508
|
-
step.startedAt?.toISOString() ?? null,
|
|
509
|
-
step.finishedAt?.toISOString() ?? null
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
return { ...step, id };
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
async getStep(stepId: string): Promise<WorkflowRunStepRecord | null> {
|
|
516
|
-
const row = this.stmts!.getStep.get(stepId) as SQLiteStepRow | undefined;
|
|
517
|
-
return row ? mapStepRow(row, this.mapperCtx) : null;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
async updateStep(stepId: string, updates: Partial<WorkflowRunStepRecord>): Promise<void> {
|
|
521
|
-
this.stmts!.updateStep.run(
|
|
522
|
-
updates.status ?? null,
|
|
523
|
-
updates.attempt ?? null,
|
|
524
|
-
updates.result !== undefined ? JSON.stringify(updates.result) : null,
|
|
525
|
-
updates.error ? JSON.stringify(sanitizeErrorForStorage(updates.error)) : null,
|
|
526
|
-
updates.finishedAt?.toISOString() ?? null,
|
|
527
|
-
stepId
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
async getStepsForRun(runId: string): Promise<WorkflowRunStepRecord[]> {
|
|
532
|
-
const rows = this.stmts!.getStepsForRun.all(runId) as SQLiteStepRow[];
|
|
533
|
-
return rows.map(row => mapStepRow(row, this.mapperCtx));
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// ============================================================================
|
|
537
|
-
// Event Operations
|
|
538
|
-
// ============================================================================
|
|
539
|
-
|
|
540
|
-
async saveEvent(event: Omit<WorkflowEventRecord, 'id'>): Promise<void> {
|
|
541
|
-
const id = generateId();
|
|
542
|
-
|
|
543
|
-
this.stmts!.insertEvent.run(
|
|
544
|
-
id,
|
|
545
|
-
event.runId,
|
|
546
|
-
event.stepKey ?? null,
|
|
547
|
-
event.eventType,
|
|
548
|
-
event.level,
|
|
549
|
-
event.payload !== undefined ? JSON.stringify(event.payload) : null,
|
|
550
|
-
event.timestamp.toISOString()
|
|
551
|
-
);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
async getEventsForRun(runId: string, options: ListEventsOptions = {}): Promise<WorkflowEventRecord[]> {
|
|
555
|
-
const limit = options.limit ?? 1000;
|
|
556
|
-
const offset = options.offset ?? 0;
|
|
557
|
-
const stepKey = options.stepKey ?? null;
|
|
558
|
-
const level = options.level ?? null;
|
|
559
|
-
|
|
560
|
-
const rows = this.stmts!.getEventsForRun.all(
|
|
561
|
-
runId,
|
|
562
|
-
stepKey, stepKey,
|
|
563
|
-
level, level,
|
|
564
|
-
limit, offset
|
|
565
|
-
) as SQLiteEventRow[];
|
|
566
|
-
|
|
567
|
-
return rows.map(row => mapEventRow(row, this.mapperCtx));
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// ============================================================================
|
|
571
|
-
// Transaction Support
|
|
572
|
-
// ============================================================================
|
|
573
|
-
|
|
574
|
-
/**
|
|
575
|
-
* Execute a function within a database transaction (async interface).
|
|
576
|
-
*
|
|
577
|
-
* @deprecated Use {@link transactionSync} instead. better-sqlite3 transactions
|
|
578
|
-
* are synchronous: COMMIT happens the moment the callback returns control to
|
|
579
|
-
* `db.transaction(fn)()`. If the callback awaits real async I/O (network,
|
|
580
|
-
* timers, etc.), the COMMIT has already happened by the time the awaited work
|
|
581
|
-
* runs, so its operations are **not** transactional.
|
|
582
|
-
*
|
|
583
|
-
* To make the footgun visible the adapter installs a runtime guard: when the
|
|
584
|
-
* callback's promise has not settled by the time the COMMIT returns, the
|
|
585
|
-
* deprecated method emits a `console.warn` so that the misuse surfaces in
|
|
586
|
-
* production logs. The promise is still returned so existing callers keep
|
|
587
|
-
* working — switch to `transactionSync` to silence the warning.
|
|
588
|
-
*/
|
|
589
|
-
async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
|
|
590
|
-
let fnPromise!: Promise<T>;
|
|
591
|
-
this.transactionSync(() => {
|
|
592
|
-
fnPromise = fn(this);
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
// COMMIT has already executed at this point. If the callback's promise is
|
|
596
|
-
// still pending, the callback is doing async work outside the transaction
|
|
597
|
-
// boundary — log a warning so the misuse is visible in production logs.
|
|
598
|
-
if (!(await isPromiseSettled(fnPromise))) {
|
|
599
|
-
console.warn(
|
|
600
|
-
'[SQLiteStorageAdapter] transaction() callback did not settle synchronously. ' +
|
|
601
|
-
'better-sqlite3 transactions are synchronous and the COMMIT has already executed; ' +
|
|
602
|
-
'any async work inside the callback is NOT transactional. ' +
|
|
603
|
-
'Use transactionSync() with purely synchronous operations instead.'
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
return fnPromise;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* Execute a synchronous transaction (preferred for better-sqlite3).
|
|
612
|
-
* Use this when you need transaction guarantees.
|
|
613
|
-
*
|
|
614
|
-
* @example
|
|
615
|
-
* ```typescript
|
|
616
|
-
* storage.transactionSync(() => {
|
|
617
|
-
* // Multiple operations in a single transaction
|
|
618
|
-
* const run = storage.createRunSync(...);
|
|
619
|
-
* storage.updateRunSync(run.id, ...);
|
|
620
|
-
* });
|
|
621
|
-
* ```
|
|
622
|
-
*/
|
|
623
|
-
transactionSync<T>(fn: () => T): T {
|
|
624
|
-
return this.db.transaction(fn)();
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// ============================================================================
|
|
628
|
-
// Cleanup Operations
|
|
629
|
-
// ============================================================================
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* Delete runs older than the specified date.
|
|
633
|
-
* Also deletes associated steps and events (via CASCADE).
|
|
634
|
-
*/
|
|
635
|
-
async deleteOldRuns(olderThan: Date): Promise<number> {
|
|
636
|
-
const isoDate = olderThan.toISOString();
|
|
637
|
-
|
|
638
|
-
// Due to CASCADE, steps and events are deleted automatically
|
|
639
|
-
// But we do it explicitly for databases without CASCADE support
|
|
640
|
-
return this.db.transaction(() => {
|
|
641
|
-
this.stmts!.deleteEventsForRuns.run(isoDate);
|
|
642
|
-
this.stmts!.deleteStepsForRuns.run(isoDate);
|
|
643
|
-
const result = this.stmts!.deleteOldRuns.run(isoDate);
|
|
644
|
-
return result.changes;
|
|
645
|
-
})();
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// ============================================================================
|
|
649
|
-
// Resume Support
|
|
650
|
-
// ============================================================================
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Get all runs that were interrupted (status is 'running' or 'queued').
|
|
654
|
-
* These runs can potentially be resumed.
|
|
655
|
-
*/
|
|
656
|
-
async getInterruptedRuns(): Promise<WorkflowRunRecord[]> {
|
|
657
|
-
const rows = this.stmts!.getInterruptedRuns.all() as SQLiteRunRow[];
|
|
658
|
-
return rows.map(row => mapRunRow(row, this.mapperCtx));
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/**
|
|
662
|
-
* Get the last completed step for a run.
|
|
663
|
-
* Useful for resuming from a checkpoint.
|
|
664
|
-
*/
|
|
665
|
-
async getLastCompletedStep(runId: string): Promise<WorkflowRunStepRecord | null> {
|
|
666
|
-
const row = this.stmts!.getLastCompletedStep.get(runId) as SQLiteStepRow | undefined;
|
|
667
|
-
return row ? mapStepRow(row, this.mapperCtx) : null;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// ============================================================================
|
|
671
|
-
// Row Mapping
|
|
672
|
-
// ============================================================================
|
|
673
|
-
//
|
|
674
|
-
// The pure row mappers (`mapRunRow`, `mapStepRow`, `mapEventRow`) live in
|
|
675
|
-
// `./sqlite-core.ts`. Each call site here builds a `MapperContext` once via
|
|
676
|
-
// `this.mapperCtx` and hands it to the mapper, which extends it with
|
|
677
|
-
// `rowId` + `column` per JSON-parse so corruption logs identify the bad row
|
|
678
|
-
// (audit M2 / L3 — PR #52).
|
|
679
|
-
|
|
680
|
-
/** Mapper context paired with the adapter's component label + optional logger. */
|
|
681
|
-
private get mapperCtx(): MapperContext {
|
|
682
|
-
return { component: 'SQLiteStorageAdapter', logger: this.logger };
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// ============================================================================
|
|
686
|
-
// Utility Methods
|
|
687
|
-
// ============================================================================
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* Close the database connection.
|
|
691
|
-
*/
|
|
692
|
-
close(): void {
|
|
693
|
-
this.db.close();
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Get database statistics.
|
|
698
|
-
*/
|
|
699
|
-
getStats(): { runs: number; steps: number; events: number } {
|
|
700
|
-
const runsCount = this.db.prepare('SELECT COUNT(*) as count FROM workflow_runs').get() as { count: number };
|
|
701
|
-
const stepsCount = this.db.prepare('SELECT COUNT(*) as count FROM workflow_run_steps').get() as { count: number };
|
|
702
|
-
const eventsCount = this.db.prepare('SELECT COUNT(*) as count FROM workflow_events').get() as { count: number };
|
|
703
|
-
|
|
704
|
-
return {
|
|
705
|
-
runs: runsCount.count,
|
|
706
|
-
steps: stepsCount.count,
|
|
707
|
-
events: eventsCount.count,
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
// ============================================================================
|
|
713
|
-
// SQLite Row Types
|
|
714
|
-
// ============================================================================
|
|
715
|
-
//
|
|
716
|
-
// `SQLiteRunRow`, `SQLiteStepRow`, and `SQLiteEventRow` now live in
|
|
717
|
-
// `./sqlite-core.ts` alongside their pure row mappers.
|