@multiplier-labs/stepflow 0.3.3 → 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 (69) 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-11_comment-assessment.md +161 -0
  4. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  5. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  6. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  7. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  8. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  9. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  10. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  11. package/.github/workflows/publish.yml +6 -0
  12. package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
  13. package/dist/chunk-5XJBAPXF.js.map +1 -0
  14. package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
  15. package/dist/chunk-FMPJBK2N.js.map +1 -0
  16. package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
  17. package/dist/chunk-JPXPB7U5.js.map +1 -0
  18. package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
  19. package/dist/chunk-U3PCVK6O.js.map +1 -0
  20. package/dist/events/index.d.ts +56 -3
  21. package/dist/events/index.js +1 -1
  22. package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
  23. package/dist/index.d.ts +2 -12
  24. package/dist/index.js +764 -485
  25. package/dist/index.js.map +1 -1
  26. package/dist/scheduler/index.d.ts +1 -1
  27. package/dist/scheduler/index.js +2 -2
  28. package/dist/storage/index.d.ts +313 -47
  29. package/dist/storage/index.js +4 -2
  30. package/docs/stepflow-api-reference.md +3 -1
  31. package/package.json +7 -4
  32. package/src/core/engine/concurrency.ts +229 -0
  33. package/src/core/engine/lifecycle.ts +176 -0
  34. package/src/core/engine.test.ts +70 -0
  35. package/src/core/engine.ts +91 -185
  36. package/src/core/orchestrator/persistence.ts +165 -0
  37. package/src/core/orchestrator/plan-resolution.ts +140 -0
  38. package/src/core/orchestrator/state-transitions.ts +109 -0
  39. package/src/core/orchestrator/step-runner.ts +427 -0
  40. package/src/core/orchestrator.ts +40 -359
  41. package/src/events/webhook.test.ts +292 -0
  42. package/src/events/webhook.ts +320 -77
  43. package/src/planning/evaluate-condition.test.ts +153 -0
  44. package/src/planning/evaluate-condition.ts +105 -0
  45. package/src/planning/planner.ts +1 -83
  46. package/src/planning/registry.ts +3 -85
  47. package/src/scheduler/cron.test.ts +9 -1
  48. package/src/scheduler/cron.ts +26 -2
  49. package/src/scheduler/postgres-persistence.ts +27 -15
  50. package/src/scheduler/sqlite-persistence.ts +28 -3
  51. package/src/storage/postgres-core.ts +550 -0
  52. package/src/storage/postgres-migration.test.ts +69 -0
  53. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  54. package/src/storage/postgres.ts +171 -690
  55. package/src/storage/sqlite-core.ts +151 -0
  56. package/src/storage/sqlite.test.ts +177 -1
  57. package/src/storage/sqlite.ts +183 -93
  58. package/src/utils/index.ts +2 -0
  59. package/src/utils/pinned-fetch.test.ts +84 -0
  60. package/src/utils/pinned-fetch.ts +118 -0
  61. package/src/utils/postgres-deps.ts +16 -0
  62. package/src/utils/safe-json.test.ts +166 -0
  63. package/src/utils/safe-json.ts +107 -0
  64. package/src/utils/webhook-signature.test.ts +111 -0
  65. package/src/utils/webhook-signature.ts +74 -0
  66. package/dist/chunk-4VOKMGP6.js.map +0 -1
  67. package/dist/chunk-GYPNJBDP.js.map +0 -1
  68. package/dist/chunk-KHTUWLVZ.js.map +0 -1
  69. package/dist/chunk-ML35PIHX.js.map +0 -1
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Shared core for the SQLite storage adapter.
3
+ *
4
+ * Mirrors the structure introduced for Postgres in `postgres-core.ts` (PR #50)
5
+ * so all storage adapters expose row mappers and types in the same shape.
6
+ *
7
+ * The SQLite case is simpler than Postgres: there is only one adapter class
8
+ * (`SQLiteStorageAdapter`), with no transaction-scoped twin. So this module
9
+ * carries only the *pure* portion of the postgres-core split:
10
+ *
11
+ * - SQLite row type interfaces for the three core tables
12
+ * (`workflow_runs`, `workflow_run_steps`, `workflow_events`).
13
+ * - Pure row mappers (`mapRunRow`, `mapStepRow`, `mapEventRow`) that take
14
+ * a `MapperContext = { component, logger }` and route every JSON-column
15
+ * parse through the shared `safeParseField` / `safeParseOptionalField`
16
+ * helpers. Corruption is reported via the configured logger with safe
17
+ * metadata only — the raw value is never logged (audit M2 / L3, PR #52).
18
+ *
19
+ * No abstract base class is needed: the adapter class binds the prepared
20
+ * statements + better-sqlite3 connection in one place and would gain nothing
21
+ * from inheritance. The mappers and row types are extracted purely so they
22
+ * are reusable, testable, and consistent with `postgres-core.ts`.
23
+ */
24
+
25
+ import {
26
+ safeParseField as sharedSafeParseField,
27
+ safeParseOptionalField as sharedSafeParseOptionalField,
28
+ type MapperContext,
29
+ type SafeJsonParseContext,
30
+ } from '../utils/safe-json';
31
+ import type {
32
+ WorkflowRunRecord,
33
+ WorkflowRunStepRecord,
34
+ WorkflowEventRecord,
35
+ } from './types';
36
+ import type { RunStatus, StepStatus, WorkflowError } from '../core/types';
37
+
38
+ /**
39
+ * Re-export the shared `MapperContext` so callers can `import { MapperContext }`
40
+ * from either storage core module.
41
+ */
42
+ export type { MapperContext };
43
+
44
+ // ============================================================================
45
+ // SQLite row types
46
+ // ============================================================================
47
+ //
48
+ // SQLite stores everything as text/integer/real, so timestamps come back as
49
+ // ISO-8601 strings (the adapter writes them via `Date.prototype.toISOString`)
50
+ // and JSON columns come back as the original `JSON.stringify` output. The
51
+ // mappers below convert each row back to the public record shape.
52
+
53
+ /** Row shape for the `workflow_runs` table. */
54
+ export interface SQLiteRunRow {
55
+ id: string;
56
+ kind: string;
57
+ status: string;
58
+ parent_run_id: string | null;
59
+ input_json: string;
60
+ metadata_json: string;
61
+ context_json: string;
62
+ completed_steps_json: string | null;
63
+ error_json: string | null;
64
+ created_at: string;
65
+ started_at: string | null;
66
+ finished_at: string | null;
67
+ }
68
+
69
+ /** Row shape for the `workflow_run_steps` table. */
70
+ export interface SQLiteStepRow {
71
+ id: string;
72
+ run_id: string;
73
+ step_key: string;
74
+ step_name: string;
75
+ status: string;
76
+ attempt: number;
77
+ result_json: string | null;
78
+ error_json: string | null;
79
+ started_at: string | null;
80
+ finished_at: string | null;
81
+ }
82
+
83
+ /** Row shape for the `workflow_events` table. */
84
+ export interface SQLiteEventRow {
85
+ id: string;
86
+ run_id: string;
87
+ step_key: string | null;
88
+ event_type: string;
89
+ level: string;
90
+ payload_json: string | null;
91
+ timestamp: string;
92
+ }
93
+
94
+ // ============================================================================
95
+ // Row mappers (pure)
96
+ // ============================================================================
97
+ //
98
+ // Adapters supply a `MapperContext` ({ component, logger }); each helper
99
+ // extends it with the row id and column being parsed so a structured log
100
+ // emitted on corruption identifies the bad row. The raw value is never
101
+ // logged — see `src/utils/safe-json.ts`.
102
+
103
+ /** Map a `workflow_runs` row to the public `WorkflowRunRecord` shape. */
104
+ export function mapRunRow(row: SQLiteRunRow, ctx: MapperContext): WorkflowRunRecord {
105
+ const fieldCtx = (column: string): SafeJsonParseContext => ({ ...ctx, rowId: row.id, column });
106
+ return {
107
+ id: row.id,
108
+ kind: row.kind,
109
+ status: row.status as RunStatus,
110
+ parentRunId: row.parent_run_id ?? undefined,
111
+ input: sharedSafeParseField(row.input_json, {}, fieldCtx('input_json')) as Record<string, unknown>,
112
+ metadata: sharedSafeParseField(row.metadata_json, {}, fieldCtx('metadata_json')) as Record<string, unknown>,
113
+ context: sharedSafeParseField(row.context_json, {}, fieldCtx('context_json')) as Record<string, unknown>,
114
+ completedSteps: sharedSafeParseOptionalField(row.completed_steps_json, fieldCtx('completed_steps_json')) as string[] | undefined,
115
+ error: sharedSafeParseOptionalField(row.error_json, fieldCtx('error_json')) as WorkflowError | undefined,
116
+ createdAt: new Date(row.created_at),
117
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
118
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
119
+ };
120
+ }
121
+
122
+ /** Map a `workflow_run_steps` row to the public `WorkflowRunStepRecord` shape. */
123
+ export function mapStepRow(row: SQLiteStepRow, ctx: MapperContext): WorkflowRunStepRecord {
124
+ const fieldCtx = (column: string): SafeJsonParseContext => ({ ...ctx, rowId: row.id, column });
125
+ return {
126
+ id: row.id,
127
+ runId: row.run_id,
128
+ stepKey: row.step_key,
129
+ stepName: row.step_name,
130
+ status: row.status as StepStatus,
131
+ attempt: row.attempt,
132
+ result: sharedSafeParseOptionalField(row.result_json, fieldCtx('result_json')),
133
+ error: sharedSafeParseOptionalField(row.error_json, fieldCtx('error_json')) as WorkflowError | undefined,
134
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
135
+ finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
136
+ };
137
+ }
138
+
139
+ /** Map a `workflow_events` row to the public `WorkflowEventRecord` shape. */
140
+ export function mapEventRow(row: SQLiteEventRow, ctx: MapperContext): WorkflowEventRecord {
141
+ const fieldCtx: SafeJsonParseContext = { ...ctx, rowId: row.id, column: 'payload_json' };
142
+ return {
143
+ id: row.id,
144
+ runId: row.run_id,
145
+ stepKey: row.step_key ?? undefined,
146
+ eventType: row.event_type,
147
+ level: row.level as 'info' | 'warn' | 'error',
148
+ payload: sharedSafeParseOptionalField(row.payload_json, fieldCtx),
149
+ timestamp: new Date(row.timestamp),
150
+ };
151
+ }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import Database from 'better-sqlite3';
3
3
  import { SQLiteStorageAdapter } from './sqlite';
4
4
  import type { WorkflowRunRecord, WorkflowRunStepRecord } from './types';
@@ -450,6 +450,38 @@ describe('SQLiteStorageAdapter', () => {
450
450
  });
451
451
  expect(result).toBe('hello');
452
452
  });
