@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,201 @@
1
+ // Input mapper DSL — projects a workflow input from an event envelope.
2
+ //
3
+ // Each `EventFilterDeclaration` carries an optional `input: InputMapper`. At
4
+ // ingest time, after the predicate matches, the mapper builds the actual
5
+ // input value passed to `driver.trigger(target, input, ...)`. Same path
6
+ // roots as the predicate evaluator (`data`, `metadata`, `name`, `emittedAt`).
7
+ //
8
+ // Variants:
9
+ // * `undefined` → pass through `envelope.data`
10
+ // * `{ passthrough: true }` → explicit pass-through of `envelope.data`
11
+ // * `{ path: string }` → workflow input = the resolved path value
12
+ // * `{ object: {...} }` → build an object by projecting each key;
13
+ // each value is itself an InputMapper or a
14
+ // `PathOrLit` for terminal projections
15
+ //
16
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §13.2.
17
+
18
+ import { type PathOrLit, type PredicateEnvelope, resolvePath } from "./predicate.js"
19
+
20
+ // ---- Public types ----
21
+
22
+ export type InputMapper =
23
+ | undefined
24
+ | { passthrough: true }
25
+ | { path: string }
26
+ | { object: Record<string, InputMapper | PathOrLit> }
27
+
28
+ // ---- Public API ----
29
+
30
+ /**
31
+ * Project a workflow input from an event envelope. Mirrors the predicate
32
+ * evaluator's no-throw contract — missing paths produce `undefined` in the
33
+ * output, registration-time linting catches structural errors.
34
+ *
35
+ * Throws `InputMapperError` only on unexpected shape errors (the mapper
36
+ * itself was constructed wrong). Drivers catch and surface this as
37
+ * `IngestMatch.status === "skipped"` with reason `"input_projection_error"`.
38
+ */
39
+ export function projectInput(mapper: InputMapper, envelope: PredicateEnvelope): unknown {
40
+ if (mapper === undefined) return envelope.data
41
+ if (typeof mapper !== "object" || mapper === null) {
42
+ throw new InputMapperError(
43
+ `input mapper must be undefined, {passthrough}, {path}, or {object}, got ${typeof mapper}`,
44
+ )
45
+ }
46
+ if ("passthrough" in mapper) {
47
+ if (mapper.passthrough !== true) {
48
+ throw new InputMapperError(`{ passthrough } must be true`)
49
+ }
50
+ return envelope.data
51
+ }
52
+ if ("path" in mapper) {
53
+ if (typeof mapper.path !== "string" || mapper.path.length === 0) {
54
+ throw new InputMapperError(`{ path } must be a non-empty string`)
55
+ }
56
+ return resolvePath(mapper.path, envelope)
57
+ }
58
+ if ("object" in mapper) {
59
+ if (typeof mapper.object !== "object" || mapper.object === null) {
60
+ throw new InputMapperError(`{ object } must map keys to InputMapper | PathOrLit`)
61
+ }
62
+ const out: Record<string, unknown> = {}
63
+ for (const [key, child] of Object.entries(mapper.object)) {
64
+ out[key] = projectChild(child, envelope)
65
+ }
66
+ return out
67
+ }
68
+ throw new InputMapperError(
69
+ `input mapper must contain one of passthrough | path | object, got keys: ${Object.keys(mapper).join(", ")}`,
70
+ )
71
+ }
72
+
73
+ // ---- Static linter ----
74
+
75
+ export interface InputMapperValidationResult {
76
+ ok: boolean
77
+ errors: string[]
78
+ }
79
+
80
+ export function validateInputMapper(mapper: InputMapper): InputMapperValidationResult {
81
+ const errors: string[] = []
82
+ walkValidate(mapper, errors, [])
83
+ return { ok: errors.length === 0, errors }
84
+ }
85
+
86
+ // ---- Internals ----
87
+
88
+ class InputMapperError extends Error {
89
+ constructor(message: string) {
90
+ super(message)
91
+ this.name = "InputMapperError"
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Resolve one entry inside `{ object: { ... } }`. The value is either a
97
+ * nested mapper (recurse) or a `PathOrLit` (terminal projection).
98
+ */
99
+ function projectChild(child: InputMapper | PathOrLit, envelope: PredicateEnvelope): unknown {
100
+ if (child === undefined) return envelope.data
101
+ if (typeof child !== "object" || child === null) {
102
+ throw new InputMapperError(`nested mapper entry must be an object, got ${typeof child}`)
103
+ }
104
+ // Distinguish PathOrLit (`{ path }` or `{ lit }`) from nested mapper.
105
+ if ("lit" in child) {
106
+ return (child as { lit: unknown }).lit
107
+ }
108
+ // `{ path: "..." }` is shared between PathOrLit and InputMapper — resolve directly.
109
+ if ("path" in child) {
110
+ if (typeof child.path !== "string" || child.path.length === 0) {
111
+ throw new InputMapperError(`nested { path } must be a non-empty string`)
112
+ }
113
+ return resolvePath(child.path, envelope)
114
+ }
115
+ // Otherwise: a nested InputMapper (passthrough / object).
116
+ return projectInput(child as InputMapper, envelope)
117
+ }
118
+
119
+ function walkValidate(mapper: InputMapper, errors: string[], path: string[]): void {
120
+ if (mapper === undefined) return
121
+ if (typeof mapper !== "object" || mapper === null) {
122
+ errors.push(
123
+ `${pathLabel(path)}: input mapper must be undefined or an object, got ${typeof mapper}`,
124
+ )
125
+ return
126
+ }
127
+ const keys = Object.keys(mapper)
128
+ if (keys.length === 0) {
129
+ errors.push(`${pathLabel(path)}: input mapper must specify passthrough | path | object`)
130
+ return
131
+ }
132
+ if ("passthrough" in mapper) {
133
+ if (mapper.passthrough !== true) {
134
+ errors.push(`${pathLabel(path)}: { passthrough } must be true`)
135
+ }
136
+ return
137
+ }
138
+ if ("path" in mapper) {
139
+ if (typeof mapper.path !== "string" || mapper.path.length === 0) {
140
+ errors.push(`${pathLabel(path)}: { path } must be a non-empty string`)
141
+ return
142
+ }
143
+ validatePathRoot(mapper.path, errors, path)
144
+ return
145
+ }
146
+ if ("object" in mapper) {
147
+ if (typeof mapper.object !== "object" || mapper.object === null) {
148
+ errors.push(`${pathLabel(path)}: { object } must map keys to InputMapper | PathOrLit`)
149
+ return
150
+ }
151
+ for (const [k, child] of Object.entries(mapper.object)) {
152
+ validateChild(child, errors, [...path, k])
153
+ }
154
+ return
155
+ }
156
+ errors.push(`${pathLabel(path)}: unknown mapper variant — keys: ${keys.join(", ")}`)
157
+ }
158
+
159
+ function validateChild(child: InputMapper | PathOrLit, errors: string[], path: string[]): void {
160
+ if (child === undefined) return
161
+ if (typeof child !== "object" || child === null) {
162
+ errors.push(`${pathLabel(path)}: nested entry must be an object`)
163
+ return
164
+ }
165
+ if ("lit" in child) {
166
+ const t = typeof (child as { lit: unknown }).lit
167
+ if (
168
+ t !== "string" &&
169
+ t !== "number" &&
170
+ t !== "boolean" &&
171
+ (child as { lit: unknown }).lit !== null
172
+ ) {
173
+ errors.push(`${pathLabel(path)}: { lit } must be string | number | boolean | null`)
174
+ }
175
+ return
176
+ }
177
+ if ("path" in child) {
178
+ if (typeof child.path !== "string" || child.path.length === 0) {
179
+ errors.push(`${pathLabel(path)}: { path } must be a non-empty string`)
180
+ return
181
+ }
182
+ validatePathRoot(child.path, errors, path)
183
+ return
184
+ }
185
+ walkValidate(child as InputMapper, errors, path)
186
+ }
187
+
188
+ function validatePathRoot(path: string, errors: string[], errorPath: string[]): void {
189
+ // Match the predicate path roots exactly so the two DSLs stay aligned.
190
+ const firstSegment = path.split(".")[0] ?? ""
191
+ const root = firstSegment.split("[")[0] ?? ""
192
+ if (root !== "data" && root !== "metadata" && root !== "name" && root !== "emittedAt") {
193
+ errors.push(
194
+ `${pathLabel(errorPath)}: path root "${root}" is not one of data | metadata | name | emittedAt`,
195
+ )
196
+ }
197
+ }
198
+
199
+ function pathLabel(path: string[]): string {
200
+ return path.length === 0 ? "(root)" : path.join(".")
201
+ }
@@ -0,0 +1,372 @@
1
+ // Build a `WorkflowManifest` from collected workflow + event-filter entries.
2
+ //
3
+ // Called once at `createApp()` boot (PR4). The resulting manifest is
4
+ // content-addressed: byte-identical inputs produce byte-identical
5
+ // `versionId`s, so concurrent registration calls don't race meaningfully —
6
+ // the second caller sees the same versionId the first did.
7
+ //
8
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §14.1.
9
+
10
+ import type {
11
+ EventFilterManifestEntry,
12
+ ManifestConcurrencyPolicy,
13
+ ManifestSchedule,
14
+ WorkflowDefinitionCapabilities,
15
+ WorkflowManifest,
16
+ WorkflowManifestBundle,
17
+ WorkflowManifestDiagnostic,
18
+ WorkflowManifestEntry,
19
+ WorkflowReleaseCapabilities,
20
+ } from "../protocol/index.js"
21
+ import type { ConcurrencyPolicy, ScheduleDeclaration } from "../workflow.js"
22
+ import { canonicalJson, shortHash } from "./payload-hash.js"
23
+ import type { EventFilterRuntimeEntry } from "./registry.js"
24
+
25
+ export interface BuildManifestArgs {
26
+ /** Project / tenant identifier. Single-tenant runtimes pass `"default"`. */
27
+ projectId?: string
28
+ /** Deployment environment. */
29
+ environment: "production" | "preview" | "development"
30
+ /** Workflow definitions collected from modules + plugins. */
31
+ workflows: ReadonlyArray<{
32
+ id: string
33
+ config?: {
34
+ description?: string
35
+ input?: unknown
36
+ output?: unknown
37
+ defaultRuntime?: "edge" | "node"
38
+ concurrency?: ConcurrencyPolicy<unknown>
39
+ retry?: unknown
40
+ timeout?: unknown
41
+ schedule?: ScheduleDeclaration | ScheduleDeclaration[]
42
+ }
43
+ }>
44
+ /** Event-filter entries from `getEventFilterRegistry()`. */
45
+ eventFilters: ReadonlyArray<EventFilterRuntimeEntry>
46
+ /** Wall-clock build time, ms-since-epoch. Defaults to `Date.now()`. */
47
+ builtAt?: number
48
+ /** Source-code version of the manifest builder. */
49
+ builderVersion?: string
50
+ /** Workflow bundle metadata, when the build tool owns the artifact. */
51
+ bundle?: WorkflowManifestBundle
52
+ /** Build/import diagnostics surfaced to Cloud during release registration. */
53
+ diagnostics?: ReadonlyArray<WorkflowManifestDiagnostic>
54
+ }
55
+
56
+ /**
57
+ * Build a deterministic `WorkflowManifest`. Same inputs always produce
58
+ * byte-identical output, including `versionId`.
59
+ *
60
+ * Does NOT write the manifest anywhere — that's the driver's
61
+ * `registerManifest(...)` responsibility. This function is pure.
62
+ */
63
+ export async function buildManifest(args: BuildManifestArgs): Promise<WorkflowManifest> {
64
+ const builtAt = args.builtAt ?? Date.now()
65
+ const builderVersion = args.builderVersion ?? "@voyant-travel/workflows@manifest-builder"
66
+ const projectId = args.projectId ?? "default"
67
+
68
+ const eventFilters: EventFilterManifestEntry[] = args.eventFilters
69
+ .map((entry) => entry.manifest)
70
+ .sort((a, b) => a.id.localeCompare(b.id))
71
+ const eventTargetWorkflowIds = new Set(eventFilters.map((filter) => filter.targetWorkflowId))
72
+
73
+ const workflows: WorkflowManifestEntry[] = args.workflows
74
+ .map((wf) => {
75
+ const schedules = serializeSchedules(wf.config?.schedule)
76
+ return {
77
+ id: wf.id,
78
+ displayName: displayNameFromId(wf.id),
79
+ description: wf.config?.description,
80
+ capabilities: workflowCapabilities({
81
+ hasSchedules: schedules.length > 0,
82
+ supportsEvents: eventTargetWorkflowIds.has(wf.id),
83
+ }),
84
+ version: "current",
85
+ inputSchema: serializeSchema(wf.config?.input),
86
+ outputSchema: serializeSchema(wf.config?.output),
87
+ steps: [],
88
+ concurrency: serializeConcurrency(wf.config?.concurrency),
89
+ schedules,
90
+ defaultRuntime: wf.config?.defaultRuntime ?? "edge",
91
+ hasCompensation: false,
92
+ sourceLocation: { file: "<runtime>", line: 0 },
93
+ }
94
+ })
95
+ .sort((a, b) => a.id.localeCompare(b.id))
96
+
97
+ const draft: Omit<WorkflowManifest, "versionId"> & { versionId?: string } = {
98
+ schemaVersion: 1,
99
+ projectId,
100
+ builtAt,
101
+ builderVersion,
102
+ capabilities: releaseCapabilities(),
103
+ workflows,
104
+ eventFilters,
105
+ diagnostics: [...(args.diagnostics ?? [])],
106
+ bundle: args.bundle,
107
+ bindings: {},
108
+ environments: { production: {}, preview: {}, development: {} },
109
+ }
110
+
111
+ // versionId is the cryptographic short hash of the canonical manifest
112
+ // body (excluding builtAt + versionId itself, which are non-load-bearing
113
+ // for content identity).
114
+ const identityBody = {
115
+ schemaVersion: draft.schemaVersion,
116
+ projectId: draft.projectId,
117
+ builderVersion: draft.builderVersion,
118
+ capabilities: draft.capabilities,
119
+ workflows: draft.workflows,
120
+ eventFilters: draft.eventFilters,
121
+ diagnostics: draft.diagnostics,
122
+ bundle: draft.bundle,
123
+ bindings: draft.bindings,
124
+ environments: draft.environments,
125
+ }
126
+ const versionId = await shortHash(identityBody)
127
+ void canonicalJson // referenced via shortHash; keep the import surface stable
128
+
129
+ return {
130
+ ...(draft as Omit<WorkflowManifest, "versionId">),
131
+ versionId,
132
+ }
133
+ }
134
+
135
+ function releaseCapabilities(): WorkflowReleaseCapabilities {
136
+ return {
137
+ trigger: true,
138
+ events: true,
139
+ schedules: true,
140
+ rerun: true,
141
+ resume: true,
142
+ cancel: true,
143
+ humanApproval: true,
144
+ stepRerun: false,
145
+ }
146
+ }
147
+
148
+ function workflowCapabilities(args: {
149
+ hasSchedules: boolean
150
+ supportsEvents: boolean
151
+ }): WorkflowDefinitionCapabilities {
152
+ return {
153
+ canTrigger: true,
154
+ canRerun: true,
155
+ canResume: false,
156
+ canCancel: true,
157
+ hasSchedules: args.hasSchedules,
158
+ supportsEvents: args.supportsEvents,
159
+ supportsHumanApproval: false,
160
+ supportsStepRerun: false,
161
+ }
162
+ }
163
+
164
+ function serializeSchema(schema: unknown): unknown {
165
+ if (schema === undefined) return undefined
166
+ return zodToJsonSchema(schema) ?? schema
167
+ }
168
+
169
+ function zodToJsonSchema(schema: unknown): unknown | undefined {
170
+ if (!isRecord(schema)) return undefined
171
+
172
+ const instanceConverter = schema.toJSONSchema
173
+ if (typeof instanceConverter === "function") {
174
+ try {
175
+ return instanceConverter.call(schema)
176
+ } catch {
177
+ // Fall through to the structural converter below.
178
+ }
179
+ }
180
+
181
+ const def = zodDef(schema)
182
+ if (!def) return undefined
183
+ return zodDefToJsonSchema(schema)
184
+ }
185
+
186
+ function zodDefToJsonSchema(schema: unknown): unknown {
187
+ const def = zodDef(schema)
188
+ const typeName = zodTypeName(schema)
189
+
190
+ if (typeName === "object") {
191
+ const shape = zodObjectShape(def)
192
+ const properties: Record<string, unknown> = {}
193
+ const required: string[] = []
194
+ for (const key of Object.keys(shape).sort()) {
195
+ const child = shape[key]
196
+ properties[key] = zodDefToJsonSchema(child)
197
+ if (!zodIsOptional(child)) required.push(key)
198
+ }
199
+
200
+ const out: Record<string, unknown> = { type: "object", properties }
201
+ if (required.length > 0) out.required = required
202
+ return withDescription(schema, out)
203
+ }
204
+
205
+ if (typeName === "string") return withDescription(schema, { type: "string" })
206
+ if (typeName === "number") return withDescription(schema, { type: "number" })
207
+ if (typeName === "bigint") return withDescription(schema, { type: "integer" })
208
+ if (typeName === "boolean") return withDescription(schema, { type: "boolean" })
209
+ if (typeName === "date") return withDescription(schema, { type: "string", format: "date-time" })
210
+ if (typeName === "null") return withDescription(schema, { type: "null" })
211
+ if (
212
+ typeName === "undefined" ||
213
+ typeName === "void" ||
214
+ typeName === "unknown" ||
215
+ typeName === "any"
216
+ ) {
217
+ return withDescription(schema, {})
218
+ }
219
+ if (typeName === "never") return withDescription(schema, false)
220
+
221
+ if (typeName === "array") {
222
+ return withDescription(schema, {
223
+ type: "array",
224
+ items: zodDefToJsonSchema(def?.type ?? def?.element),
225
+ })
226
+ }
227
+
228
+ if (typeName === "literal") {
229
+ const value = Array.isArray(def?.values) ? def.values[0] : def?.value
230
+ return withDescription(schema, { const: value })
231
+ }
232
+
233
+ if (typeName === "enum" || typeName === "nativeenum") {
234
+ return withDescription(schema, {
235
+ enum: zodEnumValues(def),
236
+ })
237
+ }
238
+
239
+ if (typeName === "union" || typeName === "discriminatedunion") {
240
+ const options = Array.isArray(def?.options) ? def.options : []
241
+ return withDescription(schema, {
242
+ anyOf: options.map((option) => zodDefToJsonSchema(option)),
243
+ })
244
+ }
245
+
246
+ if (typeName === "record") {
247
+ return withDescription(schema, {
248
+ type: "object",
249
+ additionalProperties: zodDefToJsonSchema(def?.valueType ?? def?.valueTypeDef),
250
+ })
251
+ }
252
+
253
+ if (typeName === "optional") {
254
+ return zodDefToJsonSchema(def?.innerType)
255
+ }
256
+
257
+ if (typeName === "nullable") {
258
+ return withDescription(schema, {
259
+ anyOf: [zodDefToJsonSchema(def?.innerType), { type: "null" }],
260
+ })
261
+ }
262
+
263
+ if (
264
+ typeName === "default" ||
265
+ typeName === "catch" ||
266
+ typeName === "branded" ||
267
+ typeName === "readonly" ||
268
+ typeName === "effects" ||
269
+ typeName === "pipeline"
270
+ ) {
271
+ return zodDefToJsonSchema(def?.innerType ?? def?.type ?? def?.schema ?? def?.in)
272
+ }
273
+
274
+ return withDescription(schema, {})
275
+ }
276
+
277
+ function zodTypeName(schema: unknown): string | undefined {
278
+ const def = zodDef(schema)
279
+ const raw = typeof def?.type === "string" ? def.type : def?.typeName
280
+ return typeof raw === "string" ? raw.replace(/^Zod/, "").toLowerCase() : undefined
281
+ }
282
+
283
+ function zodDef(schema: unknown): Record<string, unknown> | undefined {
284
+ if (!isRecord(schema)) return undefined
285
+ const def = schema._def ?? schema.def
286
+ return isRecord(def) ? def : undefined
287
+ }
288
+
289
+ function zodObjectShape(def: Record<string, unknown> | undefined): Record<string, unknown> {
290
+ const shape = def?.shape
291
+ if (typeof shape === "function") {
292
+ const result = shape()
293
+ return isRecord(result) ? result : {}
294
+ }
295
+ return isRecord(shape) ? shape : {}
296
+ }
297
+
298
+ function zodEnumValues(def: Record<string, unknown> | undefined): unknown[] {
299
+ if (Array.isArray(def?.values)) return def.values
300
+ if (isRecord(def?.entries)) return Object.values(def.entries)
301
+ if (isRecord(def?.values)) return Object.values(def.values)
302
+ return []
303
+ }
304
+
305
+ function zodIsOptional(schema: unknown): boolean {
306
+ const typeName = zodTypeName(schema)
307
+ if (typeName === "optional" || typeName === "default") return true
308
+ if (isRecord(schema) && typeof schema.isOptional === "function") {
309
+ try {
310
+ return schema.isOptional() === true
311
+ } catch {
312
+ return false
313
+ }
314
+ }
315
+ return false
316
+ }
317
+
318
+ function withDescription(schema: unknown, jsonSchema: unknown): unknown {
319
+ if (!isRecord(jsonSchema) || !isRecord(schema)) return jsonSchema
320
+ const def = zodDef(schema)
321
+ const description = typeof schema.description === "string" ? schema.description : def?.description
322
+ if (typeof description !== "string") return jsonSchema
323
+ return { ...jsonSchema, description }
324
+ }
325
+
326
+ function isRecord(value: unknown): value is Record<string, unknown> {
327
+ return typeof value === "object" && value !== null && !Array.isArray(value)
328
+ }
329
+
330
+ function displayNameFromId(id: string): string {
331
+ return id
332
+ .split(/[._:-]+/g)
333
+ .filter(Boolean)
334
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
335
+ .join(" ")
336
+ }
337
+
338
+ function serializeConcurrency(
339
+ concurrency: ConcurrencyPolicy<unknown> | undefined,
340
+ ): ManifestConcurrencyPolicy | undefined {
341
+ if (!concurrency) return undefined
342
+ const out: ManifestConcurrencyPolicy = {}
343
+ if (typeof concurrency.key === "string") out.key = concurrency.key
344
+ if (concurrency.limit !== undefined) out.limit = concurrency.limit
345
+ if (concurrency.strategy !== undefined) out.strategy = concurrency.strategy
346
+ return out
347
+ }
348
+
349
+ function serializeSchedules(
350
+ schedule: ScheduleDeclaration | ScheduleDeclaration[] | undefined,
351
+ ): ManifestSchedule[] {
352
+ if (!schedule) return []
353
+ const schedules = Array.isArray(schedule) ? schedule : [schedule]
354
+ return schedules.map(serializeSchedule)
355
+ }
356
+
357
+ function serializeSchedule(schedule: ScheduleDeclaration): ManifestSchedule {
358
+ const out: ManifestSchedule = {}
359
+ if ("cron" in schedule) out.cron = schedule.cron
360
+ if ("every" in schedule) out.every = schedule.every
361
+ if ("at" in schedule) {
362
+ out.at = schedule.at instanceof Date ? schedule.at.toISOString() : schedule.at
363
+ }
364
+ if (schedule.timezone !== undefined) out.timezone = schedule.timezone
365
+ if (schedule.input !== undefined && typeof schedule.input !== "function")
366
+ out.input = schedule.input
367
+ if (schedule.enabled !== undefined) out.enabled = schedule.enabled
368
+ if (schedule.overlap !== undefined) out.overlap = schedule.overlap
369
+ if (schedule.environments !== undefined) out.environments = schedule.environments
370
+ if (schedule.name !== undefined) out.name = schedule.name
371
+ return out
372
+ }
@@ -0,0 +1,110 @@
1
+ // Canonical JSON + SHA-256 helpers used to derive `payloadHash` ids for
2
+ // `EventFilterRuntimeEntry` and `WorkflowManifest.versionId`.
3
+ //
4
+ // Canonicalization recursively alphabetizes object keys before
5
+ // JSON.stringify. SHA-256 uses Web Crypto (`globalThis.crypto.subtle`),
6
+ // available on Node ≥ 19, all modern browsers, and Cloudflare Workers.
7
+ // Async because Web Crypto's digest is async — callers `await` once
8
+ // at registration time.
9
+ //
10
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §13.3.
11
+
12
+ /**
13
+ * Recursively alphabetize object keys, leaving arrays and primitives intact.
14
+ * `undefined` is converted to `null` so canonical JSON shape is stable
15
+ * (JSON.stringify drops `undefined` from objects).
16
+ */
17
+ export function canonicalize(value: unknown): unknown {
18
+ if (value === undefined) return null
19
+ if (value === null || typeof value !== "object") return value
20
+ if (Array.isArray(value)) {
21
+ return value.map(canonicalize)
22
+ }
23
+ const sorted: Record<string, unknown> = {}
24
+ const keys = Object.keys(value as Record<string, unknown>).sort()
25
+ for (const k of keys) {
26
+ sorted[k] = canonicalize((value as Record<string, unknown>)[k])
27
+ }
28
+ return sorted
29
+ }
30
+
31
+ /**
32
+ * Stable canonical JSON string for `value`. Two values that are deeply
33
+ * equal modulo key order produce identical strings; two values that
34
+ * differ in any way produce different strings. Used as the input to
35
+ * `sha256(...)` for content-derived ids.
36
+ */
37
+ export function canonicalJson(value: unknown): string {
38
+ return JSON.stringify(canonicalize(value))
39
+ }
40
+
41
+ /**
42
+ * SHA-256 hex digest of an arbitrary value (canonicalized first). Async
43
+ * — callers await once during manifest build.
44
+ *
45
+ * @returns lowercase hex string, 64 chars long.
46
+ */
47
+ export async function sha256(value: unknown): Promise<string> {
48
+ const text = canonicalJson(value)
49
+ const bytes = new TextEncoder().encode(text)
50
+ const digest = await getCrypto().subtle.digest("SHA-256", bytes)
51
+ return bytesToHex(new Uint8Array(digest))
52
+ }
53
+
54
+ /**
55
+ * Short content-derived id, used as `EventFilterManifestEntry.payloadHash`
56
+ * and `WorkflowManifest.versionId`. 16 hex chars (~64 bits) — collision
57
+ * space is fine for human-friendly ids in dashboards/logs; the canonical
58
+ * full hash is `sha256(...)` if you need it.
59
+ */
60
+ export async function shortHash(value: unknown): Promise<string> {
61
+ const full = await sha256(value)
62
+ return full.slice(0, 16)
63
+ }
64
+
65
+ /**
66
+ * Derive a stable event id from an envelope when `metadata.eventId` is
67
+ * absent. Mirrors the formula from architecture doc §15.2:
68
+ *
69
+ * `${name}:${emittedAt}:${sha256(canonical(data)).slice(0, 12)}`
70
+ *
71
+ * Same envelope content always produces the same id — concurrent retries
72
+ * of the same external HTTP delivery dedupe at the driver's
73
+ * `${filterId}:${eventId}` idempotency key derivation.
74
+ *
75
+ * Returns a fallback id of the form `evt_<name>_<emittedAt>_<hash12>`
76
+ * (URL-safe; no colons in case the id flows through path segments).
77
+ */
78
+ export async function deriveStableEventId(envelope: {
79
+ name: string
80
+ data: unknown
81
+ emittedAt: string
82
+ }): Promise<string> {
83
+ const dataHash = (await sha256(envelope.data)).slice(0, 12)
84
+ const safeName = envelope.name.replace(/[^a-zA-Z0-9._-]/g, "_")
85
+ const safeAt = envelope.emittedAt.replace(/[^a-zA-Z0-9.]/g, "_")
86
+ return `evt_${safeName}_${safeAt}_${dataHash}`
87
+ }
88
+
89
+ // ---- Internal ----
90
+
91
+ function getCrypto(): Crypto {
92
+ // `globalThis.crypto` is available on Node 19+, Workers, browsers.
93
+ // Any environment older than that needs a polyfill at the consumer level.
94
+ const c = (globalThis as { crypto?: Crypto }).crypto
95
+ if (!c?.subtle) {
96
+ throw new Error(
97
+ "@voyant-travel/workflows/events: globalThis.crypto.subtle is required for payload-hash. " +
98
+ "Polyfill via webcrypto on legacy runtimes.",
99
+ )
100
+ }
101
+ return c
102
+ }
103
+
104
+ function bytesToHex(bytes: Uint8Array): string {
105
+ let out = ""
106
+ for (let i = 0; i < bytes.length; i++) {
107
+ out += (bytes[i] ?? 0).toString(16).padStart(2, "0")
108
+ }
109
+ return out
110
+ }