@venturekit/runtime 0.0.0-dev.20260312012510 → 0.0.0-dev.20260329093916

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 (122) hide show
  1. package/README.md +1 -1
  2. package/dist/config/env.d.ts +62 -0
  3. package/dist/config/env.d.ts.map +1 -0
  4. package/dist/config/env.js +98 -0
  5. package/dist/config/env.js.map +1 -0
  6. package/dist/config/secrets.d.ts +67 -0
  7. package/dist/config/secrets.d.ts.map +1 -0
  8. package/dist/config/secrets.js +132 -0
  9. package/dist/config/secrets.js.map +1 -0
  10. package/dist/handler/adapter.d.ts.map +1 -0
  11. package/dist/{adapter.js → handler/adapter.js} +5 -44
  12. package/dist/handler/adapter.js.map +1 -0
  13. package/dist/{context.d.ts → handler/context.d.ts} +44 -3
  14. package/dist/handler/context.d.ts.map +1 -0
  15. package/dist/{context.js → handler/context.js} +9 -9
  16. package/dist/handler/context.js.map +1 -0
  17. package/dist/handler/errors.d.ts.map +1 -0
  18. package/dist/{errors.js → handler/errors.js} +11 -25
  19. package/dist/handler/errors.js.map +1 -0
  20. package/dist/{handler.d.ts → handler/handler.d.ts} +36 -3
  21. package/dist/handler/handler.d.ts.map +1 -0
  22. package/dist/{handler.js → handler/handler.js} +59 -23
  23. package/dist/handler/handler.js.map +1 -0
  24. package/dist/handler/response.d.ts.map +1 -0
  25. package/dist/{response.js → handler/response.js} +9 -17
  26. package/dist/handler/response.js.map +1 -0
  27. package/dist/handler/task-handler.d.ts +192 -0
  28. package/dist/handler/task-handler.d.ts.map +1 -0
  29. package/dist/handler/task-handler.js +262 -0
  30. package/dist/handler/task-handler.js.map +1 -0
  31. package/dist/index.d.ts +33 -14
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +30 -48
  34. package/dist/index.js.map +1 -1
  35. package/dist/{logger.d.ts → logging/logger.d.ts} +1 -1
  36. package/dist/logging/logger.d.ts.map +1 -0
  37. package/dist/{logger.js → logging/logger.js} +8 -16
  38. package/dist/logging/logger.js.map +1 -0
  39. package/dist/logging/tracing.d.ts +51 -0
  40. package/dist/logging/tracing.d.ts.map +1 -0
  41. package/dist/logging/tracing.js +77 -0
  42. package/dist/logging/tracing.js.map +1 -0
  43. package/dist/middleware/middleware.d.ts +120 -0
  44. package/dist/middleware/middleware.d.ts.map +1 -0
  45. package/dist/middleware/middleware.js +270 -0
  46. package/dist/middleware/middleware.js.map +1 -0
  47. package/dist/middleware/rate-limit-store.d.ts +153 -0
  48. package/dist/middleware/rate-limit-store.d.ts.map +1 -0
  49. package/dist/middleware/rate-limit-store.js +182 -0
  50. package/dist/middleware/rate-limit-store.js.map +1 -0
  51. package/dist/openapi/openapi.d.ts +147 -0
  52. package/dist/openapi/openapi.d.ts.map +1 -0
  53. package/dist/openapi/openapi.js +235 -0
  54. package/dist/openapi/openapi.js.map +1 -0
  55. package/dist/openapi.d.ts +10 -0
  56. package/dist/openapi.d.ts.map +1 -0
  57. package/dist/openapi.js +9 -0
  58. package/dist/openapi.js.map +1 -0
  59. package/dist/patterns/circuit-breaker.d.ts +78 -0
  60. package/dist/patterns/circuit-breaker.d.ts.map +1 -0
  61. package/dist/patterns/circuit-breaker.js +113 -0
  62. package/dist/patterns/circuit-breaker.js.map +1 -0
  63. package/dist/patterns/idempotency.d.ts +175 -0
  64. package/dist/patterns/idempotency.d.ts.map +1 -0
  65. package/dist/patterns/idempotency.js +305 -0
  66. package/dist/patterns/idempotency.js.map +1 -0
  67. package/dist/patterns/invoke.d.ts +279 -0
  68. package/dist/patterns/invoke.d.ts.map +1 -0
  69. package/dist/patterns/invoke.js +581 -0
  70. package/dist/patterns/invoke.js.map +1 -0
  71. package/dist/patterns/saga.d.ts +391 -0
  72. package/dist/patterns/saga.d.ts.map +1 -0
  73. package/dist/patterns/saga.js +556 -0
  74. package/dist/patterns/saga.js.map +1 -0
  75. package/dist/patterns.d.ts +17 -0
  76. package/dist/patterns.d.ts.map +1 -0
  77. package/dist/patterns.js +17 -0
  78. package/dist/patterns.js.map +1 -0
  79. package/dist/rate-limit.d.ts +16 -0
  80. package/dist/rate-limit.d.ts.map +1 -0
  81. package/dist/rate-limit.js +15 -0
  82. package/dist/rate-limit.js.map +1 -0
  83. package/dist/secrets.d.ts +11 -0
  84. package/dist/secrets.d.ts.map +1 -0
  85. package/dist/secrets.js +10 -0
  86. package/dist/secrets.js.map +1 -0
  87. package/dist/testing/test-utils.d.ts +99 -0
  88. package/dist/testing/test-utils.d.ts.map +1 -0
  89. package/dist/testing/test-utils.js +153 -0
  90. package/dist/testing/test-utils.js.map +1 -0
  91. package/dist/testing.d.ts +10 -0
  92. package/dist/testing.d.ts.map +1 -0
  93. package/dist/testing.js +9 -0
  94. package/dist/testing.js.map +1 -0
  95. package/dist/ws/ws.d.ts +139 -0
  96. package/dist/ws/ws.d.ts.map +1 -0
  97. package/dist/ws/ws.js +294 -0
  98. package/dist/ws/ws.js.map +1 -0
  99. package/dist/ws.d.ts +5 -133
  100. package/dist/ws.d.ts.map +1 -1
  101. package/dist/ws.js +4 -291
  102. package/dist/ws.js.map +1 -1
  103. package/package.json +47 -5
  104. package/dist/adapter.d.ts.map +0 -1
  105. package/dist/adapter.js.map +0 -1
  106. package/dist/context.d.ts.map +0 -1
  107. package/dist/context.js.map +0 -1
  108. package/dist/errors.d.ts.map +0 -1
  109. package/dist/errors.js.map +0 -1
  110. package/dist/handler.d.ts.map +0 -1
  111. package/dist/handler.js.map +0 -1
  112. package/dist/logger.d.ts.map +0 -1
  113. package/dist/logger.js.map +0 -1
  114. package/dist/middleware.d.ts +0 -47
  115. package/dist/middleware.d.ts.map +0 -1
  116. package/dist/middleware.js +0 -147
  117. package/dist/middleware.js.map +0 -1
  118. package/dist/response.d.ts.map +0 -1
  119. package/dist/response.js.map +0 -1
  120. /package/dist/{adapter.d.ts → handler/adapter.d.ts} +0 -0
  121. /package/dist/{errors.d.ts → handler/errors.d.ts} +0 -0
  122. /package/dist/{response.d.ts → handler/response.d.ts} +0 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * VentureKit Circuit Breaker
