@softize/opus 20.0.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 +141 -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/0020-icon-tiles-are-explicitly-composed.md +8 -0
- package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +14 -1
- package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
- 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/components/patterns/content-header.tsx +94 -15
- package/src/ui/components/patterns/list.tsx +2 -14
- package/src/ui/components/patterns/surface-header.tsx +58 -25
- package/src/ui/components/primitives/card.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +1 -1
- package/src/ui/components/primitives/drawer.tsx +1 -1
- package/src/ui/docs/content/action-list.md +7 -6
- 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/content.md +88 -20
- package/src/ui/docs/content/events.md +109 -11
- package/src/ui/docs/content/icon-tile.md +11 -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/ui/meta.ts +1 -1
- package/src/ui/react.tsx +1 -0
|
@@ -117,7 +117,7 @@ function DrawerContent({
|
|
|
117
117
|
data-slot="drawer-content"
|
|
118
118
|
data-layout={layout}
|
|
119
119
|
className={cn(
|
|
120
|
-
"fixed z-50 flex flex-col bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
|
120
|
+
"fixed z-50 flex flex-col bg-background shadow-lg transition ease-in-out [--surface-background:var(--background)] data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
|
121
121
|
side === "right" &&
|
|
122
122
|
"inset-y-0 right-0 h-full w-3/4 max-w-sm border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
|
123
123
|
side === "left" &&
|
|
@@ -96,10 +96,11 @@ apresentação para o consumidor, como cards de status acima da lista. No modal,
|
|
|
96
96
|
`section` formam um grupo, e `advancedFilters.columns` distribui os grupos em até três colunas.
|
|
97
97
|
Use `advancedFilters.sectionIcons` quando os grupos representarem dimensões recorrentes; a chave é
|
|
98
98
|
o mesmo rótulo de `section`, e cada grupo usa `Content as="fieldset"` para manter uma legenda
|
|
99
|
-
nativa.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
nativa. O ícone ocupa a mídia do grupo, antes do título, e recebe a moldura padrão: passe só o
|
|
100
|
+
glifo. Um `IconTile` é adotado pela mídia com o que declara, e um ícone `null` não desenha nada.
|
|
101
|
+
Quando os grupos precisarem de limites mais explícitos, `advancedFilters.sectionVariant: 'framed'`
|
|
102
|
+
envolve cada seção e pede ao `Content` o acabamento `frameVariant="legend"`, integrando visualmente
|
|
103
|
+
a legenda à borda sem atribuir esse design à semântica de `fieldset`.
|
|
103
104
|
Um filtro `select` pode declarar `searchable: true` quando a lista fechada for longa; a opção geral
|
|
104
105
|
continua disponível e participa da busca. Campos de data avançados usam calendário. A antiga `advanced: true` continua aceita durante a
|
|
105
106
|
migração e equivale a `placement: 'advanced'`.
|
|
@@ -138,8 +139,8 @@ filters: {
|
|
|
138
139
|
columns: 2,
|
|
139
140
|
sectionVariant: 'framed',
|
|
140
141
|
sectionIcons: {
|
|
141
|
-
Relacionamento: <
|
|
142
|
-
Datas: <
|
|
142
|
+
Relacionamento: <UserRound />,
|
|
143
|
+
Datas: <CalendarDays />,
|
|
143
144
|
},
|
|
144
145
|
}}
|
|
145
146
|
/>
|
|
@@ -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á.
|
|
@@ -31,21 +31,15 @@ render(
|
|
|
31
31
|
|
|
32
32
|
Use `framed` quando título e corpo precisarem formar uma única região delimitada. A moldura pertence
|
|
33
33
|
ao `Content`; componentes internos, como `DetailGroup`, continuam responsáveis somente pelo layout
|
|
34
|
-
e pela semântica dos próprios dados.
|
|
34
|
+
e pela semântica dos próprios dados. Por padrão, a borda permanece contínua e o header fica dentro
|
|
35
|
+
do recuo da região.
|
|
35
36
|
|
|
36
37
|
```tsx preview col
|
|
37
38
|
render(
|
|
38
39
|
<Content
|
|
39
40
|
framed
|
|
40
|
-
title=
|
|
41
|
-
|
|
42
|
-
<UserRound
|
|
43
|
-
className="size-4 text-muted-foreground"
|
|
44
|
-
aria-hidden="true"
|
|
45
|
-
/>
|
|
46
|
-
Cliente
|
|
47
|
-
</span>
|
|
48
|
-
}
|
|
41
|
+
title="Cliente"
|
|
42
|
+
media={<UserRound aria-hidden="true" />}
|
|
49
43
|
>
|
|
50
44
|
<DetailGroup columns={2}>
|
|
51
45
|
<DetailField label="Nome" value="Carla Souza" />
|
|
@@ -55,16 +49,66 @@ render(
|
|
|
55
49
|
);
|
|
56
50
|
```
|
|
57
51
|
|
|
52
|
+
## Header integrado à moldura
|
|
53
|
+
|
|
54
|
+
Quando uma região compacta precisar integrar visualmente o header à borda, use
|
|
55
|
+
`frameVariant="legend"`. O acabamento não muda a semântica: uma região de leitura continua sendo
|
|
56
|
+
uma `section`, o título continua sendo um heading e descrição, contador e ações permanecem
|
|
57
|
+
disponíveis.
|
|
58
|
+
|
|
59
|
+
```tsx preview col
|
|
60
|
+
render(
|
|
61
|
+
<Content
|
|
62
|
+
framed
|
|
63
|
+
frameVariant="legend"
|
|
64
|
+
title="Veículo"
|
|
65
|
+
media={<CarFront aria-hidden="true" />}
|
|
66
|
+
>
|
|
67
|
+
<DetailGroup columns={2}>
|
|
68
|
+
<DetailField label="Placa" value="ABC1D23" />
|
|
69
|
+
<DetailField label="Modelo" value="Corolla XEi" />
|
|
70
|
+
</DetailGroup>
|
|
71
|
+
</Content>,
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Para interromper a borda, o header pinta o título e as ações com o fundo da superfície que hospeda o
|
|
76
|
+
`Content`, lido da variável CSS `--surface-background`. `Card`, `Dialog` e `Drawer` já a declaram
|
|
77
|
+
com o fundo que pintam; fora deles, vale `--background`. Uma superfície com fundo próprio, como um
|
|
78
|
+
`Card` com `bg-*`, um `Popover` ou um contêiner da aplicação, precisa declarar a variável com o
|
|
79
|
+
mesmo fundo. Sem isso, a faixa atrás do título aparece com a cor da página.
|
|
80
|
+
|
|
81
|
+
```tsx preview col
|
|
82
|
+
render(
|
|
83
|
+
<Card className="bg-muted [--surface-background:var(--muted)]">
|
|
84
|
+
<CardBody>
|
|
85
|
+
<Content framed frameVariant="legend" title="Veículo">
|
|
86
|
+
<DetailGroup columns={2}>
|
|
87
|
+
<DetailField label="Placa" value="ABC1D23" />
|
|
88
|
+
<DetailField label="Modelo" value="Corolla XEi" />
|
|
89
|
+
</DetailGroup>
|
|
90
|
+
</Content>
|
|
91
|
+
</CardBody>
|
|
92
|
+
</Card>,
|
|
93
|
+
);
|
|
94
|
+
```
|
|
95
|
+
|
|
58
96
|
## Grupo de campos
|
|
59
97
|
|
|
60
98
|
Quando a região nomear um conjunto de campos relacionados, use `as="fieldset"`. O `Content`
|
|
61
99
|
renderiza o título como `legend`, preserva as props nativas do `fieldset` e mantém o mesmo contrato
|
|
62
100
|
de título e corpo. Essa escolha altera somente a semântica e o comportamento nativo; com `framed`,
|
|
63
|
-
a moldura permanece contínua.
|
|
101
|
+
a moldura permanece contínua. A mídia usa a mesma apresentação da `section`; contador, descrição e
|
|
102
|
+
ações não entram na legenda.
|
|
64
103
|
|
|
65
104
|
```tsx preview col
|
|
66
105
|
render(
|
|
67
|
-
<Content
|
|
106
|
+
<Content
|
|
107
|
+
as="fieldset"
|
|
108
|
+
framed
|
|
109
|
+
title="Proprietário"
|
|
110
|
+
media={<UserRound aria-hidden="true" />}
|
|
111
|
+
>
|
|
68
112
|
<FieldGroup>
|
|
69
113
|
<Field>
|
|
70
114
|
<FieldLabel htmlFor="owner-name">Nome</FieldLabel>
|
|
@@ -79,8 +123,9 @@ render(
|
|
|
79
123
|
);
|
|
80
124
|
```
|
|
81
125
|
|
|
82
|
-
|
|
83
|
-
|
|
126
|
+
O mesmo acabamento pode integrar a legenda nativa à borda. Declare `frameVariant="legend"` em
|
|
127
|
+
agrupamentos compactos, como as seções de filtros avançados; `as="fieldset"` continua sendo a
|
|
128
|
+
decisão semântica, e não uma forma de obter esse desenho.
|
|
84
129
|
|
|
85
130
|
```tsx preview col
|
|
86
131
|
render(
|
|
@@ -104,13 +149,16 @@ render(
|
|
|
104
149
|
|
|
105
150
|
Use os slots quando o header precisar de composição própria. A árvore aceita exatamente um
|
|
106
151
|
`ContentHeader` e um `ContentBody` como filhos diretos. O header exige um `ContentTitle` e aceita
|
|
107
|
-
uma descrição, um contador (`ContentMeta`) e uma região de ações. O
|
|
108
|
-
`Content`; `PageHeader` reconhece somente navegação, título e ações.
|
|
152
|
+
uma mídia (`ContentMedia`), uma descrição, um contador (`ContentMeta`) e uma região de ações. O
|
|
153
|
+
contador é próprio de `Content`; `PageHeader` reconhece somente navegação, título e ações.
|
|
109
154
|
|
|
110
155
|
```tsx preview col
|
|
111
156
|
render(
|
|
112
157
|
<Content level={2}>
|
|
113
158
|
<ContentHeader>
|
|
159
|
+
<ContentMedia>
|
|
160
|
+
<Monitor aria-hidden="true" />
|
|
161
|
+
</ContentMedia>
|
|
114
162
|
<ContentTitle>Dispositivos conectados</ContentTitle>
|
|
115
163
|
<ContentMeta>3</ContentMeta>
|
|
116
164
|
<ContentDescription>Sessões com acesso à sua conta.</ContentDescription>
|
|
@@ -166,6 +214,7 @@ classes adicionais; um `Content` irmão, sem lista, mantém a altura do próprio
|
|
|
166
214
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
167
215
|
| -------------- | ---------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
168
216
|
| `title` | `ReactNode` | | Forma curta: título da região (vira o heading ligado à `section`). |
|
|
217
|
+
| `media` | `ReactNode` | | Forma curta: ícone ou imagem que identifica a região antes do título. |
|
|
169
218
|
| `count` | `number` | | Forma curta: total de itens ao lado do título da região. |
|
|
170
219
|
| `description` | `ReactNode` | | Forma curta: frase de apoio sob o título. |
|
|
171
220
|
| `actions` | `ReactNode` | | Forma curta: ações sobre a região inteira, no extremo oposto ao título. |
|
|
@@ -173,11 +222,30 @@ classes adicionais; um `Content` irmão, sem lista, mantém a altura do próprio
|
|
|
173
222
|
| `variant` | `'page' \| 'section'` | `'section'` | Hierarquia visual: `page` reproduz o cabeçalho de `Page`; `section` é a região dentro de uma superfície. |
|
|
174
223
|
| `as` | `'section' \| 'fieldset'` | `'section'` | Usa uma região com heading ou um grupo de campos cujo título é a legenda nativa. |
|
|
175
224
|
| `framed` | `boolean` | `false` | Envolve o header e o body com a moldura estrutural da região. |
|
|
176
|
-
| `frameVariant` | `'default' \| 'legend'` | `'default'` |
|
|
225
|
+
| `frameVariant` | `'default' \| 'legend'` | `'default'` | Mantém a borda contínua ou integra visualmente o header à moldura. Não altera o elemento nem sua semântica. |
|
|
177
226
|
| `children` | `ReactNode` | | Corpo da região na forma curta, ou `ContentHeader` e `ContentBody` na composição explícita. |
|
|
178
227
|
|
|
179
228
|
As demais propriedades são as nativas de `section` ou, com `as="fieldset"`, de `fieldset`.
|
|
180
229
|
|
|
181
|
-
Na composição explícita
|
|
182
|
-
`
|
|
183
|
-
|
|
230
|
+
Na composição explícita de uma `section`, `ContentHeader` recebe `ContentMedia`, `ContentTitle`,
|
|
231
|
+
`ContentMeta`, `ContentDescription` e `ContentActions`. Em `fieldset`, recebe `ContentMedia` e
|
|
232
|
+
`ContentTitle`, preservando a apresentação enquanto a legenda continua nomeando somente o grupo de
|
|
233
|
+
campos. `ContentBody` recebe o conteúdo; nenhum desses slots aceita `title` ou `level` próprios — a
|
|
234
|
+
hierarquia é declarada em `Content`. `ContentHeader` não existe fora de `Content`.
|
|
235
|
+
|
|
236
|
+
## Propriedades de ContentMedia
|
|
237
|
+
|
|
238
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
239
|
+
| ----------- | ------------------------------------------------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
240
|
+
| `size` | `'xs' \| 'sm' \| 'default' \| 'lg'` | `'xs'` | Tamanho da moldura e do glifo na escala compartilhada dos controles. |
|
|
241
|
+
| `context` | `'neutral' \| 'primary' \| 'info' \| 'success' \| 'warning' \| 'danger'` | `'neutral'` | Declara o significado da cor. |
|
|
242
|
+
| `variant` | `'subtle' \| 'outline'` | `'subtle'` | Usa preenchimento tonal ou acrescenta borda. |
|
|
243
|
+
| `className` | `string` | | Ajusta a moldura sem substituir a escala. |
|
|
244
|
+
| `asChild` | `boolean` | `false` | Cede o elemento para uma marca com visual próprio — um orbe, um logotipo — que ocupa o slot sem ganhar a moldura por cima. |
|
|
245
|
+
|
|
246
|
+
`ContentMedia` já é a moldura. Passe só o glifo e escolha `size`, `context` e `variant` no slot. Um
|
|
247
|
+
`IconTile` passado em `media` ou dentro de `ContentMedia` não vira moldura dentro de moldura: o slot
|
|
248
|
+
assume o lugar dele e herda o que ele declara. `media` com `null` ou `false` não desenha nada.
|
|
249
|
+
|
|
250
|
+
Em `fieldset`, a mídia é sempre decorativa: o `Content` a esconde da árvore de acessibilidade para
|
|
251
|
+
que o grupo seja anunciado apenas pelo título.
|
|
@@ -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.
|
|
@@ -13,24 +13,24 @@ com tamanho de ícone e um nome acessível.
|
|
|
13
13
|
O tamanho controla a caixa e o glifo pela escala compartilhada dos controles. Um ícone que declara
|
|
14
14
|
uma classe `size-*` preserva a própria medida.
|
|
15
15
|
|
|
16
|
-
## Em
|
|
16
|
+
## Em slots de ícone e de mídia
|
|
17
17
|
|
|
18
|
-
Slots de ícone não criam a moldura
|
|
19
|
-
|
|
18
|
+
Slots genéricos de ícone, como `FieldLegend.icon`, não criam a moldura: passe o glifo quando quiser
|
|
19
|
+
só o ícone e componha `IconTile` quando a moldura reforçar a hierarquia.
|
|
20
|
+
|
|
21
|
+
Os slots de mídia das superfícies já são um `IconTile`: `CardMedia`, `ContentMedia`, `DialogMedia`,
|
|
22
|
+
`DrawerMedia` e `PaneMedia` desenham a moldura sozinhos. Passe só o glifo e escolha `size`,
|
|
23
|
+
`context` e `variant` no próprio slot. Em [`Content`](/ui/content), um `IconTile` passado em
|
|
24
|
+
`media` é adotado pelo slot, que herda o que ele declara, em vez de desenhar uma moldura dentro da
|
|
25
|
+
outra.
|
|
20
26
|
|
|
21
27
|
```tsx preview col md
|
|
22
28
|
<Content
|
|
23
29
|
as="fieldset"
|
|
24
30
|
framed
|
|
25
31
|
frameVariant="legend"
|
|
26
|
-
title=
|
|
27
|
-
|
|
28
|
-
<IconTile size="xs">
|
|
29
|
-
<CarFront />
|
|
30
|
-
</IconTile>
|
|
31
|
-
Veículo
|
|
32
|
-
</span>
|
|
33
|
-
}
|
|
32
|
+
title="Veículo"
|
|
33
|
+
media={<CarFront aria-hidden="true" />}
|
|
34
34
|
>
|
|
35
35
|
<FieldGroup>
|
|
36
36
|
<Field>
|
|
@@ -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
|
package/src/ui/meta.ts
CHANGED
|
@@ -123,7 +123,7 @@ export const componentMeta = {
|
|
|
123
123
|
name: "content-header",
|
|
124
124
|
ancestry: "opus",
|
|
125
125
|
whenToUse:
|
|
126
|
-
'Estruture uma região com título e corpo. No caso comum, declare título, descrição, contador e ações diretamente em Content; use `framed` quando a região precisar de limite próprio, `
|
|
126
|
+
'Estruture uma região com título e corpo. No caso comum, declare título, mídia, descrição, contador e ações diretamente em Content; use `framed` quando a região precisar de limite próprio, `frameVariant="legend"` para integrar visualmente o header à borda e `as="fieldset"` somente para agrupar campos sob uma legenda nativa. Componha ContentHeader apenas quando precisar controlar a anatomia.',
|
|
127
127
|
},
|
|
128
128
|
copyable: {
|
|
129
129
|
name: "copyable",
|