@multiplier-labs/stepflow 0.3.4 → 0.3.8

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 (109) hide show
  1. package/README.md +9 -0
  2. package/dist/{chunk-5XJBAPXF.js → chunk-4IDPWWGS.js} +15 -4
  3. package/dist/chunk-4IDPWWGS.js.map +1 -0
  4. package/dist/{chunk-U3PCVK6O.js → chunk-T6GAZBTQ.js} +70 -38
  5. package/dist/chunk-T6GAZBTQ.js.map +1 -0
  6. package/dist/{chunk-FMPJBK2N.js → chunk-TVSADUYU.js} +3 -5
  7. package/dist/{chunk-FMPJBK2N.js.map → chunk-TVSADUYU.js.map} +1 -1
  8. package/dist/events/index.d.ts +43 -3
  9. package/dist/events/index.js +7 -3
  10. package/dist/{index-Cd0IZ40L.d.ts → index-CdXDv30T.d.ts} +10 -2
  11. package/dist/index.d.ts +5 -4
  12. package/dist/index.js +159 -46
  13. package/dist/index.js.map +1 -1
  14. package/dist/scheduler/index.d.ts +2 -2
  15. package/dist/scheduler/index.js +1 -1
  16. package/dist/storage/index.d.ts +6 -4
  17. package/dist/storage/index.js +1 -1
  18. package/dist/{types-DSEjiLMj.d.ts → types-BfWe5yP6.d.ts} +2 -0
  19. package/package.json +26 -14
  20. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +0 -146
  21. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +0 -74
  22. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
  23. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +0 -161
  24. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +0 -34
  25. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
  26. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +0 -152
  27. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +0 -34
  28. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
  29. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +0 -93
  30. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
  31. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +0 -138
  32. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +0 -121
  33. package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
  34. package/.codekin/reports/security/2026-04-11_security-audit.md +0 -297
  35. package/.github/CODEOWNERS +0 -15
  36. package/.github/dependabot.yml +0 -23
  37. package/.github/workflows/ci.yml +0 -38
  38. package/.github/workflows/publish.yml +0 -42
  39. package/.github/workflows/security.yml +0 -29
  40. package/CHANGELOG.md +0 -34
  41. package/SECURITY.md +0 -40
  42. package/dist/chunk-5XJBAPXF.js.map +0 -1
  43. package/dist/chunk-U3PCVK6O.js.map +0 -1
  44. package/docs/github-packages-setup.md +0 -97
  45. package/docs/stepflow-api-reference.md +0 -1663
  46. package/src/core/engine/concurrency.ts +0 -229
  47. package/src/core/engine/lifecycle.ts +0 -176
  48. package/src/core/engine.test.ts +0 -1435
  49. package/src/core/engine.ts +0 -598
  50. package/src/core/index.ts +0 -7
  51. package/src/core/orchestrator/persistence.ts +0 -165
  52. package/src/core/orchestrator/plan-resolution.ts +0 -140
  53. package/src/core/orchestrator/state-transitions.ts +0 -109
  54. package/src/core/orchestrator/step-runner.ts +0 -427
  55. package/src/core/orchestrator.ts +0 -252
  56. package/src/core/types.ts +0 -220
  57. package/src/events/index.ts +0 -8
  58. package/src/events/memory.test.ts +0 -107
  59. package/src/events/memory.ts +0 -92
  60. package/src/events/socketio.test.ts +0 -378
  61. package/src/events/socketio.ts +0 -262
  62. package/src/events/types.ts +0 -118
  63. package/src/events/webhook.test.ts +0 -949
  64. package/src/events/webhook.ts +0 -807
  65. package/src/index.ts +0 -270
  66. package/src/planning/evaluate-condition.test.ts +0 -153
  67. package/src/planning/evaluate-condition.ts +0 -105
  68. package/src/planning/index.ts +0 -55
  69. package/src/planning/planner.ts +0 -494
  70. package/src/planning/planning.test.ts +0 -727
  71. package/src/planning/registry.test.ts +0 -398
  72. package/src/planning/registry.ts +0 -283
  73. package/src/planning/types.ts +0 -566
  74. package/src/scheduler/cron.test.ts +0 -479
  75. package/src/scheduler/cron.ts +0 -438
  76. package/src/scheduler/index.ts +0 -8
  77. package/src/scheduler/postgres-persistence.ts +0 -530
  78. package/src/scheduler/sqlite-persistence.test.ts +0 -235
  79. package/src/scheduler/sqlite-persistence.ts +0 -286
  80. package/src/scheduler/types.ts +0 -73
  81. package/src/storage/index.ts +0 -27
  82. package/src/storage/memory.test.ts +0 -349
  83. package/src/storage/memory.ts +0 -227
  84. package/src/storage/postgres-core.ts +0 -550
  85. package/src/storage/postgres-migration.test.ts +0 -69
  86. package/src/storage/postgres-transaction-pagination.test.ts +0 -107
  87. package/src/storage/postgres.ts +0 -835
  88. package/src/storage/sqlite-core.ts +0 -151
  89. package/src/storage/sqlite.test.ts +0 -642
  90. package/src/storage/sqlite.ts +0 -717
  91. package/src/storage/types.ts +0 -343
  92. package/src/utils/errors.test.ts +0 -150
  93. package/src/utils/errors.ts +0 -161
  94. package/src/utils/id.ts +0 -21
  95. package/src/utils/index.ts +0 -10
  96. package/src/utils/logger.test.ts +0 -121
  97. package/src/utils/logger.ts +0 -96
  98. package/src/utils/pinned-fetch.test.ts +0 -84
  99. package/src/utils/pinned-fetch.ts +0 -118
  100. package/src/utils/postgres-deps.ts +0 -79
  101. package/src/utils/retry.test.ts +0 -177
  102. package/src/utils/retry.ts +0 -121
  103. package/src/utils/safe-json.test.ts +0 -166
  104. package/src/utils/safe-json.ts +0 -107
  105. package/src/utils/webhook-signature.test.ts +0 -111
  106. package/src/utils/webhook-signature.ts +0 -74
  107. package/tsconfig.json +0 -21
  108. package/tsup.config.ts +0 -14
  109. package/vitest.config.ts +0 -9
