@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/src/core/schedules.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* - Schedule = tempo dispara
|
|
12
12
|
*
|
|
13
13
|
* Runtime registra no SchedulerAdapter no `start()`. Quando o tempo
|
|
14
|
-
* configurado bate, o
|
|
15
|
-
*
|
|
14
|
+
* configurado bate, o runtime executa a action como o sistema, com
|
|
15
|
+
* `ctx.provenance = { kind: 'schedule', scheduleName }`; a action precisa
|
|
16
|
+
* declarar `automatable: true` (ou `public: true`). Ver ADR 0028.
|
|
16
17
|
*
|
|
17
18
|
* Ver §16 do protocolo.
|
|
18
19
|
*/
|
package/src/core/types.ts
CHANGED
|
@@ -255,10 +255,11 @@ export interface User {
|
|
|
255
255
|
export type CanFn = (permission: string, resource?: unknown) => boolean | Promise<boolean>
|
|
256
256
|
|
|
257
257
|
/**
|
|
258
|
-
* Função que emite um `DomainEvent`. Em `ActionContext.emit`, publica
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
258
|
+
* Função que emite um `DomainEvent` pelo `EventBusAdapter`. Em `ActionContext.emit`, publica
|
|
259
|
+
* qualquer evento; um evento fora do `emits` da action gera aviso com
|
|
260
|
+
* `config.warnUndeclaredEmits`. Em `ReactionContext.emit`, publica somente eventos listados no
|
|
261
|
+
* `emits` da reaction e descarta os demais (aviso `runtime.reaction_emit_ignored`); a cadeia de
|
|
262
|
+
* reactions para em `config.maxReactionDepth`. Ver ADR 0027.
|
|
262
263
|
*/
|
|
263
264
|
export type EmitFn = <T = unknown>(
|
|
264
265
|
event: string,
|
|
@@ -295,7 +296,7 @@ export interface ReactionContext {
|
|
|
295
296
|
tenantId: string | null
|
|
296
297
|
db: unknown
|
|
297
298
|
log: Logger
|
|
298
|
-
/**
|
|
299
|
+
/** Publica os eventos declarados em `emits` da reaction; descarta os demais. Ver `EmitFn`. */
|
|
299
300
|
emit: EmitFn
|
|
300
301
|
storage: StorageAdapter | null
|
|
301
302
|
cache: CacheAdapter | null
|
|
@@ -303,6 +304,12 @@ export interface ReactionContext {
|
|
|
303
304
|
provenance: Provenance
|
|
304
305
|
trace?: TraceContext
|
|
305
306
|
meta: Record<string, unknown>
|
|
307
|
+
/**
|
|
308
|
+
* Abortado quando a tentativa excede o `timeout` da reaction. O runtime sempre preenche o
|
|
309
|
+
* campo; ele é opcional para quem monta o contexto à mão em testes. Um handler que ignora o
|
|
310
|
+
* sinal continua executando depois do timeout. Ver ADR 0026.
|
|
311
|
+
*/
|
|
312
|
+
signal?: AbortSignal
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
// =============================================================================
|
|
@@ -387,11 +394,26 @@ export interface DomainEvent<T = unknown> {
|
|
|
387
394
|
actor: { id: string | null; type?: 'user' | 'system' | 'integration' }
|
|
388
395
|
tenant?: string | null
|
|
389
396
|
data: T
|
|
397
|
+
/**
|
|
398
|
+
* A action que iniciou a cadeia. Um evento publicado por reaction herda o `source` do evento
|
|
399
|
+
* que ela processava, e `provenance.sourceActionId` continua batendo com o audit da action.
|
|
400
|
+
*/
|
|
390
401
|
source: {
|
|
391
402
|
action: string
|
|
392
403
|
actionId: string
|
|
393
404
|
correlation?: string
|
|
394
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Presente só em evento publicado por reaction: qual reaction o publicou, a partir de qual
|
|
408
|
+
* evento e a que profundidade da cadeia (1 = primeira reaction depois da action). Evento
|
|
409
|
+
* emitido por action não tem `cause` e está na profundidade 0. Ver ADR 0027.
|
|
410
|
+
*/
|
|
411
|
+
cause?: {
|
|
412
|
+
reaction: string
|
|
413
|
+
eventId: string
|
|
414
|
+
eventType: string
|
|
415
|
+
depth: number
|
|
416
|
+
}
|
|
395
417
|
trace?: TraceContext
|
|
396
418
|
meta?: Record<string, unknown>
|
|
397
419
|
}
|
|
@@ -434,6 +456,12 @@ export interface JobSpec {
|
|
|
434
456
|
requestId?: string
|
|
435
457
|
trace?: TraceContext
|
|
436
458
|
originalProvenance?: Provenance
|
|
459
|
+
/**
|
|
460
|
+
* Presente quando o próprio runtime enfileirou o job numa execução do sistema (o disparo de um
|
|
461
|
+
* schedule). O worker só reconstrói a identidade de sistema com esta marca; a provenance não
|
|
462
|
+
* basta, porque vem do chamador. Ver ADR 0028.
|
|
463
|
+
*/
|
|
464
|
+
system?: true
|
|
437
465
|
}
|
|
438
466
|
config: {
|
|
439
467
|
queue?: string
|
|
@@ -828,6 +856,11 @@ export interface ActionBase<In, Out, ParsedIn = In> {
|
|
|
828
856
|
|
|
829
857
|
// — Integração —
|
|
830
858
|
ai?: boolean | AIConfig
|
|
859
|
+
/**
|
|
860
|
+
* Aceita execução pelo sistema, sem usuário, quando um schedule a dispara. Sem este opt-in,
|
|
861
|
+
* a execução pelo sistema de uma action não pública termina em `auth.system_forbidden`. Não
|
|
862
|
+
* abre a action a chamadores anônimos. Ver `isSystemActor` e ADR 0028.
|
|
863
|
+
*/
|
|
831
864
|
automatable?: boolean
|
|
832
865
|
internal?: boolean
|
|
833
866
|
successStatus?: number
|
|
@@ -950,9 +983,17 @@ export interface ReactionDef<Event = unknown> {
|
|
|
950
983
|
|
|
951
984
|
handler: ReactionHandlerFn<Event>
|
|
952
985
|
|
|
986
|
+
/** Eventos que o handler pode publicar com `ctx.emit`; os demais são descartados (ADR 0027). */
|
|
987
|
+
emits?: string[]
|
|
988
|
+
|
|
989
|
+
// — Política de entrega (ADR 0026) —
|
|
990
|
+
/** Total de execuções do handler, com espera opcional entre elas. Sem `retry`, roda uma vez. */
|
|
953
991
|
retry?: { attempts: number; backoff?: BackoffSpec }
|
|
992
|
+
/** Prazo de cada tentativa, em ms; ao vencer, aborta `ctx.signal`. */
|
|
954
993
|
timeout?: number
|
|
994
|
+
/** Chave de idempotência: evento com chave já vista em `config.dedupCacheTtl` é descartado. */
|
|
955
995
|
dedup?: (event: DomainEvent<Event>) => string
|
|
996
|
+
/** Máximo de tentativas simultâneas desta reaction. Padrão 1. */
|
|
956
997
|
concurrency?: number
|
|
957
998
|
|
|
958
999
|
authorize?: (
|
|
@@ -1419,8 +1460,8 @@ export interface SchedulerAdapter extends Adapter {
|
|
|
1419
1460
|
* Registra um schedule e retorna função pra cancelar. Chamado pelo
|
|
1420
1461
|
* runtime no `start()` pra cada schedule.
|
|
1421
1462
|
*
|
|
1422
|
-
* Quando o tempo bater, o adapter chama `fire()
|
|
1423
|
-
*
|
|
1463
|
+
* Quando o tempo bater, o adapter chama `fire()`, e o runtime executa a
|
|
1464
|
+
* action como o sistema, com provenance schedule (ADR 0028).
|
|
1424
1465
|
*/
|
|
1425
1466
|
register(spec: ScheduleDef, fire: () => Promise<void> | void): Unsubscribe
|
|
1426
1467
|
}
|
|
@@ -1453,6 +1494,8 @@ export interface RuntimeConfig {
|
|
|
1453
1494
|
|
|
1454
1495
|
// — Resiliência —
|
|
1455
1496
|
dedupCacheTtl?: number
|
|
1497
|
+
/** Chaves concluídas que o dedup guarda por reaction; acima disso, descarta a mais antiga. Padrão 10.000. */
|
|
1498
|
+
dedupCacheMaxEntries?: number
|
|
1456
1499
|
defaultRetry?: { attempts: number; backoff?: BackoffSpec }
|
|
1457
1500
|
|
|
1458
1501
|
// — i18n —
|
|
@@ -1464,4 +1507,6 @@ export interface RuntimeConfig {
|
|
|
1464
1507
|
// — Limites —
|
|
1465
1508
|
maxConcurrentActions?: number
|
|
1466
1509
|
maxConcurrentReactions?: number
|
|
1510
|
+
/** Saltos de reaction que uma cadeia de eventos pode dar. Padrão 5 (ADR 0027). */
|
|
1511
|
+
maxReactionDepth?: number
|
|
1467
1512
|
}
|
package/src/testing/index.ts
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* capturado em `emitted`, log capturado em `logged`, `can` configurável,
|
|
10
10
|
* storage plugável (ex.: `memStorage()`).
|
|
11
11
|
* • `runAction(action, input)` — o MESMO pipeline do `runtime.execute`, em unidade:
|
|
12
|
-
* valida input → gate
|
|
13
|
-
* idêntica, `loaded` incluso) → handler →
|
|
14
|
-
* no runtime). Falha estoura o MESMO
|
|
15
|
-
* (`validation.invalid_input`, `auth.unauthenticated`,
|
|
12
|
+
* valida input → gate de autenticação (`public`, usuário ou sistema `automatable`) →
|
|
13
|
+
* `authorize` (string DSL ou closure, semântica idêntica, `loaded` incluso) → handler →
|
|
14
|
+
* valida output (pulado em `list`, como no runtime). Falha estoura o MESMO
|
|
15
|
+
* `ActionError` tipado do runtime (`validation.invalid_input`, `auth.unauthenticated`,
|
|
16
|
+
* `auth.system_forbidden`, `auth.forbidden`…). A opção `schedule` executa como o sistema.
|
|
16
17
|
* Em `OPUS_MODE=design` roteia pro `mockHandler` como o runtime — contrato puro
|
|
17
18
|
* (sem handler) é testável em design; fora dele, erro claro (`testing.no_handler`).
|
|
18
19
|
* • `memStorage()` — StorageAdapter em memória pra testar handler que anexa
|
|
@@ -31,7 +32,7 @@ import { normalizeKey } from '../storage/index.ts'
|
|
|
31
32
|
import { error } from '../core/errors.ts'
|
|
32
33
|
// Import direto do runtime (fora do index público): a régua handler×mockHandler
|
|
33
34
|
// tem UMA fonte — o harness nunca pode divergir do execute().
|
|
34
|
-
import { isOpusDesignMode } from '../core/runtime.ts'
|
|
35
|
+
import { assertAuthenticated, isOpusDesignMode, markSystemActor } from '../core/runtime.ts'
|
|
35
36
|
import type {
|
|
36
37
|
ActionContext,
|
|
37
38
|
ActionKind,
|
|
@@ -201,6 +202,7 @@ export interface TestableAction<In, Out, ParsedIn = In> {
|
|
|
201
202
|
name: string
|
|
202
203
|
kind?: ActionKind
|
|
203
204
|
public?: boolean
|
|
205
|
+
automatable?: boolean
|
|
204
206
|
authorize?: AuthorizeSpec<ParsedIn>
|
|
205
207
|
input: Schema<In, ParsedIn>
|
|
206
208
|
output: Schema<Out> | Schema<unknown, Out>
|
|
@@ -215,6 +217,7 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
|
|
|
215
217
|
name: string
|
|
216
218
|
kind: 'list'
|
|
217
219
|
public?: boolean
|
|
220
|
+
automatable?: boolean
|
|
218
221
|
authorize?: AuthorizeSpec<ParsedIn>
|
|
219
222
|
input: Schema<In, ParsedIn>
|
|
220
223
|
output: Schema<Item> | Schema<unknown, Item>
|
|
@@ -233,6 +236,12 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
|
|
|
233
236
|
export interface RunActionOptions extends TestContextOptions {
|
|
234
237
|
/** Substitui os loaders (não rodam em unidade): vai pro handler e pro evalCtx do authorize. */
|
|
235
238
|
loaded?: Record<string, unknown>
|
|
239
|
+
/**
|
|
240
|
+
* Executa como o sistema, no disparo do schedule com este nome: `user` é `null`, `can` nega
|
|
241
|
+
* tudo, a provenance é `schedule` e `isSystemActor(ctx)` responde `true`. Como no runtime, a
|
|
242
|
+
* action precisa ser `automatable` (ou `public`); senão, `auth.system_forbidden`.
|
|
243
|
+
*/
|
|
244
|
+
schedule?: string
|
|
236
245
|
}
|
|
237
246
|
|
|
238
247
|
export interface RunActionResult<Out> extends TestContext {
|
|
@@ -261,7 +270,7 @@ async function validate<T>(
|
|
|
261
270
|
|
|
262
271
|
/**
|
|
263
272
|
* Roda uma action em UNIDADE com o pipeline do `runtime.execute`: valida input →
|
|
264
|
-
* gate
|
|
273
|
+
* gate de autenticação → authorize → handler → valida output. Sucesso devolve o output
|
|
265
274
|
* validado + o contexto observável (`emitted`, `logged`); falha estoura o mesmo
|
|
266
275
|
* `ActionError` do runtime — afirme com `expect(...).rejects`.
|
|
267
276
|
*
|
|
@@ -284,19 +293,21 @@ export async function runAction<In, Out, ParsedIn = In>(
|
|
|
284
293
|
input: unknown,
|
|
285
294
|
options: RunActionOptions = {},
|
|
286
295
|
): Promise<RunActionResult<Out>> {
|
|
287
|
-
const
|
|
296
|
+
const schedule = options.schedule
|
|
297
|
+
const t = testContext(
|
|
298
|
+
schedule === undefined ? options : { ...options, user: null, can: () => false },
|
|
299
|
+
)
|
|
288
300
|
const { ctx } = t
|
|
301
|
+
if (schedule !== undefined) {
|
|
302
|
+
ctx.provenance = { kind: 'schedule', scheduleName: schedule }
|
|
303
|
+
markSystemActor(ctx)
|
|
304
|
+
}
|
|
289
305
|
const loaded = options.loaded
|
|
290
306
|
|
|
291
307
|
const parsed = await validate<ParsedIn>(action.input, input, 'input')
|
|
292
308
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
code: 'auth.unauthenticated',
|
|
296
|
-
category: 'authentication',
|
|
297
|
-
message: 'Autenticação necessária',
|
|
298
|
-
})
|
|
299
|
-
}
|
|
309
|
+
// O mesmo gate do runtime: pública, com usuário ou, pelo sistema, `automatable`.
|
|
310
|
+
assertAuthenticated(action, ctx)
|
|
300
311
|
|
|
301
312
|
if (action.authorize !== undefined) {
|
|
302
313
|
const spec = action.authorize
|
|
@@ -147,7 +147,8 @@ validações: `z.object({ tag: t.slug().zod(), contato: t.email().zod() })`. O `
|
|
|
147
147
|
schema zod de cada tipo lógico (slug, email, phone, money…), importado de `@softize/opus/schema/zod`.
|
|
148
148
|
|
|
149
149
|
A autorização é fechada por padrão: sem `public: true`, a action exige usuário autenticado; com
|
|
150
|
-
`authorize`, exige também a permissão declarada.
|
|
150
|
+
`authorize`, exige também a permissão declarada. `automatable: true` permite que um
|
|
151
|
+
[schedule](/docs/scheduler) execute a action como o sistema, sem abri-la a chamadores anônimos.
|
|
151
152
|
|
|
152
153
|
## Prototipar com dados simulados
|
|
153
154
|
|
|
@@ -99,6 +99,16 @@ O que cada action captura se ajusta no contrato: `audit: false` desliga a audito
|
|
|
99
99
|
`audit: { redact: ['senha'], output: false }` refina o registro. Por padrão, o registro inclui
|
|
100
100
|
input, output e provenance.
|
|
101
101
|
|
|
102
|
+
O `actor` diz quem executou. Uma pessoa aparece com `type: 'user'` e o próprio `id`; uma execução
|
|
103
|
+
iniciada pelo sistema, como o disparo de um [schedule](/docs/scheduler), aparece com
|
|
104
|
+
`{ id: null, type: 'system' }` e o nome do schedule na provenance.
|
|
105
|
+
|
|
106
|
+
Reactions também geram registros, um por evento que chega ao handler. Eles têm `provenance.kind`
|
|
107
|
+
igual a `reaction`, o nome da reaction em `action`, `meta: { kind: 'reaction', attempts }` e nenhum
|
|
108
|
+
`actionKind`. O `input` traz só a identidade do evento (`id`, `type` e `source`), sem o payload, que
|
|
109
|
+
já está no registro da action que emitiu. Um sink que filtra leituras por `actionKind` deve tratar
|
|
110
|
+
esses registros à parte. Veja [Eventos](/docs/events).
|
|
111
|
+
|
|
102
112
|
## Limites atuais
|
|
103
113
|
|
|
104
114
|
Os sinks só acrescentam registros: consulta e retenção ficam com o seu banco. Os drivers disponíveis
|
|
@@ -73,7 +73,11 @@ export const notaEmitir = bindAction(notaEmitirContract, {
|
|
|
73
73
|
Antes do handler, o runtime verifica a chamada nesta ordem:
|
|
74
74
|
|
|
75
75
|
1. Valida a entrada pelo schema do contrato.
|
|
76
|
-
2. Sem `public: true`, exige `ctx.user`; sem usuário, responde `auth.unauthenticated`.
|
|
76
|
+
2. Sem `public: true`, exige `ctx.user`; sem usuário, responde `auth.unauthenticated`. A exceção é
|
|
77
|
+
o disparo de um schedule, que executa como o sistema uma action com `automatable: true` e recebe
|
|
78
|
+
`auth.system_forbidden` nas demais (veja [Agendador](/docs/scheduler)). Para aceitar o sistema no
|
|
79
|
+
`authorize`, use uma função com `isSystemActor(ctx)`: a expressão em string não o reconhece, e
|
|
80
|
+
`ctx.provenance` não prova que a chamada veio de um schedule.
|
|
77
81
|
3. Executa os `loads`, que carregam os dados usados na autorização.
|
|
78
82
|
4. Avalia `authorize`, se houver: `false` vira `auth.forbidden`, e um `ActionError` devolvido é
|
|
79
83
|
lançado como está.
|
|
@@ -23,7 +23,8 @@ interface DomainEvent<T = unknown> {
|
|
|
23
23
|
actor: { id: string | null; type?: 'user' | 'system' | 'integration' }
|
|
24
24
|
tenant?: string | null
|
|
25
25
|
data: T
|
|
26
|
-
source: { action: string; actionId: string; correlation?: string }
|
|
26
|
+
source: { action: string; actionId: string; correlation?: string } // action que iniciou a cadeia
|
|
27
|
+
cause?: { reaction: string; eventId: string; eventType: string; depth: number } // só se publicado por reaction
|
|
27
28
|
trace?: TraceContext // trace da execução que emitiu
|
|
28
29
|
meta?: Record<string, unknown> // o terceiro argumento de ctx.emit
|
|
29
30
|
}
|
|
@@ -105,17 +106,114 @@ com `runtime.eventbus_required` quando ele não existe. `defineReaction` declara
|
|
|
105
106
|
quando não há separação entre cliente e servidor.
|
|
106
107
|
|
|
107
108
|
A reaction roda com provenance `reaction`; `ctx.user` traz somente o `id` do autor do evento, e
|
|
108
|
-
`ctx.tenantId`, o tenant do evento. O contexto não tem `can`: a decisão fica no
|
|
109
|
-
|
|
109
|
+
`ctx.tenantId`, o tenant do evento. O contexto não tem `can`: a decisão fica no
|
|
110
|
+
`authorize(ctx, event)` opcional, e uma resposta `false` descarta o evento sem erro. Uma falha na
|
|
111
|
+
reaction fica no log e no audit e não chega à action que emitiu.
|
|
110
112
|
|
|
111
|
-
##
|
|
113
|
+
## Proteger a entrega
|
|
114
|
+
|
|
115
|
+
Uma reaction costuma falar com algo que falha ou demora: uma API externa, uma fila, outro banco. A
|
|
116
|
+
política de entrega, declarada no binding, diz como o runtime lida com isso:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
export const leadSincronizarImpl = bindReaction(leadSincronizar, {
|
|
120
|
+
retry: { attempts: 3, backoff: { kind: 'exponential', initialMs: 1_000, maxMs: 30_000 } },
|
|
121
|
+
timeout: 10_000,
|
|
122
|
+
dedup: (event) => event.data.leadId,
|
|
123
|
+
concurrency: 2,
|
|
124
|
+
handler: async (ctx, event) => {
|
|
125
|
+
await crm.upsertLead(event.data.leadId, { signal: ctx.signal })
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
O runtime aplica cada campo a cada evento entregue, nesta ordem:
|
|
131
|
+
|
|
132
|
+
- **`dedup`** calcula uma chave para o evento. Outro evento com a mesma chave é descartado enquanto
|
|
133
|
+
o primeiro está na fila ou em andamento e durante `config.dedupCacheTtl` depois de ele concluir
|
|
134
|
+
(padrão de 60 segundos). Se a entrega falhar em definitivo, a chave é liberada, e o evento pode ser
|
|
135
|
+
entregue de novo. Cada reaction guarda no máximo `config.dedupCacheMaxEntries` chaves concluídas
|
|
136
|
+
(padrão 10.000); acima disso, a mais antiga sai antes do TTL, e um evento com ela volta a ser
|
|
137
|
+
aceito.
|
|
138
|
+
- **`concurrency`** limita quantos eventos da reaction rodam ao mesmo tempo. O padrão é 1: sem
|
|
139
|
+
declarar, a reaction processa um evento por vez, na ordem de chegada. Acima disso,
|
|
140
|
+
`config.maxConcurrentReactions` (padrão 10) limita a soma de todas as reactions do processo. Com o
|
|
141
|
+
padrão, um handler sem `timeout` que trava — uma chamada de rede sem resposta, por exemplo —
|
|
142
|
+
segura a única vaga, e a reaction inteira para de processar eventos até o processo reiniciar.
|
|
143
|
+
Declare `timeout` em toda reaction que depende de algo externo.
|
|
144
|
+
- **`authorize`**, quando declarado, roda uma vez, antes da primeira tentativa.
|
|
145
|
+
- **`timeout`** é o prazo de cada tentativa, em milissegundos. Ao vencer, o runtime aborta
|
|
146
|
+
`ctx.signal` e encerra a tentativa com `runtime.reaction_timeout`. Passe o sinal adiante, como no
|
|
147
|
+
exemplo: um handler que o ignora continua executando em segundo plano, fora do limite de
|
|
148
|
+
concorrência.
|
|
149
|
+
- **`retry`** repete o handler que falhou. `attempts` é o total de execuções, contando a primeira.
|
|
150
|
+
`fixed` espera `delayMs` entre elas; `exponential` começa em `initialMs`, multiplica por
|
|
151
|
+
`multiplier` (padrão 2) e para em `maxMs`. A espera entre tentativas não ocupa vaga de
|
|
152
|
+
concorrência.
|
|
153
|
+
|
|
154
|
+
Uma exceção comum, como `throw new Error(...)`, e o `runtime.reaction_timeout` são sempre repetidos.
|
|
155
|
+
Um `ActionError`, criado com `error()`, segue o próprio `retriable`, que por padrão vem da categoria:
|
|
156
|
+
só `rate_limit` e `dependency` repetem; `validation`, `authentication`, `authorization`,
|
|
157
|
+
`not_found`, `conflict` e `internal` encerram as tentativas. Para repetir um erro de outra categoria,
|
|
158
|
+
declare-o ao criá-lo:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
throw error({ code: 'crm.lead.locked', category: 'conflict', retriable: true })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Sem política declarada, o handler roda uma vez e um evento por vez. `config.defaultRetry` vale só
|
|
165
|
+
para jobs em background, não para reactions. Com `retry`, o handler precisa tolerar a repetição: o
|
|
166
|
+
`dedup` evita processar o mesmo evento duas vezes, mas não desfaz o que uma tentativa anterior
|
|
167
|
+
já gravou. Declare também `timeout`: com a concorrência padrão, é ele que impede um handler travado
|
|
168
|
+
de parar a reaction inteira.
|
|
169
|
+
|
|
170
|
+
Cada tentativa que falha e será repetida gera o aviso `runtime.reaction_retry` no log, e a falha
|
|
171
|
+
definitiva, a linha `reaction failed` com o número de tentativas. Um evento descartado pelo `dedup`
|
|
172
|
+
gera uma linha `debug` com `runtime.reaction_duplicate_skipped`. Valores fora do domínio, como
|
|
173
|
+
`concurrency: 0`, interrompem o registro com `runtime.invalid_reaction_policy`.
|
|
112
174
|
|
|
113
|
-
|
|
175
|
+
Cada entrega que chega ao handler gera um registro de [auditoria](/docs/audit): `action` traz o
|
|
176
|
+
nome da reaction, `meta` traz `{ kind: 'reaction', attempts }` e `input` traz só a identidade do
|
|
177
|
+
evento (`id`, `type` e `source`). O payload fica no registro da action que emitiu, com a redação
|
|
178
|
+
dela. Um evento recusado pelo `authorize` ou descartado pelo `dedup` não gera registro.
|
|
179
|
+
|
|
180
|
+
## Encadear reactions
|
|
181
|
+
|
|
182
|
+
Quando o efeito de uma reaction é um fato que outra parte do sistema precisa saber, ela o publica
|
|
183
|
+
com `ctx.emit`, como uma action. A reaction declara no binding os eventos que publica:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
export const leadSincronizarImpl = bindReaction(leadSincronizar, {
|
|
187
|
+
emits: ['lead.sincronizado'],
|
|
188
|
+
handler: async (ctx, event) => {
|
|
189
|
+
const externo = await crm.upsertLead(event.data.leadId)
|
|
190
|
+
await ctx.emit('lead.sincronizado', { leadId: event.data.leadId, externoId: externo.id })
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Diferente de uma action, uma reaction só publica o que está em `emits`. Um evento fora da lista,
|
|
196
|
+
inclusive de uma reaction sem `emits`, é descartado, e o runtime registra o aviso
|
|
197
|
+
`runtime.reaction_emit_ignored` com a reaction, o evento e a lista declarada. O aviso vem de
|
|
198
|
+
`config.warnUndeclaredEmits`, ativo por padrão fora de produção.
|
|
199
|
+
|
|
200
|
+
O evento publicado mantém `actor`, `tenant` e `source` do evento que a reaction processava: `source`
|
|
201
|
+
continua apontando para a action que iniciou a cadeia. O campo `cause` registra a reaction que
|
|
202
|
+
publicou, o evento de origem e a profundidade na cadeia, que começa em 1. Evento emitido por action
|
|
203
|
+
não tem `cause`.
|
|
204
|
+
|
|
205
|
+
Uma cadeia pode dar até `config.maxReactionDepth` saltos de reaction (padrão 5). O limite impede que
|
|
206
|
+
um laço, como uma reaction que publica um evento que volta a acioná-la, consuma o processo. O
|
|
207
|
+
`ctx.emit` que passaria do limite não publica e registra o erro `runtime.reaction_chain_too_deep`
|
|
208
|
+
com a cadeia observada. Com `emitMode: 'strict'`, o mesmo código também interrompe a reaction.
|
|
209
|
+
|
|
210
|
+
O manifest projeta o `emits` de cada reaction, e o [`opus introspect`](/docs/cli) liga esse `emits`
|
|
211
|
+
às reactions que escutam o evento.
|
|
212
|
+
|
|
213
|
+
## Limites atuais
|
|
114
214
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
215
|
+
O driver `mitt` funciona no mesmo processo, sem durabilidade nem entrega entre processos. As
|
|
216
|
+
políticas de entrega também valem por processo, em memória: com vários processos consumindo o mesmo
|
|
217
|
+
barramento, cada um mantém o próprio `dedup` e os próprios limites de concorrência, e um evento que
|
|
218
|
+
espera vaga se perde se o processo cair.
|
|
119
219
|
|
|
120
|
-
`retry`, `timeout`, `dedup` e `concurrency` são aceitos na declaração, mas o runtime ainda não os
|
|
121
|
-
aplica: uma reaction que falha não é repetida.
|
|
@@ -41,8 +41,10 @@ subdomínios; um nome repetido de action, reaction, schedule ou Produto de Dados
|
|
|
41
41
|
entre domínios registrados separadamente.
|
|
42
42
|
|
|
43
43
|
O `start()` falha quando há reactions registradas sem `eventBus` (`runtime.eventbus_required`) ou
|
|
44
|
-
schedules sem `scheduler` (`runtime.scheduler_required`)
|
|
45
|
-
|
|
44
|
+
schedules sem `scheduler` (`runtime.scheduler_required`), e quando um schedule ativo aponta para uma
|
|
45
|
+
action não registrada (`runtime.schedule_action_not_found`) ou que não aceita execução pelo sistema
|
|
46
|
+
(`runtime.schedule_action_not_automatable`); veja [Agendador](/docs/scheduler). A declaração de um
|
|
47
|
+
domínio está em [Actions & contratos](/docs/actions).
|
|
46
48
|
|
|
47
49
|
## As capacidades
|
|
48
50
|
|
|
@@ -122,6 +124,13 @@ Duas formas de código rodam sem uma requisição: um evento de domínio dispara
|
|
|
122
124
|
[reaction](/docs/events), e o tempo dispara um [schedule](/docs/scheduler), que executa uma action
|
|
123
125
|
com provenance própria. Ambos são registrados junto com as actions, e o manifest projeta os três.
|
|
124
126
|
|
|
127
|
+
O runtime aplica a cada reaction a política de entrega declarada — `retry`, `timeout`, `dedup` e
|
|
128
|
+
`concurrency` — e limita as execuções simultâneas de todas elas com `config.maxConcurrentReactions`
|
|
129
|
+
(padrão 10). Uma reaction publica os eventos que declara em `emits`, e a cadeia para em
|
|
130
|
+
`config.maxReactionDepth` saltos (padrão 5). O schedule executa a action como o sistema, sem
|
|
131
|
+
usuário, quando o contrato dela declara `automatable: true`; `isSystemActor(ctx)` identifica essa
|
|
132
|
+
execução no `authorize` e no handler.
|
|
133
|
+
|
|
125
134
|
`opus introspect` lista reactions, schedules e as ligações entre eles, mas só reconhece
|
|
126
135
|
declarações feitas com `defineAction`, `defineReaction` e `defineSchedule`. Actions do par
|
|
127
136
|
`defineContract` e `bindAction` e reactions do par `defineReactionContract` e `bindReaction` ficam de
|
|
@@ -5,8 +5,8 @@ title: Agendador
|
|
|
5
5
|
# Agendador
|
|
6
6
|
|
|
7
7
|
Use um schedule para executar uma action por cron ou em intervalos, sem uma requisição. O
|
|
8
|
-
`SchedulerAdapter` registra o agendamento, e o runtime executa a action
|
|
9
|
-
que aparece no audit e no contexto do handler.
|
|
8
|
+
`SchedulerAdapter` registra o agendamento, e o runtime executa a action como o sistema, com
|
|
9
|
+
provenance `schedule`, que aparece no audit e no contexto do handler.
|
|
10
10
|
|
|
11
11
|
## O contrato
|
|
12
12
|
|
|
@@ -47,7 +47,7 @@ Declare o schedule com `defineSchedule` e registre-o no domínio, ao lado da act
|
|
|
47
47
|
|
|
48
48
|
```ts
|
|
49
49
|
import { createRuntime, defineDomain, defineSchedule } from '@softize/opus/core'
|
|
50
|
-
import { caixaFechar } from './caixa-fechar.ts' // a action 'caixa.fechar',
|
|
50
|
+
import { caixaFechar } from './caixa-fechar.ts' // a action 'caixa.fechar', preparada abaixo
|
|
51
51
|
|
|
52
52
|
const fecharDia = defineSchedule({
|
|
53
53
|
name: 'caixa.fechar-dia',
|
|
@@ -71,22 +71,63 @@ await runtime.start()
|
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
No `start()`, o runtime confere se cada schedule aponta para uma action registrada no mesmo runtime
|
|
74
|
-
e depois o registra no adapter.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
e que aceita execução pelo sistema, e depois o registra no adapter. As duas conferências acontecem
|
|
75
|
+
antes de inicializar os adapters, e cada mensagem lista os schedules e as actions afetadas. Um nome
|
|
76
|
+
sem action registrada interrompe o `start()` com `runtime.schedule_action_not_found`; uma action
|
|
77
|
+
sem `automatable` nem `public`, com `runtime.schedule_action_not_automatable` (veja a próxima
|
|
78
|
+
seção). Com schedules registrados e sem `scheduler`, o `start()` falha com
|
|
79
|
+
`runtime.scheduler_required`. Um schedule com `enabled: false` não é registrado nem
|
|
78
80
|
conferido, então pode apontar para uma action que ainda não existe no ambiente.
|
|
79
81
|
|
|
80
82
|
## Preparar a action agendada
|
|
81
83
|
|
|
82
|
-
O schedule executa a action
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
O schedule executa a action como o sistema, sem uma pessoa por trás. Uma action que exige usuário
|
|
85
|
+
recusa o sistema até que o contrato dela aceite essa execução com `automatable: true`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { defineContract, isSystemActor } from '@softize/opus/core'
|
|
89
|
+
|
|
90
|
+
export const caixaFecharContract = defineContract({
|
|
91
|
+
name: 'caixa.fechar',
|
|
92
|
+
kind: 'simple',
|
|
93
|
+
input: z.object({}),
|
|
94
|
+
output: z.object({ fechados: z.number() }),
|
|
95
|
+
automatable: true,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
export const caixaFechar = bindAction(caixaFecharContract, {
|
|
99
|
+
// O sistema não tem permissões: aceite-o de forma explícita ao lado da regra das pessoas.
|
|
100
|
+
authorize: (ctx) => isSystemActor(ctx) || ctx.can('caixa:fechar'),
|
|
101
|
+
handler: async (ctx) => ({ fechados: await caixa.fecharDia() }),
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`automatable` só vale para o disparo de um schedule. A action continua exigindo usuário em
|
|
106
|
+
qualquer outra chamada: uma requisição HTTP sem usuário recebe `auth.unauthenticated`, como antes,
|
|
107
|
+
mesmo que informe uma provenance `schedule`.
|
|
108
|
+
|
|
109
|
+
Na execução pelo sistema, `ctx.user` é `null`, `ctx.can` nega toda permissão e `ctx.provenance` é
|
|
110
|
+
`{ kind: 'schedule', scheduleName }`. `isSystemActor(ctx)` responde `true` só quando o próprio
|
|
111
|
+
runtime criou essa execução, a partir de um schedule ou do job em background que ele enfileirou; o
|
|
112
|
+
job leva a identidade em uma marca que o runtime grava no envelope, e não pela provenance. Uma tool
|
|
113
|
+
que a action agendada chama por `ctx.ai` não herda a identidade.
|
|
114
|
+
|
|
115
|
+
Aceite o sistema no `authorize` escrito como função, com `isSystemActor(ctx)`, como no exemplo. Um
|
|
116
|
+
`authorize` em string, como `'user.role == "admin"'`, não tem como reconhecer o sistema: para uma
|
|
117
|
+
action agendada com autorização própria, use a função. Não teste `ctx.provenance.kind` para esse
|
|
118
|
+
fim: qualquer código do servidor que chama `runtime.execute` pode preencher a provenance com
|
|
119
|
+
`schedule`. Sem `authorize`, `automatable` basta. O audit registra o ator como
|
|
120
|
+
`{ id: null, type: 'system' }`, com o nome do schedule na provenance.
|
|
121
|
+
|
|
122
|
+
Um schedule ativo que aponta para uma action sem `automatable` e sem `public` impede o `start()` com
|
|
123
|
+
`runtime.schedule_action_not_automatable`. Para resolver, declare `automatable: true` no contrato da
|
|
124
|
+
action ou, se o agendamento não deve rodar neste ambiente, `enabled: false` no schedule. Uma action
|
|
125
|
+
`public: true` continua podendo ser agendada, mas fica aberta a qualquer chamador; prefira
|
|
126
|
+
`automatable`.
|
|
86
127
|
|
|
87
128
|
Quando um disparo não conclui a action, o runtime registra um erro no log com o schedule, a action,
|
|
88
129
|
o código e a mensagem do erro; o agendamento continua ativo e o próximo disparo executa a action de
|
|
89
|
-
novo. Uma falha dentro do pipeline da action, como `
|
|
130
|
+
novo. Uma falha dentro do pipeline da action, como um erro do `authorize` ou do handler,
|
|
90
131
|
também fica registrada no audit. O callback `onError` do driver recebe somente erros lançados antes
|
|
91
132
|
da execução, como uma falha na função `input`.
|
|
92
133
|
|
|
@@ -12,8 +12,8 @@ autorização. `@softize/opus/testing` executa esse fluxo em unidade, sem montar
|
|
|
12
12
|
`runAction` segue a ordem do runtime: valida a entrada, exige usuário quando a action não é
|
|
13
13
|
`public`, avalia `authorize`, executa o handler e valida a saída. Em `list`, a validação da saída é
|
|
14
14
|
pulada, porque o schema descreve o item e o handler devolve a página. Falhas usam o mesmo
|
|
15
|
-
`ActionError` tipado do runtime, como `validation.invalid_input`, `auth.unauthenticated
|
|
16
|
-
`auth.forbidden`.
|
|
15
|
+
`ActionError` tipado do runtime, como `validation.invalid_input`, `auth.unauthenticated`,
|
|
16
|
+
`auth.system_forbidden` e `auth.forbidden`.
|
|
17
17
|
|
|
18
18
|
Passe a action ligada ao handler, produzida por `bindAction`. O contrato puro não tem handler e só
|
|
19
19
|
executa em modo design, descrito abaixo.
|
|
@@ -59,10 +59,22 @@ opcionais.
|
|
|
59
59
|
| `ai` | Adapter de IA entregue em `ctx.ai`. Sem runtime, `run` e `runStream` não recebem tools. |
|
|
60
60
|
| `meta`, `trace` | Valores de `ctx.meta` e `ctx.trace`. |
|
|
61
61
|
| `loaded` | Substitui os loaders e chega ao `authorize` e ao handler. |
|
|
62
|
+
| `schedule` | Executa como o sistema, no disparo do schedule com esse nome: ignora `user` e `can`. |
|
|
62
63
|
|
|
63
64
|
`requires` é declarativo também no harness: nada o executa. Um handler que chama `ctx.can` recebe a
|
|
64
65
|
resposta da opção `can`.
|
|
65
66
|
|
|
67
|
+
Para uma action agendada, a opção `schedule` reproduz a execução pelo sistema com a mesma regra do
|
|
68
|
+
runtime: `ctx.user` é `null`, `ctx.can` nega tudo e `isSystemActor(ctx)` responde `true`. Sem
|
|
69
|
+
`automatable: true` no contrato, a action falha com `auth.system_forbidden`:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
it('o fechamento diário roda pelo sistema', async () => {
|
|
73
|
+
const { output } = await runAction(caixaFechar, {}, { schedule: 'caixa.fechar-dia' })
|
|
74
|
+
expect(output.fechados).toBeGreaterThan(0)
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
66
78
|
## Observar efeitos
|
|
67
79
|
|
|
68
80
|
`runAction` devolve, além de `output`, os efeitos capturados: `emitted` guarda cada `ctx.emit` e
|