slapflow 1.1.0 → 1.2.1

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/README.md CHANGED
@@ -162,7 +162,7 @@ const config = {
162
162
  - A broad set of [built-in conditions](SPEC.md#built-in-conditions) for comparisons, type checks, collections, and compound logic.
163
163
  - Typed PubSub bindings and delegated DOM bindings.
164
164
  - `parallel`, `latest`, `queue`, and `drop` concurrency modes with per-entity lanes.
165
- - A WebSocket bridge for forwarding selected bus events.
165
+ - A native WebSocket client that proxies socket events into the bus.
166
166
  - `core.fetch` with response parsing, cancellation, and retry backoff.
167
167
  - Normalized results, execution trace, validation, and lifecycle diagnostics such as `slapflow.run.started` and `slapflow.run.failed`.
168
168
  - Runtime variables for configuration values, templates, and expressions.
package/SPEC-RU.md CHANGED
@@ -12,23 +12,20 @@
12
12
 
13
13
  ```ts
14
14
  import {
15
- createActionsRegistry,
16
- createConditionsRegistry,
15
+ BUILTIN_ACTIONS,
16
+ BUILTIN_CONDITIONS,
17
17
  createMemoryTraceSink,
18
18
  defineErrorReporter,
19
19
  createPubSub,
20
20
  PubSub,
21
21
  createFlow,
22
- createWS,
22
+ createWebSocket,
23
23
  catchError,
24
24
  } from 'slapflow'
25
25
  ```
26
26
 
27
27
  ```ts
28
- const flow = createFlow<Context, Patch>(
29
- { config: { strategies: {} } },
30
- { context: () => ({} as Context) }
31
- )
28
+ const flow = createFlow<Context, Patch>({ config: { strategies: {} } }, { context: () => ({}) as Context })
32
29
  const runner = flow.runner
33
30
 
34
31
  runner.registerAction('jobs.execute', executeJob)
@@ -48,10 +45,7 @@ type Config = {
48
45
  guards?: Record<string, ConditionExpression>
49
46
  }
50
47
 
51
- type ConditionExpression =
52
- | boolean
53
- | [operator: string, ...args: unknown[]]
54
- | ['guard', name: string]
48
+ type ConditionExpression = boolean | [operator: string, ...args: unknown[]] | ['guard', name: string]
55
49
 
56
50
  type Strategy = {
57
51
  fn: string
@@ -85,7 +79,7 @@ const reportError = defineErrorReporter({
85
79
 
86
80
  const flow = createFlow(
87
81
  { config: { strategies: {} } },
88
- { context: () => ({} as Context), trace: true, onError: reportError }
82
+ { context: () => ({}) as Context, trace: true, onError: reportError }
89
83
  )
90
84
  ```
91
85
 
@@ -123,40 +117,34 @@ type ErrorStage = {
123
117
 
124
118
  Возвращаемое значение действия нормализуется в один итог. Соответствие:
125
119
 
126
- | Возврат | Итог |
127
- | ------------------------------------------------- | ------------------------------------------------------------ |
128
- | `undefined` / `null` | `success` |
129
- | `false` | `skipped` |
130
- | `{ type: 'skip', reason?, data? }` | `skipped` (селектор пробует следующую ветку) |
131
- | `{ type: 'stop', reason?, patch?, events? }` | `stopped` (цепочка останавливается без ошибки) |
132
- | `{ type: 'fail', reason?, data?, error? }` | `failed` (запускается `catch`, затем `onError`) |
120
+ | Возврат | Итог |
121
+ | ------------------------------------------------- | ------------------------------------------------------------- |
122
+ | `undefined` / `null` | `success` |
123
+ | `false` | `skipped` |
124
+ | `{ type: 'skip', reason?, data? }` | `skipped` (селектор пробует следующую ветку) |
125
+ | `{ type: 'stop', reason?, patch?, events? }` | `stopped` (цепочка останавливается без ошибки) |
126
+ | `{ type: 'fail', reason?, data?, error? }` | `failed` (запускается `catch`, затем `onError`) |
133
127
  | `{ context?, data?, patch?, events?, continue? }` | `success`; `continue: false` прерывает оставшиеся `then`-цели |
134
128
 
135
129
  Брошенное исключение трактуется как `fail`. Возврат `false` и `{ type: 'skip' }` эквивалентны.
136
130
 
137
131
  ## Модель реестров
138
132
 
139
- Исполнитель использует собственные реестры:
133
+ Встроенные элементы живут в двух общих константах — по одной на вид:
140
134
 
141
- ```text
142
- src/registry/
143
- actions.ts
144
- conditions.ts
135
+ ```ts
136
+ import { BUILTIN_ACTIONS, BUILTIN_CONDITIONS } from 'slapflow'
145
137
  ```
146
138
 
147
- `createActionsRegistry()` создаёт `Map`, предварительно заполненный встроенными действиями.
139
+ `BUILTIN_ACTIONS` это readonly-список `[name, action][]`, предварительно заполненный встроенными действиями; `BUILTIN_CONDITIONS` содержит встроенные условия. Каждый исполнитель получает собственный `new Map(BUILTIN_ACTIONS)` / `new Map(BUILTIN_CONDITIONS)`, поэтому регистрации остаются изолированными по исполнителям.
148
140
 
149
- `createConditionsRegistry()` создаёт `Map`, предварительно заполненный встроенными условиями.
150
-
151
- Каждый исполнитель получает собственную изменяемую копию реестра. Приложения могут переопределить любое встроенное действие или условие:
141
+ Встроенные элементы неизменяемы: `registerAction` и `registerCondition` отклоняют попытку переопределить встроенное имя.
152
142
 
153
143
  ```ts
154
144
  runner.registerAction('app.setData', customSetData)
155
- runner.registerCondition('eq', customEq)
145
+ runner.registerCondition('hasQueue', hasItems)
156
146
  ```
157
147
 
158
- Таким образом, встроенные элементы являются значениями по умолчанию, а не отдельным неизменяемым слоем.
159
-
160
148
  Проверка конфигурации обращается к реестрам через минимальный контракт `has(name)`.
161
149
 
162
150
  ## Встроенные действия
@@ -250,11 +238,11 @@ export const config = {
250
238
 
251
239
  Не-`success` итог шага меняет дальнейшее поведение в зависимости от режима:
252
240
 
253
- | Итог | `sequence` | `selector` |
254
- | -------- | ------------------------- | -------------------------- |
255
- | `skipped`| **прерывает остаток** | пробует следующую ветку |
241
+ | Итог | `sequence` | `selector` |
242
+ | --------- | --------------------- | ----------------------- |
243
+ | `skipped` | **прерывает остаток** | пробует следующую ветку |
256
244
 
257
- `sequence` — режим по умолчанию, и он прерывает оставшиеся `then`-цели на *любом* не-`success` (`skipped`, `stopped`, `failed`) — не только на сбое. Условный шаг внутри последовательности — это, таким образом, скрытый ранний выход для всего остатка. Если пропуск шага не должен рвать цепочку, заверните его в селектор с запасным `core.noop`.
245
+ `sequence` — режим по умолчанию, и он прерывает оставшиеся `then`-цели на _любом_ не-`success` (`skipped`, `stopped`, `failed`) — не только на сбое. Условный шаг внутри последовательности — это, таким образом, скрытый ранний выход для всего остатка. Если пропуск шага не должен рвать цепочку, заверните его в селектор с запасным `core.noop`.
258
246
 
259
247
  `terminal: true` останавливает `then`-цепочку после этой стратегии даже при `success`; `continue: false` в `ActionSuccess` даёт тот же эффект.
260
248
 
@@ -399,12 +387,12 @@ type BusEvent<TPayload> = {
399
387
  На тему можно подписаться по шаблону, где `*` соответствует ровно одному сегменту, разделённому точкой. Шаблонный символ не пересекает границу `.`.
400
388
 
401
389
  ```ts
402
- bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
403
- bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
404
- bus.on('hub.*.export', ({ parsed }) => {}) // НЕ hub.user.audit.export (один сегмент)
390
+ bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
391
+ bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
392
+ bus.on('hub.*.export', ({ parsed }) => {}) // НЕ hub.user.audit.export (один сегмент)
405
393
  ```
406
394
 
407
- Подписки с точным именем остаются O(1); шаблоны обрабатываются отдельно, поэтому регистрации без `*` не несут накладных затрат на сравнение. Обработчик шаблона получает `parsed` как `unknown` — сужайте тип перед использованием. Шаблоны работают в `bus.on`/`bus.off`, а также в `inboundTopics`/`outboundTopics` у `createWS`.
395
+ Подписки с точным именем остаются O(1); шаблоны обрабатываются отдельно, поэтому регистрации без `*` не несут накладных затрат на сравнение. Обработчик шаблона получает `parsed` как `unknown` — сужайте тип перед использованием. Шаблоны работают в `bus.on`/`bus.off`.
408
396
 
409
397
  ## Поток
410
398
 
@@ -489,24 +477,30 @@ type ConcurrencyOptions<TPayload> = {
489
477
 
490
478
  `defaultInput` имеет тип `{ type, value?, dataset, form? }`. `dataset` содержит все атрибуты `data-*` совпавшего элемента в виде ключей camelCase. `form` строится по ближайшему элементу `<form>`; повторяющиеся поля формы превращаются в массивы, а `File` остаётся `File`. Для `submit` значение `preventDefault` по умолчанию равно `true`; для остальных событий оно и `stopPropagation` по умолчанию равны `false`.
491
479
 
492
- ### WebSocket-мост
480
+ ### WebSocket-клиент
493
481
 
494
- `createWS` подключает шину к WebSocket-подобному транспорту. Мост принимает `createSocket`, поэтому одинаково работает с браузерным WebSocket и серверным адаптером.
482
+ `createWebSocket` открывает нативный `WebSocket` по `url` и проксирует каждое событие сокета в шину. Формат провода не предполагается — каждое событие отправляется с фиксированной темой и конвертом, где `parsed` содержит сырую полезную нагрузку.
495
483
 
496
484
  ```ts
497
- const ws = createWS({
485
+ const socket = createWebSocket({
486
+ url,
498
487
  bus,
499
- createSocket: () => new WebSocket(url),
500
- inboundTopics: ['order.created'],
501
- outboundTopics: ['slapflow.run.finished'],
502
- origin: 'worker',
503
- retry: { initialDelay: 500, maxDelay: 10_000, multiplier: 2, jitter: true, maxAttempts: 5 },
488
+ origin: 'client',
504
489
  })
505
490
 
506
- ws.start()
491
+ socket.start()
507
492
  ```
508
493
 
509
- Входящие темы проходят через явный список разрешений. Каждая запись может быть точной темой или шаблоном, где `*` соответствует одному сегменту, разделённому точкой. Мост разбирает 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`.
494
+ ```ts
495
+ bus.on('message', ({ parsed }) => {}) // parsed = сырое сообщение (JSON-декодированное, когда возможно)
496
+ bus.on('open', ({ parsed }) => {}) // { url }
497
+ bus.on('close', ({ parsed }) => {}) // { code, reason }
498
+ bus.on('error', ({ parsed }) => {}) // { error }
499
+ ```
500
+
501
+ События сокета приходят с темой `open`, `message`, `close` или `error`. Полезная нагрузка `message` JSON-декодируется в `parsed`, если валидна, иначе остаётся сырой строкой. Фильтрация тем — забота потребителя: внутри клиента ничего не разрешается и не отклоняется. `reconnect` принимает `initialDelay`, `maxDelay`, `multiplier`, `jitter` и `maxAttempts`; без `maxAttempts` повторы идут бесконечно. `start`, `stop`, `reconnect` и `status` управляют жизненным циклом; статус — одно из `idle`, `connecting`, `connected`, `reconnecting` или `stopped`.
502
+
503
+ `createWS` помечен как deprecated и будет удалён; мигрируйте на `createWebSocket`.
510
504
 
511
505
  ## Ограничения безопасности
512
506
 
package/SPEC.md CHANGED
@@ -12,23 +12,20 @@ The package is not coupled to a UI, server framework, scheduler, or domain model
12
12
 
13
13
  ```ts
14
14
  import {
15
- createActionsRegistry,
16
- createConditionsRegistry,
15
+ BUILTIN_ACTIONS,
16
+ BUILTIN_CONDITIONS,
17
17
  createMemoryTraceSink,
18
18
  defineErrorReporter,
19
19
  createPubSub,
20
20
  PubSub,
21
21
  createFlow,
22
- createWS,
22
+ createWebSocket,
23
23
  catchError,
24
24
  } from 'slapflow'
25
25
  ```
26
26
 
27
27
  ```ts
28
- const flow = createFlow<Context, Patch>(
29
- { config: { strategies: {} } },
30
- { context: () => ({} as Context) }
31
- )
28
+ const flow = createFlow<Context, Patch>({ config: { strategies: {} } }, { context: () => ({}) as Context })
32
29
  const runner = flow.runner
33
30
 
34
31
  runner.registerAction('jobs.execute', executeJob)
@@ -48,10 +45,7 @@ type Config = {
48
45
  guards?: Record<string, ConditionExpression>
49
46
  }
50
47
 
51
- type ConditionExpression =
52
- | boolean
53
- | [operator: string, ...args: unknown[]]
54
- | ['guard', name: string]
48
+ type ConditionExpression = boolean | [operator: string, ...args: unknown[]] | ['guard', name: string]
55
49
 
56
50
  type Strategy = {
57
51
  fn: string
@@ -85,7 +79,7 @@ const reportError = defineErrorReporter({
85
79
 
86
80
  const flow = createFlow(
87
81
  { config: { strategies: {} } },
88
- { context: () => ({} as Context), trace: true, onError: reportError }
82
+ { context: () => ({}) as Context, trace: true, onError: reportError }
89
83
  )
90
84
  ```
91
85
 
@@ -123,40 +117,34 @@ If an error is recovered through `catch`, `onError` is still invoked for the ori
123
117
 
124
118
  An action's return value is normalized into one outcome. The mapping:
125
119
 
126
- | Return | Outcome |
127
- | ------------------------------------------------- | -------------------------------------------------------------- |
128
- | `undefined` / `null` | `success` |
129
- | `false` | `skipped` |
130
- | `{ type: 'skip', reason?, data? }` | `skipped` (a selector tries the next branch) |
131
- | `{ type: 'stop', reason?, patch?, events? }` | `stopped` (the chain halts without error) |
132
- | `{ type: 'fail', reason?, data?, error? }` | `failed` (`catch` runs, then `onError`) |
120
+ | Return | Outcome |
121
+ | ------------------------------------------------- | --------------------------------------------------------------- |
122
+ | `undefined` / `null` | `success` |
123
+ | `false` | `skipped` |
124
+ | `{ type: 'skip', reason?, data? }` | `skipped` (a selector tries the next branch) |
125
+ | `{ type: 'stop', reason?, patch?, events? }` | `stopped` (the chain halts without error) |
126
+ | `{ type: 'fail', reason?, data?, error? }` | `failed` (`catch` runs, then `onError`) |
133
127
  | `{ context?, data?, patch?, events?, continue? }` | `success`; `continue: false` halts the remaining `then` targets |
134
128
 
135
129
  A thrown exception is treated as `fail`. Returning `false` and `{ type: 'skip' }` are equivalent.
136
130
 
137
131
  ## Registry Model
138
132
 
139
- The runner uses runner-scoped registries:
133
+ Built-ins live in two shared constants, one per kind:
140
134
 
141
- ```text
142
- src/registry/
143
- actions.ts
144
- conditions.ts
135
+ ```ts
136
+ import { BUILTIN_ACTIONS, BUILTIN_CONDITIONS } from 'slapflow'
145
137
  ```
146
138
 
147
- `createActionsRegistry()` creates a `Map` prepopulated with built-in actions.
139
+ `BUILTIN_ACTIONS` is a readonly `[name, action][]` list prepopulated with built-in actions; `BUILTIN_CONDITIONS` holds built-in conditions. Each runner receives its own `new Map(BUILTIN_ACTIONS)` / `new Map(BUILTIN_CONDITIONS)` seed, so registrations stay isolated per runner.
148
140
 
149
- `createConditionsRegistry()` creates a `Map` prepopulated with built-in conditions.
150
-
151
- Each runner receives its own mutable registry copy. Applications can override any built-in action or condition:
141
+ Built-ins are immutable defaults: `registerAction` and `registerCondition` reject an attempt to override a built-in name.
152
142
 
153
143
  ```ts
154
144
  runner.registerAction('app.setData', customSetData)
155
- runner.registerCondition('eq', customEq)
145
+ runner.registerCondition('hasQueue', hasItems)
156
146
  ```
157
147
 
158
- Built-ins are therefore default values, not a separate immutable layer.
159
-
160
148
  Configuration validation accesses registries through the minimal `has(name)` contract.
161
149
 
162
150
  ## Built-In Actions
@@ -250,11 +238,11 @@ export const config = {
250
238
 
251
239
  A non-`success` outcome at a step changes what happens next, depending on the mode:
252
240
 
253
- | Outcome | `sequence` | `selector` |
254
- | --------- | ---------------------- | ------------------------ |
255
- | `skipped` | **interrupts the rest** | tries the next branch |
241
+ | Outcome | `sequence` | `selector` |
242
+ | --------- | ----------------------- | --------------------- |
243
+ | `skipped` | **interrupts the rest** | tries the next branch |
256
244
 
257
- `sequence` is the default mode and interrupts the remaining `then` targets on *any* non-`success` (`skipped`, `stopped`, `failed`) — not only on failure. A conditional step inside a sequence is therefore a hidden early exit for the whole remainder. When skipping a step must not break the chain, wrap it in a selector with a `core.noop` fallback.
245
+ `sequence` is the default mode and interrupts the remaining `then` targets on _any_ non-`success` (`skipped`, `stopped`, `failed`) — not only on failure. A conditional step inside a sequence is therefore a hidden early exit for the whole remainder. When skipping a step must not break the chain, wrap it in a selector with a `core.noop` fallback.
258
246
 
259
247
  `terminal: true` stops the `then` chain after that strategy even on `success`; `continue: false` in `ActionSuccess` has the same effect.
260
248
 
@@ -399,12 +387,12 @@ type BusEvent<TPayload> = {
399
387
  A topic may be subscribed by pattern, using `*` to match exactly one dot-delimited segment. A wildcard does not cross a `.` boundary.
400
388
 
401
389
  ```ts
402
- bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
403
- bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
404
- bus.on('hub.*.export', ({ parsed }) => {}) // NOT hub.user.audit.export (wildcard spans one segment)
390
+ bus.on('hub.user.*', ({ parsed }) => {}) // hub.user.created, hub.user.deleted
391
+ bus.on('hub.*.created', ({ parsed }) => {}) // hub.user.created, hub.team.created
392
+ bus.on('hub.*.export', ({ parsed }) => {}) // NOT hub.user.audit.export (wildcard spans one segment)
405
393
  ```
406
394
 
407
- Exact-name subscriptions stay O(1); wildcard patterns are matched separately, so registrations without `*` pay no matching cost. A wildcard handler receives `parsed` as `unknown` — narrow it before use. Wildcards work in `bus.on`/`bus.off` and in `inboundTopics`/`outboundTopics` of `createWS`.
395
+ Exact-name subscriptions stay O(1); wildcard patterns are matched separately, so registrations without `*` pay no matching cost. A wildcard handler receives `parsed` as `unknown` — narrow it before use. Wildcards work in `bus.on`/`bus.off`.
408
396
 
409
397
  ## Flow
410
398
 
@@ -489,24 +477,30 @@ A DOM binding key uses the `[dom] <css-selector>:<event>` format. Slapflow insta
489
477
 
490
478
  `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`.
491
479
 
492
- ### WebSocket Bridge
480
+ ### WebSocket Client
493
481
 
494
- `createWS` connects a bus to a WebSocket-like transport. The bridge accepts `createSocket`, so it works with browser WebSocket and a server adapter alike.
482
+ `createWebSocket` opens a native `WebSocket` to `url` and proxies every socket event into the bus. No wire format is assumed each event is dispatched with a fixed topic and an envelope whose `parsed` holds the raw payload.
495
483
 
496
484
  ```ts
497
- const ws = createWS({
485
+ const socket = createWebSocket({
486
+ url,
498
487
  bus,
499
- createSocket: () => new WebSocket(url),
500
- inboundTopics: ['order.created'],
501
- outboundTopics: ['slapflow.run.finished'],
502
- origin: 'worker',
503
- retry: { initialDelay: 500, maxDelay: 10_000, multiplier: 2, jitter: true, maxAttempts: 5 },
488
+ origin: 'client',
504
489
  })
505
490
 
506
- ws.start()
491
+ socket.start()
507
492
  ```
508
493
 
509
- Inbound topics pass an explicit allowlist. Each entry may be an exact topic or a wildcard pattern, where `*` matches one dot-delimited segment. 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`.
494
+ ```ts
495
+ bus.on('message', ({ parsed }) => {}) // parsed = the raw message (JSON-decoded when possible)
496
+ bus.on('open', ({ parsed }) => {}) // { url }
497
+ bus.on('close', ({ parsed }) => {}) // { code, reason }
498
+ bus.on('error', ({ parsed }) => {}) // { error }
499
+ ```
500
+
501
+ Socket events are forwarded with the topic `open`, `message`, `close`, or `error`. `message` payloads are JSON-decoded into `parsed` when valid, otherwise kept as the raw string. Filtering topics is the consumer's responsibility — nothing is allow-listed or rejected inside the client. `reconnect` accepts `initialDelay`, `maxDelay`, `multiplier`, `jitter`, and `maxAttempts`; omitting `maxAttempts` retries indefinitely. `start`, `stop`, `reconnect`, and `status` manage the lifecycle; status is one of `idle`, `connecting`, `connected`, `reconnecting`, or `stopped`.
502
+
503
+ `createWS` is deprecated and will be removed; migrate to `createWebSocket`.
510
504
 
511
505
  ## Safety Limits
512
506