453
+
454
+ it('warns when the callback awaits real async I/O (post-COMMIT work is not transactional)', async () => {
455
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
456
+ try {
457
+ const result = await storage.transaction(async () => {
458
+ // setTimeout is a macrotask — by the time it fires, COMMIT is long done.
459
+ await new Promise((resolve) => setTimeout(resolve, 5));
460
+ return 'late';
461
+ });
462
+ expect(result).toBe('late');
463
+ expect(warnSpy).toHaveBeenCalled();
464
+ const message = warnSpy.mock.calls[0]?.[0] ?? '';
465
+ expect(message).toContain('did not settle synchronously');
466
+ expect(message).toContain('transactionSync');
467
+ } finally {
468
+ warnSpy.mockRestore();
469
+ }
470
+ });
471
+
472
+ it('does not warn when the callback completes synchronously', async () => {
473
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
474
+ try {
475
+ await storage.transaction(async () => 1);
476
+ await storage.transaction(async () => {
477
+ await Promise.resolve();
478
+ return 2;
479
+ });
480
+ expect(warnSpy).not.toHaveBeenCalled();
481
+ } finally {
482
+ warnSpy.mockRestore();
483
+ }
484
+ });
453
485
  });
454
486
 
455
487
  describe('getStats', () => {
@@ -463,4 +495,148 @@ describe('SQLiteStorageAdapter', () => {
463
495
  expect(stats.events).toBe(0);
464
496
  });
465
497
  });
