slapflow 1.0.2

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/SPEC.md ADDED
@@ -0,0 +1,448 @@
1
+ # Slapflow Specification
2
+
3
+ `slapflow` is an npm package for declaratively executing synchronous and asynchronous actions in ordered chains with execution conditions, fallback branches, trace output, and safety limits.
4
+
5
+ The package is not coupled to a UI, server framework, scheduler, or domain model. An application registers actions and conditions, supplies context and input, and the runner returns the chain execution result.
6
+
7
+ ## Runtime Flow
8
+
9
+ [View the runtime flow](RUNTIME-FLOW.mmd).
10
+
11
+ ## Public API
12
+
13
+ ```ts
14
+ import {
15
+ createActionsRegistry,
16
+ createConditionsRegistry,
17
+ createMemoryTraceSink,
18
+ defineErrorReporter,
19
+ createPubSub,
20
+ PubSub,
21
+ createFlow,
22
+ createWS,
23
+ catchError,
24
+ } from 'slapflow'
25
+ ```
26
+
27
+ ```ts
28
+ const flow = createFlow<Context, Patch>(
29
+ { config: { strategies: {} } },
30
+ { context: () => ({} as Context) }
31
+ )
32
+ const runner = flow.runner
33
+
34
+ runner.registerAction('jobs.execute', executeJob)
35
+ runner.registerCondition('hasQueue', ({ context }) => context.queue.length > 0)
36
+
37
+ runner.loadConfig(config)
38
+ const result = await runner.run('worker.tick', context, input)
39
+ ```
40
+
41
+ ## Core Types
42
+
43
+ ```ts
44
+ type Config = {
45
+ version?: 1
46
+ strategies: Record<string, Strategy>
47
+ entrypoints?: Record<string, string>
48
+ }
49
+
50
+ type Strategy = {
51
+ fn: string
52
+ props?: Record<string, unknown>
53
+ when?: ConditionExpression
54
+ then?: Next[]
55
+ catch?: Next[]
56
+ mode?: 'sequence' | 'selector' | 'parallel'
57
+ terminal?: boolean
58
+ }
59
+ ```
60
+
61
+ ## Error Reporting
62
+
63
+ The runner works as a declarative try/catch pipeline: an action can return `runtime.fail(...)` or throw, a strategy can define `catch`, and an application can centrally report errors through `onError`.
64
+
65
+ ```ts
66
+ const reportError = defineErrorReporter({
67
+ report: ({ error, context, input, data, patches, events, trace }) => {
68
+ Sentry.captureException(error.cause ?? error, {
69
+ tags: {
70
+ code: error.code,
71
+ phase: error.stage?.phase,
72
+ strategy: error.stage?.strategy,
73
+ fn: error.stage?.fn,
74
+ },
75
+ extra: { context, input, data, patches, events, trace },
76
+ })
77
+ },
78
+ })
79
+
80
+ const flow = createFlow(
81
+ { config: { strategies: {} } },
82
+ { context: () => ({} as Context), trace: true, onError: reportError }
83
+ )
84
+ ```
85
+
86
+ `onError` receives `SlapErrorEvent`:
87
+
88
+ ```ts
89
+ type SlapErrorEvent<TContext, TPatch> = {
90
+ error: SlapError
91
+ context: TContext
92
+ input: Input
93
+ data: Record<string, unknown>
94
+ patches: TPatch[]
95
+ events: SlapEvent[]
96
+ trace?: TraceEntry[]
97
+ }
98
+ ```
99
+
100
+ `SlapError.stage` identifies the chain phase:
101
+
102
+ ```ts
103
+ type ErrorStage = {
104
+ phase: 'entrypoint' | 'condition' | 'action' | 'catch' | 'limit'
105
+ entrypoint?: string
106
+ strategy?: string
107
+ fn?: string
108
+ mode?: Mode
109
+ step?: number
110
+ depth?: number
111
+ }
112
+ ```
113
+
114
+ If an error is recovered through `catch`, `onError` is still invoked for the original failure and the final `run` may finish with `success`.
115
+
116
+ ## Registry Model
117
+
118
+ The runner uses runner-scoped registries:
119
+
120
+ ```text
121
+ src/registry/
122
+ actions.ts
123
+ conditions.ts
124
+ ```
125
+
126
+ `createActionsRegistry()` creates a `Map` prepopulated with built-in actions.
127
+
128
+ `createConditionsRegistry()` creates a `Map` prepopulated with built-in conditions.
129
+
130
+ Each runner receives its own mutable registry copy. Applications can override any built-in action or condition:
131
+
132
+ ```ts
133
+ runner.registerAction('app.setData', customSetData)
134
+ runner.registerCondition('eq', customEq)
135
+ ```
136
+
137
+ Built-ins are therefore default values, not a separate immutable layer.
138
+
139
+ Configuration validation accesses registries through the minimal `has(name)` contract.
140
+
141
+ ## Built-In Actions
142
+
143
+ | Action | Props | Description |
144
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
145
+ | `core.noop` | — | Completes successfully without changing runtime state. |
146
+ | `core.stop` | `reason?` | Stops the run with an optional reason. |
147
+ | `core.fail` | `reason?`, `data?` | Fails the current strategy with an optional reason and error data. |
148
+ | `core.fetch` | **`url`**, `method?`, `headers?`, `body?`, `credentials?`, `response?`, `dataPath?`, `contextPath?`, `acceptStatuses?`, `retryStatuses?`, `retry?` | Fetches data with cancellation, response parsing, status control, and retry backoff. |
149
+ | `core.loop` | `duration?`, `max?`, `immediate?` | Repeats its `then` branch on an interval until aborted or the iteration limit is reached. |
150
+ | `core.sequence` | — | Executes `then` targets in order. |
151
+ | `core.selector` | — | Executes `then` targets until one succeeds or stops. |
152
+ | `core.parallel` | — | Executes `then` targets concurrently in isolated context and data branches. |
153
+ | `core.set` | **`path`**, `value?`, `data?` | Writes `value` to a nested context `path`; optional `data` is merged into runtime data. |
154
+ | `core.setData` | **`path`**, `value?`, `data?` | **Deprecated.** Writes `value` to runtime data; use `runtime.data.set(path, value)` inside an application action. |
155
+ | `core.emit` | **`type`**, `payload?` | Appends an event to the run result. |
156
+ | `core.patch` | **`patch`** | Appends a patch to the run result. |
157
+ | `core.delay` | `ms?` | Waits for the configured duration or until the run is aborted. |
158
+
159
+ Bold props are required; `?` marks optional props. All names in this column are fields of the strategy's `props` object.
160
+
161
+ `core.loop` executes its `then` branch every `props.duration` milliseconds until the run is aborted or `props.max` iterations complete. The default maximum is `999`, leaving one of the default `maxStepCount: 1000` steps for the loop action itself; `max: -1` disables the iteration limit, but not runner safety limits. Zero, values below `-1`, `NaN`, and infinity fall back to the default. When `props.immediate` is `true`, the first iteration executes immediately, counts toward `max`, and does not wait for the first interval. Overlapping iterations are skipped. A failed iteration executes `catch`; the loop continues when `catch` succeeds.
162
+ Nested `core.loop` strategies are invalid, including transitive references through `then` or `catch`. Sibling loops in separate branches are allowed.
163
+
164
+ Actions can execute their own configured branches through `runtime.executeThen()` and `runtime.executeCatch()`. `executeThen()` honors the strategy's `mode`, so control actions such as `core.loop` can compose with `sequence`, `selector`, and `parallel` execution without accessing runner internals.
165
+
166
+ `core.set` writes a nested context value through `runtime.set`. `core.setData` remains available for compatibility; new application actions should write temporary chain data through `runtime.data.set(path, value)`.
167
+
168
+ `core.fetch` uses native `fetch` with the run signal. Its `response` prop selects `json`, `text`, `blob`, `arrayBuffer`, or `none`; successful responses are normalized as `{ status, ok, headers, body }` and can be written to `dataPath` or `contextPath`. `acceptStatuses` overrides the default `Response.ok` success condition. `credentials` accepts `include`, `same-origin`, or `omit` and is forwarded to native `fetch`. CORS, preflight requests, SameSite cookie rules, and server cookie policy remain the responsibility of the browser and server. `retry` accepts `initialDelay`, `maxDelay`, `multiplier`, `jitter`, and `maxAttempts`; `retryStatuses` overrides the default retryable statuses. The default performs two retries for network failures and statuses `408`, `425`, `429`, and `5xx`. Response-body parsing failures do not retry. An aborted request or retry returns `skip`. Retries are intended for replayable request bodies.
169
+
170
+ ## Built-In Conditions
171
+
172
+ | Condition | Description | Example |
173
+ | --------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
174
+ | `and` | Matches when every nested condition matches. | `['and', ['typeIs', '$input.id', 'string'], ['notEmpty', '$input.id']]` |
175
+ | `or` | Matches when at least one nested condition matches. | `['or', ['eq', '$context.status', 'ready'], ['eq', '$context.status', 'idle']]` |
176
+ | `not` | Inverts a nested condition. | `['not', ['truthy', '$context.disabled']]` |
177
+ | `eq` | Compares two values with `Object.is`. | `['eq', '$context.status', 'ready']` |
178
+ | `neq` | Matches when `Object.is` does not consider the values equal. | `['neq', '$context.status', 'failed']` |
179
+ | `gt` | Compares values numerically with `>`. | `['gt', '$context.count', 0]` |
180
+ | `gte` | Compares values numerically with `>=`. | `['gte', '$context.count', 1]` |
181
+ | `lt` | Compares values numerically with `<`. | `['lt', '$context.count', 100]` |
182
+ | `lte` | Compares values numerically with `<=`. | `['lte', '$context.count', 99]` |
183
+ | `truthy` | Applies JavaScript truthiness. | `['truthy', '$context.enabled']` |
184
+ | `falsy` | Applies JavaScript falsiness. | `['falsy', '$context.disabled']` |
185
+ | `exists` | Matches values other than `null` and `undefined`. | `['exists', '$data.response']` |
186
+ | `missing` | Matches `null` or `undefined`. | `['missing', '$data.error']` |
187
+ | `empty` | Matches empty strings, arrays, maps, sets, objects, and nullish values. | `['empty', '$context.items']` |
188
+ | `notEmpty` | Matches supported values with a size greater than zero. | `['notEmpty', '$context.items']` |
189
+ | `includes` | Checks membership in strings, arrays, and sets. | `['includes', ['parts', 'food'], '$input.resource']` |
190
+ | `typeIs` | Matches `string`, `number`, `finite-number`, `boolean`, `array`, or `record`. | `['typeIs', '$input.amount', 'finite-number']` |
191
+ | `changed` | Matches when current and previous values differ by `Object.is`. | `['changed', '$context.current', '$context.previous']` |
192
+ | `cooldownReady` | Matches when no previous timestamp exists or the cooldown has elapsed. | `['cooldownReady', '$context.now', '$context.lastAt', 1000]` |
193
+
194
+ ## Config Example
195
+
196
+ ```ts
197
+ export const config = {
198
+ version: 1,
199
+ entrypoints: {
200
+ 'worker.tick': 'worker.tick',
201
+ },
202
+ strategies: {
203
+ 'worker.tick': {
204
+ fn: 'core.selector',
205
+ mode: 'selector',
206
+ then: ['worker.pickQueuedJob', 'worker.idle'],
207
+ },
208
+ 'worker.pickQueuedJob': {
209
+ fn: 'jobs.findNext',
210
+ when: ['and', ['eq', '$context.worker.state', 'idle'], ['gt', '$context.worker.queueSize', 0]],
211
+ then: ['jobs.reserve', 'jobs.execute'],
212
+ },
213
+ 'worker.idle': {
214
+ fn: 'core.noop',
215
+ },
216
+ },
217
+ }
218
+ ```
219
+
220
+ ## Execution Modes
221
+
222
+ `sequence` executes `then` targets in order.
223
+
224
+ `selector` executes `then` targets until the first successful or stopped step. `skip` means “try the next option.”
225
+
226
+ `parallel` runs `then` targets independently. Plain objects and arrays in runtime context and data are cloned for each branch; infrastructure values such as functions, DOM nodes, and class instances remain references. Safety limits, including `maxStepCount`, remain shared by the whole run. Resulting patches and events are returned to the caller; the runner does not apply them.
227
+
228
+ ## Runtime Helpers
229
+
230
+ ```ts
231
+ type Runtime = {
232
+ get(path: string): unknown
233
+ set(path: string, value: unknown): void
234
+ data: {
235
+ get(path: string): unknown
236
+ set(path: string, value: unknown): void
237
+ }
238
+ variables?: {
239
+ get(path: string): unknown
240
+ }
241
+ /** @deprecated Use runtime.data.get(path). */
242
+ getData(path: string): unknown
243
+ /** @deprecated Use runtime.data.set(path, value). */
244
+ setData(path: string, value: unknown): void
245
+ resolve(value: unknown): unknown
246
+ signal: AbortSignal
247
+ executeThen(): Promise<RuntimeBranchResult>
248
+ executeCatch(): Promise<RuntimeBranchResult | undefined>
249
+ emit(event: SlapEvent): void
250
+ patch(patch: unknown): void
251
+ stop(reason?: string): ActionStop<unknown>
252
+ fail(reason?: string, data?: Record<string, unknown>): ActionFail
253
+ }
254
+ ```
255
+
256
+ `runtime.get` and `runtime.set` read and write nested context values. `runtime.data.get` and `runtime.data.set` read and write temporary chain data.
257
+
258
+ `runtime.getData` and `runtime.setData` are deprecated compatibility aliases and emit a console warning when called.
259
+
260
+ `runtime.variables.get` reads immutable runtime variables. `runtime.resolve` resolves `$context.*`, `$data.*`, `$input.*`, and immutable `$variables.*` values. It also evaluates `$expression` and `$template` objects recursively, using the expression operators registered in runner options. In `$template`, `{{ path }}` reads runtime data for compatibility; `{{ data.path }}`, `{{ context.path }}`, and `{{ input.path }}` select their source explicitly.
261
+
262
+ Runtime path get/set is implemented directly through `objwalk`.
263
+
264
+ ## Validation
265
+
266
+ `validateConfig` validates:
267
+
268
+ - unknown actions through `actionsRegistry.has(fn)`;
269
+ - unknown condition operators through `conditionsRegistry.has(operator)`;
270
+ - missing strategies in `then`, `catch`, and `entrypoints`;
271
+ - invalid modes;
272
+ - invalid path references;
273
+ - cycles without a terminal step.
274
+
275
+ ## Trace
276
+
277
+ Trace entries contain:
278
+
279
+ - step/depth;
280
+ - strategy/fn/mode;
281
+ - status;
282
+ - input;
283
+ - props;
284
+ - dataBefore/dataAfter;
285
+ - durationMs;
286
+ - reason.
287
+
288
+ Trace does not store a complete context snapshot.
289
+
290
+ ## Pub/Sub Bus
291
+
292
+ `PubSub` is a process-local singleton event bus. Use `createPubSub` for isolated runtimes.
293
+
294
+ ```ts
295
+ type AppEvents = {
296
+ 'auth.signed-in': { userId: string }
297
+ }
298
+
299
+ const bus = createPubSub<AppEvents>()
300
+ const unsubscribe = bus.on('auth.signed-in', ({ parsed, serialized }) => {
301
+ console.log(parsed.userId)
302
+ socket.send(serialized)
303
+ })
304
+
305
+ bus.emit('auth.signed-in', { userId: 'ada' }, { origin: 'api' })
306
+ unsubscribe()
307
+ ```
308
+
309
+ ```ts
310
+ type Bus<TEvents extends object = Record<string, unknown>> = {
311
+ on<TEvent extends keyof TEvents>(
312
+ event: TEvent,
313
+ handler: (event: BusEvent<TEvents[TEvent]>) => void
314
+ ): () => void
315
+ off<TEvent extends keyof TEvents>(event: TEvent, handler?: (event: BusEvent<TEvents[TEvent]>) => void): void
316
+ emit<TEvent extends keyof TEvents>(
317
+ topic: TEvent,
318
+ payload: TEvents[TEvent],
319
+ options?: { origin?: string }
320
+ ): BusEvent<TEvents[TEvent]>
321
+ }
322
+
323
+ type BusEvent<TPayload> = {
324
+ id: string
325
+ topic: string
326
+ occurredAt: number
327
+ origin?: string
328
+ parsed: TPayload
329
+ serialized: string
330
+ }
331
+ ```
332
+
333
+ `emit` creates an envelope and serializes the payload once before subscribers run. Event identifiers are opaque 12-character alphanumeric runtime IDs for correlation and echo suppression. They are not cryptographically secure and must not be used for access tokens, signatures, public links, or any security-sensitive purpose. `on` returns an unsubscribe function. `off(event, handler)` removes one handler, while `off(event)` clears the channel. An error in one subscriber does not block the others; `createPubSub({ onError })` receives the error and original event. On serialization failure, the bus delivers `{ error }` as `parsed` and the error body as `serialized`, then calls `onError` with the original cause.
334
+
335
+ ## Flow
336
+
337
+ `createFlow` combines configuration, actions, conditions, a context provider, and event bindings. It creates a runner (accessible via `flow.runner`) and supports the `start`/`stop` lifecycle.
338
+
339
+ ```ts
340
+ type Events = {
341
+ 'form.submit': { email: string }
342
+ }
343
+
344
+ const flow = createFlow<Context, Patch, Events>(
345
+ {
346
+ actions: { 'form.save': saveForm },
347
+ conditions: { allowed: isAllowed },
348
+ events: { '[bus] form.submit': { entrypoint: 'form.submit' } },
349
+ config,
350
+ },
351
+ { bus, context: () => appStore.getState() }
352
+ )
353
+
354
+ const started = flow.start()
355
+ flow.stop()
356
+ ```
357
+
358
+ A `[bus] <event-name>` binding starts an `entrypoint` from `config.entrypoints`. The event payload must be an object and is passed to the runner as `input`. Context is read for each event, so a context provider returns current state.
359
+
360
+ ```ts
361
+ type StartResult = {
362
+ active: string[]
363
+ inactive: Array<{ binding: string; reason: 'unsupported-source' }>
364
+ validation: ValidationResult
365
+ }
366
+ ```
367
+
368
+ `start()` registers actions and conditions, validates and loads configuration. Bindings are not installed after failed validation. Calling `start()` again replaces existing bindings. `stop()` releases only subscriptions owned by the current chain.
369
+
370
+ `onRunnerError` in `FlowOptions` is called only when final `RunResult.status === 'failed'`. The callback receives `error`, `result`, `binding`, `entrypoint`, `runId`, and optional `key`. An error recovered by a strategy through `catch` does not invoke `onRunnerError`.
371
+
372
+ ### Concurrency
373
+
374
+ Each binding supports `parallel`, `latest`, `queue`, and `drop`. The default mode is `parallel`. Concurrency applies to one binding and lane; `key(payload)` creates independent lanes.
375
+
376
+ ```ts
377
+ type ConcurrencyOptions<TPayload> = {
378
+ mode?: 'parallel' | 'latest' | 'queue' | 'drop'
379
+ key?: (payload: TPayload) => string
380
+ maxQueueSize?: number
381
+ overflow?: 'drop-oldest' | 'drop-newest'
382
+ }
383
+ ```
384
+
385
+ Options are set globally in `createFlow` and can be overridden by a binding. `queue` is limited by `maxQueueSize`, which defaults to `50`. On overflow, Slapflow publishes `slapflow.queue.overflow` and `slapflow.run.dropped`.
386
+
387
+ `ActionArgs` and `Runtime` contain `signal: AbortSignal`. `latest` aborts the previous run in the same lane. `flow.stop({ force: true })` aborts every active run; normal `stop()` removes bindings and does not cancel running actions. Abort is cooperative: an action uses the signal for fetches, timers, and its own asynchronous work.
388
+
389
+ Lifecycle diagnostics are published through the configured bus:
390
+
391
+ - `slapflow.run.started`;
392
+ - `slapflow.run.finished`;
393
+ - `slapflow.run.failed`;
394
+ - `slapflow.run.cancelled`;
395
+ - `slapflow.run.dropped`;
396
+ - `slapflow.queue.overflow`.
397
+
398
+ ### DOM Bindings
399
+
400
+ A DOM binding key uses the `[dom] <css-selector>:<event>` format. Slapflow installs a delegated listener on `options.root` or `document`. In a runtime without DOM, the binding is added to `inactive` with reason `dom-unavailable`.
401
+
402
+ ```ts
403
+ '[dom] .app-button[type="submit"]:click': {
404
+ entrypoint: 'form.submit',
405
+ options: {
406
+ preventDefault: true,
407
+ stopPropagation: false,
408
+ capture: false,
409
+ once: false,
410
+ concurrency: { mode: 'drop' },
411
+ input: ({ event, element, defaultInput }) => defaultInput,
412
+ },
413
+ }
414
+ ```
415
+
416
+ `defaultInput` has type `{ type, value?, dataset, form? }`. `dataset` contains all `data-*` attributes from the matching element as camelCase keys. `form` is built from the nearest `<form>`; repeated form entries become arrays and `File` remains `File`. For `submit`, `preventDefault` defaults to `true`; for other events, it and `stopPropagation` default to `false`.
417
+
418
+ ### WebSocket Bridge
419
+
420
+ `createWS` connects a bus to a WebSocket-like transport. The bridge accepts `createSocket`, so it works with browser WebSocket and a server adapter alike.
421
+
422
+ ```ts
423
+ const ws = createWS({
424
+ bus,
425
+ createSocket: () => new WebSocket(url),
426
+ inboundTopics: ['order.created'],
427
+ outboundTopics: ['slapflow.run.finished'],
428
+ origin: 'worker',
429
+ retry: { initialDelay: 500, maxDelay: 10_000, multiplier: 2, jitter: true, maxAttempts: 5 },
430
+ })
431
+
432
+ ws.start()
433
+ ```
434
+
435
+ Inbound topics pass an explicit allowlist. The bridge parses a JSON envelope and calls `bus.dispatch(event)`, preserving `id`, `occurredAt`, `origin`, `parsed`, and `serialized`; it remembers accepted inbound IDs and does not send them back outbound. Outbound topics send the complete event envelope as JSON, so the remote bridge can dispatch it without recreating its identity. `maxAttempts` limits reconnects; omitting it retries indefinitely. `start`, `stop`, `reconnect`, and `status` manage the transport lifecycle. Diagnostics: `slapflow.ws.connecting`, `slapflow.ws.connected`, `slapflow.ws.disconnected`, `slapflow.ws.retrying`, and `slapflow.ws.message.rejected`.
436
+
437
+ ## Safety Limits
438
+
439
+ Defaults:
440
+
441
+ - `maxStepCount`: `1000`
442
+ - `maxDepth`: `32`
443
+ - `timeout`: `0`
444
+ - `trace`: `false`
445
+
446
+ Limit failures are returned as failed results with `MAX_STEPS`, `MAX_DEPTH`, and `TIMEOUT` codes.
447
+
448
+ Set `maxStepCount` or `maxDepth` to `-1` to disable that check. Validation emits a `LIMIT_DISABLED` warning because unbounded runs may execute indefinitely and unbounded nesting may exhaust the call stack.