@softize/opus 20.1.0 → 21.0.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/CHANGELOG.md +95 -0
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +3 -0
- package/bin/lib/introspect.mjs +5 -4
- package/docs/adr/0026-reactions-apply-declared-delivery-policy.md +123 -0
- package/docs/adr/0027-reactions-publish-declared-events-with-bounded-chains.md +96 -0
- package/docs/adr/0028-schedules-run-actions-as-the-system-by-action-opt-in.md +127 -0
- package/docs/protocol.md +60 -24
- package/package.json +1 -1
- package/src/core/audit.ts +2 -1
- package/src/core/index.ts +1 -1
- package/src/core/reactions.ts +6 -2
- package/src/core/runtime.ts +598 -70
- package/src/core/schedules.ts +3 -2
- package/src/core/types.ts +52 -7
- package/src/testing/index.ts +25 -14
- package/src/ui/docs/content/actions.md +2 -1
- package/src/ui/docs/content/audit.md +10 -0
- package/src/ui/docs/content/auth.md +5 -1
- package/src/ui/docs/content/events.md +109 -11
- package/src/ui/docs/content/runtime.md +11 -2
- package/src/ui/docs/content/scheduler.md +53 -12
- package/src/ui/docs/content/testing.md +14 -2
package/docs/protocol.md
CHANGED
|
@@ -98,7 +98,7 @@ type ActionBase<In, Out> = {
|
|
|
98
98
|
|
|
99
99
|
// — Integração —
|
|
100
100
|
ai?: boolean | AIConfig // default true (opt-out via false)
|
|
101
|
-
automatable?: boolean //
|
|
101
|
+
automatable?: boolean // schedule pode executar como o sistema (§4, ADR 0028)
|
|
102
102
|
internal?: boolean // só service-to-service; não expõe REST
|
|
103
103
|
successStatus?: number // HTTP code de sucesso (default 200)
|
|
104
104
|
}
|
|
@@ -606,6 +606,20 @@ O Opus **não autentica** — não valida token, não emite sessão. O adapter (
|
|
|
606
606
|
|
|
607
607
|
Se `ctx.user` é `null` e `public !== true`, runtime rejeita com `{ code: 'auth.unauthenticated', category: 'authentication' }` **antes** dos loaders e de `authorize`. Assim um chamador anônimo não dispara consultas nem descobre, pelo erro de um loader, se um registro existe.
|
|
608
608
|
|
|
609
|
+
### Identidade de sistema
|
|
610
|
+
|
|
611
|
+
O disparo de um schedule executa a action **como o sistema**: `ctx.user` é `null`, `ctx.can` nega toda permissão e `ctx.provenance` é `{ kind: 'schedule', scheduleName }` (ou `background` com essa provenance original, no job que o schedule enfileirou). Só o runtime cria essa execução; `runtime.execute` não a aceita de fora, mesmo com uma provenance `schedule`. O job carrega a identidade pela marca `JobSpec.ctx.system`, gravada no enfileiramento só quando a execução é do sistema; o worker não a deduz da provenance.
|
|
612
|
+
|
|
613
|
+
O acesso do sistema é **opt-in por action** e negado por padrão. Sem usuário, o gate de autenticação aceita o sistema apenas quando a action declara `automatable: true`; nas demais, responde `{ code: 'auth.system_forbidden', category: 'authorization' }`. Uma action `public: true` continua aceitando qualquer chamador, inclusive o sistema.
|
|
614
|
+
|
|
615
|
+
`isSystemActor(ctx)` responde se o ator é o sistema. Ele consulta um registro interno dos contextos que o runtime criou, e não a provenance. Como o sistema não detém permissões, um `authorize` que delega a `ctx.can` o aceita de forma explícita, numa closure — a DSL em string não expressa isso, e `ctx.provenance` não serve como teste de sistema, porque o chamador de `runtime.execute` pode preenchê-la:
|
|
616
|
+
|
|
617
|
+
```ts
|
|
618
|
+
authorize: (ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar')
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
`requires` continua declarativo; o manifest projeta `automatable` ao lado de `permission`. O audit registra `actor: { id: null, type: 'system' }`. Ver ADR 0028.
|
|
622
|
+
|
|
609
623
|
---
|
|
610
624
|
|
|
611
625
|
## 5. Contrato de audit
|
|
@@ -719,6 +733,10 @@ registerAuditSink({
|
|
|
719
733
|
|
|
720
734
|
Múltiplos sinks são permitidos. Action sem `audit.sink` emite para todos os sinks registrados; com `audit.sink: 'compliance'` emite só pro sink nomeado.
|
|
721
735
|
|
|
736
|
+
### Registros de reaction
|
|
737
|
+
|
|
738
|
+
Reactions também auditam: um registro por entrega que chega ao handler, com `action` igual ao nome da reaction, `provenance.kind: 'reaction'`, `meta: { kind: 'reaction', attempts }`, sem `actionKind` e com `input: { event: { id, type, source } }` — sem o payload, que já está no audit da action emissora. Registros de reaction vão para todos os sinks. Ver §12.
|
|
739
|
+
|
|
722
740
|
### Falha de sink
|
|
723
741
|
|
|
724
742
|
Falha ao emitir audit **não** falha a action. Sink lançou → opus loga `{ code: 'audit.sink.failed', cause: <error> }` no logger configurado e continua. Persistência de audit é responsabilidade do sink (retry, fila, etc).
|
|
@@ -1517,10 +1535,16 @@ type DomainEvent<T = unknown> = {
|
|
|
1517
1535
|
tenant?: string | null
|
|
1518
1536
|
data: T // payload (livre, handler decide)
|
|
1519
1537
|
source: {
|
|
1520
|
-
action: string // 'deal.archive'
|
|
1521
|
-
actionId: string // bate com AuditRecord.id
|
|
1538
|
+
action: string // action que iniciou a cadeia ('deal.archive')
|
|
1539
|
+
actionId: string // bate com AuditRecord.id dessa action
|
|
1522
1540
|
correlation?: string // request id / trace
|
|
1523
1541
|
}
|
|
1542
|
+
cause?: { // só em evento publicado por reaction (§12)
|
|
1543
|
+
reaction: string // reaction que publicou
|
|
1544
|
+
eventId: string // evento que ela processava
|
|
1545
|
+
eventType: string
|
|
1546
|
+
depth: number // 1 = primeira reaction depois da action
|
|
1547
|
+
}
|
|
1524
1548
|
trace?: TraceContext // propagado para reactions/consumidores
|
|
1525
1549
|
meta?: Record<string, unknown>
|
|
1526
1550
|
}
|
|
@@ -1577,10 +1601,11 @@ type ReactionDef<Event = unknown> = {
|
|
|
1577
1601
|
// — Execução —
|
|
1578
1602
|
handler: (ctx: ReactionContext, event: DomainEvent<Event>) =>
|
|
1579
1603
|
Promise<void> | void
|
|
1604
|
+
emits?: string[] // eventos que o handler pode publicar
|
|
1580
1605
|
|
|
1581
1606
|
// — Resiliência —
|
|
1582
|
-
retry?: { attempts: number, backoff?: BackoffSpec }
|
|
1583
|
-
timeout?: number // ms
|
|
1607
|
+
retry?: { attempts: number, backoff?: BackoffSpec } // attempts = total de execuções
|
|
1608
|
+
timeout?: number // ms por tentativa; aborta ctx.signal
|
|
1584
1609
|
dedup?: (event: DomainEvent<Event>) => string // chave de idempotência
|
|
1585
1610
|
concurrency?: number // máx execuções paralelas (default 1)
|
|
1586
1611
|
|
|
@@ -1628,16 +1653,21 @@ type ReactionContext = {
|
|
|
1628
1653
|
user: User | null // ator que disparou o evento (pode ser system)
|
|
1629
1654
|
tenantId: string | null
|
|
1630
1655
|
db: unknown // mesmo data adapter
|
|
1631
|
-
emit: EmitFn //
|
|
1656
|
+
emit: EmitFn // publica só os eventos de `emits` (encadeamento limitado)
|
|
1632
1657
|
log: LoggerAdapter // logger pre-bound com contexto da reaction
|
|
1633
1658
|
storage: StorageAdapter | null // storage de arquivos (experimental)
|
|
1634
1659
|
provenance: Provenance // { kind: 'reaction', … }
|
|
1635
1660
|
trace?: TraceContext // span filho do evento recebido
|
|
1636
1661
|
meta: Record<string, unknown>
|
|
1662
|
+
signal?: AbortSignal // abortado quando a tentativa excede `timeout`
|
|
1637
1663
|
}
|
|
1638
1664
|
```
|
|
1639
1665
|
|
|
1640
|
-
|
|
1666
|
+
### Encadeamento
|
|
1667
|
+
|
|
1668
|
+
Uma reaction publica com `ctx.emit` somente os eventos listados no seu `emits`. Qualquer outro evento, inclusive de reaction sem `emits`, é descartado com o aviso `runtime.reaction_emit_ignored`, controlado por `config.warnUndeclaredEmits`. O evento publicado herda `actor`, `tenant` e `source` do evento recebido e ganha `cause: { reaction, eventId, eventType, depth }`.
|
|
1669
|
+
|
|
1670
|
+
Uma cadeia dá no máximo `config.maxReactionDepth` saltos (default 5). O `ctx.emit` que passaria do limite não publica e registra o erro `runtime.reaction_chain_too_deep`; com `emitMode: 'strict'`, o mesmo código interrompe a reaction. Falha de `publish` segue `emitMode`, como nas actions. Ver ADR 0027.
|
|
1641
1671
|
|
|
1642
1672
|
### Setup unificado
|
|
1643
1673
|
|
|
@@ -1663,20 +1693,20 @@ await runtime.start()
|
|
|
1663
1693
|
|
|
1664
1694
|
### Resiliência: quem implementa o quê
|
|
1665
1695
|
|
|
1666
|
-
| Campo | Implementação
|
|
1667
|
-
|
|
1668
|
-
| `dedup` |
|
|
1669
|
-
| `retry` |
|
|
1670
|
-
| `timeout` |
|
|
1671
|
-
| `concurrency` |
|
|
1696
|
+
| Campo | Implementação no core (ADR 0026) |
|
|
1697
|
+
|---------------|----------------------------------|
|
|
1698
|
+
| `dedup` | Chave por reaction, em memória: ocupada da chegada até o fim da entrega e, após sucesso, por `config.dedupCacheTtl` (default 60 s), com no máximo `config.dedupCacheMaxEntries` chaves concluídas por reaction (default 10.000; a mais antiga sai primeiro). Falha definitiva libera a chave. Duplicata vira `debug` `runtime.reaction_duplicate_skipped`. |
|
|
1699
|
+
| `retry` | Em memória. Sem `retry`, uma execução (`config.defaultRetry` vale só para background). Exceções comuns e timeout repetem. `ActionError` segue `retriable`, cujo padrão vem da categoria: só `rate_limit` e `dependency` repetem (`internal` não); `error({ ..., retriable: true })` força a repetição. Cada repetição avisa `runtime.reaction_retry`. |
|
|
1700
|
+
| `timeout` | `AbortController` por tentativa: aborta `ctx.signal` e falha com `runtime.reaction_timeout` (retriable). A vaga é liberada no timeout. |
|
|
1701
|
+
| `concurrency` | Semáforo por reaction (default 1) e global (`config.maxConcurrentReactions`, default 10), em ordem de chegada. A espera entre tentativas não ocupa vaga. |
|
|
1672
1702
|
|
|
1673
|
-
|
|
1703
|
+
As políticas valem por processo. Delegar retry e concorrência a um adapter durável (consumer groups, filas) fica para quando existir driver distribuído. Valores inválidos falham no registro com `runtime.invalid_reaction_policy` ou na criação do runtime com `runtime.invalid_config`.
|
|
1674
1704
|
|
|
1675
1705
|
### Falha de reaction não afeta o emissor
|
|
1676
1706
|
|
|
1677
1707
|
Reaction roda **assíncrono** ao handler que emitiu o evento. Se reaction falha, é problema dela (retry / dead-letter via adapter); a action que emitiu o evento já completou.
|
|
1678
1708
|
|
|
1679
|
-
Falhas registradas no log do runtime
|
|
1709
|
+
Falhas definitivas são registradas no log do runtime (`reaction failed`, com o número de tentativas) e no audit (registro com `outcome: 'error'`). Adapter de eventos pode rotear pra dead-letter (v1).
|
|
1680
1710
|
|
|
1681
1711
|
### Pipeline de uma reaction
|
|
1682
1712
|
|
|
@@ -1684,14 +1714,14 @@ Falhas registradas no log do runtime + audit (entrada do tipo `'reaction.failed'
|
|
|
1684
1714
|
EventBusAdapter recebe evento (publish externo ou ctx.emit interno)
|
|
1685
1715
|
↓
|
|
1686
1716
|
matching: encontra reactions registradas pra esse padrão
|
|
1687
|
-
↓ (paralelo, com concurrency limit)
|
|
1688
1717
|
para cada reaction:
|
|
1689
|
-
↓ (a) dedup
|
|
1690
|
-
↓
|
|
1718
|
+
↓ (a) dedup: reserva a chave na chegada (se declarado)
|
|
1719
|
+
↓ espera vaga da reaction e vaga global (concurrency)
|
|
1720
|
+
↓ (b) authorize, uma vez (se declarado)
|
|
1691
1721
|
↓ (c) handler executa (com timeout)
|
|
1692
|
-
↓ (d) ctx.emit
|
|
1693
|
-
↓ (e)
|
|
1694
|
-
↓ (f)
|
|
1722
|
+
↓ (d) ctx.emit publica os eventos declarados em `emits`, até a profundidade máxima
|
|
1723
|
+
↓ (e) retry se falhou e a política permite (libera as vagas durante a espera)
|
|
1724
|
+
↓ (f) audit, um registro por entrega (completed/failed, com as tentativas)
|
|
1695
1725
|
```
|
|
1696
1726
|
|
|
1697
1727
|
### O que **não** entra em v0
|
|
@@ -1758,6 +1788,7 @@ type RuntimeConfig = {
|
|
|
1758
1788
|
|
|
1759
1789
|
// — Resiliência / dedup —
|
|
1760
1790
|
dedupCacheTtl?: number // ms, default 60_000
|
|
1791
|
+
dedupCacheMaxEntries?: number // chaves concluídas por reaction, default 10_000
|
|
1761
1792
|
defaultRetry?: { attempts: number, backoff?: BackoffSpec }
|
|
1762
1793
|
|
|
1763
1794
|
// — i18n —
|
|
@@ -1768,7 +1799,8 @@ type RuntimeConfig = {
|
|
|
1768
1799
|
|
|
1769
1800
|
// — Limites —
|
|
1770
1801
|
maxConcurrentActions?: number // default Infinity
|
|
1771
|
-
maxConcurrentReactions?: number // default 10
|
|
1802
|
+
maxConcurrentReactions?: number // default 10, somando todas as reactions
|
|
1803
|
+
maxReactionDepth?: number // default 5 saltos de reaction por cadeia
|
|
1772
1804
|
}
|
|
1773
1805
|
```
|
|
1774
1806
|
|
|
@@ -1831,9 +1863,11 @@ Se você não passa `config`, o runtime usa:
|
|
|
1831
1863
|
validateOutputInDev: true,
|
|
1832
1864
|
warnUndeclaredEmits: true,
|
|
1833
1865
|
dedupCacheTtl: 60_000,
|
|
1866
|
+
dedupCacheMaxEntries: 10_000,
|
|
1834
1867
|
defaultRetry: { attempts: 3, backoff: { kind: 'exponential', initialMs: 1000 } },
|
|
1835
1868
|
maxConcurrentActions: Number.POSITIVE_INFINITY,
|
|
1836
1869
|
maxConcurrentReactions: 10,
|
|
1870
|
+
maxReactionDepth: 5,
|
|
1837
1871
|
}
|
|
1838
1872
|
```
|
|
1839
1873
|
|
|
@@ -2134,12 +2168,14 @@ chamada, inclusive por IA e MCP.
|
|
|
2134
2168
|
- **`QueueAdapter`** — adapter que executa background jobs. Ver §8/§10.
|
|
2135
2169
|
- **`ctx.emit`** — função disponível no contexto do handler para emitir eventos declarados em `emits`. Ver §11.
|
|
2136
2170
|
- **`requires`** — permissão declarativa (string ou array) usada por adapters de docs/OpenAPI. Distinta de `authorize` (lógica runtime). Ver §4.
|
|
2137
|
-
- **`automatable`** —
|
|
2171
|
+
- **`automatable`** — opt-in que permite a um schedule executar a action como o sistema, sem usuário. Negado por padrão; não abre a action a anônimos. Distinta de `ai.enabled`. Ver §4.
|
|
2172
|
+
- **`isSystemActor(ctx)`** — responde se a execução foi criada pelo runtime para o sistema (schedule ou job que ele enfileirou). Usado no `authorize` de actions `automatable`. Ver §4.
|
|
2138
2173
|
- **`internal`** — flag que indica action service-to-service apenas; server adapter não expõe como REST público. Ver §2.
|
|
2139
2174
|
- **Reaction** — declaração que escuta evento(s) e executa handler. Auto-registrada pelo runtime via EventBusAdapter. Pareada com Action (ator inicia × sistema reage). Ver §12.
|
|
2140
2175
|
- **`defineReaction`** — função que constrói uma reaction declarativa.
|
|
2141
2176
|
- **`ReactionContext`** — contexto entregue ao handler de reaction (user, tenant, db, emit, log, meta).
|
|
2142
|
-
- **`dedup`** — função em reaction que produz chave de idempotência; runtime descarta evento
|
|
2177
|
+
- **`dedup`** — função em reaction que produz chave de idempotência; runtime descarta evento com chave em processamento ou processada dentro de `dedupCacheTtl`. Ver §12.
|
|
2178
|
+
- **`cause`** — campo de `DomainEvent` publicado por reaction: reaction, evento de origem e profundidade na cadeia. Ver §11/§12.
|
|
2143
2179
|
- **`LoggerAdapter`** — interface de logger plugável (levels, structured, child binding). Default: `@softize/opus/log-console`. Ver §8.
|
|
2144
2180
|
- **`RuntimeConfig`** — behavior knobs do runtime (auditMode, emitMode, env, i18n, limits). Recebe valores literais; core nunca lê env direto. Ver §14.
|
|
2145
2181
|
- **`healthCheck()`** — método opcional em qualquer adapter, retorna `{ ok, details? }`. Agregado por `runtime.healthCheck()` e exposto em `/ready`. Ver §8.
|
package/package.json
CHANGED
package/src/core/audit.ts
CHANGED
|
@@ -61,7 +61,8 @@ export class AuditEmitter {
|
|
|
61
61
|
if (targetSinks.length === 0) return
|
|
62
62
|
|
|
63
63
|
const results = await Promise.allSettled(
|
|
64
|
-
|
|
64
|
+
// `then` também captura o sink que lança de forma síncrona: toda falha vira rejeição tratada.
|
|
65
|
+
targetSinks.map((sink) => Promise.resolve().then(() => sink.emit(processed))),
|
|
65
66
|
)
|
|
66
67
|
|
|
67
68
|
const failures = results.filter(
|
package/src/core/index.ts
CHANGED
|
@@ -216,7 +216,7 @@ export type {
|
|
|
216
216
|
export { AuditEmitter } from './audit.ts'
|
|
217
217
|
|
|
218
218
|
// — Runtime ———————————————————————————————————————————————————————————————————
|
|
219
|
-
export { Runtime, createRuntime } from './runtime.ts'
|
|
219
|
+
export { Runtime, createRuntime, isSystemActor } from './runtime.ts'
|
|
220
220
|
export type {
|
|
221
221
|
RuntimeSetup,
|
|
222
222
|
RuntimeSetupConfig,
|
package/src/core/reactions.ts
CHANGED
|
@@ -27,7 +27,7 @@ export function defineReaction<Event = unknown>(
|
|
|
27
27
|
// =============================================================================
|
|
28
28
|
|
|
29
29
|
/** Campos server-only que o `bindReaction` fornece — o resto é a DECLARAÇÃO (isomorfa). */
|
|
30
|
-
type ReactionBindingKeys = 'handler' | 'retry' | 'timeout' | 'dedup' | 'concurrency'
|
|
30
|
+
type ReactionBindingKeys = 'handler' | 'emits' | 'retry' | 'timeout' | 'dedup' | 'concurrency'
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* A DECLARAÇÃO de uma reação, autorável na sessão de design (isomorfa, SEM efeito): qual
|
|
@@ -36,9 +36,13 @@ type ReactionBindingKeys = 'handler' | 'retry' | 'timeout' | 'dedup' | 'concurre
|
|
|
36
36
|
*/
|
|
37
37
|
export type ReactionContract<Event = unknown> = Omit<ReactionDef<Event>, ReactionBindingKeys>
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Parte server-only amarrada por `bindReaction`: o handler, os eventos que ele publica e a
|
|
41
|
+
* política de entrega. Como no `bindAction`, `emits` fica no binding, ao lado do handler.
|
|
42
|
+
*/
|
|
40
43
|
export interface ReactionBinding<Event = unknown> {
|
|
41
44
|
handler: ReactionHandlerFn<Event>
|
|
45
|
+
emits?: string[]
|
|
42
46
|
retry?: { attempts: number; backoff?: BackoffSpec }
|
|
43
47
|
timeout?: number
|
|
44
48
|
dedup?: (event: DomainEvent<Event>) => string
|