@@ -1,121 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { ConsoleLogger, SilentLogger, createScopedLogger } from './logger';
3
-
4
- describe('ConsoleLogger', () => {
5
- it('should log debug messages with prefix', () => {
6
- const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
7
- const logger = new ConsoleLogger('[test]', 'debug');
8
-
9
- logger.debug('hello', { extra: true });
10
-
11
- expect(spy).toHaveBeenCalledWith('[test] [DEBUG]', 'hello', { extra: true });
12
- spy.mockRestore();
13
- });
14
-
15
- it('should log info messages with prefix', () => {
16
- const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
17
- const logger = new ConsoleLogger('[test]');
18
-
19
- logger.info('info msg');
20
-
21
- expect(spy).toHaveBeenCalledWith('[test] [INFO]', 'info msg');
22
- spy.mockRestore();
23
- });
24
-
25
- it('should log warn messages with prefix', () => {
26
- const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
27
- const logger = new ConsoleLogger('[test]');
28
-
29
- logger.warn('warning');
30
-
31
- expect(spy).toHaveBeenCalledWith('[test] [WARN]', 'warning');
32
- spy.mockRestore();
33
- });
34
-
35
- it('should log error messages with prefix', () => {
36
- const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
37
- const logger = new ConsoleLogger('[test]');
38
-
39
- logger.error('error msg', new Error('boom'));
40
-
41
- expect(spy).toHaveBeenCalledWith('[test] [ERROR]', 'error msg', expect.any(Error));
42
- spy.mockRestore();
43
- });
44
-
45
- it('should use default prefix when none provided', () => {
46
- const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
47
- const logger = new ConsoleLogger();
48
-
49
- logger.info('test');
50
-
51
- expect(spy).toHaveBeenCalledWith('[workflow] [INFO]', 'test');
52
- spy.mockRestore();
53
- });
54
- });
55
-
56
- describe('SilentLogger', () => {
57
- it('should not throw on any log level', () => {
58
- const logger = new SilentLogger();
59
-
60
- expect(() => logger.debug('test')).not.toThrow();
61
- expect(() => logger.info('test')).not.toThrow();
62
- expect(() => logger.warn('test')).not.toThrow();
63
- expect(() => logger.error('test')).not.toThrow();
64
- });
65
- });
66
-
67
- describe('createScopedLogger', () => {
68
- it('should prefix messages with run ID', () => {
69
- const calls: string[] = [];
70
- const baseLogger = {
71
- debug: (msg: string) => calls.push(`debug:${msg}`),
72
- info: (msg: string) => calls.push(`info:${msg}`),
73
- warn: (msg: string) => calls.push(`warn:${msg}`),
74
- error: (msg: string) => calls.push(`error:${msg}`),
75
- };
76
-
77
- const scoped = createScopedLogger(baseLogger, 'run-123');
78
-
79
- scoped.debug('d');
80
- scoped.info('i');
81
- scoped.warn('w');
82
- scoped.error('e');
83
-
84
- expect(calls).toEqual([
85
- 'debug:[run:run-123] d',
86
- 'info:[run:run-123] i',
87
- 'warn:[run:run-123] w',
88
- 'error:[run:run-123] e',
89
- ]);
90
- });
91
-
92
- it('should include step key when provided', () => {
93
- const calls: string[] = [];
94
- const baseLogger = {
95
- debug: (msg: string) => calls.push(msg),
96
- info: (msg: string) => calls.push(msg),
97
- warn: (msg: string) => calls.push(msg),
98
- error: (msg: string) => calls.push(msg),
99
- };
100
-
101
- const scoped = createScopedLogger(baseLogger, 'run-1', 'step-a');
102
- scoped.info('hello');
103
-
104
- expect(calls[0]).toBe('[run:run-1][step:step-a] hello');
105
- });
106
-
107
- it('should forward extra arguments', () => {
108
- const captured: unknown[][] = [];
109
- const baseLogger = {
110
- debug: (...args: unknown[]) => captured.push(args),
111
- info: (...args: unknown[]) => captured.push(args),
112
- warn: (...args: unknown[]) => captured.push(args),
113
- error: (...args: unknown[]) => captured.push(args),
114
- };
115
-
116
- const scoped = createScopedLogger(baseLogger, 'r1');
117
- scoped.info('msg', { data: 1 }, 42);
118
-
119
- expect(captured[0]).toEqual(['[run:r1] msg', { data: 1 }, 42]);
120
- });
121
- });
@@ -1,96 +0,0 @@
1
- /**
2
- * Logger utilities for the workflow engine.
3
- */
4
-
5
- import type { Logger, WorkflowError } from '../core/types';
6
-
7
- /** Log level for ConsoleLogger. Levels are ordered: debug < info < warn < error. */
8
- export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
9
-
10
- const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
11
- debug: 0,
12
- info: 1,
13
- warn: 2,
14
- error: 3,
15
- };
16
-
17
- /**
18
- * Console-based logger implementation.
19
- * Uses console.log with prefixes for different levels.
20
- * Supports a configurable minimum log level (default: 'info').
21
- */
22
- export class ConsoleLogger implements Logger {
23
- private prefix: string;
24
- private minLevel: number;
25
-
26
- constructor(prefix = '[workflow]', level: LogLevel = 'info') {
27
- this.prefix = prefix;
28
- this.minLevel = LOG_LEVEL_PRIORITY[level];
29
- }
30
-
31
- debug(message: string, ...args: unknown[]): void {
32
- if (this.minLevel <= LOG_LEVEL_PRIORITY.debug) {
33
- console.debug(`${this.prefix} [DEBUG]`, message, ...args);
34
- }
35
- }
36
-
37
- info(message: string, ...args: unknown[]): void {
38
- if (this.minLevel <= LOG_LEVEL_PRIORITY.info) {
39
- console.info(`${this.prefix} [INFO]`, message, ...args);
40
- }
41
- }
42
-
43
- warn(message: string, ...args: unknown[]): void {
44
- if (this.minLevel <= LOG_LEVEL_PRIORITY.warn) {
45
- console.warn(`${this.prefix} [WARN]`, message, ...args);
46
- }
47
- }
48
-
49
- error(message: string, ...args: unknown[]): void {
50
- if (this.minLevel <= LOG_LEVEL_PRIORITY.error) {
51
- console.error(`${this.prefix} [ERROR]`, message, ...args);
52
- }
53
- }
54
- }
55
-
56
- /**
57
- * Silent logger that does nothing.
58
- * Useful for testing or when logs are not wanted.
59
- */
60
- export class SilentLogger implements Logger {
61
- debug(): void {}
62
- info(): void {}
63
- warn(): void {}
64
- error(): void {}
65
- }
66
-
67
- /**
68
- * Strip stack traces from a WorkflowError before persisting to storage.
69
- * Stack traces expose internal file paths and should be kept in logs only.
70
- */
71
- export function sanitizeErrorForStorage(error: WorkflowError): WorkflowError {
72
- const { stack: _stack, ...rest } = error;
73
- return rest;
74
- }
75
-
76
- /**
77
- * Create a scoped logger that includes run/step context.
78
- */
79
- export function createScopedLogger(
80
- logger: Logger,
81
- runId: string,
82
- stepKey?: string
83
- ): Logger {
84
- const prefix = stepKey ? `[run:${runId}][step:${stepKey}]` : `[run:${runId}]`;
85
-
86
- return {
87
- debug: (message: string, ...args: unknown[]) =>
88
- logger.debug(`${prefix} ${message}`, ...args),
89
- info: (message: string, ...args: unknown[]) =>
90
- logger.info(`${prefix} ${message}`, ...args),
91
- warn: (message: string, ...args: unknown[]) =>
92
- logger.warn(`${prefix} ${message}`, ...args),
93
- error: (message: string, ...args: unknown[]) =>
94
- logger.error(`${prefix} ${message}`, ...args),
95
- };
96
- }
@@ -1,84 +0,0 @@
1
- /**
2
- * pinnedFetch is the linchpin of the M1 (DNS rebinding TOCTOU) fix: by
3
- * passing a `lookup` function that returns a caller-supplied IP, the TCP
4
- * connection cannot be re-resolved to a different address. We test it
5
- * against a real loopback HTTP server because ESM forbids spying on the
6
- * `http` module's exports and we want to exercise the real request path
7
- * end-to-end.
8
- */
9
-
10
- import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11
- import * as http from 'node:http';
12
- import { pinnedFetch } from './pinned-fetch';
13
-
14
- describe('pinnedFetch', () => {
15
- let server: http.Server;
16
- let port: number;
17
- let lastRequest: { hostHeader?: string; method?: string; path?: string; body: string } = {
18
- body: '',
19
- };
20
-
21
- beforeAll(async () => {
22
- server = http.createServer((req, res) => {
23
- const chunks: Buffer[] = [];
24
- req.on('data', (c: Buffer) => chunks.push(c));
25
- req.on('end', () => {
26
- lastRequest = {
27
- hostHeader: req.headers.host,
28
- method: req.method,
29
- path: req.url,
30
- body: Buffer.concat(chunks).toString('utf8'),
31
- };
32
- res.writeHead(204, 'No Content');
33
- res.end();
34
- });
35
- });
36
- await new Promise<void>((resolve) => {
37
- server.listen(0, '127.0.0.1', () => resolve());
38
- });
39
- port = (server.address() as { port: number }).port;
40
- });
41
-
42
- afterAll(async () => {
43
- await new Promise<void>((resolve) => server.close(() => resolve()));
44
- });
45
-
46
- it('connects to the pinned IP even when the URL hostname is unrelated', async () => {
47
- // The URL hostname is one that would never resolve to 127.0.0.1 via DNS.
48
- // pinnedFetch must ignore the URL's would-be DNS answer and connect to
49
- // the pinned address. If pinning is broken, the connection will fail
50
- // or hit a different host.
51
- const response = await pinnedFetch(
52
- `http://rebind.invalid:${port}/hook`,
53
- { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"ok":1}' },
54
- '127.0.0.1',
55
- 4,
56
- );
57
-
58
- expect(response.ok).toBe(true);
59
- expect(response.status).toBe(204);
60
- expect(lastRequest.method).toBe('POST');
61
- expect(lastRequest.path).toBe('/hook');
62
- expect(lastRequest.body).toBe('{"ok":1}');
63
- });
64
-
65
- it('preserves the original hostname in the Host header (vhost / TLS SNI)', async () => {
66
- await pinnedFetch(
67
- `http://example.test:${port}/`,
68
- { method: 'GET' },
69
- '127.0.0.1',
70
- 4,
71
- );
72
-
73
- // The Host header must reflect the URL hostname, not the pinned IP, so
74
- // that virtual-host routing and TLS certificate validation work.
75
- expect(lastRequest.hostHeader).toContain('example.test');
76
- });
77
-
78
- it('rejects when the connection cannot be established', async () => {
79
- // Pin to a non-listening port on loopback.
80
- await expect(
81
- pinnedFetch(`http://example.test:1/`, { method: 'GET' }, '127.0.0.1', 4),
82
- ).rejects.toThrow();
83
- });
84
- });
@@ -1,118 +0,0 @@
1
- /**
2
- * DNS-pinned fetch helper.
3
- *
4
- * Performs an HTTP(S) request that connects to a caller-supplied IP address
5
- * instead of re-resolving the URL hostname. Used by the webhook transport to
6
- * close the SSRF DNS-rebinding TOCTOU window: the hostname is resolved
7
- * exactly once (by the caller), the resolved IP is validated, and the
8
- * resolved IP is then pinned for the connection so the request cannot be
9
- * rerouted by a hostile DNS server.
10
- *
11
- * The original hostname is preserved for TLS SNI / certificate verification
12
- * and for the HTTP `Host` header.
13
- *
14
- * Audit: 2026-04-27 finding M1 — DNS TOCTOU between SSRF validation and
15
- * connect.
16
- */
17
-
18
- import * as http from 'node:http';
19
- import * as https from 'node:https';
20
- import type { LookupAddress, LookupOptions } from 'node:dns';
21
-
22
- /** Minimal fetch-compatible Response surface used by the webhook transport. */
23
- export interface PinnedFetchResponse {
24
- ok: boolean;
25
- status: number;
26
- statusText: string;
27
- }
28
-
29
- /** Subset of `RequestInit` accepted by `pinnedFetch`. */
30
- export interface PinnedFetchInit {
31
- method?: string;
32
- headers?: Record<string, string>;
33
- body?: string;
34
- signal?: AbortSignal;
35
- }
36
-
37
- /**
38
- * Make an HTTP(S) request that connects to `address` instead of resolving
39
- * `url`'s hostname. Returns a minimal `Response`-shaped object compatible
40
- * with the webhook transport's status / ok checks.
41
- *
42
- * @throws Error on transport failure or abort
43
- */
44
- export function pinnedFetch(
45
- url: string,
46
- init: PinnedFetchInit,
47
- address: string,
48
- family: 4 | 6 = 4,
49
- ): Promise<PinnedFetchResponse> {
50
- const parsed = new URL(url);
51
- const isHttps = parsed.protocol === 'https:';
52
- const lib = isHttps ? https : http;
53
- const port = parsed.port ? Number(parsed.port) : isHttps ? 443 : 80;
54
-
55
- // `lookup` is called by the agent's socket-create path. By returning the
56
- // pre-validated address here we guarantee the TCP connection target is the
57
- // IP whose safety the caller already verified. Node may invoke this with
58
- // `{ all: true }` (expects an array result) or single-address mode
59
- // (expects positional address/family) — handle both for cross-version safety.
60
- const lookup = (
61
- _hostname: string,
62
- options: LookupOptions,
63
- cb: (err: NodeJS.ErrnoException | null, addr: string | LookupAddress[], fam?: number) => void,
64
- ): void => {
65
- if (options && options.all === true) {
66
- cb(null, [{ address, family }]);
67
- } else {
68
- cb(null, address, family);
69
- }
70
- };
71
-
72
- return new Promise<PinnedFetchResponse>((resolve, reject) => {
73
- const req = lib.request(
74
- {
75
- hostname: parsed.hostname,
76
- port,
77
- path: `${parsed.pathname || '/'}${parsed.search}`,
78
- method: init.method ?? 'GET',
79
- headers: init.headers,
80
- lookup,
81
- servername: parsed.hostname,
82
- },
83
- (res) => {
84
- // Drain the body — we only inspect status, but leaving the socket
85
- // half-read would leak it.
86
- res.resume();
87
- res.on('end', () => {
88
- const status = res.statusCode ?? 0;
89
- resolve({
90
- ok: status >= 200 && status < 300,
91
- status,
92
- statusText: res.statusMessage ?? '',
93
- });
94
- });
95
- res.on('error', reject);
96
- },
97
- );
98
-
99
- req.on('error', reject);
100
-
101
- if (init.signal) {
102
- if (init.signal.aborted) {
103
- req.destroy(new Error('Request aborted'));
104
- } else {
105
- init.signal.addEventListener(
106
- 'abort',
107
- () => req.destroy(new Error('Request aborted')),
108
- { once: true },
109
- );
110
- }
111
- }
112
-
113
- if (init.body !== undefined) {
114
- req.write(init.body);
115
- }
116
- req.end();
117
- });
118
- }
@@ -1,79 +0,0 @@
1
- /**
2
- * Shared lazy-loader for PostgreSQL dependencies (kysely + pg).
3
- *
4
- * Both `PostgresStorageAdapter` and `PostgresSchedulePersistence` need the same
5
- * set of optional peer dependencies. This module loads them once and caches the
6
- * result so the logic isn't duplicated across files.
7
- */
8
-
9
- /**
10
- * Dynamic imports prevent static type resolution for these modules;
11
- * `any` is intentional here — callers cast to the appropriate Kysely/pg types.
12
- */
13
- export interface PostgresDeps {
14
- Kysely: any;
15
- PostgresDialect: any;
16
- sql: any;
17
- pgModule: any;
18
- }
19
-
20
- let cached: PostgresDeps | undefined;
21
- let loadingPromise: Promise<PostgresDeps> | undefined;
22
-
23
- /**
24
- * Lazily load and cache the PostgreSQL peer dependencies (kysely + pg).
25
- * @returns The cached `{Kysely, PostgresDialect, sql, pgModule}` object.
26
- * @throws {Error} If the `kysely` optional peer dependency is not installed.
27
- * @throws {Error} If the `pg` optional peer dependency is not installed.
28
- */
29
- export async function loadPostgresDeps(): Promise<PostgresDeps> {
30
- if (cached) return cached;
31
- if (loadingPromise) return loadingPromise;
32
-
33
- loadingPromise = (async () => {
34
- let Kysely: any;
35
- let PostgresDialect: any;
36
- let sql: any;
37
- let pgModule: any;
38
-
39
- try {
40
- const kyselyMod = await import('kysely');
41
- Kysely = kyselyMod.Kysely;
42
- PostgresDialect = kyselyMod.PostgresDialect;
43
- sql = kyselyMod.sql;
44
- } catch {
45
- throw new Error(
46
- 'PostgreSQL support requires the "kysely" package. Install it with: npm install kysely'
47
- );
48
- }
49
- try {
50
- const pg = await import('pg');
51
- pgModule = pg.default ?? pg;
52
- } catch {
53
- throw new Error(
54
- 'PostgreSQL support requires the "pg" package. Install it with: npm install pg'
55
- );
56
- }
57
-
58
- cached = { Kysely, PostgresDialect, sql, pgModule };
59
- return cached;
60
- })();
61
-
62
- return loadingPromise;
63
- }
64
-
65
- /**
66
- * Synchronously access the cached PostgreSQL deps. Throws if
67
- * `loadPostgresDeps()` has not yet completed. Used by code paths that run
68
- * after `PostgresStorageAdapter.initialize()` and need direct access to
69
- * `sql` / `Kysely` without re-awaiting.
70
- */
71
- export function requirePostgresDeps(): PostgresDeps {
72
- if (!cached) {
73
- throw new Error(
74
- 'PostgreSQL dependencies have not been loaded yet. ' +
75
- 'Call loadPostgresDeps() (or PostgresStorageAdapter.initialize()) first.'
76
- );
77
- }
78
- return cached;
79
- }
@@ -1,177 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { sleep, withRetry, calculateRetryDelay, DEFAULT_RETRY_OPTIONS } from './retry';
3
- import { WorkflowCanceledError } from './errors';
4
-
5
- describe('sleep', () => {
6
- it('should resolve after the given time', async () => {
7
- const start = Date.now();
8
- await sleep(50);
9
- const elapsed = Date.now() - start;
10
- expect(elapsed).toBeGreaterThanOrEqual(40);
11
- });
12
-
13
- it('should reject immediately if signal is already aborted', async () => {
14
- const controller = new AbortController();
15
- controller.abort();
16
-
17
- await expect(sleep(1000, controller.signal)).rejects.toThrow(WorkflowCanceledError);
18
- });
19
-
20
- it('should reject when signal is aborted during sleep', async () => {
21
- const controller = new AbortController();
22
-
23
- const promise = sleep(5000, controller.signal);
24
- setTimeout(() => controller.abort(), 20);
25
-
26
- await expect(promise).rejects.toThrow(WorkflowCanceledError);
27
- });
28
- });
29
-
30
- describe('calculateRetryDelay', () => {
31
- it('should return base delay for first attempt', () => {
32
- expect(calculateRetryDelay(1, 1000, 2)).toBe(1000);
33
- });
34
-
35
- it('should apply backoff multiplier', () => {
36
- expect(calculateRetryDelay(2, 1000, 2)).toBe(2000);
37
- expect(calculateRetryDelay(3, 1000, 2)).toBe(4000);
38
- });
39
-
40
- it('should work with non-integer backoff', () => {
41
- expect(calculateRetryDelay(2, 100, 1.5)).toBe(150);
42
- });
43
- });
44
-
45
- describe('DEFAULT_RETRY_OPTIONS', () => {
46
- it('should have expected defaults', () => {
47
- expect(DEFAULT_RETRY_OPTIONS.maxRetries).toBe(3);
48
- expect(DEFAULT_RETRY_OPTIONS.delay).toBe(1000);
49
- expect(DEFAULT_RETRY_OPTIONS.backoff).toBe(2);
50
- });
51
- });
52
-
53
- describe('withRetry', () => {
54
- it('should return result on first success', async () => {
55
- const fn = vi.fn().mockResolvedValue('ok');
56
- const result = await withRetry(fn, { maxRetries: 3, delay: 10, backoff: 1 });
57
-
58
- expect(result).toBe('ok');
59
- expect(fn).toHaveBeenCalledTimes(1);
60
- });
61
-
62
- it('should retry on failure and succeed', async () => {
63
- let attempt = 0;
64
- const fn = vi.fn(async () => {
65
- attempt++;
66
- if (attempt < 3) throw new Error(`fail ${attempt}`);
67
- return 'success';
68
- });
69
-
70
- const result = await withRetry(fn, { maxRetries: 3, delay: 10, backoff: 1 });
71
-
72
- expect(result).toBe('success');
73
- expect(fn).toHaveBeenCalledTimes(3);
74
- });
75
-
76
- it('should throw after exhausting retries', async () => {
77
- const fn = vi.fn().mockRejectedValue(new Error('always fails'));
78
-
79
- await expect(
80
- withRetry(fn, { maxRetries: 2, delay: 10, backoff: 1 })
81
- ).rejects.toThrow('always fails');
82
-
83
- // 1 initial + 2 retries = 3 calls
84
- expect(fn).toHaveBeenCalledTimes(3);
85
- });
86
-
87
- it('should call onRetry callback', async () => {
88
- let attempt = 0;
89
- const onRetryCalls: Array<{ attempt: number; delay: number }> = [];
90
-
91
- const fn = vi.fn(async () => {
92
- attempt++;
93
- if (attempt < 3) throw new Error('fail');
94
- return 'ok';
95
- });
96
-
97
- await withRetry(fn, {
98
- maxRetries: 3,
99
- delay: 100,
100
- backoff: 2,
101
- onRetry: (a, _err, d) => onRetryCalls.push({ attempt: a, delay: d }),
102
- });
103
-
104
- expect(onRetryCalls).toHaveLength(2);
105
- expect(onRetryCalls[0]).toEqual({ attempt: 1, delay: 100 });
106
- expect(onRetryCalls[1]).toEqual({ attempt: 2, delay: 200 });
107
- });
108
-
109
- it('should respect abort signal before first attempt', async () => {
110
- const controller = new AbortController();
111
- controller.abort();
112
-
113
- const fn = vi.fn().mockResolvedValue('ok');
114
-
115
- await expect(
116
- withRetry(fn, { maxRetries: 3, delay: 10, backoff: 1, signal: controller.signal })
117
- ).rejects.toThrow('canceled');
118
-
119
- expect(fn).not.toHaveBeenCalled();
120
- });
121
-
122
- it('should respect abort signal during retry wait', async () => {
123
- const controller = new AbortController();
124
-
125
- const fn = vi.fn().mockRejectedValue(new Error('fail'));
126
-
127
- const promise = withRetry(fn, {
128
- maxRetries: 5,
129
- delay: 5000,
130
- backoff: 1,
131
- signal: controller.signal,
132
- });
133
-
134
- // Abort during the retry delay
135
- setTimeout(() => controller.abort(), 50);
136
-
137
- await expect(promise).rejects.toThrow(WorkflowCanceledError);
138
- });
139
-
140
- it('should apply backoff between retries', async () => {
141
- const timestamps: number[] = [];
142
- let attempt = 0;
143
-
144
- const fn = vi.fn(async () => {
145
- timestamps.push(Date.now());
146
- attempt++;
147
- if (attempt <= 2) throw new Error('fail');
148
- return 'ok';
149
- });
150
-
151
- await withRetry(fn, { maxRetries: 3, delay: 50, backoff: 2 });
152
-
153
- // Check that delay increases between retries
154
- const gap1 = timestamps[1] - timestamps[0];
155
- const gap2 = timestamps[2] - timestamps[1];
156
-
157
- expect(gap1).toBeGreaterThanOrEqual(40); // ~50ms first delay
158
- expect(gap2).toBeGreaterThanOrEqual(80); // ~100ms second delay (50*2)
159
- });
160
-
161
- it('should convert non-Error throws to Error', async () => {
162
- const fn = vi.fn(async () => {
163
- throw 'string error';
164
- });
165
-
166
- await expect(
167
- withRetry(fn, { maxRetries: 0, delay: 10, backoff: 1 })
168
- ).rejects.toThrow('string error');
169
- });
170
-
171
- it('should use default options when none provided', async () => {
172
- const fn = vi.fn().mockResolvedValue('ok');
173
- const result = await withRetry(fn);
174
-
175
- expect(result).toBe('ok');
176
- });
177
- });