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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Sergey Khalilov
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
15
+ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # Slapflow
2
+
3
+ When the same business flow can start from a form, an API route, a job, or a WebSocket message, its control flow tends to spread across the application. Slapflow gives that flow one explicit home: a chain of ordinary TypeScript functions.
4
+
5
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/slapflow?label=bundle%20size)](https://bundlephobia.com/package/slapflow)
6
+ [![Socket security](https://socket.dev/api/badge/npm/package/slapflow/1.0.2)](https://socket.dev/npm/package/slapflow/overview/1.0.2)
7
+
8
+ Slapflow takes care of orchestration, concurrency, cancellation, and diagnostics. Your application keeps ownership of its domain state and side effects.
9
+
10
+ ## Why use it?
11
+
12
+ - **Keep the business flow visible.** Put a scenario in one chain instead of hiding it among UI handlers, transport callbacks, and service code.
13
+ - **Test the scenario without the surrounding application.** Pass in context and events; actions and conditions are just TypeScript functions.
14
+ - **Make async behavior deliberate.** Choose `parallel`, `latest`, `queue`, or `drop` for each event source. Actions receive an `AbortSignal` when cancellation matters.
15
+ - **Use the same flow in more than one place.** The chain can start from a typed bus, DOM event, API callback, timer, worker, or WebSocket message.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install slapflow
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ This example handles an order submission from a typed event bus. The `latest` mode cancels a previous submission for the same order when a newer event arrives.
26
+
27
+ ```ts
28
+ import { createFlow, createPubSub } from 'slapflow'
29
+
30
+ type Context = {
31
+ orders: Map<string, { id: string; status: 'draft' | 'submitted' }>
32
+ }
33
+
34
+ type Events = {
35
+ 'order.submit': { orderId: string }
36
+ }
37
+
38
+ const bus = createPubSub<Events>()
39
+ const context: Context = {
40
+ orders: new Map([['order-1', { id: 'order-1', status: 'draft' }]]),
41
+ }
42
+
43
+ const flow = createFlow<Context, unknown, Events>(
44
+ {
45
+ events: {
46
+ '[bus] order.submit': {
47
+ entrypoint: 'order.submit',
48
+ options: {
49
+ concurrency: {
50
+ mode: 'latest',
51
+ key: ({ orderId }) => orderId,
52
+ },
53
+ },
54
+ },
55
+ },
56
+ actions: {
57
+ 'order.submit': ({ context, input }) => {
58
+ const order = context.orders.get(input.orderId as string)
59
+
60
+ if (order) {
61
+ order.status = 'submitted'
62
+ }
63
+ },
64
+ },
65
+ config: {
66
+ entrypoints: { 'order.submit': 'order.submit' },
67
+ strategies: { 'order.submit': { fn: 'order.submit' } },
68
+ },
69
+ },
70
+ { bus, context }
71
+ )
72
+
73
+ flow.start()
74
+ bus.emit('order.submit', { orderId: 'order-1' }, { origin: 'api' })
75
+ ```
76
+
77
+ ## Fetch data with cancellation and retries
78
+
79
+ `core.fetch` uses the run `AbortSignal`, retries transient failures, and keeps the response available to the next strategy without coupling the flow to a specific HTTP client.
80
+
81
+ ```ts
82
+ const config = {
83
+ strategies: {
84
+ 'catalog.load': {
85
+ fn: 'core.fetch',
86
+ props: {
87
+ url: '/api/catalog',
88
+ response: 'json',
89
+ dataPath: 'catalogResponse',
90
+ retry: { maxAttempts: 2, initialDelay: 250, maxDelay: 1_000 },
91
+ },
92
+ then: ['catalog.apply'],
93
+ },
94
+ 'catalog.apply': { fn: 'catalog.apply' },
95
+ },
96
+ }
97
+
98
+ const applyCatalog = ({ runtime }) => {
99
+ const { body } = runtime.data.get('catalogResponse')
100
+
101
+ // Update your application state with body.
102
+ }
103
+ ```
104
+
105
+ ## Route branches with compound conditions
106
+
107
+ Every `then` and `catch` target can have its own condition. Combine built-in conditions with `and`, `or`, and `not` to keep branching in the graph:
108
+
109
+ ```ts
110
+ const config = {
111
+ strategies: {
112
+ 'catalog.load': {
113
+ fn: 'core.fetch',
114
+ props: {
115
+ url: '/api/catalog',
116
+ response: 'json',
117
+ dataPath: 'catalogResponse',
118
+ },
119
+ then: [
120
+ {
121
+ strategy: 'catalog.apply',
122
+ when: [
123
+ 'and',
124
+ ['typeIs', '$data.catalogResponse.body', 'record'],
125
+ ['typeIs', '$data.catalogResponse.body.items', 'array'],
126
+ ['not', ['empty', '$data.catalogResponse.body.items']],
127
+ ],
128
+ },
129
+ {
130
+ strategy: 'catalog.showEmpty',
131
+ when: ['or', ['missing', '$data.catalogResponse.body.items'], ['empty', '$data.catalogResponse.body.items']],
132
+ },
133
+ ],
134
+ catch: [
135
+ {
136
+ strategy: 'catalog.queueRetry',
137
+ when: ['and', ['falsy', '$context.network.online'], ['includes', ['startup', 'refresh'], '$input.source']],
138
+ },
139
+ {
140
+ strategy: 'catalog.showError',
141
+ when: ['or', ['truthy', '$context.network.online'], ['eq', '$input.source', 'manual']],
142
+ },
143
+ ],
144
+ },
145
+ 'catalog.apply': { fn: 'catalog.apply' },
146
+ 'catalog.showEmpty': { fn: 'catalog.showEmpty' },
147
+ 'catalog.queueRetry': { fn: 'catalog.queueRetry' },
148
+ 'catalog.showError': { fn: 'catalog.showError' },
149
+ },
150
+ }
151
+ ```
152
+
153
+ ## What Slapflow provides
154
+
155
+ - Declarative strategies, conditions, error branches, and entrypoints.
156
+ - A broad set of [built-in conditions](SPEC.md#built-in-conditions) for comparisons, type checks, collections, and compound logic.
157
+ - Typed PubSub bindings and delegated DOM bindings.
158
+ - `parallel`, `latest`, `queue`, and `drop` concurrency modes with per-entity lanes.
159
+ - A WebSocket bridge for forwarding selected bus events.
160
+ - `core.fetch` with response parsing, cancellation, and retry backoff.
161
+ - Normalized results, execution trace, validation, and lifecycle diagnostics such as `slapflow.run.started` and `slapflow.run.failed`.
162
+ - Runtime variables for configuration values, templates, and expressions.
163
+
164
+ ## Where to go next
165
+
166
+ - Read the complete [technical specification](SPEC.md) for the runner API, built-in actions and conditions, expressions, validation, safety limits, transport behavior, and lifecycle semantics.
167
+ - Russian documentation: [README-RU.md](README-RU.md) and [SPEC-RU.md](SPEC-RU.md).
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ npm test
173
+ npm run build
174
+ npm run pack:check
175
+ ```
@@ -0,0 +1,18 @@
1
+ flowchart LR
2
+ DOM["DOM event"] --> Slapflow["Slapflow binding"]
3
+ SYNTHETIC["Synthetic event<br/>API, timer, worker"] --> BUS["PubSubBehavior"]
4
+ TRANSPORT["WebSocket transport"] --> WS["WebSocket bridge"]
5
+ WS --> BUS
6
+ BUS --> WS
7
+ WS --> TRANSPORT
8
+ BUS --> Slapflow
9
+
10
+ Slapflow --> CONCURRENCY["Concurrency lane"]
11
+ CONCURRENCY --> RUNNER["Behavior runner"]
12
+ RUNNER --> CONDITIONS["Conditions"]
13
+ CONDITIONS --> ACTIONS["Actions"]
14
+ ACTIONS --> RESULT["Run result"]
15
+
16
+ RUNNER --> DIAGNOSTICS["slapflow.* diagnostics"]
17
+ DIAGNOSTICS --> BUS
18
+ RESULT --> DIAGNOSTICS
package/SPEC-RU.md ADDED
@@ -0,0 +1,448 @@
1
+ # Спецификация Slapflow
2
+
3
+ `slapflow` — npm-пакет для декларативного выполнения синхронных и асинхронных действий в упорядоченных цепочках с условиями выполнения, резервными ветками, трассировкой и ограничениями безопасности.
4
+
5
+ Пакет не привязан к интерфейсу, серверному фреймворку, планировщику или модели предметной области. Приложение регистрирует действия и условия, передаёт контекст и входные данные, а исполнитель возвращает результат выполнения цепочки.
6
+
7
+ ## Поток выполнения
8
+
9
+ [Посмотреть схему потока выполнения](RUNTIME-FLOW.mmd).
10
+
11
+ ## Публичный 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
+ ## Основные типы
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
+ ## Сообщение об ошибках
62
+
63
+ Исполнитель работает как декларативный конвейер try/catch: действие может вернуть `runtime.fail(...)` или выбросить исключение, стратегия может определить `catch`, а приложение — централизованно сообщать об ошибках через `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` получает `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` определяет фазу цепочки:
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
+ Если ошибка обработана через `catch`, `onError` всё равно вызывается для исходного сбоя, а итоговый `run` может завершиться со статусом `success`.
115
+
116
+ ## Модель реестров
117
+
118
+ Исполнитель использует собственные реестры:
119
+
120
+ ```text
121
+ src/registry/
122
+ actions.ts
123
+ conditions.ts
124
+ ```
125
+
126
+ `createActionsRegistry()` создаёт `Map`, предварительно заполненный встроенными действиями.
127
+
128
+ `createConditionsRegistry()` создаёт `Map`, предварительно заполненный встроенными условиями.
129
+
130
+ Каждый исполнитель получает собственную изменяемую копию реестра. Приложения могут переопределить любое встроенное действие или условие:
131
+
132
+ ```ts
133
+ runner.registerAction('app.setData', customSetData)
134
+ runner.registerCondition('eq', customEq)
135
+ ```
136
+
137
+ Таким образом, встроенные элементы являются значениями по умолчанию, а не отдельным неизменяемым слоем.
138
+
139
+ Проверка конфигурации обращается к реестрам через минимальный контракт `has(name)`.
140
+
141
+ ## Встроенные действия
142
+
143
+ | Действие | Props | Описание |
144
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
145
+ | `core.noop` | — | Успешно завершается, не изменяя состояние runtime. |
146
+ | `core.stop` | `reason?` | Останавливает запуск с необязательной причиной. |
147
+ | `core.fail` | `reason?`, `data?` | Завершает текущую стратегию ошибкой с необязательной причиной и данными ошибки. |
148
+ | `core.fetch` | **`url`**, `method?`, `headers?`, `body?`, `credentials?`, `response?`, `dataPath?`, `contextPath?`, `acceptStatuses?`, `retryStatuses?`, `retry?` | Загружает данные с отменой, разбором ответа, контролем статусов и retry backoff. |
149
+ | `core.loop` | `duration?`, `max?`, `immediate?` | Повторяет ветку `then` по интервалу до отмены или достижения лимита итераций. |
150
+ | `core.sequence` | — | Выполняет цели `then` по порядку. |
151
+ | `core.selector` | — | Выполняет цели `then` до первого успешного результата или остановки. |
152
+ | `core.parallel` | — | Выполняет цели `then` параллельно в изолированных ветках context и data. |
153
+ | `core.set` | **`path`**, `value?`, `data?` | Записывает `value` во вложенный путь context; необязательный `data` объединяется с runtime data. |
154
+ | `core.setData` | **`path`**, `value?`, `data?` | **Устарело.** Записывает `value` в runtime data; в прикладном действии используйте `runtime.data.set(path, value)`. |
155
+ | `core.emit` | **`type`**, `payload?` | Добавляет событие в результат запуска. |
156
+ | `core.patch` | **`patch`** | Добавляет patch в результат запуска. |
157
+ | `core.delay` | `ms?` | Ждёт указанное время или отмену запуска. |
158
+
159
+ Жирным отмечены обязательные props; `?` обозначает необязательные. Все имена в этой колонке являются полями объекта `props` стратегии.
160
+
161
+ `core.loop` выполняет ветку `then` каждые `props.duration` миллисекунд до отмены запуска или завершения `props.max` итераций. Максимум по умолчанию — `999`: один из стандартных `maxStepCount: 1000` шагов расходуется на сам loop action. Значение `max: -1` отключает ограничение количества итераций, но не safety limits runner-а. Ноль, значения меньше `-1`, `NaN` и бесконечность заменяются значением по умолчанию. Если `props.immediate` равен `true`, первая итерация выполняется сразу, учитывается в `max` и не ждёт первого интервала. Пересекающиеся итерации пропускаются. При ошибке итерации выполняется `catch`; после успешного `catch` цикл продолжается.
162
+ Вложенные стратегии `core.loop` запрещены, включая транзитивные ссылки через `then` или `catch`. Соседние циклы в отдельных ветках разрешены.
163
+
164
+ Экшены могут выполнять собственные настроенные ветки через `runtime.executeThen()` и `runtime.executeCatch()`. `executeThen()` учитывает `mode` стратегии, поэтому управляющие экшены вроде `core.loop` могут компоноваться с выполнением `sequence`, `selector` и `parallel`, не обращаясь к внутренностям runner.
165
+
166
+ `core.set` записывает вложенное значение контекста через `runtime.set`. `core.setData` сохранён для совместимости; новые прикладные действия должны записывать временные данные цепочки через `runtime.data.set(path, value)`.
167
+
168
+ `core.fetch` использует нативный `fetch` с signal текущего запуска. Свойство `response` выбирает `json`, `text`, `blob`, `arrayBuffer` или `none`; успешный ответ нормализуется в `{ status, ok, headers, body }` и может быть записан по `dataPath` или `contextPath`. `acceptStatuses` переопределяет стандартную проверку успеха через `Response.ok`. `credentials` принимает `include`, `same-origin` или `omit` и передаётся в нативный `fetch`. CORS, preflight-запросы, правила SameSite cookie и политика cookie сервера остаются ответственностью браузера и сервера. `retry` принимает `initialDelay`, `maxDelay`, `multiplier`, `jitter` и `maxAttempts`; `retryStatuses` переопределяет стандартный набор повторяемых статусов. По умолчанию выполняются две повторные попытки для сетевых ошибок и статусов `408`, `425`, `429` и `5xx`. Ошибки разбора response body не повторяются. Отменённый запрос или retry возвращает `skip`. Ретраи предназначены для body, который можно безопасно повторно отправить.
169
+
170
+ ## Встроенные условия
171
+
172
+ | Условие | Описание | Пример |
173
+ | --------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
174
+ | `and` | Совпадает, когда совпали все вложенные условия. | `['and', ['typeIs', '$input.id', 'string'], ['notEmpty', '$input.id']]` |
175
+ | `or` | Совпадает, когда совпало хотя бы одно вложенное условие. | `['or', ['eq', '$context.status', 'ready'], ['eq', '$context.status', 'idle']]` |
176
+ | `not` | Инвертирует вложенное условие. | `['not', ['truthy', '$context.disabled']]` |
177
+ | `eq` | Сравнивает два значения через `Object.is`. | `['eq', '$context.status', 'ready']` |
178
+ | `neq` | Совпадает, когда `Object.is` не считает значения равными. | `['neq', '$context.status', 'failed']` |
179
+ | `gt` | Численно сравнивает значения через `>`. | `['gt', '$context.count', 0]` |
180
+ | `gte` | Численно сравнивает значения через `>=`. | `['gte', '$context.count', 1]` |
181
+ | `lt` | Численно сравнивает значения через `<`. | `['lt', '$context.count', 100]` |
182
+ | `lte` | Численно сравнивает значения через `<=`. | `['lte', '$context.count', 99]` |
183
+ | `truthy` | Применяет JavaScript truthiness. | `['truthy', '$context.enabled']` |
184
+ | `falsy` | Применяет JavaScript falsiness. | `['falsy', '$context.disabled']` |
185
+ | `exists` | Совпадает для значений, отличных от `null` и `undefined`. | `['exists', '$data.response']` |
186
+ | `missing` | Совпадает для `null` или `undefined`. | `['missing', '$data.error']` |
187
+ | `empty` | Совпадает для пустых строк, массивов, map, set, объектов и nullish-значений. | `['empty', '$context.items']` |
188
+ | `notEmpty` | Совпадает для поддерживаемых значений с размером больше нуля. | `['notEmpty', '$context.items']` |
189
+ | `includes` | Проверяет вхождение в строки, массивы и set. | `['includes', ['parts', 'food'], '$input.resource']` |
190
+ | `typeIs` | Совпадает с `string`, `number`, `finite-number`, `boolean`, `array` или `record`. | `['typeIs', '$input.amount', 'finite-number']` |
191
+ | `changed` | Совпадает, когда текущее и предыдущее значения различаются по `Object.is`. | `['changed', '$context.current', '$context.previous']` |
192
+ | `cooldownReady` | Совпадает, когда предыдущей метки времени нет или задержка истекла. | `['cooldownReady', '$context.now', '$context.lastAt', 1000]` |
193
+
194
+ ## Пример конфигурации
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
+ ## Режимы выполнения
221
+
222
+ `sequence` выполняет цели `then` по порядку.
223
+
224
+ `selector` выполняет цели `then` до первого успешного или остановленного шага. `skip` означает «попробовать следующий вариант».
225
+
226
+ `parallel` запускает цели `then` независимо. Простые объекты и массивы контекста и runtime data копируются для каждой ветки; инфраструктурные значения вроде функций, DOM-узлов и экземпляров классов остаются ссылками. Safety limits, включая `maxStepCount`, остаются общими для всего запуска. Полученные патчи и события возвращаются вызывающей стороне; исполнитель их не применяет.
227
+
228
+ ## Вспомогательные средства среды выполнения
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 Используйте runtime.data.get(path). */
242
+ getData(path: string): unknown
243
+ /** @deprecated Используйте 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` и `runtime.set` читают и записывают вложенные значения контекста. `runtime.data.get` и `runtime.data.set` читают и записывают временные данные цепочки.
257
+
258
+ `runtime.getData` и `runtime.setData` сохранены как устаревшие алиасы для совместимости и при вызове выводят предупреждение в консоль.
259
+
260
+ `runtime.variables.get` читает неизменяемые runtime-переменные. `runtime.resolve` разрешает ссылки `$context.*`, `$data.*`, `$input.*` и неизменяемые значения `$variables.*`. Он также рекурсивно вычисляет объекты `$expression` и `$template`, используя операторы выражений, зарегистрированные в опциях runner. В `$template` для совместимости `{{ path }}` читает runtime data; `{{ data.path }}`, `{{ context.path }}` и `{{ input.path }}` явно выбирают источник.
261
+
262
+ Чтение и запись путей во время выполнения реализованы непосредственно через `objwalk`.
263
+
264
+ ## Проверка конфигурации
265
+
266
+ `validateConfig` проверяет:
267
+
268
+ - неизвестные действия через `actionsRegistry.has(fn)`;
269
+ - неизвестные операторы условий через `conditionsRegistry.has(operator)`;
270
+ - отсутствующие стратегии в `then`, `catch` и `entrypoints`;
271
+ - недопустимые режимы;
272
+ - недопустимые ссылки на пути;
273
+ - циклы без завершающего шага.
274
+
275
+ ## Трассировка
276
+
277
+ Записи трассировки содержат:
278
+
279
+ - шаг и глубину (`step`/`depth`);
280
+ - стратегию, функцию и режим (`strategy`/`fn`/`mode`);
281
+ - статус (`status`);
282
+ - входные данные (`input`);
283
+ - свойства (`props`);
284
+ - данные до и после (`dataBefore`/`dataAfter`);
285
+ - длительность (`durationMs`);
286
+ - причину (`reason`).
287
+
288
+ Трассировка не хранит полный снимок контекста.
289
+
290
+ ## Шина публикации и подписки
291
+
292
+ `PubSub` — локальная для процесса шина событий-одиночка. Для изолированных сред выполнения используйте `createPubSub`.
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` создаёт конверт и сериализует полезную нагрузку один раз до запуска подписчиков. Идентификаторы событий — непрозрачные 12-символьные буквенно-цифровые runtime-ID для корреляции и подавления эха. Они не криптографически стойкие: не используйте их для access token, подписей, публичных ссылок или иных security-sensitive задач. `on` возвращает функцию отписки. `off(event, handler)` удаляет один обработчик, а `off(event)` очищает канал. Ошибка одного подписчика не блокирует остальных; `createPubSub({ onError })` получает ошибку и исходное событие. При ошибке сериализации шина передаёт `{ error }` в качестве `parsed` и тело ошибки в качестве `serialized`, после чего вызывает `onError` с исходной причиной.
334
+
335
+ ## Поток
336
+
337
+ `createFlow` объединяет конфигурацию, действия, условия, поставщик контекста и привязки событий. Функция создаёт исполнитель (доступный через `flow.runner`) и поддерживает жизненный цикл `start`/`stop`.
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
+ Привязка `[bus] <event-name>` запускает `entrypoint` из `config.entrypoints`. Полезная нагрузка события должна быть объектом и передаётся исполнителю как `input`. Контекст считывается для каждого события, поэтому поставщик контекста возвращает актуальное состояние.
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()` регистрирует действия и условия, проверяет и загружает конфигурацию. Если проверка завершилась ошибкой, привязки не устанавливаются. Повторный вызов `start()` заменяет существующие привязки. `stop()` освобождает только подписки, принадлежащие текущему поведению.
369
+
370
+ `onRunnerError` в `FlowOptions` вызывается только тогда, когда итоговый `RunResult.status === 'failed'`. Колбэк получает `error`, `result`, `binding`, `entrypoint`, `runId` и необязательный `key`. Ошибка, обработанная стратегией через `catch`, не вызывает `onRunnerError`.
371
+
372
+ ### Конкурентное выполнение
373
+
374
+ Каждая привязка поддерживает `parallel`, `latest`, `queue` и `drop`. Режим по умолчанию — `parallel`. Управление конкурентностью действует в пределах одной привязки и линии; `key(payload)` создаёт независимые линии.
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
+ Параметры задаются глобально в `createFlow` и могут быть переопределены привязкой. Размер `queue` ограничен параметром `maxQueueSize`, который по умолчанию равен `50`. При переполнении Slapflow публикует `slapflow.queue.overflow` и `slapflow.run.dropped`.
386
+
387
+ `ActionArgs` и `Runtime` содержат `signal: AbortSignal`. Режим `latest` прерывает предыдущий запуск в той же линии. `flow.stop({ force: true })` прерывает все активные запуски; обычный `stop()` удаляет привязки, но не отменяет выполняющиеся действия. Прерывание является кооперативным: действие использует сигнал для запросов, таймеров и собственной асинхронной работы.
388
+
389
+ Диагностика жизненного цикла публикуется через настроенную шину:
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-привязки
399
+
400
+ Ключ DOM-привязки имеет формат `[dom] <css-selector>:<event>`. Slapflow устанавливает делегированный слушатель на `options.root` или `document`. В среде без DOM привязка добавляется в `inactive` с причиной `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` имеет тип `{ type, value?, dataset, form? }`. `dataset` содержит все атрибуты `data-*` совпавшего элемента в виде ключей camelCase. `form` строится по ближайшему элементу `<form>`; повторяющиеся поля формы превращаются в массивы, а `File` остаётся `File`. Для `submit` значение `preventDefault` по умолчанию равно `true`; для остальных событий оно и `stopPropagation` по умолчанию равны `false`.
417
+
418
+ ### WebSocket-мост
419
+
420
+ `createWS` подключает шину к WebSocket-подобному транспорту. Мост принимает `createSocket`, поэтому одинаково работает с браузерным WebSocket и серверным адаптером.
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
+ Входящие темы проходят через явный список разрешений. Мост разбирает JSON-конверт и вызывает `bus.dispatch(event)`, сохраняя `id`, `occurredAt`, `origin`, `parsed` и `serialized`; принятые входящие идентификаторы запоминаются и не отправляются обратно наружу. Исходящие темы отправляют полный JSON-конверт события, поэтому удалённый мост может передать его в шину, не создавая новый идентификатор. `maxAttempts` ограничивает reconnect; без него повторы идут бесконечно. `start`, `stop`, `reconnect` и `status` управляют жизненным циклом транспорта. Диагностические события: `slapflow.ws.connecting`, `slapflow.ws.connected`, `slapflow.ws.disconnected`, `slapflow.ws.retrying` и `slapflow.ws.message.rejected`.
436
+
437
+ ## Ограничения безопасности
438
+
439
+ Значения по умолчанию:
440
+
441
+ - `maxStepCount`: `1000`
442
+ - `maxDepth`: `32`
443
+ - `timeout`: `0`
444
+ - `trace`: `false`
445
+
446
+ Нарушения ограничений возвращаются как неуспешные результаты с кодами `MAX_STEPS`, `MAX_DEPTH` и `TIMEOUT`.
447
+
448
+ Значение `-1` для `maxStepCount` или `maxDepth` отключает соответствующую проверку. Валидация возвращает предупреждение `LIMIT_DISABLED`, поскольку неограниченный запуск может выполняться бесконечно, а неограниченная вложенность — исчерпать стек вызовов.