@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/sqlite.ts
CHANGED
|
@@ -2,10 +2,25 @@
|
|
|
2
2
|
* SQLite storage adapter using better-sqlite3.
|
|
3
3
|
*
|
|
4
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.
|
|
5
20
|
*/
|
|
6
21
|
|
|
7
|
-
import type Database from
|
|
8
|
-
import { generateId } from
|
|
22
|
+
import type Database from 'better-sqlite3';
|
|
23
|
+
import { generateId } from '../utils/id';
|
|
9
24
|
import type {
|
|
10
25
|
StorageAdapter,
|
|
11
26
|
WorkflowRunRecord,
|
|
@@ -14,9 +29,18 @@ import type {
|
|
|
14
29
|
ListRunsOptions,
|
|
15
30
|
ListEventsOptions,
|
|
16
31
|
PaginatedResult,
|
|
17
|
-
} from
|
|
18
|
-
import type {
|
|
19
|
-
import { sanitizeErrorForStorage } from
|
|
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';
|
|
20
44
|
|
|
21
45
|
// ============================================================================
|
|
22
46
|
// Schema SQL
|
|
@@ -36,6 +60,7 @@ CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
|
36
60
|
input_json TEXT NOT NULL,
|
|
37
61
|
metadata_json TEXT NOT NULL,
|
|
38
62
|
context_json TEXT NOT NULL,
|
|
63
|
+
completed_steps_json TEXT,
|
|
39
64
|
error_json TEXT,
|
|
40
65
|
created_at TEXT NOT NULL,
|
|
41
66
|
started_at TEXT,
|
|
@@ -81,6 +106,22 @@ CREATE INDEX IF NOT EXISTS idx_workflow_events_run ON workflow_events(run_id);
|
|
|
81
106
|
CREATE INDEX IF NOT EXISTS idx_workflow_events_run_ts ON workflow_events(run_id, timestamp);
|
|
82
107
|
`;
|
|
83
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
|
+
|
|
84
125
|
// ============================================================================
|
|
85
126
|
// Configuration
|
|
86
127
|
// ============================================================================
|
|
@@ -106,6 +147,61 @@ export interface SQLiteStorageConfig {
|
|
|
106
147
|
* Table names are always prefixed with 'workflow_'.
|
|
107
148
|
*/
|
|
108
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;
|
|
109
205
|
}
|
|
110
206
|
|
|
111
207
|
// ============================================================================
|
|
@@ -135,6 +231,7 @@ export interface SQLiteStorageConfig {
|
|
|
135
231
|
export class SQLiteStorageAdapter implements StorageAdapter {
|
|
136
232
|
private db: Database.Database;
|
|
137
233
|
private prefix: string;
|
|
234
|
+
private logger: Logger | undefined;
|
|
138
235
|
|
|
139
236
|
// Prepared statements (cached for performance)
|
|
140
237
|
private stmts: {
|
|
@@ -158,16 +255,25 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
158
255
|
|
|
159
256
|
constructor(config: SQLiteStorageConfig) {
|
|
160
257
|
this.db = config.db;
|
|
161
|
-
this.prefix = config.tablePrefix ??
|
|
258
|
+
this.prefix = config.tablePrefix ?? 'workflow';
|
|
259
|
+
this.logger = config.logger;
|
|
162
260
|
|
|
163
261
|
// Enable foreign keys
|
|
164
|
-
this.db.pragma(
|
|
262
|
+
this.db.pragma('foreign_keys = ON');
|
|
165
263
|
|
|
166
264
|
// Create tables if needed
|
|
167
265
|
if (config.autoCreateTables !== false) {
|
|
168
266
|
this.createTables();
|
|
169
267
|
}
|
|
170
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
|
+
|
|
171
277
|
// Prepare statements
|
|
172
278
|
this.prepareStatements();
|
|
173
279
|
}
|
|
@@ -179,14 +285,47 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
179
285
|
this.db.exec(CREATE_TABLES_SQL);
|
|
180
286
|
}
|
|
181
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
|
+
|
|
182
321
|
/**
|
|
183
322
|
* Prepare all SQL statements for better performance.
|
|
184
323
|
*/
|
|
185
324
|
private prepareStatements(): void {
|
|
186
325
|
this.stmts = {
|
|
187
326
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
190
329
|
`),
|
|
191
330
|
getRun: this.db.prepare(`
|
|
192
331
|
SELECT * FROM workflow_runs WHERE id = ?
|
|
@@ -195,6 +334,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
195
334
|
UPDATE workflow_runs
|
|
196
335
|
SET status = COALESCE(?, status),
|
|
197
336
|
context_json = COALESCE(?, context_json),
|
|
337
|
+
completed_steps_json = COALESCE(?, completed_steps_json),
|
|
198
338
|
error_json = COALESCE(?, error_json),
|
|
199
339
|
started_at = COALESCE(?, started_at),
|
|
200
340
|
finished_at = COALESCE(?, finished_at)
|
|
@@ -277,9 +417,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
277
417
|
// Run Operations
|
|
278
418
|
// ============================================================================
|
|
279
419
|
|
|
280
|
-
async createRun(
|
|
281
|
-
run: Omit<WorkflowRunRecord, "id" | "createdAt">,
|
|
282
|
-
): Promise<WorkflowRunRecord> {
|
|
420
|
+
async createRun(run: Omit<WorkflowRunRecord, 'id' | 'createdAt'>): Promise<WorkflowRunRecord> {
|
|
283
421
|
const id = generateId();
|
|
284
422
|
const createdAt = new Date();
|
|
285
423
|
|
|
@@ -291,10 +429,11 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
291
429
|
JSON.stringify(run.input),
|
|
292
430
|
JSON.stringify(run.metadata),
|
|
293
431
|
JSON.stringify(run.context),
|
|
432
|
+
run.completedSteps ? JSON.stringify(run.completedSteps) : null,
|
|
294
433
|
run.error ? JSON.stringify(sanitizeErrorForStorage(run.error)) : null,
|
|
295
434
|
createdAt.toISOString(),
|
|
296
435
|
run.startedAt?.toISOString() ?? null,
|
|
297
|
-
run.finishedAt?.toISOString() ?? null
|
|
436
|
+
run.finishedAt?.toISOString() ?? null
|
|
298
437
|
);
|
|
299
438
|
|
|
300
439
|
return {
|
|
@@ -306,59 +445,44 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
306
445
|
|
|
307
446
|
async getRun(runId: string): Promise<WorkflowRunRecord | null> {
|
|
308
447
|
const row = this.stmts!.getRun.get(runId) as SQLiteRunRow | undefined;
|
|
309
|
-
return row ?
|
|
448
|
+
return row ? mapRunRow(row, this.mapperCtx) : null;
|
|
310
449
|
}
|
|
311
450
|
|
|
312
|
-
async updateRun(
|
|
313
|
-
runId: string,
|
|
314
|
-
updates: Partial<WorkflowRunRecord>,
|
|
315
|
-
): Promise<void> {
|
|
451
|
+
async updateRun(runId: string, updates: Partial<WorkflowRunRecord>): Promise<void> {
|
|
316
452
|
this.stmts!.updateRun.run(
|
|
317
453
|
updates.status ?? null,
|
|
318
454
|
updates.context ? JSON.stringify(updates.context) : null,
|
|
319
|
-
updates.
|
|
320
|
-
|
|
321
|
-
: null,
|
|
455
|
+
updates.completedSteps ? JSON.stringify(updates.completedSteps) : null,
|
|
456
|
+
updates.error ? JSON.stringify(sanitizeErrorForStorage(updates.error)) : null,
|
|
322
457
|
updates.startedAt?.toISOString() ?? null,
|
|
323
458
|
updates.finishedAt?.toISOString() ?? null,
|
|
324
|
-
runId
|
|
459
|
+
runId
|
|
325
460
|
);
|
|
326
461
|
}
|
|
327
462
|
|
|
328
|
-
async listRuns(
|
|
329
|
-
options: ListRunsOptions = {},
|
|
330
|
-
): Promise<PaginatedResult<WorkflowRunRecord>> {
|
|
463
|
+
async listRuns(options: ListRunsOptions = {}): Promise<PaginatedResult<WorkflowRunRecord>> {
|
|
331
464
|
const limit = options.limit ?? 50;
|
|
332
465
|
const offset = options.offset ?? 0;
|
|
333
466
|
const kind = options.kind ?? null;
|
|
334
467
|
const status = options.status ?? null;
|
|
335
|
-
const statusJson = status
|
|
336
|
-
? JSON.stringify(Array.isArray(status) ? status : [status])
|
|
337
|
-
: null;
|
|
468
|
+
const statusJson = status ? JSON.stringify(Array.isArray(status) ? status : [status]) : null;
|
|
338
469
|
const parentRunId = options.parentRunId ?? null;
|
|
339
470
|
|
|
340
471
|
const rows = this.stmts!.listRuns.all(
|
|
341
|
-
kind,
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
parentRunId,
|
|
346
|
-
parentRunId,
|
|
347
|
-
limit,
|
|
348
|
-
offset,
|
|
472
|
+
kind, kind,
|
|
473
|
+
statusJson, statusJson,
|
|
474
|
+
parentRunId, parentRunId,
|
|
475
|
+
limit, offset
|
|
349
476
|
) as SQLiteRunRow[];
|
|
350
477
|
|
|
351
478
|
const countResult = this.stmts!.countRuns.get(
|
|
352
|
-
kind,
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
statusJson,
|
|
356
|
-
parentRunId,
|
|
357
|
-
parentRunId,
|
|
479
|
+
kind, kind,
|
|
480
|
+
statusJson, statusJson,
|
|
481
|
+
parentRunId, parentRunId
|
|
358
482
|
) as { count: number };
|
|
359
483
|
|
|
360
484
|
return {
|
|
361
|
-
items: rows.map(
|
|
485
|
+
items: rows.map(row => mapRunRow(row, this.mapperCtx)),
|
|
362
486
|
total: countResult.count,
|
|
363
487
|
limit,
|
|
364
488
|
offset,
|
|
@@ -369,9 +493,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
369
493
|
// Step Operations
|
|
370
494
|
// ============================================================================
|
|
371
495
|
|
|
372
|
-
async createStep(
|
|
373
|
-
step: Omit<WorkflowRunStepRecord, "id">,
|
|
374
|
-
): Promise<WorkflowRunStepRecord> {
|
|
496
|
+
async createStep(step: Omit<WorkflowRunStepRecord, 'id'>): Promise<WorkflowRunStepRecord> {
|
|
375
497
|
const id = generateId();
|
|
376
498
|
|
|
377
499
|
this.stmts!.insertStep.run(
|
|
@@ -384,7 +506,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
384
506
|
step.result !== undefined ? JSON.stringify(step.result) : null,
|
|
385
507
|
step.error ? JSON.stringify(sanitizeErrorForStorage(step.error)) : null,
|
|
386
508
|
step.startedAt?.toISOString() ?? null,
|
|
387
|
-
step.finishedAt?.toISOString() ?? null
|
|
509
|
+
step.finishedAt?.toISOString() ?? null
|
|
388
510
|
);
|
|
389
511
|
|
|
390
512
|
return { ...step, id };
|
|
@@ -392,35 +514,30 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
392
514
|
|
|
393
515
|
async getStep(stepId: string): Promise<WorkflowRunStepRecord | null> {
|
|
394
516
|
const row = this.stmts!.getStep.get(stepId) as SQLiteStepRow | undefined;
|
|
395
|
-
return row ?
|
|
517
|
+
return row ? mapStepRow(row, this.mapperCtx) : null;
|
|
396
518
|
}
|
|
397
519
|
|
|
398
|
-
async updateStep(
|
|
399
|
-
stepId: string,
|
|
400
|
-
updates: Partial<WorkflowRunStepRecord>,
|
|
401
|
-
): Promise<void> {
|
|
520
|
+
async updateStep(stepId: string, updates: Partial<WorkflowRunStepRecord>): Promise<void> {
|
|
402
521
|
this.stmts!.updateStep.run(
|
|
403
522
|
updates.status ?? null,
|
|
404
523
|
updates.attempt ?? null,
|
|
405
524
|
updates.result !== undefined ? JSON.stringify(updates.result) : null,
|
|
406
|
-
updates.error
|
|
407
|
-
? JSON.stringify(sanitizeErrorForStorage(updates.error))
|
|
408
|
-
: null,
|
|
525
|
+
updates.error ? JSON.stringify(sanitizeErrorForStorage(updates.error)) : null,
|
|
409
526
|
updates.finishedAt?.toISOString() ?? null,
|
|
410
|
-
stepId
|
|
527
|
+
stepId
|
|
411
528
|
);
|
|
412
529
|
}
|
|
413
530
|
|
|
414
531
|
async getStepsForRun(runId: string): Promise<WorkflowRunStepRecord[]> {
|
|
415
532
|
const rows = this.stmts!.getStepsForRun.all(runId) as SQLiteStepRow[];
|
|
416
|
-
return rows.map(
|
|
533
|
+
return rows.map(row => mapStepRow(row, this.mapperCtx));
|
|
417
534
|
}
|
|
418
535
|
|
|
419
536
|
// ============================================================================
|
|
420
537
|
// Event Operations
|
|
421
538
|
// ============================================================================
|
|
422
539
|
|
|
423
|
-
async saveEvent(event: Omit<WorkflowEventRecord,
|
|
540
|
+
async saveEvent(event: Omit<WorkflowEventRecord, 'id'>): Promise<void> {
|
|
424
541
|
const id = generateId();
|
|
425
542
|
|
|
426
543
|
this.stmts!.insertEvent.run(
|
|
@@ -430,14 +547,11 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
430
547
|
event.eventType,
|
|
431
548
|
event.level,
|
|
432
549
|
event.payload !== undefined ? JSON.stringify(event.payload) : null,
|
|
433
|
-
event.timestamp.toISOString()
|
|
550
|
+
event.timestamp.toISOString()
|
|
434
551
|
);
|
|
435
552
|
}
|
|
436
553
|
|
|
437
|
-
async getEventsForRun(
|
|
438
|
-
runId: string,
|
|
439
|
-
options: ListEventsOptions = {},
|
|
440
|
-
): Promise<WorkflowEventRecord[]> {
|
|
554
|
+
async getEventsForRun(runId: string, options: ListEventsOptions = {}): Promise<WorkflowEventRecord[]> {
|
|
441
555
|
const limit = options.limit ?? 1000;
|
|
442
556
|
const offset = options.offset ?? 0;
|
|
443
557
|
const stepKey = options.stepKey ?? null;
|
|
@@ -445,15 +559,12 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
445
559
|
|
|
446
560
|
const rows = this.stmts!.getEventsForRun.all(
|
|
447
561
|
runId,
|
|
448
|
-
stepKey,
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
level,
|
|
452
|
-
limit,
|
|
453
|
-
offset,
|
|
562
|
+
stepKey, stepKey,
|
|
563
|
+
level, level,
|
|
564
|
+
limit, offset
|
|
454
565
|
) as SQLiteEventRow[];
|
|
455
566
|
|
|
456
|
-
return rows.map(
|
|
567
|
+
return rows.map(row => mapEventRow(row, this.mapperCtx));
|
|
457
568
|
}
|
|
458
569
|
|
|
459
570
|
// ============================================================================
|
|
@@ -463,16 +574,36 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
463
574
|
/**
|
|
464
575
|
* Execute a function within a database transaction (async interface).
|
|
465
576
|
*
|
|
466
|
-
* @deprecated Use
|
|
467
|
-
*
|
|
468
|
-
* If the callback awaits real async I/O (network,
|
|
469
|
-
*
|
|
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.
|
|
470
588
|
*/
|
|
471
589
|
async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
|
|
472
590
|
let fnPromise!: Promise<T>;
|
|
473
591
|
this.transactionSync(() => {
|
|
474
592
|
fnPromise = fn(this);
|
|
475
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
|
+
|
|
476
607
|
return fnPromise;
|
|
477
608
|
}
|
|
478
609
|
|
|
@@ -524,67 +655,31 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
524
655
|
*/
|
|
525
656
|
async getInterruptedRuns(): Promise<WorkflowRunRecord[]> {
|
|
526
657
|
const rows = this.stmts!.getInterruptedRuns.all() as SQLiteRunRow[];
|
|
527
|
-
return rows.map(
|
|
658
|
+
return rows.map(row => mapRunRow(row, this.mapperCtx));
|
|
528
659
|
}
|
|
529
660
|
|
|
530
661
|
/**
|
|
531
662
|
* Get the last completed step for a run.
|
|
532
663
|
* Useful for resuming from a checkpoint.
|
|
533
664
|
*/
|
|
534
|
-
async getLastCompletedStep(
|
|
535
|
-
runId
|
|
536
|
-
|
|
537
|
-
const row = this.stmts!.getLastCompletedStep.get(runId) as
|
|
538
|
-
| SQLiteStepRow
|
|
539
|
-
| undefined;
|
|
540
|
-
return row ? this.mapStepRow(row) : null;
|
|
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;
|
|
541
668
|
}
|
|
542
669
|
|
|
543
670
|
// ============================================================================
|
|
544
671
|
// Row Mapping
|
|
545
672
|
// ============================================================================
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
};
|
|
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 };
|
|
588
683
|
}
|
|
589
684
|
|
|
590
685
|
// ============================================================================
|
|
@@ -602,15 +697,9 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
602
697
|
* Get database statistics.
|
|
603
698
|
*/
|
|
604
699
|
getStats(): { runs: number; steps: number; events: number } {
|
|
605
|
-
const runsCount = this.db
|
|
606
|
-
|
|
607
|
-
|
|
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 };
|
|
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 };
|
|
614
703
|
|
|
615
704
|
return {
|
|
616
705
|
runs: runsCount.count,
|
|
@@ -623,40 +712,6 @@ export class SQLiteStorageAdapter implements StorageAdapter {
|
|
|
623
712
|
// ============================================================================
|
|
624
713
|
// SQLite Row Types
|
|
625
714
|
// ============================================================================
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
}
|
|
715
|
+
//
|
|
716
|
+
// `SQLiteRunRow`, `SQLiteStepRow`, and `SQLiteEventRow` now live in
|
|
717
|
+
// `./sqlite-core.ts` alongside their pure row mappers.
|