@voyant-travel/workflows 0.107.10

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 (120) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +52 -0
  3. package/README.md +79 -0
  4. package/dist/auth/index.d.ts +125 -0
  5. package/dist/auth/index.d.ts.map +1 -0
  6. package/dist/auth/index.js +352 -0
  7. package/dist/bindings.d.ts +119 -0
  8. package/dist/bindings.d.ts.map +1 -0
  9. package/dist/bindings.js +19 -0
  10. package/dist/client.d.ts +135 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +305 -0
  13. package/dist/conditions.d.ts +29 -0
  14. package/dist/conditions.d.ts.map +1 -0
  15. package/dist/conditions.js +5 -0
  16. package/dist/config.d.ts +93 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/config.js +7 -0
  19. package/dist/driver.d.ts +237 -0
  20. package/dist/driver.d.ts.map +1 -0
  21. package/dist/driver.js +53 -0
  22. package/dist/errors.d.ts +58 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +76 -0
  25. package/dist/events/compile.d.ts +34 -0
  26. package/dist/events/compile.d.ts.map +1 -0
  27. package/dist/events/compile.js +204 -0
  28. package/dist/events/index.d.ts +8 -0
  29. package/dist/events/index.d.ts.map +1 -0
  30. package/dist/events/index.js +11 -0
  31. package/dist/events/input-mapper.d.ts +24 -0
  32. package/dist/events/input-mapper.d.ts.map +1 -0
  33. package/dist/events/input-mapper.js +169 -0
  34. package/dist/events/manifest-builder.d.ts +42 -0
  35. package/dist/events/manifest-builder.d.ts.map +1 -0
  36. package/dist/events/manifest-builder.js +313 -0
  37. package/dist/events/payload-hash.d.ts +46 -0
  38. package/dist/events/payload-hash.d.ts.map +1 -0
  39. package/dist/events/payload-hash.js +98 -0
  40. package/dist/events/predicate.d.ts +77 -0
  41. package/dist/events/predicate.d.ts.map +1 -0
  42. package/dist/events/predicate.js +347 -0
  43. package/dist/events/registry.d.ts +37 -0
  44. package/dist/events/registry.d.ts.map +1 -0
  45. package/dist/events/registry.js +47 -0
  46. package/dist/handler/index.d.ts +114 -0
  47. package/dist/handler/index.d.ts.map +1 -0
  48. package/dist/handler/index.js +267 -0
  49. package/dist/handler/resume.d.ts +41 -0
  50. package/dist/handler/resume.d.ts.map +1 -0
  51. package/dist/handler/resume.js +44 -0
  52. package/dist/http-ingest.d.ts +54 -0
  53. package/dist/http-ingest.d.ts.map +1 -0
  54. package/dist/http-ingest.js +214 -0
  55. package/dist/index.d.ts +6 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +10 -0
  58. package/dist/protocol/index.d.ts +345 -0
  59. package/dist/protocol/index.d.ts.map +1 -0
  60. package/dist/protocol/index.js +110 -0
  61. package/dist/rate-limit/index.d.ts +40 -0
  62. package/dist/rate-limit/index.d.ts.map +1 -0
  63. package/dist/rate-limit/index.js +139 -0
  64. package/dist/runtime/ctx.d.ts +111 -0
  65. package/dist/runtime/ctx.d.ts.map +1 -0
  66. package/dist/runtime/ctx.js +624 -0
  67. package/dist/runtime/determinism.d.ts +19 -0
  68. package/dist/runtime/determinism.d.ts.map +1 -0
  69. package/dist/runtime/determinism.js +61 -0
  70. package/dist/runtime/errors.d.ts +21 -0
  71. package/dist/runtime/errors.d.ts.map +1 -0
  72. package/dist/runtime/errors.js +45 -0
  73. package/dist/runtime/executor.d.ts +166 -0
  74. package/dist/runtime/executor.d.ts.map +1 -0
  75. package/dist/runtime/executor.js +226 -0
  76. package/dist/runtime/journal.d.ts +56 -0
  77. package/dist/runtime/journal.d.ts.map +1 -0
  78. package/dist/runtime/journal.js +28 -0
  79. package/dist/testing/index.d.ts +117 -0
  80. package/dist/testing/index.d.ts.map +1 -0
  81. package/dist/testing/index.js +599 -0
  82. package/dist/trigger.d.ts +37 -0
  83. package/dist/trigger.d.ts.map +1 -0
  84. package/dist/trigger.js +11 -0
  85. package/dist/types.d.ts +63 -0
  86. package/dist/types.d.ts.map +1 -0
  87. package/dist/types.js +3 -0
  88. package/dist/workflow.d.ts +222 -0
  89. package/dist/workflow.d.ts.map +1 -0
  90. package/dist/workflow.js +55 -0
  91. package/package.json +120 -0
  92. package/src/auth/index.ts +398 -0
  93. package/src/bindings.ts +135 -0
  94. package/src/client.ts +498 -0
  95. package/src/conditions.ts +43 -0
  96. package/src/config.ts +114 -0
  97. package/src/driver.ts +277 -0
  98. package/src/errors.ts +109 -0
  99. package/src/events/compile.ts +268 -0
  100. package/src/events/index.ts +42 -0
  101. package/src/events/input-mapper.ts +201 -0
  102. package/src/events/manifest-builder.ts +372 -0
  103. package/src/events/payload-hash.ts +110 -0
  104. package/src/events/predicate.ts +390 -0
  105. package/src/events/registry.ts +86 -0
  106. package/src/handler/index.ts +413 -0
  107. package/src/handler/resume.ts +100 -0
  108. package/src/http-ingest.ts +299 -0
  109. package/src/index.ts +18 -0
  110. package/src/protocol/index.ts +483 -0
  111. package/src/rate-limit/index.ts +181 -0
  112. package/src/runtime/ctx.ts +876 -0
  113. package/src/runtime/determinism.ts +75 -0
  114. package/src/runtime/errors.ts +58 -0
  115. package/src/runtime/executor.ts +442 -0
  116. package/src/runtime/journal.ts +80 -0
  117. package/src/testing/index.ts +796 -0
  118. package/src/trigger.ts +63 -0
  119. package/src/types.ts +80 -0
  120. package/src/workflow.ts +328 -0