3
+ *
4
+ * Prevents cascading failures when invoking other Lambda functions.
5
+ * Tracks failures per target and "opens" the circuit after a threshold,
6
+ * returning a configurable default/fallback result instead of attempting
7
+ * the call.
8
+ *
9
+ * ## States
10
+ *
11
+ * - **Closed** — normal operation, requests pass through.
12
+ * - **Open** — too many recent failures, requests are short-circuited
13
+ * with the fallback result.
14
+ * - **Half-open** — after a cooldown period, one probe request is
15
+ * allowed through. If it succeeds the circuit closes; if it fails
16
+ * the circuit re-opens.
17
+ *
18
+ * ## Quick Start
19
+ *
20
+ * ```typescript
21
+ * import { createCircuitBreaker } from '@venturekit/runtime';
22
+ *
23
+ * const breaker = createCircuitBreaker({
24
+ * failureThreshold: 5, // open after 5 failures
25
+ * resetTimeoutMs: 30_000, // try again after 30 s
26
+ * });
27
+ *
28
+ * const result = await breaker.call(
29
+ * 'billing-dev-fn-charge',
30
+ * () => invoke({ function: 'charge', project: 'billing' }, { payload }),
31
+ * { statusCode: 503, data: null, headers: {} }, // fallback
32
+ * );
33
+ * ```
34
+ */
35
+ export type CircuitState = 'closed' | 'open' | 'half-open';
36
+ export interface CircuitBreakerOptions {
37
+ /**
38
+ * Number of consecutive failures before the circuit opens.
39
+ * @default 5
40
+ */
41
+ failureThreshold?: number;
42
+ /**
43
+ * Time in ms to wait before switching from open → half-open.
44
+ * @default 30000 (30 seconds)
45
+ */
46
+ resetTimeoutMs?: number;
47
+ /**
48
+ * Optional callback fired when the circuit state changes.
49
+ */
50
+ onStateChange?: (target: string, from: CircuitState, to: CircuitState) => void;
51
+ }
52
+ export interface CircuitBreaker {
53
+ /**
54
+ * Execute `fn` with circuit breaker protection.
55
+ *
56
+ * @param target - Logical name of the target (e.g. function name)
57
+ * @param fn - The async function to execute
58
+ * @param fallback - Value returned when the circuit is open
59
+ * @returns The result of `fn`, or `fallback` if the circuit is open.
60
+ */
61
+ call<T>(target: string, fn: () => Promise<T>, fallback: T): Promise<T>;
62
+ /** Get the current state for a target. */
63
+ getState(target: string): CircuitState;
64
+ /** Manually reset a target to closed. */
65
+ reset(target: string): void;
66
+ /** Reset all targets. */
67
+ resetAll(): void;
68
+ }
69
+ /**
70
+ * Create a circuit breaker instance.
71
+ *
72
+ * The returned object is stateful (in-process) — each Lambda warm
73
+ * container gets its own breaker. This is intentional: a single
74
+ * container seeing repeated failures should stop hammering the target,
75
+ * while other containers may still be healthy.
76
+ */
77
+ export declare function createCircuitBreaker(options?: CircuitBreakerOptions): CircuitBreaker;
78
+ //# sourceMappingURL=circuit-breaker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"circuit-breaker.d.ts","sourceRoot":"","sources":["../../src/patterns/circuit-breaker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAMH,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;AAE3D,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;OAEG;IACH,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,EAAE,EAAE,EAAE,YAAY,KAAK,IAAI,CAAC;CAChF;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;;;OAOG;IACH,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAEvE,0CAA0C;IAC1C,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAAC;IAEvC,yCAAyC;IACzC,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAE5B,yBAAyB;IACzB,QAAQ,IAAI,IAAI,CAAC;CAClB;AAgBD;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,qBAA0B,GAAG,cAAc,CAiFxF"}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * VentureKit Circuit Breaker
3
+ *
4
+ * Prevents cascading failures when invoking other Lambda functions.
5
+ * Tracks failures per target and "opens" the circuit after a threshold,
6
+ * returning a configurable default/fallback result instead of attempting
7
+ * the call.
8
+ *
9
+ * ## States
10
+ *
11
+ * - **Closed** — normal operation, requests pass through.
12
+ * - **Open** — too many recent failures, requests are short-circuited
13
+ * with the fallback result.
14
+ * - **Half-open** — after a cooldown period, one probe request is
15
+ * allowed through. If it succeeds the circuit closes; if it fails
16
+ * the circuit re-opens.
17
+ *
18
+ * ## Quick Start
19
+ *
20
+ * ```typescript
21
+ * import { createCircuitBreaker } from '@venturekit/runtime';
22
+ *
23
+ * const breaker = createCircuitBreaker({
24
+ * failureThreshold: 5, // open after 5 failures
25
+ * resetTimeoutMs: 30_000, // try again after 30 s
26
+ * });
27
+ *
28
+ * const result = await breaker.call(
29
+ * 'billing-dev-fn-charge',
30
+ * () => invoke({ function: 'charge', project: 'billing' }, { payload }),
31
+ * { statusCode: 503, data: null, headers: {} }, // fallback
32
+ * );
33
+ * ```
34
+ */
35
+ // ---------------------------------------------------------------------------
36
+ // Implementation
37
+ // ---------------------------------------------------------------------------
38
+ /**
39
+ * Create a circuit breaker instance.
40
+ *
41
+ * The returned object is stateful (in-process) — each Lambda warm
42
+ * container gets its own breaker. This is intentional: a single
43
+ * container seeing repeated failures should stop hammering the target,
44
+ * while other containers may still be healthy.
45
+ */
46
+ export function createCircuitBreaker(options = {}) {
47
+ const { failureThreshold = 5, resetTimeoutMs = 30_000, onStateChange, } = options;
48
+ const targets = new Map();
49
+ function getOrCreate(target) {
50
+ let ts = targets.get(target);
51
+ if (!ts) {
52
+ ts = { state: 'closed', failures: 0, lastFailureAt: 0 };
53
+ targets.set(target, ts);
54
+ }
55
+ return ts;
56
+ }
57
+ function transition(target, ts, to) {
58
+ if (ts.state === to)
59
+ return;
60
+ const from = ts.state;
61
+ ts.state = to;
62
+ onStateChange?.(target, from, to);
63
+ }
64
+ return {
65
+ async call(target, fn, fallback) {
66
+ const ts = getOrCreate(target);
67
+ // --- Open state ---
68
+ if (ts.state === 'open') {
69
+ // Check if cooldown has elapsed → half-open
70
+ if (Date.now() - ts.lastFailureAt >= resetTimeoutMs) {
71
+ transition(target, ts, 'half-open');
72
+ }
73
+ else {
74
+ // Still open — return fallback
75
+ return fallback;
76
+ }
77
+ }
78
+ // --- Closed or Half-open: attempt the call ---
79
+ try {
80
+ const result = await fn();
81
+ // Success: reset to closed
82
+ ts.failures = 0;
83
+ transition(target, ts, 'closed');
84
+ return result;
85
+ }
86
+ catch (error) {
87
+ ts.failures++;
88
+ ts.lastFailureAt = Date.now();
89
+ if (ts.state === 'half-open') {
90
+ // Probe failed — re-open immediately
91
+ transition(target, ts, 'open');
92
+ return fallback;
93
+ }
94
+ if (ts.failures >= failureThreshold) {
95
+ transition(target, ts, 'open');
96
+ return fallback;
97
+ }
98
+ // Below threshold — propagate error normally
99
+ throw error;
100
+ }
101
+ },
102
+ getState(target) {
103
+ return getOrCreate(target).state;
104
+ },
105
+ reset(target) {
106
+ targets.delete(target);
107
+ },
108
+ resetAll() {
109
+ targets.clear();
110
+ },
111
+ };
112
+ }
113
+ //# sourceMappingURL=circuit-breaker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"circuit-breaker.js","sourceRoot":"","sources":["../../src/patterns/circuit-breaker.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,175 @@
1
+ /**
2
+ * VentureKit Idempotency Support
3
+ *
4
+ * Middleware for ensuring handler idempotency.
5
+ * Stores request results keyed by an idempotency key, returning
6
+ * cached responses for duplicate requests within a TTL window.
7
+ *
8
+ * Storage backend is pluggable (DynamoDB, Postgres, in-memory, etc.).
9
+ */
10
+ import type { RequestContext } from '../handler/context.js';
11
+ import type { Middleware } from '../middleware/middleware.js';
12
+ /**
13
+ * Stored idempotency record
14
+ */
15
+ export interface IdempotencyRecord {
16
+ /** The idempotency key */
17
+ key: string;
18
+ /** The cached response (serialized) */
19
+ response: string;
20
+ /** Status of the record */
21
+ status: 'pending' | 'completed';
22
+ /** Expiry timestamp (ms since epoch) */
23
+ expiresAt: number;
24
+ }
25
+ /**
26
+ * Idempotency store interface.
27
+ * Implement this to plug in any storage backend.
28
+ */
29
+ export interface IdempotencyStore {
30
+ /**
31
+ * Get a stored record by key.
32
+ * Returns null if not found or expired.
33
+ */
34
+ get(key: string): Promise<IdempotencyRecord | null>;
35
+ /**
36
+ * Save a record. If a record with the same key already exists
37
+ * and is 'pending', throw a ConflictError (concurrent request).
38
+ */
39
+ save(record: IdempotencyRecord): Promise<void>;
40
+ /**
41
+ * Update an existing record (e.g. from pending → completed).
42
+ */
43
+ update(key: string, updates: Partial<IdempotencyRecord>): Promise<void>;
44
+ /**
45
+ * Delete a record (e.g. on failure, to allow retry).
46
+ */
47
+ delete(key: string): Promise<void>;
48
+ }
49
+ /**
50
+ * Key extraction strategy
51
+ */
52
+ export type IdempotencyKeyExtractor = (ctx: RequestContext) => string | null;
53
+ /**
54
+ * Extract idempotency key from a header
55
+ */
56
+ export declare function headerKeyExtractor(headerName: string): IdempotencyKeyExtractor;
57
+ /**
58
+ * Extract idempotency key from a body field
59
+ */
60
+ export declare function bodyFieldKeyExtractor(fieldName: string): IdempotencyKeyExtractor;
61
+ /**
62
+ * Idempotency middleware options
63
+ */
64
+ export interface IdempotencyOptions {
65
+ /** Storage backend for idempotency records */
66
+ store: IdempotencyStore;
67
+ /**
68
+ * Extract the idempotency key from the request.
69
+ * Default: reads from 'Idempotency-Key' header.
70
+ */
71
+ keyExtractor?: IdempotencyKeyExtractor;
72
+ /** TTL in seconds for idempotency records (default: 86400 = 24h) */
73
+ ttlSeconds?: number;
74
+ /**
75
+ * If true, requests without an idempotency key are rejected with 400.
76
+ * If false (default), requests without a key pass through without idempotency.
77
+ */
78
+ required?: boolean;
79
+ }
80
+ /**
81
+ * Idempotency middleware
82
+ *
83
+ * Ensures that duplicate requests with the same idempotency key
84
+ * return the same response without re-executing the handler.
85
+ *
86
+ * Flow:
87
+ * 1. Extract idempotency key from request
88
+ * 2. Check store for existing record
89
+ * - If completed: return cached response
90
+ * - If pending: return 409 Conflict (concurrent request in flight)
91
+ * 3. Save 'pending' record
92
+ * 4. Execute handler
93
+ * 5. Update record to 'completed' with response
94
+ * 6. On error: delete record so the request can be retried
95
+ */
96
+ export declare function idempotencyMiddleware(options: IdempotencyOptions): Middleware<RequestContext>;
97
+ /**
98
+ * In-memory idempotency store (for development/testing).
99
+ *
100
+ * **WARNING:** State is per-process. Does NOT work correctly with
101
+ * multiple Lambda instances. Use only for local dev/testing.
102
+ * For production, use `createDynamoDBIdempotencyStore()`.
103
+ */
104
+ export declare function createMemoryIdempotencyStore(): IdempotencyStore;
105
+ /**
106
+ * DynamoDB idempotency store options.
107
+ */
108
+ export interface DynamoDBIdempotencyStoreOptions {
109
+ /** DynamoDB table name (default: 'venturekit-idempotency') */
110
+ tableName?: string;
111
+ /** AWS region (default: process.env.AWS_REGION) */
112
+ region?: string;
113
+ }
114
+ /**
115
+ * Create a DynamoDB-backed idempotency store.
116
+ *
117
+ * Uses conditional writes for conflict detection and DynamoDB TTL
118
+ * for automatic cleanup of expired records.
119
+ *
120
+ * ### DynamoDB Table Schema
121
+ *
122
+ * The table must have:
123
+ * - **Partition key:** `pk` (String) — the idempotency key
124
+ * - **TTL attribute:** `ttl` (Number) — epoch seconds for auto-cleanup
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * import { idempotencyMiddleware, createDynamoDBIdempotencyStore } from '@venturekit/runtime';
129
+ *
130
+ * const store = await createDynamoDBIdempotencyStore();
131
+ * const idempotency = idempotencyMiddleware({ store });
132
+ * ```
133
+ */
134
+ export declare function createDynamoDBIdempotencyStore(options?: DynamoDBIdempotencyStoreOptions): Promise<IdempotencyStore>;
135
+ /**
136
+ * Error thrown when an idempotency conflict is detected
137
+ * (a request with the same key is already in progress).
138
+ */
139
+ export declare class IdempotencyConflictError extends Error {
140
+ readonly code = "IDEMPOTENCY_CONFLICT";
141
+ constructor(key: string);
142
+ }
143
+ /**
144
+ * Error thrown when a required idempotency key is missing.
145
+ */
146
+ export declare class MissingIdempotencyKeyError extends Error {
147
+ readonly code = "MISSING_IDEMPOTENCY_KEY";
148
+ constructor();
149
+ }
150
+ /**
151
+ * Options for the default idempotency store.
152
+ */
153
+ export interface DefaultIdempotencyStoreOptions {
154
+ /** DynamoDB table name */
155
+ tableName?: string;
156
+ /** Force in-memory store even in production */
157
+ forceMemory?: boolean;
158
+ }
159
+ /**
160
+ * Create an idempotency store with sensible defaults.
161
+ *
162
+ * - In production (Lambda): uses DynamoDB for distributed idempotency
163
+ * - In local dev: uses in-memory store
164
+ *
165
+ * @example
166
+ * ```typescript
167
+ * import { idempotencyMiddleware, createDefaultIdempotencyStore } from '@venturekit/runtime';
168
+ *
169
+ * // Zero-config — auto-selects DynamoDB (Lambda) or memory (local)
170
+ * const store = await createDefaultIdempotencyStore();
171
+ * const idempotency = idempotencyMiddleware({ store });
172
+ * ```
173
+ */
174
+ export declare function createDefaultIdempotencyStore(options?: DefaultIdempotencyStoreOptions): Promise<IdempotencyStore>;
175
+ //# sourceMappingURL=idempotency.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../../src/patterns/idempotency.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAe,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,0BAA0B;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,uCAAuC;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,2BAA2B;IAC3B,MAAM,EAAE,SAAS,GAAG,WAAW,CAAC;IAChC,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAC;IAEpD;;;OAGG;IACH,IAAI,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/C;;OAEG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,iBAAiB,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAExE;;OAEG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG,CAAC,GAAG,EAAE,cAAc,KAAK,MAAM,GAAG,IAAI,CAAC;AAE7E;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,uBAAuB,CAK9E;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,GAAG,uBAAuB,CAShF;AAED;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,8CAA8C;IAC9C,KAAK,EAAE,gBAAgB,CAAC;IAExB;;;OAGG;IACH,YAAY,CAAC,EAAE,uBAAuB,CAAC;IAEvC,oEAAoE;IACpE,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,kBAAkB,GAAG,UAAU,CAAC,cAAc,CAAC,CA0E7F;AAED;;;;;;GAMG;AACH,wBAAgB,4BAA4B,IAAI,gBAAgB,CAiC/D;AAMD;;GAEG;AACH,MAAM,WAAW,+BAA+B;IAC9C,8DAA8D;IAC9D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,8BAA8B,CAClD,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,gBAAgB,CAAC,CAwG3B;AAED;;;GAGG;AACH,qBAAa,wBAAyB,SAAQ,KAAK;IACjD,SAAgB,IAAI,0BAA0B;gBAClC,GAAG,EAAE,MAAM;CAIxB;AAED;;GAEG;AACH,qBAAa,0BAA2B,SAAQ,KAAK;IACnD,SAAgB,IAAI,6BAA6B;;CAKlD;AAMD;;GAEG;AACH,MAAM,WAAW,8BAA8B;IAC7C,0BAA0B;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,6BAA6B,CACjD,OAAO,GAAE,8BAAmC,GAC3C,OAAO,CAAC,gBAAgB,CAAC,CAU3B"}
@@ -0,0 +1,305 @@
1
+ /**
2
+ * VentureKit Idempotency Support
3
+ *
4
+ * Middleware for ensuring handler idempotency.
5
+ * Stores request results keyed by an idempotency key, returning
6
+ * cached responses for duplicate requests within a TTL window.
7
+ *
8
+ * Storage backend is pluggable (DynamoDB, Postgres, in-memory, etc.).
9
+ */
10
+ /**
11
+ * Extract idempotency key from a header
12
+ */
13
+ export function headerKeyExtractor(headerName) {
14
+ return (ctx) => {
15
+ const key = ctx.rawEvent.headers?.[headerName.toLowerCase()];
16
+ return key ?? null;
17
+ };
18
+ }
19
+ /**
20
+ * Extract idempotency key from a body field
21
+ */
22
+ export function bodyFieldKeyExtractor(fieldName) {
23
+ return (ctx) => {
24
+ try {
25
+ const body = ctx.rawEvent.body ? JSON.parse(ctx.rawEvent.body) : null;
26
+ return body?.[fieldName] ?? null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ };
32
+ }
33
+ /**
34
+ * Idempotency middleware
35
+ *
36
+ * Ensures that duplicate requests with the same idempotency key
37
+ * return the same response without re-executing the handler.
38
+ *
39
+ * Flow:
40
+ * 1. Extract idempotency key from request
41
+ * 2. Check store for existing record
42
+ * - If completed: return cached response
43
+ * - If pending: return 409 Conflict (concurrent request in flight)
44
+ * 3. Save 'pending' record
45
+ * 4. Execute handler
46
+ * 5. Update record to 'completed' with response
47
+ * 6. On error: delete record so the request can be retried
48
+ */
49
+ export function idempotencyMiddleware(options) {
50
+ const { store, keyExtractor = headerKeyExtractor('idempotency-key'), ttlSeconds = 86400, required = false, } = options;
51
+ return {
52
+ name: 'idempotency',
53
+ fn: async (ctx, next) => {
54
+ const key = keyExtractor(ctx);
55
+ // No idempotency key
56
+ if (!key) {
57
+ if (required) {
58
+ return {
59
+ statusCode: 400,
60
+ body: JSON.stringify({
61
+ error: { code: 'MISSING_IDEMPOTENCY_KEY', message: 'Idempotency-Key header is required' },
62
+ }),
63
+ };
64
+ }
65
+ // Pass through without idempotency
66
+ return next();
67
+ }
68
+ // Check for existing record
69
+ const existing = await store.get(key);
70
+ if (existing) {
71
+ if (existing.status === 'completed') {
72
+ // Return cached response
73
+ return JSON.parse(existing.response);
74
+ }
75
+ if (existing.status === 'pending') {
76
+ // Concurrent request — reject
77
+ return {
78
+ statusCode: 409,
79
+ body: JSON.stringify({
80
+ error: { code: 'IDEMPOTENCY_CONFLICT', message: 'A request with this idempotency key is already in progress' },
81
+ }),
82
+ };
83
+ }
84
+ }
85
+ // Save pending record
86
+ const record = {
87
+ key,
88
+ response: '',
89
+ status: 'pending',
90
+ expiresAt: Date.now() + (ttlSeconds * 1000),
91
+ };
92
+ await store.save(record);
93
+ try {
94
+ // Execute handler
95
+ const response = await next();
96
+ // Store completed response
97
+ await store.update(key, {
98
+ status: 'completed',
99
+ response: JSON.stringify(response),
100
+ });
101
+ return response;
102
+ }
103
+ catch (error) {
104
+ // Delete record so request can be retried
105
+ await store.delete(key);
106
+ throw error;
107
+ }
108
+ },
109
+ };
110
+ }
111
+ /**
112
+ * In-memory idempotency store (for development/testing).
113
+ *
114
+ * **WARNING:** State is per-process. Does NOT work correctly with
115
+ * multiple Lambda instances. Use only for local dev/testing.
116
+ * For production, use `createDynamoDBIdempotencyStore()`.
117
+ */
118
+ export function createMemoryIdempotencyStore() {
119
+ const records = new Map();
120
+ return {
121
+ async get(key) {
122
+ const record = records.get(key);
123
+ if (!record)
124
+ return null;
125
+ if (record.expiresAt < Date.now()) {
126
+ records.delete(key);
127
+ return null;
128
+ }
129
+ return record;
130
+ },
131
+ async save(record) {
132
+ const existing = records.get(record.key);
133
+ if (existing && existing.status === 'pending' && existing.expiresAt >= Date.now()) {
134
+ throw new Error('Concurrent idempotency conflict');
135
+ }
136
+ records.set(record.key, record);
137
+ },
138
+ async update(key, updates) {
139
+ const existing = records.get(key);
140
+ if (existing) {
141
+ records.set(key, { ...existing, ...updates });
142
+ }
143
+ },
144
+ async delete(key) {
145
+ records.delete(key);
146
+ },
147
+ };
148
+ }
149
+ /**
150
+ * Create a DynamoDB-backed idempotency store.
151
+ *
152
+ * Uses conditional writes for conflict detection and DynamoDB TTL
153
+ * for automatic cleanup of expired records.
154
+ *
155
+ * ### DynamoDB Table Schema
156
+ *
157
+ * The table must have:
158
+ * - **Partition key:** `pk` (String) — the idempotency key
159
+ * - **TTL attribute:** `ttl` (Number) — epoch seconds for auto-cleanup
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * import { idempotencyMiddleware, createDynamoDBIdempotencyStore } from '@venturekit/runtime';
164
+ *
165
+ * const store = await createDynamoDBIdempotencyStore();
166
+ * const idempotency = idempotencyMiddleware({ store });
167
+ * ```
168
+ */
169
+ export async function createDynamoDBIdempotencyStore(options = {}) {
170
+ const tableName = options.tableName
171
+ || process.env.VENTURE_IDEMPOTENCY_TABLE
172
+ || 'venturekit-idempotency';
173
+ const region = options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1';
174
+ const { DynamoDBClient, PutItemCommand, GetItemCommand, UpdateItemCommand, DeleteItemCommand, } = await import('@aws-sdk/client-dynamodb');
175
+ const client = new DynamoDBClient({ region });
176
+ return {
177
+ async get(key) {
178
+ const result = await client.send(new GetItemCommand({
179
+ TableName: tableName,
180
+ Key: { pk: { S: key } },
181
+ }));
182
+ if (!result.Item)
183
+ return null;
184
+ const expiresAt = parseInt(result.Item.expiresAt?.N ?? '0', 10);
185
+ if (expiresAt < Date.now())
186
+ return null;
187
+ return {
188
+ key: result.Item.pk.S,
189
+ response: result.Item.response?.S ?? '',
190
+ status: result.Item.status?.S,
191
+ expiresAt,
192
+ };
193
+ },
194
+ async save(record) {
195
+ const ttl = Math.ceil(record.expiresAt / 1000);
196
+ try {
197
+ await client.send(new PutItemCommand({
198
+ TableName: tableName,
199
+ Item: {
200
+ pk: { S: record.key },
201
+ response: { S: record.response },
202
+ status: { S: record.status },
203
+ expiresAt: { N: String(record.expiresAt) },
204
+ ttl: { N: String(ttl) },
205
+ },
206
+ // Conditional: only allow write if no pending record exists
207
+ ConditionExpression: 'attribute_not_exists(pk) OR #s <> :pending OR #exp < :now',
208
+ ExpressionAttributeNames: { '#s': 'status', '#exp': 'expiresAt' },
209
+ ExpressionAttributeValues: {
210
+ ':pending': { S: 'pending' },
211
+ ':now': { N: String(Date.now()) },
212
+ },
213
+ }));
214
+ }
215
+ catch (err) {
216
+ if (err.name === 'ConditionalCheckFailedException') {
217
+ throw new Error('Concurrent idempotency conflict');
218
+ }
219
+ throw err;
220
+ }
221
+ },
222
+ async update(key, updates) {
223
+ const expressionParts = [];
224
+ const names = {};
225
+ const values = {};
226
+ let counter = 0;
227
+ for (const [k, v] of Object.entries(updates)) {
228
+ if (v === undefined)
229
+ continue;
230
+ counter++;
231
+ const attrName = `#a${counter}`;
232
+ const attrValue = `:v${counter}`;
233
+ names[attrName] = k;
234
+ if (typeof v === 'number') {
235
+ values[attrValue] = { N: String(v) };
236
+ }
237
+ else {
238
+ values[attrValue] = { S: String(v) };
239
+ }
240
+ expressionParts.push(`${attrName} = ${attrValue}`);
241
+ }
242
+ if (expressionParts.length === 0)
243
+ return;
244
+ await client.send(new UpdateItemCommand({
245
+ TableName: tableName,
246
+ Key: { pk: { S: key } },
247
+ UpdateExpression: `SET ${expressionParts.join(', ')}`,
248
+ ExpressionAttributeNames: names,
249
+ ExpressionAttributeValues: values,
250
+ }));
251
+ },
252
+ async delete(key) {
253
+ await client.send(new DeleteItemCommand({
254
+ TableName: tableName,
255
+ Key: { pk: { S: key } },
256
+ }));
257
+ },
258
+ };
259
+ }
260
+ /**
261
+ * Error thrown when an idempotency conflict is detected
262
+ * (a request with the same key is already in progress).
263
+ */
264
+ export class IdempotencyConflictError extends Error {
265
+ code = 'IDEMPOTENCY_CONFLICT';
266
+ constructor(key) {
267
+ super(`A request with idempotency key '${key}' is already in progress`);
268
+ this.name = 'IdempotencyConflictError';
269
+ }
270
+ }
271
+ /**
272
+ * Error thrown when a required idempotency key is missing.
273
+ */
274
+ export class MissingIdempotencyKeyError extends Error {
275
+ code = 'MISSING_IDEMPOTENCY_KEY';
276
+ constructor() {
277
+ super('Idempotency key is required but was not found in the request');
278
+ this.name = 'MissingIdempotencyKeyError';
279
+ }
280
+ }
281
+ /**
282
+ * Create an idempotency store with sensible defaults.
283
+ *
284
+ * - In production (Lambda): uses DynamoDB for distributed idempotency
285
+ * - In local dev: uses in-memory store
286
+ *
287
+ * @example
288
+ * ```typescript
289
+ * import { idempotencyMiddleware, createDefaultIdempotencyStore } from '@venturekit/runtime';
290
+ *
291
+ * // Zero-config — auto-selects DynamoDB (Lambda) or memory (local)
292
+ * const store = await createDefaultIdempotencyStore();
293
+ * const idempotency = idempotencyMiddleware({ store });
294
+ * ```
295
+ */
296
+ export async function createDefaultIdempotencyStore(options = {}) {
297
+ const isLocal = !process.env.AWS_LAMBDA_FUNCTION_NAME && !process.env.AWS_EXECUTION_ENV;
298
+ if (isLocal || options.forceMemory) {
299
+ return createMemoryIdempotencyStore();
300
+ }
301
+ return createDynamoDBIdempotencyStore({
302
+ tableName: options.tableName,
303
+ });
304
+ }
305
+ //# sourceMappingURL=idempotency.js.map