@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.
Files changed (112) hide show
  1. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
  2. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
  3. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
  4. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
  5. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  6. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
  7. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  8. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  9. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
  10. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  11. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
  12. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  13. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  14. package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
  15. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  16. package/.github/CODEOWNERS +15 -0
  17. package/.github/dependabot.yml +23 -0
  18. package/.github/workflows/ci.yml +38 -0
  19. package/.github/workflows/publish.yml +17 -5
  20. package/.github/workflows/security.yml +29 -0
  21. package/CHANGELOG.md +34 -0
  22. package/README.md +24 -0
  23. package/SECURITY.md +40 -0
  24. package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
  25. package/dist/chunk-5XJBAPXF.js.map +1 -0
  26. package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
  27. package/dist/chunk-FMPJBK2N.js.map +1 -0
  28. package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
  29. package/dist/chunk-JPXPB7U5.js.map +1 -0
  30. package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
  31. package/dist/chunk-U3PCVK6O.js.map +1 -0
  32. package/dist/events/index.d.ts +60 -7
  33. package/dist/events/index.js +1 -1
  34. package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
  35. package/dist/index.d.ts +15 -23
  36. package/dist/index.js +784 -547
  37. package/dist/index.js.map +1 -1
  38. package/dist/scheduler/index.d.ts +4 -4
  39. package/dist/scheduler/index.js +2 -2
  40. package/dist/storage/index.d.ts +322 -56
  41. package/dist/storage/index.js +4 -2
  42. package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
  43. package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
  44. package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
  45. package/docs/stepflow-api-reference.md +5 -4
  46. package/package.json +12 -8
  47. package/src/core/engine/concurrency.ts +229 -0
  48. package/src/core/engine/lifecycle.ts +176 -0
  49. package/src/core/engine.test.ts +536 -522
  50. package/src/core/engine.ts +116 -235
  51. package/src/core/index.ts +3 -3
  52. package/src/core/orchestrator/persistence.ts +165 -0
  53. package/src/core/orchestrator/plan-resolution.ts +140 -0
  54. package/src/core/orchestrator/state-transitions.ts +109 -0
  55. package/src/core/orchestrator/step-runner.ts +427 -0
  56. package/src/core/orchestrator.ts +57 -395
  57. package/src/core/types.ts +7 -29
  58. package/src/events/index.ts +4 -4
  59. package/src/events/memory.test.ts +39 -41
  60. package/src/events/memory.ts +7 -10
  61. package/src/events/socketio.test.ts +112 -162
  62. package/src/events/socketio.ts +20 -27
  63. package/src/events/types.ts +16 -19
  64. package/src/events/webhook.test.ts +489 -235
  65. package/src/events/webhook.ts +355 -139
  66. package/src/index.ts +19 -19
  67. package/src/planning/evaluate-condition.test.ts +153 -0
  68. package/src/planning/evaluate-condition.ts +105 -0
  69. package/src/planning/index.ts +6 -3
  70. package/src/planning/planner.ts +45 -152
  71. package/src/planning/planning.test.ts +303 -366
  72. package/src/planning/registry.test.ts +204 -251
  73. package/src/planning/registry.ts +20 -106
  74. package/src/planning/types.ts +23 -27
  75. package/src/scheduler/cron.test.ts +149 -150
  76. package/src/scheduler/cron.ts +58 -69
  77. package/src/scheduler/index.ts +4 -14
  78. package/src/scheduler/postgres-persistence.ts +89 -105
  79. package/src/scheduler/sqlite-persistence.test.ts +96 -96
  80. package/src/scheduler/sqlite-persistence.ts +40 -22
  81. package/src/scheduler/types.ts +4 -9
  82. package/src/storage/index.ts +5 -5
  83. package/src/storage/memory.test.ts +133 -183
  84. package/src/storage/memory.ts +21 -42
  85. package/src/storage/postgres-core.ts +550 -0
  86. package/src/storage/postgres-migration.test.ts +69 -0
  87. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  88. package/src/storage/postgres.ts +222 -937
  89. package/src/storage/sqlite-core.ts +151 -0
  90. package/src/storage/sqlite.test.ts +330 -281
  91. package/src/storage/sqlite.ts +225 -170
  92. package/src/storage/types.ts +33 -58
  93. package/src/utils/errors.test.ts +78 -80
  94. package/src/utils/errors.ts +27 -57
  95. package/src/utils/id.ts +3 -3
  96. package/src/utils/index.ts +6 -4
  97. package/src/utils/logger.test.ts +52 -58
  98. package/src/utils/logger.ts +4 -4
  99. package/src/utils/pinned-fetch.test.ts +84 -0
  100. package/src/utils/pinned-fetch.ts +118 -0
  101. package/src/utils/postgres-deps.ts +20 -4
  102. package/src/utils/retry.test.ts +47 -67
  103. package/src/utils/retry.ts +9 -9
  104. package/src/utils/safe-json.test.ts +166 -0
  105. package/src/utils/safe-json.ts +107 -0
  106. package/src/utils/webhook-signature.test.ts +111 -0
  107. package/src/utils/webhook-signature.ts +74 -0
  108. package/dist/chunk-FQCT3I2A.js.map +0 -1
  109. package/dist/chunk-GXY66V7Q.js.map +0 -1
  110. package/dist/chunk-U5QGN3RT.js.map +0 -1
  111. package/dist/chunk-XCF4PLN7.js.map +0 -1
  112. package/src/scheduler/postgres-persistence.test.ts +0 -906
@@ -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 "better-sqlite3";
8
- import { generateId } from "../utils/id";
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 "./types";
18
- import type { RunStatus, StepStatus, WorkflowError } from "../core/types";
19
- import { sanitizeErrorForStorage } from "../utils/logger";
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 ?? "workflow";
258
+ this.prefix = config.tablePrefix ?? 'workflow';
259
+ this.logger = config.logger;
162
260
 
163
261
  // Enable foreign keys
164
- this.db.pragma("foreign_keys = ON");
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 ? this.mapRunRow(row) : null;
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.error
320
- ? JSON.stringify(sanitizeErrorForStorage(updates.error))
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
- kind,
343
- statusJson,
344
- statusJson,
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
- kind,
354
- statusJson,
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((row) => this.mapRunRow(row)),
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 ? this.mapStepRow(row) : null;
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((row) => this.mapStepRow(row));
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, "id">): Promise<void> {
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
- stepKey,
450
- level,
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((row) => this.mapEventRow(row));
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 `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.
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((row) => this.mapRunRow(row));
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: 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;
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
- 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
- };
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
- .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 };
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
- 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
- }
715
+ //
716
+ // `SQLiteRunRow`, `SQLiteStepRow`, and `SQLiteEventRow` now live in
717
+ // `./sqlite-core.ts` alongside their pure row mappers.