@@ -0,0 +1,483 @@
1
+ // @voyant-travel/workflows/protocol
2
+ //
3
+ // Wire-protocol types shared with the orchestrator. Full contract
4
+ // in docs/runtime-protocol.md; types here are exported so callers
5
+ // (test harness, adapters, dashboards) can build and inspect wire
6
+ // payloads without reaching into runtime internals.
7
+
8
+ import type { JournalSlice, WaitpointResolutionEntry } from "../runtime/journal.js"
9
+
10
+ export type ProtocolVersion = 1
11
+ export const PROTOCOL_VERSION: ProtocolVersion = 1
12
+
13
+ // Journal types: shape of the tenant-side view of a run's state.
14
+ // Re-exported so orchestrators and tools can build/inspect journals
15
+ // without reaching into the runtime subpath.
16
+ export type {
17
+ CompensationJournalEntry,
18
+ JournalSlice,
19
+ StepJournalEntry,
20
+ WaitpointResolutionEntry,
21
+ } from "../runtime/journal.js"
22
+
23
+ export type ExecutionStatus =
24
+ | "CREATED"
25
+ | "QUEUED"
26
+ | "EXECUTING"
27
+ | "EXECUTING_WITH_WAITPOINTS"
28
+ | "SUSPENDED"
29
+ | "PENDING_CANCEL"
30
+ | "FINISHED"
31
+
32
+ export type WaitpointKind = "DATETIME" | "EVENT" | "SIGNAL" | "RUN" | "MANUAL"
33
+
34
+ export interface SerializedError {
35
+ category: "USER_ERROR" | "RUNTIME_ERROR"
36
+ code: string
37
+ message: string
38
+ name?: string
39
+ stack?: string
40
+ cause?: SerializedError
41
+ data?: Record<string, unknown>
42
+ }
43
+
44
+ export type PayloadLocation = "INLINE" | "EXTERNAL"
45
+
46
+ export interface WorkflowManifest {
47
+ schemaVersion: 1
48
+ projectId: string
49
+ versionId: string
50
+ builtAt: number
51
+ builderVersion: string
52
+ capabilities: WorkflowReleaseCapabilities
53
+ workflows: WorkflowManifestEntry[]
54
+ eventFilters: EventFilterManifestEntry[]
55
+ diagnostics: WorkflowManifestDiagnostic[]
56
+ bundle?: WorkflowManifestBundle
57
+ bindings: Record<string, { type: "d1" | "r2" | "kv" | "queue"; name: string }>
58
+ environments: Record<string, { customDomain?: string }>
59
+ }
60
+
61
+ export interface WorkflowManifestEntry {
62
+ id: string
63
+ displayName?: string
64
+ description?: string
65
+ capabilities: WorkflowDefinitionCapabilities
66
+ version: string
67
+ inputSchema?: unknown
68
+ outputSchema?: unknown
69
+ concurrency?: ManifestConcurrencyPolicy
70
+ steps: ManifestStep[]
71
+ schedules: ManifestSchedule[]
72
+ defaultRuntime: "edge" | "node"
73
+ hasCompensation: boolean
74
+ sourceLocation: { file: string; line: number }
75
+ }
76
+
77
+ export interface WorkflowReleaseCapabilities {
78
+ trigger: boolean
79
+ events: boolean
80
+ schedules: boolean
81
+ rerun: boolean
82
+ resume: boolean
83
+ cancel: boolean
84
+ humanApproval: boolean
85
+ stepRerun: boolean
86
+ }
87
+
88
+ export interface WorkflowDefinitionCapabilities {
89
+ canTrigger: boolean
90
+ canRerun: boolean
91
+ canResume: boolean
92
+ canCancel: boolean
93
+ hasSchedules: boolean
94
+ supportsEvents: boolean
95
+ supportsHumanApproval: boolean
96
+ supportsStepRerun: boolean
97
+ }
98
+
99
+ export interface WorkflowManifestBundle {
100
+ artifactName?: string
101
+ sizeBytes?: number
102
+ hash?: string
103
+ hashAlgorithm?: "sha256" | "sha512" | (string & {})
104
+ }
105
+
106
+ export interface WorkflowBundleReference {
107
+ key?: string
108
+ url?: string
109
+ signedUrl?: string
110
+ hash?: string
111
+ hashAlgorithm?: "sha256" | "sha512" | (string & {})
112
+ sizeBytes?: number
113
+ }
114
+
115
+ export interface WorkflowPayloadReference {
116
+ location: PayloadLocation
117
+ key?: string
118
+ url?: string
119
+ hash?: string
120
+ hashAlgorithm?: "sha256" | "sha512" | (string & {})
121
+ sizeBytes?: number
122
+ contentType?: string
123
+ }
124
+
125
+ export interface WorkflowJournalReference {
126
+ location: PayloadLocation
127
+ key?: string
128
+ url?: string
129
+ hash?: string
130
+ hashAlgorithm?: "sha256" | "sha512" | (string & {})
131
+ }
132
+
133
+ export interface WorkflowManifestDiagnostic {
134
+ code: string
135
+ severity: "info" | "warning" | "error"
136
+ message: string
137
+ sourceLocation?: { file: string; line?: number; column?: number }
138
+ }
139
+
140
+ export interface ManifestConcurrencyPolicy {
141
+ key?: string
142
+ limit?: number
143
+ strategy?: "queue" | "cancel-in-progress" | "cancel-newest" | "round-robin"
144
+ }
145
+
146
+ export interface ManifestStep {
147
+ id: string
148
+ runtime: "edge" | "node"
149
+ hasCompensation: boolean
150
+ sourceLocation: { file: string; line: number }
151
+ }
152
+
153
+ export interface ManifestSchedule {
154
+ cron?: string
155
+ every?: string | number
156
+ at?: string
157
+ timezone?: string
158
+ input?: unknown
159
+ enabled?: boolean
160
+ overlap?: "skip" | "queue" | "allow"
161
+ environments?: ("production" | "preview" | "development")[]
162
+ name?: string
163
+ }
164
+
165
+ export interface EventFilterManifestEntry {
166
+ /** Stable id derived from `payloadHash` of the canonicalized declaration. */
167
+ id: string
168
+ /** Event name the filter targets — matches `EventEnvelope.name`. */
169
+ eventType: string
170
+ /**
171
+ * Optional structured `where` predicate. When absent, every event of the
172
+ * matching `eventType` fires the target workflow. Concrete shape lives in
173
+ * `@voyant-travel/workflows/events` (`PredicateExpr`); the protocol declares
174
+ * it as an opaque object so old orchestrators that don't understand the
175
+ * shape don't have to evaluate it.
176
+ */
177
+ where?: unknown
178
+ /**
179
+ * Optional input mapper. When absent, the workflow input = `envelope.data`.
180
+ * Concrete shape lives in `@voyant-travel/workflows/events` (`InputMapper`).
181
+ */
182
+ input?: unknown
183
+ /** Content-derived hash of the canonicalized declaration. */
184
+ payloadHash: string
185
+ /** Workflow id this filter triggers. */
186
+ targetWorkflowId: string
187
+ }
188
+
189
+ export interface WorkflowWaitpointSource {
190
+ clientWaitpointId: string
191
+ kind: WaitpointKind
192
+ meta: Record<string, unknown>
193
+ timeoutMs?: number
194
+ }
195
+
196
+ export interface WorkflowWaitpointSnapshot {
197
+ /** Framework-stable waitpoint id, usually the runtime client waitpoint id. */
198
+ id: string
199
+ /** Stable key Cloud can store and later address without re-deriving metadata. */
200
+ key: string
201
+ kind: WaitpointKind
202
+ eventName?: string
203
+ signalName?: string
204
+ tokenId?: string
205
+ expiresAt?: number
206
+ timeoutMs?: number
207
+ metadata: Record<string, unknown>
208
+ }
209
+
210
+ export type WorkflowWaitpointResumeTarget = WorkflowWaitpointSource | WorkflowWaitpointSnapshot
211
+
212
+ export interface WorkflowActivationFreshness {
213
+ dispatchedAt: number
214
+ expiresAt?: number
215
+ attempt?: number
216
+ leaseId?: string
217
+ }
218
+
219
+ export type WorkflowActivationMetadata =
220
+ | {
221
+ kind: "initial"
222
+ workflowReleaseId?: string
223
+ releaseId?: string
224
+ bundle?: WorkflowBundleReference
225
+ freshness?: WorkflowActivationFreshness
226
+ }
227
+ | WorkflowResumeActivationMetadata
228
+
229
+ export interface WorkflowResumeActivationMetadata {
230
+ kind: "resume"
231
+ workflowReleaseId?: string
232
+ releaseId?: string
233
+ bundle?: WorkflowBundleReference
234
+ journalRef?: WorkflowJournalReference
235
+ waitpoint: WorkflowWaitpointSnapshot
236
+ resumePayloadRef?: WorkflowPayloadReference
237
+ freshness?: WorkflowActivationFreshness
238
+ }
239
+
240
+ export interface ApplyWorkflowResumeInput {
241
+ journal: JournalSlice
242
+ waitpoints: readonly WorkflowWaitpointResumeTarget[]
243
+ waitpointId?: string
244
+ waitpointKey?: string
245
+ parkedAt?: number
246
+ payload?: unknown
247
+ payloadRef?: WorkflowPayloadReference
248
+ resolvedAt?: number
249
+ matchedEventId?: string
250
+ source?: WaitpointResolutionEntry["source"]
251
+ }
252
+
253
+ export type ApplyWorkflowResumeResult =
254
+ | {
255
+ ok: true
256
+ journal: JournalSlice
257
+ waitpoint: WorkflowWaitpointSnapshot
258
+ resolution: WaitpointResolutionEntry
259
+ }
260
+ | {
261
+ ok: false
262
+ code: "missing_waitpoint_selector" | "waitpoint_not_found"
263
+ message: string
264
+ }
265
+
266
+ export function workflowWaitpointKey(waitpoint: WorkflowWaitpointResumeTarget): string {
267
+ if (isWorkflowWaitpointSnapshot(waitpoint)) return waitpoint.key
268
+ return `${waitpoint.kind}:${waitpoint.clientWaitpointId}`
269
+ }
270
+
271
+ export function snapshotWorkflowWaitpoint(
272
+ waitpoint: WorkflowWaitpointResumeTarget,
273
+ parkedAt = Date.now(),
274
+ ): WorkflowWaitpointSnapshot {
275
+ const metadata = {
276
+ ...(isWorkflowWaitpointSnapshot(waitpoint) ? waitpoint.metadata : waitpoint.meta),
277
+ }
278
+ const timeoutMs = waitpoint.timeoutMs
279
+ const wakeAt = typeof metadata.wakeAt === "number" ? metadata.wakeAt : undefined
280
+ const expiresAt = isWorkflowWaitpointSnapshot(waitpoint)
281
+ ? waitpoint.expiresAt
282
+ : (wakeAt ??
283
+ (typeof timeoutMs === "number" && timeoutMs > 0 ? parkedAt + timeoutMs : undefined))
284
+
285
+ const snapshot: WorkflowWaitpointSnapshot = {
286
+ id: workflowWaitpointId(waitpoint),
287
+ key: workflowWaitpointKey(waitpoint),
288
+ kind: waitpoint.kind,
289
+ metadata,
290
+ }
291
+ if (typeof timeoutMs === "number") snapshot.timeoutMs = timeoutMs
292
+ if (typeof expiresAt === "number") snapshot.expiresAt = expiresAt
293
+ if (waitpoint.kind === "EVENT") {
294
+ const eventName = isWorkflowWaitpointSnapshot(waitpoint)
295
+ ? waitpoint.eventName
296
+ : typeof metadata.eventType === "string"
297
+ ? metadata.eventType
298
+ : undefined
299
+ if (eventName) snapshot.eventName = eventName
300
+ }
301
+ if (waitpoint.kind === "SIGNAL") {
302
+ const signalName = isWorkflowWaitpointSnapshot(waitpoint)
303
+ ? waitpoint.signalName
304
+ : typeof metadata.signalName === "string"
305
+ ? metadata.signalName
306
+ : undefined
307
+ if (signalName) snapshot.signalName = signalName
308
+ }
309
+ if (waitpoint.kind === "MANUAL") {
310
+ const tokenId = isWorkflowWaitpointSnapshot(waitpoint)
311
+ ? waitpoint.tokenId
312
+ : typeof metadata.tokenId === "string"
313
+ ? metadata.tokenId
314
+ : undefined
315
+ if (tokenId) snapshot.tokenId = tokenId
316
+ }
317
+ return snapshot
318
+ }
319
+
320
+ export function applyWorkflowResumeToJournal(
321
+ input: ApplyWorkflowResumeInput,
322
+ ): ApplyWorkflowResumeResult {
323
+ if (!input.waitpointId && !input.waitpointKey) {
324
+ return {
325
+ ok: false,
326
+ code: "missing_waitpoint_selector",
327
+ message: "resume requires waitpointId or waitpointKey",
328
+ }
329
+ }
330
+
331
+ const matched = input.waitpoints.find((waitpoint) => {
332
+ if (input.waitpointId && workflowWaitpointId(waitpoint) === input.waitpointId) return true
333
+ return (
334
+ input.waitpointKey !== undefined && workflowWaitpointKey(waitpoint) === input.waitpointKey
335
+ )
336
+ })
337
+
338
+ if (!matched) {
339
+ const selector = input.waitpointId
340
+ ? `waitpointId=${input.waitpointId}`
341
+ : `waitpointKey=${input.waitpointKey}`
342
+ return {
343
+ ok: false,
344
+ code: "waitpoint_not_found",
345
+ message: `no pending waitpoint matches ${selector}`,
346
+ }
347
+ }
348
+
349
+ const journal = structuredClone(input.journal) as JournalSlice
350
+ const resolution: WaitpointResolutionEntry = {
351
+ kind: matched.kind,
352
+ resolvedAt: input.resolvedAt ?? Date.now(),
353
+ source: input.source ?? "live",
354
+ }
355
+ if ("payload" in input) resolution.payload = input.payload
356
+ if (input.payloadRef) resolution.payloadRef = input.payloadRef
357
+ if (input.matchedEventId) resolution.matchedEventId = input.matchedEventId
358
+ journal.waitpointsResolved[workflowWaitpointId(matched)] = resolution
359
+
360
+ return {
361
+ ok: true,
362
+ journal,
363
+ waitpoint: snapshotWorkflowWaitpoint(matched, input.parkedAt ?? resolution.resolvedAt),
364
+ resolution,
365
+ }
366
+ }
367
+
368
+ function workflowWaitpointId(waitpoint: WorkflowWaitpointResumeTarget): string {
369
+ return isWorkflowWaitpointSnapshot(waitpoint) ? waitpoint.id : waitpoint.clientWaitpointId
370
+ }
371
+
372
+ function isWorkflowWaitpointSnapshot(
373
+ waitpoint: WorkflowWaitpointResumeTarget,
374
+ ): waitpoint is WorkflowWaitpointSnapshot {
375
+ return "id" in waitpoint
376
+ }
377
+
378
+ // WebSocket stream events — full union in docs/runtime-protocol.md §6.2.
379
+ export type StreamEvent =
380
+ | {
381
+ kind: "step.started"
382
+ eventId: string
383
+ at: number
384
+ stepId: string
385
+ runtime: "edge" | "node"
386
+ machine?: string
387
+ }
388
+ | {
389
+ kind: "step.ok"
390
+ eventId: string
391
+ at: number
392
+ stepId: string
393
+ attempt: number
394
+ durationMs: number
395
+ output?: unknown
396
+ }
397
+ | {
398
+ kind: "step.err"
399
+ eventId: string
400
+ at: number
401
+ stepId: string
402
+ attempt: number
403
+ error: SerializedError
404
+ }
405
+ | { kind: "step.skipped"; eventId: string; at: number; stepId: string; reason: string }
406
+ | {
407
+ kind: "step.compensated"
408
+ eventId: string
409
+ at: number
410
+ stepId: string
411
+ status: "ok" | "err"
412
+ error?: SerializedError
413
+ }
414
+ | {
415
+ kind: "waitpoint.registered"
416
+ eventId: string
417
+ at: number
418
+ waitpointId: string
419
+ waitpointKind: WaitpointKind
420
+ meta: Record<string, unknown>
421
+ }
422
+ | {
423
+ kind: "waitpoint.resolved"
424
+ eventId: string
425
+ at: number
426
+ waitpointId: string
427
+ payload?: unknown
428
+ source: "live" | "inbox" | "replay"
429
+ }
430
+ | { kind: "metadata.changed"; eventId: string; at: number; metadata: Record<string, unknown> }
431
+ | {
432
+ kind: "stream.chunk"
433
+ eventId: string
434
+ at: number
435
+ streamId: string
436
+ chunk: unknown
437
+ encoding: "json" | "text" | "base64"
438
+ final: boolean
439
+ }
440
+ | {
441
+ kind: "log"
442
+ eventId: string
443
+ at: number
444
+ level: "info" | "warn" | "error"
445
+ message: string
446
+ stepId?: string
447
+ data?: object
448
+ }
449
+ | { kind: "version.rebased"; eventId: string; at: number; fromVersion: string; toVersion: string }
450
+ | { kind: "run.cancelled"; eventId: string; at: number; reason?: string }
451
+ | {
452
+ kind: "run.finished"
453
+ eventId: string
454
+ at: number
455
+ status: string
456
+ output?: unknown
457
+ error?: SerializedError
458
+ }
459
+
460
+ // Shared envelope for journal events written by the orchestrator,
461
+ // the tenant worker, or a node-runtime container. Concrete `kind`
462
+ // discriminants are owned by the emitting layer.
463
+ export interface JournalEventEnvelope<TKind extends string = string, TData = unknown> {
464
+ eventId: string
465
+ runId: string
466
+ createdAt: number
467
+ kind: TKind
468
+ data: TData
469
+ snapshotId?: string
470
+ writtenBy: "orchestrator" | "tenant" | "node"
471
+ }
472
+
473
+ export interface PublicAccessTokenClaims {
474
+ sub: "pat"
475
+ tenantId: string
476
+ environment: "production" | "preview" | "development"
477
+ scope: ("read" | "trigger" | "cancel")[]
478
+ target:
479
+ | { kind: "run"; runId: string }
480
+ | { kind: "workflow"; workflowId: string }
481
+ | { kind: "tag"; tag: string }
482
+ exp: number
483
+ }
@@ -0,0 +1,181 @@
1
+ // @voyant-travel/workflows/rate-limit
2
+ //
3
+ // Reference rate limiter used by `ctx.step({ rateLimit: ... })`.
4
+ //
5
+ // A RateLimiter is a small interface: `acquire()` blocks (or throws,
6
+ // depending on `onLimit`) until `units` are available under `key` for
7
+ // a sliding window of `windowMs`. One shared limiter instance lives
8
+ // per tenant Worker process — callers wire it into `createStepHandler`
9
+ // via `{ rateLimiter: createInMemoryRateLimiter() }`.
10
+ //
11
+ // The in-memory impl is a token bucket: `capacity = limit`, refill
12
+ // rate = `limit / windowMs`. It's suitable for local dev and
13
+ // single-process deployments. Multi-region / sharded deployments
14
+ // should swap in a Durable-Object or Redis-backed implementation that
15
+ // shares state across isolates.
16
+
17
+ import type { Duration } from "../types.js"
18
+
19
+ export interface AcquireArgs {
20
+ /** Bucket key — usually a tenant id, a url host, a user id, etc. */
21
+ key: string
22
+ /** Maximum units the bucket can hold. */
23
+ limit: number
24
+ /** Units the current call consumes. */
25
+ units: number
26
+ /** Refill window in ms. `limit` tokens per `windowMs`. */
27
+ windowMs: number
28
+ /** `queue` → wait until capacity; `fail` → throw immediately. */
29
+ onLimit: "queue" | "fail"
30
+ /** Forwarded from the run; limiter observes aborts during queue waits. */
31
+ signal?: AbortSignal
32
+ }
33
+
34
+ export interface RateLimiter {
35
+ acquire(args: AcquireArgs): Promise<void>
36
+ }
37
+
38
+ /** Error thrown when `onLimit === "fail"` and the bucket is empty. */
39
+ export class RateLimitExceededError extends Error {
40
+ readonly code = "RATE_LIMITED"
41
+ readonly retryAfterMs: number
42
+ constructor(key: string, retryAfterMs: number) {
43
+ super(`rate limit exceeded for key "${key}" (retry after ${retryAfterMs}ms)`)
44
+ this.name = "RateLimitExceededError"
45
+ this.retryAfterMs = retryAfterMs
46
+ }
47
+ }
48
+
49
+ interface Bucket {
50
+ tokens: number
51
+ capacity: number
52
+ refillPerMs: number
53
+ lastRefillAt: number
54
+ }
55
+
56
+ export interface InMemoryLimiterOptions {
57
+ /** Injectable clock, ms since epoch. Defaults to Date.now. */
58
+ now?: () => number
59
+ /** Injectable delay; defaults to setTimeout. Tests override this. */
60
+ delay?: (ms: number, signal?: AbortSignal) => Promise<void>
61
+ }
62
+
63
+ /**
64
+ * Token-bucket rate limiter held in-process. Independent buckets per
65
+ * `key`; bucket parameters (`capacity`, `refillPerMs`) come from the
66
+ * `limit` / `windowMs` of the first `acquire` call and are updated on
67
+ * subsequent calls that change them.
68
+ */
69
+ export function createInMemoryRateLimiter(opts: InMemoryLimiterOptions = {}): RateLimiter {
70
+ const now = opts.now ?? (() => Date.now())
71
+ const delay = opts.delay ?? defaultDelay
72
+ const buckets = new Map<string, Bucket>()
73
+
74
+ return {
75
+ async acquire(args) {
76
+ if (args.units <= 0) return
77
+ if (args.limit <= 0) {
78
+ throw new Error(`rate-limit: "limit" must be > 0 (got ${args.limit}) for key "${args.key}"`)
79
+ }
80
+ if (args.windowMs <= 0) {
81
+ throw new Error(
82
+ `rate-limit: "windowMs" must be > 0 (got ${args.windowMs}) for key "${args.key}"`,
83
+ )
84
+ }
85
+ if (args.units > args.limit) {
86
+ // The step will never be admissible — short-circuit regardless
87
+ // of onLimit to avoid hanging indefinitely under queue mode.
88
+ throw new Error(
89
+ `rate-limit: units (${args.units}) > limit (${args.limit}) for key "${args.key}" — step can never be admitted`,
90
+ )
91
+ }
92
+
93
+ while (true) {
94
+ const bucket = refill(buckets, args, now())
95
+ if (bucket.tokens >= args.units) {
96
+ bucket.tokens -= args.units
97
+ return
98
+ }
99
+ const missing = args.units - bucket.tokens
100
+ const waitMs = Math.ceil(missing / bucket.refillPerMs)
101
+ if (args.onLimit === "fail") {
102
+ throw new RateLimitExceededError(args.key, waitMs)
103
+ }
104
+ await delay(waitMs, args.signal)
105
+ if (args.signal?.aborted) {
106
+ throw args.signal.reason ?? new Error("rate-limit: aborted while queued")
107
+ }
108
+ }
109
+ },
110
+ }
111
+ }
112
+
113
+ function refill(buckets: Map<string, Bucket>, args: AcquireArgs, nowMs: number): Bucket {
114
+ const refillPerMs = args.limit / args.windowMs
115
+ let b = buckets.get(args.key)
116
+ if (!b) {
117
+ b = {
118
+ tokens: args.limit,
119
+ capacity: args.limit,
120
+ refillPerMs,
121
+ lastRefillAt: nowMs,
122
+ }
123
+ buckets.set(args.key, b)
124
+ return b
125
+ }
126
+ // Re-parameterize if the caller changed limit / windowMs. Clamp
127
+ // tokens to the new capacity so a shrink doesn't leave stale excess.
128
+ if (b.capacity !== args.limit || b.refillPerMs !== refillPerMs) {
129
+ b.capacity = args.limit
130
+ b.refillPerMs = refillPerMs
131
+ if (b.tokens > b.capacity) b.tokens = b.capacity
132
+ }
133
+ const elapsed = Math.max(0, nowMs - b.lastRefillAt)
134
+ if (elapsed > 0) {
135
+ b.tokens = Math.min(b.capacity, b.tokens + elapsed * b.refillPerMs)
136
+ b.lastRefillAt = nowMs
137
+ }
138
+ return b
139
+ }
140
+
141
+ function defaultDelay(ms: number, signal?: AbortSignal): Promise<void> {
142
+ return new Promise((resolve, reject) => {
143
+ if (signal?.aborted) {
144
+ reject(signal.reason ?? new Error("aborted"))
145
+ return
146
+ }
147
+ const timer = setTimeout(() => {
148
+ signal?.removeEventListener("abort", onAbort)
149
+ resolve()
150
+ }, ms)
151
+ const onAbort = () => {
152
+ clearTimeout(timer)
153
+ reject(signal?.reason ?? new Error("aborted"))
154
+ }
155
+ signal?.addEventListener("abort", onAbort, { once: true })
156
+ })
157
+ }
158
+
159
+ /** Normalize a Duration to milliseconds. Same units as `toMs` in ctx.ts. */
160
+ export function durationToMs(d: Duration): number {
161
+ if (typeof d === "number") return d
162
+ const m = /^(\d+)(ms|s|m|h|d|w)$/.exec(d)
163
+ if (!m) throw new Error(`rate-limit: invalid duration "${d}"`)
164
+ const n = Number(m[1])
165
+ switch (m[2]) {
166
+ case "ms":
167
+ return n
168
+ case "s":
169
+ return n * 1_000
170
+ case "m":
171
+ return n * 60_000
172
+ case "h":
173
+ return n * 3_600_000
174
+ case "d":
175
+ return n * 86_400_000
176
+ case "w":
177
+ return n * 604_800_000
178
+ default:
179
+ throw new Error(`rate-limit: invalid duration "${d}"`)
180
+ }
181
+ }