@voyantjs/workflows 0.28.3 → 0.29.0

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 (59) hide show
  1. package/dist/driver.d.ts +237 -0
  2. package/dist/driver.d.ts.map +1 -0
  3. package/dist/driver.js +53 -0
  4. package/dist/events/compile.d.ts +34 -0
  5. package/dist/events/compile.d.ts.map +1 -0
  6. package/dist/events/compile.js +204 -0
  7. package/dist/events/index.d.ts +8 -0
  8. package/dist/events/index.d.ts.map +1 -0
  9. package/dist/events/index.js +11 -0
  10. package/dist/events/input-mapper.d.ts +24 -0
  11. package/dist/events/input-mapper.d.ts.map +1 -0
  12. package/dist/events/input-mapper.js +169 -0
  13. package/dist/events/manifest-builder.d.ts +32 -0
  14. package/dist/events/manifest-builder.d.ts.map +1 -0
  15. package/dist/events/manifest-builder.js +66 -0
  16. package/dist/events/payload-hash.d.ts +46 -0
  17. package/dist/events/payload-hash.d.ts.map +1 -0
  18. package/dist/events/payload-hash.js +98 -0
  19. package/dist/events/predicate.d.ts +77 -0
  20. package/dist/events/predicate.d.ts.map +1 -0
  21. package/dist/events/predicate.js +347 -0
  22. package/dist/events/registry.d.ts +37 -0
  23. package/dist/events/registry.d.ts.map +1 -0
  24. package/dist/events/registry.js +47 -0
  25. package/dist/handler/index.d.ts +8 -0
  26. package/dist/handler/index.d.ts.map +1 -1
  27. package/dist/handler/index.js +1 -0
  28. package/dist/http-ingest.d.ts +54 -0
  29. package/dist/http-ingest.d.ts.map +1 -0
  30. package/dist/http-ingest.js +214 -0
  31. package/dist/protocol/index.d.ts +17 -2
  32. package/dist/protocol/index.d.ts.map +1 -1
  33. package/dist/runtime/ctx.d.ts +9 -0
  34. package/dist/runtime/ctx.d.ts.map +1 -1
  35. package/dist/runtime/ctx.js +17 -0
  36. package/dist/runtime/executor.d.ts +7 -0
  37. package/dist/runtime/executor.d.ts.map +1 -1
  38. package/dist/runtime/executor.js +1 -0
  39. package/dist/trigger.d.ts +28 -14
  40. package/dist/trigger.d.ts.map +1 -1
  41. package/dist/trigger.js +4 -4
  42. package/dist/workflow.d.ts +10 -0
  43. package/dist/workflow.d.ts.map +1 -1
  44. package/package.json +14 -2
  45. package/src/driver.ts +277 -0
  46. package/src/events/compile.ts +268 -0
  47. package/src/events/index.ts +42 -0
  48. package/src/events/input-mapper.ts +201 -0
  49. package/src/events/manifest-builder.ts +97 -0
  50. package/src/events/payload-hash.ts +110 -0
  51. package/src/events/predicate.ts +390 -0
  52. package/src/events/registry.ts +88 -0
  53. package/src/handler/index.ts +9 -0
  54. package/src/http-ingest.ts +299 -0
  55. package/src/protocol/index.ts +17 -2
  56. package/src/runtime/ctx.ts +29 -0
  57. package/src/runtime/executor.ts +8 -0
  58. package/src/trigger.ts +31 -15
  59. package/src/workflow.ts +11 -0
