@voyantjs/workflows 0.28.3 → 0.30.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.
- package/dist/driver.d.ts +237 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +53 -0
- package/dist/events/compile.d.ts +34 -0
- package/dist/events/compile.d.ts.map +1 -0
- package/dist/events/compile.js +204 -0
- package/dist/events/index.d.ts +8 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +11 -0
- package/dist/events/input-mapper.d.ts +24 -0
- package/dist/events/input-mapper.d.ts.map +1 -0
- package/dist/events/input-mapper.js +169 -0
- package/dist/events/manifest-builder.d.ts +32 -0
- package/dist/events/manifest-builder.d.ts.map +1 -0
- package/dist/events/manifest-builder.js +66 -0
- package/dist/events/payload-hash.d.ts +46 -0
- package/dist/events/payload-hash.d.ts.map +1 -0
- package/dist/events/payload-hash.js +98 -0
- package/dist/events/predicate.d.ts +77 -0
- package/dist/events/predicate.d.ts.map +1 -0
- package/dist/events/predicate.js +347 -0
- package/dist/events/registry.d.ts +37 -0
- package/dist/events/registry.d.ts.map +1 -0
- package/dist/events/registry.js +47 -0
- package/dist/handler/index.d.ts +8 -0
- package/dist/handler/index.d.ts.map +1 -1
- package/dist/handler/index.js +1 -0
- package/dist/http-ingest.d.ts +54 -0
- package/dist/http-ingest.d.ts.map +1 -0
- package/dist/http-ingest.js +214 -0
- package/dist/protocol/index.d.ts +17 -2
- package/dist/protocol/index.d.ts.map +1 -1
- package/dist/runtime/ctx.d.ts +9 -0
- package/dist/runtime/ctx.d.ts.map +1 -1
- package/dist/runtime/ctx.js +17 -0
- package/dist/runtime/executor.d.ts +7 -0
- package/dist/runtime/executor.d.ts.map +1 -1
- package/dist/runtime/executor.js +1 -0
- package/dist/trigger.d.ts +28 -14
- package/dist/trigger.d.ts.map +1 -1
- package/dist/trigger.js +4 -4
- package/dist/workflow.d.ts +10 -0
- package/dist/workflow.d.ts.map +1 -1
- package/package.json +14 -2
- package/src/driver.ts +277 -0
- package/src/events/compile.ts +268 -0
- package/src/events/index.ts +42 -0
- package/src/events/input-mapper.ts +201 -0
- package/src/events/manifest-builder.ts +97 -0
- package/src/events/payload-hash.ts +110 -0
- package/src/events/predicate.ts +390 -0
- package/src/events/registry.ts +88 -0
- package/src/handler/index.ts +9 -0
- package/src/http-ingest.ts +299 -0
- package/src/protocol/index.ts +17 -2
- package/src/runtime/ctx.ts +29 -0
- package/src/runtime/executor.ts +8 -0
- package/src/trigger.ts +31 -15
- package/src/workflow.ts +11 -0
package/src/driver.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// The WorkflowDriver contract.
|
|
2
|
+
//
|
|
3
|
+
// A driver is the runtime-side object that backs `createApp({ workflows })`:
|
|
4
|
+
// it owns manifest registration, run triggering, event ingest, and (optionally)
|
|
5
|
+
// admin reads. Concrete drivers live in downstream packages
|
|
6
|
+
// (`@voyantjs/workflows-orchestrator` for InMemory, `-node` for Mode 2 / Postgres,
|
|
7
|
+
// `-cloudflare` for Mode 1 / DO+KV).
|
|
8
|
+
//
|
|
9
|
+
// Drivers are constructed via *factories* — `DriverFactory` is a function the
|
|
10
|
+
// framework invokes after `createApp()` has assembled its `ModuleContainer`.
|
|
11
|
+
// This lets concrete factories accept their environment-specific options
|
|
12
|
+
// (DO namespaces, DB pool, etc.) at user-call time and receive framework
|
|
13
|
+
// deps (services, logger, …) at boot time, without a setter API.
|
|
14
|
+
//
|
|
15
|
+
// Authoritative architecture: docs/architecture/workflows-runtime-architecture.md §6.
|
|
16
|
+
|
|
17
|
+
import type { WorkflowManifest } from "./protocol/index.js"
|
|
18
|
+
import type { ListRunsOptions, Run, RunDetail, RunSummary, TriggerOptions } from "./trigger.js"
|
|
19
|
+
import type { EnvironmentName, WaitpointKind } from "./types.js"
|
|
20
|
+
import type { WorkflowDefinition } from "./workflow.js"
|
|
21
|
+
|
|
22
|
+
// ---- Structural deps (kept local to avoid an @voyantjs/core dep here) ----
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read-only view of a service container. Step bodies resolve services via
|
|
26
|
+
* `ctx.services.resolve(...)`. The framework's `ModuleContainer`
|
|
27
|
+
* (in `@voyantjs/core`) satisfies this shape structurally; we don't import
|
|
28
|
+
* it directly to keep `@voyantjs/workflows` a leaf package.
|
|
29
|
+
*/
|
|
30
|
+
export interface ServiceResolver {
|
|
31
|
+
resolve<T>(name: string): T
|
|
32
|
+
has(name: string): boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Structural shape of an event envelope as ingested by a driver. Matches
|
|
37
|
+
* `EventEnvelope` from `@voyantjs/core` (`name`, `data`, `metadata?`, `emittedAt`),
|
|
38
|
+
* declared structurally so the SDK package doesn't import core.
|
|
39
|
+
*/
|
|
40
|
+
export interface IngestEventEnvelope<TData = unknown> {
|
|
41
|
+
/** Event name in `<resource>.<pastTenseAction>` form. */
|
|
42
|
+
name: string
|
|
43
|
+
/** Business payload. */
|
|
44
|
+
data: TData
|
|
45
|
+
/** Optional metadata. `metadata.eventId` is the canonical idempotency seed
|
|
46
|
+
* (a fresh ULID, stamped by the framework's EventBus forwarder). External
|
|
47
|
+
* callers may supply their own. See architecture doc §15.2. */
|
|
48
|
+
metadata?: Record<string, unknown> & { eventId?: string }
|
|
49
|
+
/** ISO timestamp string. */
|
|
50
|
+
emittedAt: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Minimal logger contract drivers can rely on. Matches the framework logger's
|
|
55
|
+
* call signature without taking a hard dependency on it.
|
|
56
|
+
*/
|
|
57
|
+
export type DriverLogger = (
|
|
58
|
+
level: "debug" | "info" | "warn" | "error",
|
|
59
|
+
msg: string,
|
|
60
|
+
data?: object,
|
|
61
|
+
) => void
|
|
62
|
+
|
|
63
|
+
// ---- Driver-factory wiring ----
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Deps the framework injects into a `DriverFactory` at boot. Driver factories
|
|
67
|
+
* close over their environment-specific options (DO bindings, DB pool, etc.)
|
|
68
|
+
* and read framework deps from this argument.
|
|
69
|
+
*/
|
|
70
|
+
export interface DriverFactoryDeps {
|
|
71
|
+
/** Read-only view of the framework's `ModuleContainer`. */
|
|
72
|
+
services: ServiceResolver
|
|
73
|
+
/** Framework logger. */
|
|
74
|
+
logger: DriverLogger
|
|
75
|
+
/** Injectable clock — defaults to `() => Date.now()`. */
|
|
76
|
+
now?: () => number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Concrete driver factories return this. `createApp()` calls it once, after
|
|
81
|
+
* the container is built, to obtain the `WorkflowDriver`.
|
|
82
|
+
*/
|
|
83
|
+
export type DriverFactory = (deps: DriverFactoryDeps) => WorkflowDriver
|
|
84
|
+
|
|
85
|
+
// ---- Event ingest types ----
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Argument to `driver.ingestEvent(...)`. The framework's EventBus forwarder
|
|
89
|
+
* builds this from the core `EventEnvelope`; external HTTP callers (the
|
|
90
|
+
* optional ingest adapter, voyant-cloud) build it from a wire payload.
|
|
91
|
+
*/
|
|
92
|
+
export interface IngestEventArgs {
|
|
93
|
+
environment: EnvironmentName
|
|
94
|
+
envelope: IngestEventEnvelope
|
|
95
|
+
/** Optional caller-supplied idempotency override. When absent, the driver
|
|
96
|
+
* derives a key per match from `metadata.eventId` (or a content hash
|
|
97
|
+
* fallback). See architecture doc §15.2. */
|
|
98
|
+
idempotencyKey?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Per-filter outcome from a single `ingestEvent` call. */
|
|
102
|
+
export type IngestMatch =
|
|
103
|
+
| {
|
|
104
|
+
filterId: string
|
|
105
|
+
targetWorkflowId: string
|
|
106
|
+
runId: string
|
|
107
|
+
idempotencyKey: string
|
|
108
|
+
status: "queued"
|
|
109
|
+
}
|
|
110
|
+
| {
|
|
111
|
+
filterId: string
|
|
112
|
+
status: "skipped"
|
|
113
|
+
reason: "where_eval_error" | "input_projection_error" | "input_schema_violation"
|
|
114
|
+
details?: string
|
|
115
|
+
}
|
|
116
|
+
| {
|
|
117
|
+
filterId: string
|
|
118
|
+
targetWorkflowId: string
|
|
119
|
+
status: "error"
|
|
120
|
+
reason: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type IngestEventResponse =
|
|
124
|
+
| {
|
|
125
|
+
ok: true
|
|
126
|
+
eventId: string
|
|
127
|
+
matches: IngestMatch[]
|
|
128
|
+
}
|
|
129
|
+
| {
|
|
130
|
+
ok: false
|
|
131
|
+
reason:
|
|
132
|
+
| "manifest_not_registered"
|
|
133
|
+
| "environment_mismatch"
|
|
134
|
+
| "payload_too_large"
|
|
135
|
+
| "trigger_failed_for_all_matches"
|
|
136
|
+
message?: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- Driver — execution contract ----
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The mandatory driver contract. Every concrete driver — InMemory, Mode 2
|
|
143
|
+
* (Postgres), Mode 1 (CF edge) — implements all five methods. The compliance
|
|
144
|
+
* test suite (`driver-compliance.test.ts`) is the contract.
|
|
145
|
+
*/
|
|
146
|
+
export interface WorkflowDriver {
|
|
147
|
+
/**
|
|
148
|
+
* Idempotent. Same manifest body returns the same `versionId` across calls.
|
|
149
|
+
* Failures are NOT swallowed — `createApp()`'s bootstrap surfaces rejections
|
|
150
|
+
* (see architecture doc §21.22).
|
|
151
|
+
*/
|
|
152
|
+
registerManifest(args: {
|
|
153
|
+
environment: EnvironmentName
|
|
154
|
+
manifest: WorkflowManifest
|
|
155
|
+
}): Promise<{ versionId: string }>
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Trigger a workflow run by id or definition handle. Honors
|
|
159
|
+
* `opts.idempotencyKey` for dedup (returns the existing run on conflict).
|
|
160
|
+
*/
|
|
161
|
+
trigger<TIn, TOut>(
|
|
162
|
+
workflow: WorkflowDefinition<TIn, TOut> | string,
|
|
163
|
+
input: TIn,
|
|
164
|
+
opts?: TriggerOptions,
|
|
165
|
+
): Promise<Run<TOut>>
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Ingest one event. Synchronously (from the caller's POV) loads the
|
|
169
|
+
* registered manifest, evaluates `where`, projects `input`, and triggers
|
|
170
|
+
* one run per match. See architecture doc §15.
|
|
171
|
+
*/
|
|
172
|
+
ingestEvent(args: IngestEventArgs): Promise<IngestEventResponse>
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Read the registered manifest. Used at boot for version-mismatch detection
|
|
176
|
+
* and by the dashboard for filter inspection.
|
|
177
|
+
*/
|
|
178
|
+
getManifest(args: { environment: EnvironmentName }): Promise<WorkflowManifest | null>
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Optional. Drains in-flight steps, refuses new triggers, awaits time-wheel
|
|
182
|
+
* exit. Default `gracefulMs` is 30_000.
|
|
183
|
+
*/
|
|
184
|
+
shutdown?(opts?: { gracefulMs?: number }): Promise<void>
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Optional read-side surface. Drivers that can support listings, run detail,
|
|
188
|
+
* and journal streams declare this; consumers (the dashboard) duck-type on
|
|
189
|
+
* `driver.admin`. See architecture doc §6.2.
|
|
190
|
+
*/
|
|
191
|
+
admin?: WorkflowAdmin | Partial<WorkflowAdmin>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- Driver — admin (optional) ----
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read-side operations. Implemented natively by the Mode 2 driver against
|
|
198
|
+
* Postgres, partially by Mode 1 (single-run reads only — `listRuns` is not
|
|
199
|
+
* implemented in self-host Mode 1; voyant-cloud provides an index layer).
|
|
200
|
+
* See architecture doc §6.2 + §8.3.
|
|
201
|
+
*/
|
|
202
|
+
export interface WorkflowAdmin {
|
|
203
|
+
listRuns(opts?: ListRunsOptions): Promise<{ runs: RunSummary[]; nextCursor?: string }>
|
|
204
|
+
getRun(runId: string): Promise<RunDetail | null>
|
|
205
|
+
cancelRun(runId: string, opts?: { reason?: string; compensate?: boolean }): Promise<void>
|
|
206
|
+
/**
|
|
207
|
+
* Subscribe to journal events for a run. The async-iterable shape lets
|
|
208
|
+
* dashboards stream live without polling.
|
|
209
|
+
*/
|
|
210
|
+
streamRun(runId: string): AsyncIterable<AdminStreamEvent>
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The admin streaming surface re-uses the protocol's `StreamEvent` for
|
|
215
|
+
* step/waitpoint/log events; this type is its alias to keep the import
|
|
216
|
+
* surface narrow.
|
|
217
|
+
*/
|
|
218
|
+
export type AdminStreamEvent =
|
|
219
|
+
| { kind: "run.snapshot"; at: number; status: string; metadata: Record<string, unknown> }
|
|
220
|
+
| { kind: "step.started"; at: number; stepId: string; runtime: "edge" | "node" }
|
|
221
|
+
| { kind: "step.ok"; at: number; stepId: string; durationMs: number }
|
|
222
|
+
| { kind: "step.err"; at: number; stepId: string; error: { code: string; message: string } }
|
|
223
|
+
| { kind: "waitpoint.registered"; at: number; waitpointId: string; waitpointKind: WaitpointKind }
|
|
224
|
+
| { kind: "waitpoint.resolved"; at: number; waitpointId: string }
|
|
225
|
+
| { kind: "log"; at: number; level: "info" | "warn" | "error"; message: string }
|
|
226
|
+
| { kind: "run.finished"; at: number; status: string }
|
|
227
|
+
|
|
228
|
+
// ---- Errors ----
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Base class for typed driver errors. Concrete subclasses below; consumers
|
|
232
|
+
* `instanceof` to handle specific cases.
|
|
233
|
+
*/
|
|
234
|
+
export class WorkflowDriverError extends Error {
|
|
235
|
+
readonly code: string
|
|
236
|
+
readonly cause?: unknown
|
|
237
|
+
|
|
238
|
+
constructor(code: string, message: string, opts?: { cause?: unknown }) {
|
|
239
|
+
super(message)
|
|
240
|
+
this.name = "WorkflowDriverError"
|
|
241
|
+
this.code = code
|
|
242
|
+
this.cause = opts?.cause
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export class ManifestNotRegisteredError extends WorkflowDriverError {
|
|
247
|
+
constructor(environment: EnvironmentName) {
|
|
248
|
+
super(
|
|
249
|
+
"manifest_not_registered",
|
|
250
|
+
`No manifest is registered for environment "${environment}". ` +
|
|
251
|
+
`createApp() must complete its bootstrap before driver.ingestEvent(...) can fire.`,
|
|
252
|
+
)
|
|
253
|
+
this.name = "ManifestNotRegisteredError"
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export class EventTooLargeError extends WorkflowDriverError {
|
|
258
|
+
readonly bytes: number
|
|
259
|
+
readonly limit: number
|
|
260
|
+
|
|
261
|
+
constructor(bytes: number, limit = 256 * 1024) {
|
|
262
|
+
super("payload_too_large", `Event payload is ${bytes} bytes, exceeding the ${limit}-byte cap.`)
|
|
263
|
+
this.name = "EventTooLargeError"
|
|
264
|
+
this.bytes = bytes
|
|
265
|
+
this.limit = limit
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export class EnvironmentMismatchError extends WorkflowDriverError {
|
|
270
|
+
constructor(eventEnv: EnvironmentName, manifestEnv: EnvironmentName) {
|
|
271
|
+
super(
|
|
272
|
+
"environment_mismatch",
|
|
273
|
+
`Event environment "${eventEnv}" does not match the registered manifest's environment "${manifestEnv}".`,
|
|
274
|
+
)
|
|
275
|
+
this.name = "EnvironmentMismatchError"
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Compile an `EventFilterDeclaration` (authored in user code) into an
|
|
2
|
+
// `EventFilterRuntimeEntry`. Validates the predicate + mapper at
|
|
3
|
+
// registration time so authoring errors fail fast; computes the
|
|
4
|
+
// content-derived `id` / `payloadHash` so the manifest is deterministic.
|
|
5
|
+
//
|
|
6
|
+
// Called from `trigger.on(...)` in `../trigger.js`.
|
|
7
|
+
|
|
8
|
+
import type { EventFilterManifestEntry } from "../protocol/index.js"
|
|
9
|
+
import type { EventFilterDeclaration } from "../trigger.js"
|
|
10
|
+
import { type InputMapper, validateInputMapper } from "./input-mapper.js"
|
|
11
|
+
import { canonicalJson, sha256 } from "./payload-hash.js"
|
|
12
|
+
import { type PredicateExpr, validatePredicate } from "./predicate.js"
|
|
13
|
+
import { type EventFilterRuntimeEntry, getEventFilterRegistry } from "./registry.js"
|
|
14
|
+
|
|
15
|
+
export class EventFilterCompileError extends Error {
|
|
16
|
+
readonly errors: string[]
|
|
17
|
+
|
|
18
|
+
constructor(message: string, errors: string[] = []) {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = "EventFilterCompileError"
|
|
21
|
+
this.errors = errors
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compile an authored declaration into a runtime entry. Throws
|
|
27
|
+
* `EventFilterCompileError` on shape errors so authoring problems fail
|
|
28
|
+
* at module-load time.
|
|
29
|
+
*/
|
|
30
|
+
export async function compileEventFilter<T>(
|
|
31
|
+
eventType: string,
|
|
32
|
+
declaration: EventFilterDeclaration<T>,
|
|
33
|
+
): Promise<EventFilterRuntimeEntry> {
|
|
34
|
+
if (typeof eventType !== "string" || eventType.length === 0) {
|
|
35
|
+
throw new EventFilterCompileError(
|
|
36
|
+
`trigger.on(eventType, ...): eventType must be a non-empty string`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
if (typeof declaration !== "object" || declaration === null) {
|
|
40
|
+
throw new EventFilterCompileError(
|
|
41
|
+
`trigger.on("${eventType}", ...): declaration must be an object`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
if (!declaration.target || typeof declaration.target !== "object") {
|
|
45
|
+
throw new EventFilterCompileError(
|
|
46
|
+
`trigger.on("${eventType}", ...): "target" must be a workflow definition (got ${typeof declaration.target})`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
const targetWorkflowId =
|
|
50
|
+
typeof (declaration.target as { id?: unknown }).id === "string"
|
|
51
|
+
? (declaration.target as { id: string }).id
|
|
52
|
+
: ""
|
|
53
|
+
if (targetWorkflowId.length === 0) {
|
|
54
|
+
throw new EventFilterCompileError(
|
|
55
|
+
`trigger.on("${eventType}", ...): "target.id" must be a non-empty string`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validate `where` if supplied.
|
|
60
|
+
const where = (declaration as { where?: PredicateExpr }).where
|
|
61
|
+
if (where !== undefined) {
|
|
62
|
+
const result = validatePredicate(where)
|
|
63
|
+
if (!result.ok) {
|
|
64
|
+
throw new EventFilterCompileError(
|
|
65
|
+
`trigger.on("${eventType}", target=${targetWorkflowId}): invalid where clause`,
|
|
66
|
+
result.errors,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate `input` if supplied.
|
|
72
|
+
const input = (declaration as { input?: InputMapper }).input
|
|
73
|
+
if (input !== undefined) {
|
|
74
|
+
const result = validateInputMapper(input)
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
throw new EventFilterCompileError(
|
|
77
|
+
`trigger.on("${eventType}", target=${targetWorkflowId}): invalid input mapper`,
|
|
78
|
+
result.errors,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Reject the legacy `match` callback explicitly so authoring errors are obvious.
|
|
84
|
+
if (typeof (declaration as { match?: unknown }).match === "function") {
|
|
85
|
+
throw new EventFilterCompileError(
|
|
86
|
+
`trigger.on("${eventType}"): the "match" callback is no longer supported. ` +
|
|
87
|
+
`Use the structured "where" predicate instead. See architecture doc §12.`,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Canonical content-derived id. Stable across re-deploys because the
|
|
92
|
+
// canonicalized JSON of the declaration is identical for byte-equivalent
|
|
93
|
+
// sources.
|
|
94
|
+
const canonicalDeclaration = {
|
|
95
|
+
eventType,
|
|
96
|
+
where: where ?? null,
|
|
97
|
+
input: input ?? null,
|
|
98
|
+
targetWorkflowId,
|
|
99
|
+
}
|
|
100
|
+
const fullHash = await sha256(canonicalDeclaration)
|
|
101
|
+
const payloadHash = fullHash
|
|
102
|
+
// Filter id seeds from the same hash but stays human-friendly in logs.
|
|
103
|
+
const id = `ef_${fullHash.slice(0, 16)}`
|
|
104
|
+
|
|
105
|
+
const manifest: EventFilterManifestEntry = {
|
|
106
|
+
id,
|
|
107
|
+
eventType,
|
|
108
|
+
payloadHash,
|
|
109
|
+
targetWorkflowId,
|
|
110
|
+
...(where !== undefined ? { where } : {}),
|
|
111
|
+
...(input !== undefined ? { input } : {}),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const entry: EventFilterRuntimeEntry = {
|
|
115
|
+
id,
|
|
116
|
+
eventType,
|
|
117
|
+
manifest,
|
|
118
|
+
declaration: declaration as EventFilterDeclaration<unknown>,
|
|
119
|
+
targetWorkflowId,
|
|
120
|
+
}
|
|
121
|
+
return entry
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compile + register in one step. The synchronous wrapper for `trigger.on()`
|
|
126
|
+
* that user code calls — validates the declaration, computes the
|
|
127
|
+
* content-derived id, registers the entry, and returns it.
|
|
128
|
+
*
|
|
129
|
+
* Returning the full {@link EventFilterRuntimeEntry} is what makes the
|
|
130
|
+
* authoring shape from the architecture doc work directly — modules can
|
|
131
|
+
* write `eventFilters: [trigger.on(...)]` without a registry round-trip:
|
|
132
|
+
* the entry already structurally satisfies `EventFilterDescriptor`
|
|
133
|
+
* (matching `id` + `eventType` fields) and carries the `manifest` payload
|
|
134
|
+
* `createApp()`'s wireWorkflowRuntime needs to register with the driver.
|
|
135
|
+
*/
|
|
136
|
+
export function compileAndRegister<T>(
|
|
137
|
+
eventType: string,
|
|
138
|
+
declaration: EventFilterDeclaration<T>,
|
|
139
|
+
): EventFilterRuntimeEntry {
|
|
140
|
+
// Run validation + compile synchronously where possible. The async
|
|
141
|
+
// boundary is the SHA-256 digest from Web Crypto; we bridge via a
|
|
142
|
+
// synchronous canonical-JSON hash so trigger.on() can stay sync.
|
|
143
|
+
const entry = compileEventFilterSync(eventType, declaration)
|
|
144
|
+
getEventFilterRegistry().add(entry)
|
|
145
|
+
return entry
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Synchronous compile — uses a deterministic but non-cryptographic hash
|
|
150
|
+
* over the canonical JSON. Suitable for the registry id (we just need
|
|
151
|
+
* stable + collision-resistant-enough across realistic registration
|
|
152
|
+
* counts). The crypto-grade `sha256(...)` is used at *manifest build*
|
|
153
|
+
* time when async is fine.
|
|
154
|
+
*/
|
|
155
|
+
export function compileEventFilterSync<T>(
|
|
156
|
+
eventType: string,
|
|
157
|
+
declaration: EventFilterDeclaration<T>,
|
|
158
|
+
): EventFilterRuntimeEntry {
|
|
159
|
+
if (typeof eventType !== "string" || eventType.length === 0) {
|
|
160
|
+
throw new EventFilterCompileError(
|
|
161
|
+
`trigger.on(eventType, ...): eventType must be a non-empty string`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
if (typeof declaration !== "object" || declaration === null) {
|
|
165
|
+
throw new EventFilterCompileError(
|
|
166
|
+
`trigger.on("${eventType}", ...): declaration must be an object`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (!declaration.target || typeof declaration.target !== "object") {
|
|
170
|
+
throw new EventFilterCompileError(
|
|
171
|
+
`trigger.on("${eventType}", ...): "target" must be a workflow definition (got ${typeof declaration.target})`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
const targetWorkflowId =
|
|
175
|
+
typeof (declaration.target as { id?: unknown }).id === "string"
|
|
176
|
+
? (declaration.target as { id: string }).id
|
|
177
|
+
: ""
|
|
178
|
+
if (targetWorkflowId.length === 0) {
|
|
179
|
+
throw new EventFilterCompileError(
|
|
180
|
+
`trigger.on("${eventType}", ...): "target.id" must be a non-empty string`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const where = (declaration as { where?: PredicateExpr }).where
|
|
185
|
+
if (where !== undefined) {
|
|
186
|
+
const result = validatePredicate(where)
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
throw new EventFilterCompileError(
|
|
189
|
+
`trigger.on("${eventType}", target=${targetWorkflowId}): invalid where clause`,
|
|
190
|
+
result.errors,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const input = (declaration as { input?: InputMapper }).input
|
|
196
|
+
if (input !== undefined) {
|
|
197
|
+
const result = validateInputMapper(input)
|
|
198
|
+
if (!result.ok) {
|
|
199
|
+
throw new EventFilterCompileError(
|
|
200
|
+
`trigger.on("${eventType}", target=${targetWorkflowId}): invalid input mapper`,
|
|
201
|
+
result.errors,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (typeof (declaration as { match?: unknown }).match === "function") {
|
|
207
|
+
throw new EventFilterCompileError(
|
|
208
|
+
`trigger.on("${eventType}"): the "match" callback is no longer supported. ` +
|
|
209
|
+
`Use the structured "where" predicate instead. See architecture doc §12.`,
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const canonicalDeclaration = {
|
|
214
|
+
eventType,
|
|
215
|
+
where: where ?? null,
|
|
216
|
+
input: input ?? null,
|
|
217
|
+
targetWorkflowId,
|
|
218
|
+
}
|
|
219
|
+
const json = canonicalJson(canonicalDeclaration)
|
|
220
|
+
const shortId = nonCryptoHash16(json)
|
|
221
|
+
const id = `ef_${shortId}`
|
|
222
|
+
|
|
223
|
+
const manifest: EventFilterManifestEntry = {
|
|
224
|
+
id,
|
|
225
|
+
eventType,
|
|
226
|
+
// payloadHash on the manifest is stable (same canonical JSON) but
|
|
227
|
+
// upgraded to the crypto-grade sha256 at manifest-build time. Until
|
|
228
|
+
// then it carries the same short hash so consumers that read it pre-
|
|
229
|
+
// build (e.g. dashboards in dev mode) still see something sensible.
|
|
230
|
+
payloadHash: shortId,
|
|
231
|
+
targetWorkflowId,
|
|
232
|
+
...(where !== undefined ? { where } : {}),
|
|
233
|
+
...(input !== undefined ? { input } : {}),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
id,
|
|
238
|
+
eventType,
|
|
239
|
+
manifest,
|
|
240
|
+
declaration: declaration as EventFilterDeclaration<unknown>,
|
|
241
|
+
targetWorkflowId,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Deterministic 16-hex-char hash for synchronous registration. Uses FNV-1a
|
|
247
|
+
* 64-bit folded into hex — collision-resistant enough at the cardinality of
|
|
248
|
+
* "filters per project" (low thousands at most). The cryptographic SHA-256
|
|
249
|
+
* is the source of truth for manifest-level identity; this is just enough
|
|
250
|
+
* to be a stable id for the in-process registry.
|
|
251
|
+
*/
|
|
252
|
+
function nonCryptoHash16(text: string): string {
|
|
253
|
+
// FNV-1a 64-bit, computed as two 32-bit halves to avoid bigint overhead
|
|
254
|
+
// in tight loops. Output: 16 hex chars (concatenation of the two halves).
|
|
255
|
+
let h1 = 0xcbf29ce4
|
|
256
|
+
let h2 = 0x84222325
|
|
257
|
+
for (let i = 0; i < text.length; i++) {
|
|
258
|
+
const c = text.charCodeAt(i)
|
|
259
|
+
h1 = (h1 ^ c) >>> 0
|
|
260
|
+
h2 = (h2 ^ c) >>> 0
|
|
261
|
+
// Multiply by 0x100000001b3 split into the two halves.
|
|
262
|
+
const lo = h2 * 0x01b3
|
|
263
|
+
const hi = h1 * 0x01b3 + Math.floor(lo / 0x100000000)
|
|
264
|
+
h2 = (lo & 0xffffffff) >>> 0
|
|
265
|
+
h1 = hi >>> 0
|
|
266
|
+
}
|
|
267
|
+
return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0")
|
|
268
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// `@voyantjs/workflows/events` — the events runtime: structured `where`
|
|
2
|
+
// predicate + `input` mapper, event-filter registry, manifest builder.
|
|
3
|
+
//
|
|
4
|
+
// Authoritative architecture: docs/architecture/workflows-runtime-architecture.md
|
|
5
|
+
// §12 (trigger.on runtime), §13 (DSLs), §14 (manifest lifecycle).
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
compileAndRegister,
|
|
9
|
+
compileEventFilterSync,
|
|
10
|
+
EventFilterCompileError,
|
|
11
|
+
} from "./compile.js"
|
|
12
|
+
export type { InputMapper } from "./input-mapper.js"
|
|
13
|
+
export {
|
|
14
|
+
type InputMapperValidationResult,
|
|
15
|
+
projectInput,
|
|
16
|
+
validateInputMapper,
|
|
17
|
+
} from "./input-mapper.js"
|
|
18
|
+
export {
|
|
19
|
+
type BuildManifestArgs,
|
|
20
|
+
buildManifest,
|
|
21
|
+
} from "./manifest-builder.js"
|
|
22
|
+
export {
|
|
23
|
+
canonicalize,
|
|
24
|
+
canonicalJson,
|
|
25
|
+
deriveStableEventId,
|
|
26
|
+
sha256,
|
|
27
|
+
shortHash,
|
|
28
|
+
} from "./payload-hash.js"
|
|
29
|
+
export {
|
|
30
|
+
evaluatePredicate,
|
|
31
|
+
type PathOrLit,
|
|
32
|
+
type PredicateEnvelope,
|
|
33
|
+
type PredicateExpr,
|
|
34
|
+
type PredicateValidationResult,
|
|
35
|
+
resolvePath,
|
|
36
|
+
validatePredicate,
|
|
37
|
+
} from "./predicate.js"
|
|
38
|
+
export {
|
|
39
|
+
__resetEventFilterRegistry,
|
|
40
|
+
type EventFilterRuntimeEntry,
|
|
41
|
+
getEventFilterRegistry,
|
|
42
|
+
} from "./registry.js"
|