498
+
499
+ describe('schema migrations', () => {
500
+ /**
501
+ * The pre-`completed_steps_json` schema. Reproduces the state of a database
502
+ * created by an older version of the adapter, before the column was added.
503
+ * Includes the unchanged sibling tables so prepared statements that touch
504
+ * `workflow_run_steps` / `workflow_events` find them on construction.
505
+ */
506
+ const LEGACY_SCHEMA_SQL = `
507
+ CREATE TABLE workflow_runs (
508
+ id TEXT PRIMARY KEY,
509
+ kind TEXT NOT NULL,
510
+ status TEXT NOT NULL,
511
+ parent_run_id TEXT,
512
+ input_json TEXT NOT NULL,
513
+ metadata_json TEXT NOT NULL,
514
+ context_json TEXT NOT NULL,
515
+ error_json TEXT,
516
+ created_at TEXT NOT NULL,
517
+ started_at TEXT,
518
+ finished_at TEXT
519
+ );
520
+ CREATE TABLE workflow_run_steps (
521
+ id TEXT PRIMARY KEY,
522
+ run_id TEXT NOT NULL,
523
+ step_key TEXT NOT NULL,
524
+ step_name TEXT,
525
+ status TEXT NOT NULL,
526
+ attempt INTEGER NOT NULL DEFAULT 1,
527
+ result_json TEXT,
528
+ error_json TEXT,
529
+ started_at TEXT,
530
+ finished_at TEXT,
531
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
532
+ );
533
+ CREATE TABLE workflow_events (
534
+ id TEXT PRIMARY KEY,
535
+ run_id TEXT NOT NULL,
536
+ step_key TEXT,
537
+ event_type TEXT NOT NULL,
538
+ level TEXT NOT NULL,
539
+ payload_json TEXT,
540
+ timestamp TEXT NOT NULL,
541
+ FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
542
+ );
543
+ `;
544
+
545
+ function columnNames(database: Database.Database, table: string): string[] {
546
+ const rows = database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
547
+ return rows.map((r) => r.name);
548
+ }
549
+
550
+ it('adds completed_steps_json to a legacy workflow_runs table on startup', () => {
551
+ // Regression: this is the exact column that broke dev-benchmark when an
552
+ // older deploy's `workflow_runs` survived an upgrade. Renaming the column
553
+ // requires updating this assertion AND `SCHEMA_MIGRATIONS` in sqlite.ts.
554
+ const legacyDb = new Database(':memory:');
555
+ legacyDb.exec(LEGACY_SCHEMA_SQL);
556
+ expect(columnNames(legacyDb, 'workflow_runs')).not.toContain('completed_steps_json');
557
+
558
+ const adapter = new SQLiteStorageAdapter({ db: legacyDb });
559
+ try {
560
+ expect(columnNames(legacyDb, 'workflow_runs')).toContain('completed_steps_json');
561
+ } finally {
562
+ adapter.close();
563
+ }
564
+ });
565
+
566
+ it('persists completedSteps after migrating a legacy table', async () => {
567
+ const legacyDb = new Database(':memory:');
568
+ legacyDb.exec(LEGACY_SCHEMA_SQL);
569
+
570
+ const adapter = new SQLiteStorageAdapter({ db: legacyDb });
571
+ try {
572
+ const run = await adapter.createRun({
573
+ kind: 'test.workflow',
574
+ status: 'running',
575
+ input: {},
576
+ metadata: {},
577
+ context: {},
578
+ completedSteps: ['step1', 'step2'],
579
+ });
580
+
581
+ const retrieved = await adapter.getRun(run.id);
582
+ expect(retrieved!.completedSteps).toEqual(['step1', 'step2']);
583
+ } finally {
584
+ adapter.close();
585
+ }
586
+ });
587
+
588
+ it('is a no-op on a fresh database', () => {
589
+ // Pure additive: the second adapter on the same DB must not error or
590
+ // change anything when every column is already present.
591
+ const freshDb = new Database(':memory:');
592
+ const adapter1 = new SQLiteStorageAdapter({ db: freshDb });
593
+ const before = columnNames(freshDb, 'workflow_runs');
594
+
595
+ // Simulate a second startup against a fully-migrated DB.
596
+ const adapter2 = new SQLiteStorageAdapter({ db: freshDb });
597
+ const after = columnNames(freshDb, 'workflow_runs');
598
+
599
+ try {
600
+ expect(after).toEqual(before);
601
+ expect(after).toContain('completed_steps_json');
602
+ } finally {
603
+ adapter1.close();
604
+ adapter2.close();
605
+ }
606
+ });
607
+
608
+ it('still applies migrations when autoCreateTables is false', () => {
609
+ // The prepared statements always reference the latest schema, so we have
610
+ // to reconcile columns even when the caller manages CREATE TABLE.
611
+ const legacyDb = new Database(':memory:');
612
+ legacyDb.exec(LEGACY_SCHEMA_SQL);
613
+
614
+ const adapter = new SQLiteStorageAdapter({ db: legacyDb, autoCreateTables: false });
615
+ try {
616
+ expect(columnNames(legacyDb, 'workflow_runs')).toContain('completed_steps_json');
617
+ } finally {
618
+ adapter.close();
619
+ }
620
+ });
621
+
622
+ it('is idempotent across multiple instantiations', () => {
623
+ // Migrating a DB twice must not error or duplicate columns — exercises
624
+ // the existence-check skip path for an already-migrated column.
625
+ const legacyDb = new Database(':memory:');
626
+ legacyDb.exec(LEGACY_SCHEMA_SQL);
627
+
628
+ // First adapter migrates the legacy table.
629
+ new SQLiteStorageAdapter({ db: legacyDb });
630
+ const after1 = columnNames(legacyDb, 'workflow_runs');
631
+ expect(after1).toContain('completed_steps_json');
632
+
633
+ // Second adapter on the same DB must be a no-op.
634
+ const second = new SQLiteStorageAdapter({ db: legacyDb });
635
+ try {
636
+ expect(columnNames(legacyDb, 'workflow_runs')).toEqual(after1);
637
+ } finally {
638
+ second.close();
639
+ }
640
+ });
641
+ });
466
642
  });