@@ -0,0 +1,390 @@
1
+ // Predicate DSL — the structured `where` filter on EventFilterDeclaration.
2
+ //
3
+ // Closed grammar of 12 operators, evaluated against the standard
4
+ // EventEnvelope shape (`data`, `metadata`, `name`, `emittedAt`). No `eval`,
5
+ // no Function constructor, no callback-via-network — everything is data.
6
+ //
7
+ // Authoring shape (from a module's source):
8
+ //
9
+ // trigger.on("promotion.changed", {
10
+ // target: bulkReindexProducts,
11
+ // where: { eq: [{ path: "data.affected.kind" }, { lit: "all" }] },
12
+ // input: { ... },
13
+ // })
14
+ //
15
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §13.1.
16
+
17
+ // ---- Path / literal references ----
18
+
19
+ /** Either a path into the envelope or an inline literal value. */
20
+ export type PathOrLit = { path: string } | { lit: string | number | boolean | null }
21
+
22
+ // ---- The grammar ----
23
+
24
+ export type PredicateExpr =
25
+ | { eq: [PathOrLit, PathOrLit] }
26
+ | { neq: [PathOrLit, PathOrLit] }
27
+ | { in: [PathOrLit, PathOrLit[]] }
28
+ | { gt: [PathOrLit, PathOrLit] }
29
+ | { gte: [PathOrLit, PathOrLit] }
30
+ | { lt: [PathOrLit, PathOrLit] }
31
+ | { lte: [PathOrLit, PathOrLit] }
32
+ | { exists: PathOrLit }
33
+ | { not: PredicateExpr }
34
+ | { and: PredicateExpr[] }
35
+ | { or: PredicateExpr[] }
36
+
37
+ // ---- Envelope view (what paths address into) ----
38
+
39
+ /**
40
+ * Minimal structural envelope the evaluator reads. Matches the standard
41
+ * `EventEnvelope` from `@voyantjs/core`. Declared structurally here so the
42
+ * SDK package stays a leaf.
43
+ */
44
+ export interface PredicateEnvelope<TData = unknown> {
45
+ name: string
46
+ data: TData
47
+ metadata?: Record<string, unknown> | undefined
48
+ emittedAt: string
49
+ }
50
+
51
+ // ---- Public API ----
52
+
53
+ /**
54
+ * Evaluate a predicate against an event envelope. Returns `true` / `false`.
55
+ * Path resolution against missing keys yields `undefined`, which makes
56
+ * comparison ops `false` (not throw). The evaluator never throws on data
57
+ * mismatches — registration-time linting catches structural errors via
58
+ * {@link validatePredicate}.
59
+ *
60
+ * Throws `PredicateEvalError` only on unexpected shape errors (the predicate
61
+ * itself was constructed wrong, e.g. malformed operator). Drivers catch
62
+ * this and surface it as `IngestMatch.status === "skipped"` with reason
63
+ * `"where_eval_error"`.
64
+ */
65
+ export function evaluatePredicate(expr: PredicateExpr, envelope: PredicateEnvelope): boolean {
66
+ if ("eq" in expr) {
67
+ return strictEquals(resolveSide(expr.eq[0], envelope), resolveSide(expr.eq[1], envelope))
68
+ }
69
+ if ("neq" in expr) {
70
+ return !strictEquals(resolveSide(expr.neq[0], envelope), resolveSide(expr.neq[1], envelope))
71
+ }
72
+ if ("in" in expr) {
73
+ const lhs = resolveSide(expr.in[0], envelope)
74
+ const rhs = expr.in[1].map((item) => resolveSide(item, envelope))
75
+ return rhs.some((candidate) => strictEquals(lhs, candidate))
76
+ }
77
+ if ("gt" in expr) return compareTwo(expr.gt, envelope, ">")
78
+ if ("gte" in expr) return compareTwo(expr.gte, envelope, ">=")
79
+ if ("lt" in expr) return compareTwo(expr.lt, envelope, "<")
80
+ if ("lte" in expr) return compareTwo(expr.lte, envelope, "<=")
81
+ if ("exists" in expr) {
82
+ return resolveSide(expr.exists, envelope) !== undefined
83
+ }
84
+ if ("not" in expr) return !evaluatePredicate(expr.not, envelope)
85
+ if ("and" in expr) {
86
+ if (!Array.isArray(expr.and)) {
87
+ throw new PredicateEvalError("`and` clause must be an array of predicates")
88
+ }
89
+ return expr.and.every((sub) => evaluatePredicate(sub, envelope))
90
+ }
91
+ if ("or" in expr) {
92
+ if (!Array.isArray(expr.or)) {
93
+ throw new PredicateEvalError("`or` clause must be an array of predicates")
94
+ }
95
+ return expr.or.some((sub) => evaluatePredicate(sub, envelope))
96
+ }
97
+ throw new PredicateEvalError(`unknown predicate operator: ${stringify(expr)}`)
98
+ }
99
+
100
+ /**
101
+ * Resolve a path string against an envelope. Public so consumers (input
102
+ * mapper, manifest builder) can share the same path semantics without
103
+ * re-implementing them.
104
+ *
105
+ * Path syntax: dot-separated; `[N]` for array index; missing intermediate
106
+ * keys produce `undefined`. Roots: `data`, `metadata`, `name`, `emittedAt`.
107
+ *
108
+ * Returns `undefined` on any shape mismatch — that's how runtime evaluation
109
+ * stays no-throw.
110
+ */
111
+ export function resolvePath(path: string, envelope: PredicateEnvelope): unknown {
112
+ const segments = parsePath(path)
113
+ if (segments.length === 0) return undefined
114
+ const [root, ...rest] = segments
115
+ let value: unknown
116
+ switch (root) {
117
+ case "name":
118
+ value = envelope.name
119
+ break
120
+ case "emittedAt":
121
+ value = envelope.emittedAt
122
+ break
123
+ case "data":
124
+ value = envelope.data
125
+ break
126
+ case "metadata":
127
+ value = envelope.metadata
128
+ break
129
+ default:
130
+ // Unknown roots evaluate to undefined at runtime. Registration-time
131
+ // linter surfaces this as a structural error so callers see it earlier.
132
+ return undefined
133
+ }
134
+ for (const segment of rest) {
135
+ if (value === undefined || value === null) return undefined
136
+ if (typeof segment === "number") {
137
+ if (!Array.isArray(value)) return undefined
138
+ value = value[segment]
139
+ } else {
140
+ if (typeof value !== "object") return undefined
141
+ value = (value as Record<string, unknown>)[segment]
142
+ }
143
+ }
144
+ return value
145
+ }
146
+
147
+ // ---- Static linter (registration-time) ----
148
+
149
+ export interface PredicateValidationResult {
150
+ ok: boolean
151
+ errors: string[]
152
+ }
153
+
154
+ /**
155
+ * Static structural check on a `PredicateExpr`. Catches path roots that
156
+ * aren't `data`/`metadata`/`name`/`emittedAt`, type mismatches on
157
+ * comparison operators (number vs string lhs/rhs), and malformed grammars.
158
+ * Surfaced at `trigger.on()` registration so authoring errors fail fast.
159
+ */
160
+ export function validatePredicate(expr: PredicateExpr): PredicateValidationResult {
161
+ const errors: string[] = []
162
+ walk(expr, errors, [])
163
+ return { ok: errors.length === 0, errors }
164
+ }
165
+
166
+ function walk(expr: PredicateExpr, errors: string[], path: string[]): void {
167
+ if (typeof expr !== "object" || expr === null) {
168
+ errors.push(`${pathLabel(path)}: predicate must be an object, got ${typeof expr}`)
169
+ return
170
+ }
171
+ const keys = Object.keys(expr)
172
+ if (keys.length !== 1) {
173
+ errors.push(
174
+ `${pathLabel(path)}: a predicate object must have exactly one operator key, got ${keys.length} (${keys.join(", ")})`,
175
+ )
176
+ return
177
+ }
178
+ const op = keys[0] as keyof PredicateExpr
179
+ switch (op) {
180
+ case "eq":
181
+ case "neq":
182
+ case "gt":
183
+ case "gte":
184
+ case "lt":
185
+ case "lte": {
186
+ const raw = (expr as Record<string, unknown>)[op]
187
+ if (!Array.isArray(raw) || raw.length !== 2) {
188
+ errors.push(`${pathLabel([...path, op])}: expected [lhs, rhs] tuple`)
189
+ return
190
+ }
191
+ const sides = raw as [PathOrLit, PathOrLit]
192
+ validateSide(sides[0], errors, [...path, op, "0"])
193
+ validateSide(sides[1], errors, [...path, op, "1"])
194
+ // Type-sanity for ordered comparisons: both literals must be the same numeric/string flavor.
195
+ if (op !== "eq" && op !== "neq") {
196
+ const lhsLit = sideLitType(sides[0])
197
+ const rhsLit = sideLitType(sides[1])
198
+ if (
199
+ lhsLit !== "unknown" &&
200
+ rhsLit !== "unknown" &&
201
+ lhsLit !== rhsLit &&
202
+ (lhsLit === "number" || lhsLit === "string")
203
+ ) {
204
+ errors.push(
205
+ `${pathLabel([...path, op])}: ordered comparison sides must agree on type (got ${lhsLit} vs ${rhsLit})`,
206
+ )
207
+ }
208
+ }
209
+ return
210
+ }
211
+ case "in": {
212
+ const tuple = (expr as { in: [PathOrLit, PathOrLit[]] }).in
213
+ if (!Array.isArray(tuple) || tuple.length !== 2) {
214
+ errors.push(`${pathLabel([...path, "in"])}: expected [lhs, rhs[]] tuple`)
215
+ return
216
+ }
217
+ validateSide(tuple[0], errors, [...path, "in", "0"])
218
+ if (!Array.isArray(tuple[1])) {
219
+ errors.push(`${pathLabel([...path, "in", "1"])}: rhs must be an array of paths/literals`)
220
+ return
221
+ }
222
+ for (let i = 0; i < tuple[1].length; i++) {
223
+ const s = tuple[1][i] as PathOrLit
224
+ validateSide(s, errors, [...path, "in", "1", String(i)])
225
+ }
226
+ return
227
+ }
228
+ case "exists":
229
+ validateSide((expr as { exists: PathOrLit }).exists, errors, [...path, "exists"])
230
+ return
231
+ case "not":
232
+ walk((expr as { not: PredicateExpr }).not, errors, [...path, "not"])
233
+ return
234
+ case "and":
235
+ case "or": {
236
+ const raw = (expr as Record<string, unknown>)[op]
237
+ if (!Array.isArray(raw)) {
238
+ errors.push(`${pathLabel([...path, op])}: ${op} must be an array of predicates`)
239
+ return
240
+ }
241
+ const arr = raw as PredicateExpr[]
242
+ for (let i = 0; i < arr.length; i++) {
243
+ const sub = arr[i] as PredicateExpr
244
+ walk(sub, errors, [...path, op, String(i)])
245
+ }
246
+ return
247
+ }
248
+ default:
249
+ errors.push(`${pathLabel(path)}: unknown predicate operator "${String(op)}"`)
250
+ }
251
+ }
252
+
253
+ function validateSide(side: PathOrLit, errors: string[], path: string[]): void {
254
+ if (typeof side !== "object" || side === null) {
255
+ errors.push(`${pathLabel(path)}: expected { path } or { lit }, got ${typeof side}`)
256
+ return
257
+ }
258
+ if ("path" in side) {
259
+ if (typeof side.path !== "string" || side.path.length === 0) {
260
+ errors.push(`${pathLabel(path)}: "path" must be a non-empty string`)
261
+ return
262
+ }
263
+ const root = parsePath(side.path)[0]
264
+ if (root !== "data" && root !== "metadata" && root !== "name" && root !== "emittedAt") {
265
+ errors.push(
266
+ `${pathLabel(path)}: path root "${String(root)}" is not one of data | metadata | name | emittedAt`,
267
+ )
268
+ }
269
+ return
270
+ }
271
+ if ("lit" in side) {
272
+ const t = typeof side.lit
273
+ if (t !== "string" && t !== "number" && t !== "boolean" && side.lit !== null) {
274
+ errors.push(`${pathLabel(path)}: lit must be string | number | boolean | null`)
275
+ }
276
+ return
277
+ }
278
+ errors.push(`${pathLabel(path)}: must specify "path" or "lit"`)
279
+ }
280
+
281
+ // ---- Internal helpers ----
282
+
283
+ class PredicateEvalError extends Error {
284
+ constructor(message: string) {
285
+ super(message)
286
+ this.name = "PredicateEvalError"
287
+ }
288
+ }
289
+
290
+ function resolveSide(side: PathOrLit, envelope: PredicateEnvelope): unknown {
291
+ if ("lit" in side) return side.lit
292
+ if ("path" in side) return resolvePath(side.path, envelope)
293
+ return undefined
294
+ }
295
+
296
+ function strictEquals(a: unknown, b: unknown): boolean {
297
+ if (a === undefined || b === undefined) return false
298
+ if (a === null && b === null) return true
299
+ if (a === null || b === null) return false
300
+ if (typeof a !== typeof b) return false
301
+ if (typeof a === "object") {
302
+ // Strict equality only for primitives + null. Object equality is not
303
+ // supported in v1; users who need it project specific paths to compare.
304
+ return false
305
+ }
306
+ return a === b
307
+ }
308
+
309
+ function compareTwo(
310
+ sides: [PathOrLit, PathOrLit],
311
+ envelope: PredicateEnvelope,
312
+ op: ">" | ">=" | "<" | "<=",
313
+ ): boolean {
314
+ const lhs = resolveSide(sides[0], envelope)
315
+ const rhs = resolveSide(sides[1], envelope)
316
+ if (lhs === undefined || rhs === undefined) return false
317
+ if (typeof lhs !== typeof rhs) return false
318
+ if (typeof lhs !== "number" && typeof lhs !== "string") return false
319
+ switch (op) {
320
+ case ">":
321
+ return (lhs as number | string) > (rhs as number | string)
322
+ case ">=":
323
+ return (lhs as number | string) >= (rhs as number | string)
324
+ case "<":
325
+ return (lhs as number | string) < (rhs as number | string)
326
+ case "<=":
327
+ return (lhs as number | string) <= (rhs as number | string)
328
+ }
329
+ }
330
+
331
+ function sideLitType(side: PathOrLit): "number" | "string" | "boolean" | "null" | "unknown" {
332
+ if ("lit" in side) {
333
+ if (side.lit === null) return "null"
334
+ const t = typeof side.lit
335
+ if (t === "number" || t === "string" || t === "boolean") return t
336
+ }
337
+ return "unknown"
338
+ }
339
+
340
+ /**
341
+ * Parse a dot-and-bracket path into segments. `data.items[0].id` becomes
342
+ * `["data", "items", 0, "id"]`. Numeric segments inside `[N]` are returned
343
+ * as numbers; everything else as strings. Malformed input yields `[]`.
344
+ */
345
+ function parsePath(path: string): Array<string | number> {
346
+ if (typeof path !== "string" || path.length === 0) return []
347
+ const segments: Array<string | number> = []
348
+ let i = 0
349
+ let buf = ""
350
+ const flushBuf = (): void => {
351
+ if (buf.length > 0) {
352
+ segments.push(buf)
353
+ buf = ""
354
+ }
355
+ }
356
+ while (i < path.length) {
357
+ const c = path[i]
358
+ if (c === ".") {
359
+ flushBuf()
360
+ i++
361
+ continue
362
+ }
363
+ if (c === "[") {
364
+ flushBuf()
365
+ const end = path.indexOf("]", i)
366
+ if (end === -1) return []
367
+ const idx = Number(path.slice(i + 1, end))
368
+ if (!Number.isInteger(idx) || idx < 0) return []
369
+ segments.push(idx)
370
+ i = end + 1
371
+ continue
372
+ }
373
+ buf += c
374
+ i++
375
+ }
376
+ flushBuf()
377
+ return segments
378
+ }
379
+
380
+ function pathLabel(path: string[]): string {
381
+ return path.length === 0 ? "(root)" : path.join(".")
382
+ }
383
+
384
+ function stringify(value: unknown): string {
385
+ try {
386
+ return JSON.stringify(value)
387
+ } catch {
388
+ return String(value)
389
+ }
390
+ }
@@ -0,0 +1,88 @@
1
+ // Process-local registry for event-filter runtime entries.
2
+ //
3
+ // `trigger.on(eventName, filter)` adds an entry here at module-load time.
4
+ // `createApp()` (PR4) walks `getEventFilterRegistry().list()` to build a
5
+ // manifest, hand it to the configured driver, and install the EventBus
6
+ // forwarder.
7
+ //
8
+ // Backed by `globalThis` so bundles that inline their own copy of
9
+ // `@voyantjs/workflows` still share the registry with the loader's copy
10
+ // — same pattern `getWorkflow()` uses for the workflow registry.
11
+ //
12
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §12.
13
+
14
+ import type { EventFilterManifestEntry } from "../protocol/index.js"
15
+ import type { EventFilterDeclaration } from "../trigger.js"
16
+
17
+ const REGISTRY_KEY = "__voyantEventFilterRegistry" as const
18
+
19
+ /**
20
+ * Internal/runtime form of a registered event filter. Carries:
21
+ * - The serializable manifest entry (sent to drivers, persisted in
22
+ * manifest stores, evaluated by the event router).
23
+ * - A debug-only structural copy of the original declaration. Not
24
+ * serialized; used by the dashboard / dev mode for human-readable
25
+ * filter inspection.
26
+ *
27
+ * Concrete `WorkflowDefinition` from `workflow.ts` satisfies the structural
28
+ * `target` shape via TypeScript compat.
29
+ */
30
+ export interface EventFilterRuntimeEntry {
31
+ readonly id: string
32
+ readonly eventType: string
33
+ readonly manifest: EventFilterManifestEntry
34
+ /** Debug-only — non-serializable. */
35
+ readonly declaration: EventFilterDeclaration<unknown>
36
+ /** Workflow id this filter targets, for quick lookup. */
37
+ readonly targetWorkflowId: string
38
+ }
39
+
40
+ interface EventFilterRegistry {
41
+ add(entry: EventFilterRuntimeEntry): void
42
+ list(): EventFilterRuntimeEntry[]
43
+ /** Reset to empty. Test-only — production code never calls this. */
44
+ reset(): void
45
+ }
46
+
47
+ const globalRef = globalThis as unknown as Record<
48
+ typeof REGISTRY_KEY,
49
+ Map<string, EventFilterRuntimeEntry> | undefined
50
+ >
51
+
52
+ const REGISTRY: Map<string, EventFilterRuntimeEntry> =
53
+ globalRef[REGISTRY_KEY] ?? new Map<string, EventFilterRuntimeEntry>()
54
+ globalRef[REGISTRY_KEY] = REGISTRY
55
+
56
+ const registry: EventFilterRegistry = {
57
+ add(entry) {
58
+ if (REGISTRY.has(entry.id)) {
59
+ // Same id implies same canonicalized declaration. HMR re-imports
60
+ // and re-evaluations of the same module file land here; replace
61
+ // (overwrite is harmless since the entry is content-addressed).
62
+ // For genuine duplicates from different declarations, the canonical
63
+ // hash would differ, so different ids would result.
64
+ REGISTRY.set(entry.id, entry)
65
+ return
66
+ }
67
+ REGISTRY.set(entry.id, entry)
68
+ },
69
+ list() {
70
+ return [...REGISTRY.values()]
71
+ },
72
+ reset() {
73
+ REGISTRY.clear()
74
+ },
75
+ }
76
+
77
+ /** Process-local event-filter registry. Singleton per realm. */
78
+ export function getEventFilterRegistry(): EventFilterRegistry {
79
+ return registry
80
+ }
81
+
82
+ /**
83
+ * Internal: clear every registered filter. Used by tests that import the
84
+ * SDK and need a clean slate between cases. Not part of the public API.
85
+ */
86
+ export function __resetEventFilterRegistry(): void {
87
+ registry.reset()
88
+ }
@@ -69,6 +69,14 @@ export interface StepHandlerDeps {
69
69
  * the target runtime; the executor only cares that a runner exists.
70
70
  */
71
71
  nodeStepRunner?: StepRunner
72
+ /**
73
+ * Read-only service resolver, surfaced to step bodies as `ctx.services`.
74
+ * The framework's `createApp()` wires this from its `ModuleContainer`;
75
+ * raw orchestrator harnesses (tests, ad-hoc scripts) typically leave
76
+ * it unset, in which case `ctx.services.resolve(...)` throws with a
77
+ * clear message. See architecture doc §11.
78
+ */
79
+ services?: import("../driver.js").ServiceResolver
72
80
  }
73
81
 
74
82
  /** The HTTP request body the orchestrator sends. */
@@ -235,6 +243,7 @@ async function runStepInner(
235
243
  stepRunner,
236
244
  nodeStepRunner: deps.nodeStepRunner,
237
245
  rateLimiter: deps.rateLimiter,
246
+ services: deps.services,
238
247
  now,
239
248
  abortSignal: opts.signal,
240
249
  onStreamChunk: opts.onStreamChunk,