@softize/opus 22.0.0 → 22.2.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 +59 -0
- package/bin/cli.mjs +18 -1
- package/bin/lib/check.mjs +28 -2
- package/bin/lib/gen-manifest.mjs +2 -0
- package/bin/lib/gen-runner.mjs +5 -0
- package/docs/adr/0037-design-data-lives-outside-the-contract.md +117 -0
- package/docs/adr/0041-actions-declare-effect-and-personal-data.md +139 -0
- package/docs/protocol.md +15 -2
- package/package.json +5 -1
- package/registry/skills/create-opus-action/SKILL.md +7 -2
- package/registry/skills/create-opus-action/references/contract-and-binding.md +11 -4
- package/registry/skills/create-opus-action/references/evaluations.md +3 -0
- package/registry/skills/create-opus-action/scripts/scaffold.mjs +6 -4
- package/registry/skills/test-opus-action/SKILL.md +2 -2
- package/registry/skills/test-opus-action/references/harness.md +2 -2
- package/registry/templates/app/src/App.tsx +1 -1
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +2 -19
- package/registry/templates/app/src/domains/tasks/actions/list.ts +5 -5
- package/registry/templates/app/vite.config.ts +3 -2
- package/src/core/action-nature.ts +40 -0
- package/src/core/audit.ts +12 -4
- package/src/core/domain.ts +2 -2
- package/src/core/index.ts +2 -0
- package/src/core/runtime.ts +56 -1
- package/src/core/types.ts +46 -20
- package/src/design/index.ts +179 -0
- package/src/mcp/index.ts +6 -0
- package/src/testing/fake.ts +2 -2
- package/src/testing/index.ts +7 -3
- package/src/ui/docs/content/actions.md +79 -44
- package/src/ui/docs/content/mcp.md +6 -0
- package/src/ui/docs/content/testing.md +9 -25
- package/src/vite/design.ts +133 -23
package/src/testing/index.ts
CHANGED
|
@@ -206,9 +206,13 @@ export interface TestableAction<In, Out, ParsedIn = In> {
|
|
|
206
206
|
authorize?: AuthorizeSpec<ParsedIn>
|
|
207
207
|
input: Schema<In, ParsedIn>
|
|
208
208
|
output: Schema<Out> | Schema<unknown, Out>
|
|
209
|
-
/** Opcional: contrato puro (sem handler) executa
|
|
209
|
+
/** Opcional: contrato puro (sem handler) só executa pelo `mockHandler` obsoleto. */
|
|
210
210
|
handler?: HandlerFn<ParsedIn, Out>
|
|
211
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime.
|
|
213
|
+
*
|
|
214
|
+
* @deprecated Desde a 22.1.0 (ADR 0037); teste a action ligada ao handler.
|
|
215
|
+
*/
|
|
212
216
|
mockHandler?: HandlerFn<ParsedIn, Out>
|
|
213
217
|
}
|
|
214
218
|
|
|
@@ -329,7 +333,7 @@ export async function runAction<In, Out, ParsedIn = In>(
|
|
|
329
333
|
throw error({
|
|
330
334
|
code: 'testing.no_handler',
|
|
331
335
|
category: 'internal',
|
|
332
|
-
message: `Action "${action.name}" não tem handler executável
|
|
336
|
+
message: `Action "${action.name}" não tem handler executável: teste o ActionDef produzido pelo bindAction. Os dados do modo design são gerados pelo plugin opusDesign() e não são comportamento da action.`,
|
|
333
337
|
})
|
|
334
338
|
}
|
|
335
339
|
const raw = await handlerToRun(ctx, parsed, loaded)
|
|
@@ -132,7 +132,7 @@ nessa estrutura. Os grupos seguem esta sequência; dentro de cada grupo, a ordem
|
|
|
132
132
|
```text
|
|
133
133
|
name, kind
|
|
134
134
|
→ label, title, summary, description, icon, color, messages, tags, examples, errors
|
|
135
|
-
→ input, output
|
|
135
|
+
→ input, output, effect, personalData
|
|
136
136
|
→ public, requires, authorize, loads
|
|
137
137
|
→ handler, mockHandler
|
|
138
138
|
→ audit, confirm, invalidates, rateLimit, idempotency
|
|
@@ -146,60 +146,97 @@ Declare `input` e `output` com `z.object` e use os tipos lógicos do catálogo e
|
|
|
146
146
|
validações: `z.object({ tag: t.slug().zod(), contato: t.email().zod() })`. O `.zod()` devolve o
|
|
147
147
|
schema zod de cada tipo lógico (slug, email, phone, money…), importado de `@softize/opus/schema/zod`.
|
|
148
148
|
|
|
149
|
+
## Declarar o que a action faz com os dados
|
|
150
|
+
|
|
151
|
+
Dois fatos sobre a action valem para qualquer consumidor, da interface à IA, e ficam declarados no
|
|
152
|
+
contrato:
|
|
153
|
+
|
|
154
|
+
- **`effect`** diz se a action só lê (`'read'`) ou altera estado (`'write'`). `list` e `view` já são
|
|
155
|
+
leitura, porque são servidas por `GET`, e não precisam declarar. `simple` e `form` declaram,
|
|
156
|
+
porque o `kind` não diz isso: uma análise sob demanda costuma ser `simple` e só ler.
|
|
157
|
+
- **`personalData`** diz se a resposta contém dados que identificam pessoas, como nome, documento,
|
|
158
|
+
contato, placa ou texto livre sobre alguém. `false` também é uma declaração.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
export const salesPerformanceContract = defineContract({
|
|
162
|
+
name: 'sales.performance',
|
|
163
|
+
kind: 'simple',
|
|
164
|
+
input: z.object({ period: z.string() }),
|
|
165
|
+
output: z.object({ total: z.number() }),
|
|
166
|
+
effect: 'read',
|
|
167
|
+
personalData: false,
|
|
168
|
+
})
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Com `personalData: true`, toda execução bem-sucedida fica marcada no registro de auditoria com a
|
|
172
|
+
quantidade de registros devolvidos, inclusive quando a action declara `audit: false`; nesse caso o
|
|
173
|
+
registro é mínimo, sem entrada nem saída. No modo design, a resposta simulada não é marcada. Um
|
|
174
|
+
host de IA pode usar `effect` para decidir o que oferecer ao modelo sem procedimento, e o MCP publica
|
|
175
|
+
a anotação `readOnlyHint`. O `opus check` avisa quantas actions ainda não declaram os dois fatos; uma
|
|
176
|
+
major futura passará a exigi-los.
|
|
177
|
+
|
|
149
178
|
A autorização é fechada por padrão: sem `public: true`, a action exige usuário autenticado; com
|
|
150
179
|
`authorize`, exige também a permissão declarada. `automatable: true` permite que um
|
|
151
180
|
[schedule](/docs/scheduler) execute a action como o sistema, sem abri-la a chamadores anônimos.
|
|
152
181
|
|
|
153
182
|
## Prototipar com dados simulados
|
|
154
183
|
|
|
155
|
-
Para desenhar a interface antes de o backend existir,
|
|
156
|
-
|
|
157
|
-
`
|
|
158
|
-
|
|
184
|
+
Para desenhar a interface antes de o backend existir, rode o app em modo design. Nesse modo, toda
|
|
185
|
+
action responde sem que o contrato declare nada: a resposta é gerada pelo `fake` a partir do
|
|
186
|
+
`output` do contrato (ADR 0037).
|
|
187
|
+
|
|
188
|
+
- A mesma requisição recebe sempre a mesma resposta.
|
|
189
|
+
- Uma `list` devolve três páginas do tamanho pedido em `limit` (20 quando a entrada não pede, até
|
|
190
|
+
100) e respeita a paginação declarada. Busca e filtros não filtram o dado gerado.
|
|
191
|
+
- Quando a entrada e a saída têm `id`, a saída repete o `id` da entrada, desde que o schema da saída
|
|
192
|
+
aceite esse valor. Assim, abrir um item da lista mostra o mesmo identificador.
|
|
159
193
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
194
|
+
O dado gerado tem o formato certo, mas não segue a regra de negócio: os valores de um enum são
|
|
195
|
+
sorteados, e uma listagem e um detalhe só coincidem no `id`. Quando o design precisa de coerência,
|
|
196
|
+
como um item em cada situação ou algo criado que aparece na lista, escreva um cenário. Ele fica fora
|
|
197
|
+
do contrato e é ligado à action pelo nome:
|
|
163
198
|
|
|
164
199
|
```ts
|
|
165
|
-
import {
|
|
166
|
-
import {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
200
|
+
import { defineDesignScenario } from '@softize/opus/design'
|
|
201
|
+
import { eventGet, eventList } from '../domains/event/contract.ts'
|
|
202
|
+
|
|
203
|
+
// Valor fixo: o caso mais comum.
|
|
204
|
+
export const eventsInEachState = defineDesignScenario(eventList, {
|
|
205
|
+
items: [
|
|
206
|
+
{ id: 'e1', title: 'Revisão agendada', status: 'scheduled' },
|
|
207
|
+
{ id: 'e2', title: 'Revisão perdida', status: 'lost' },
|
|
208
|
+
],
|
|
209
|
+
cursor: { next: null },
|
|
210
|
+
total: 2,
|
|
211
|
+
})
|
|
170
212
|
|
|
171
|
-
//
|
|
172
|
-
const
|
|
213
|
+
// Função: quando a resposta depende da entrada.
|
|
214
|
+
export const eventDetail = defineDesignScenario(eventGet, (input) => ({
|
|
215
|
+
id: input.id,
|
|
216
|
+
title: 'Revisão agendada',
|
|
217
|
+
status: 'scheduled',
|
|
218
|
+
}))
|
|
219
|
+
```
|
|
173
220
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
mockHandler: () => fake(eventRowSchema),
|
|
180
|
-
})
|
|
221
|
+
A resposta é tipada pela entrada e pela saída do contrato; em `list`, é a página inteira. Informe ao
|
|
222
|
+
plugin onde os cenários estão, com caminhos ou globs:
|
|
223
|
+
`opusDesign({ scenarios: 'design/scenarios/*.ts' })`. O cenário vence a resposta gerada. Um cenário
|
|
224
|
+
de uma action que não existe, ou dois cenários para a mesma action, impedem o runtime de design de
|
|
225
|
+
subir, e o erro lista os nomes.
|
|
181
226
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
input: z.object({}),
|
|
186
|
-
output: eventRowSchema,
|
|
187
|
-
paginate: 'cursor',
|
|
188
|
-
mockHandler: async () => ({ items: fakeMany(eventRowSchema, 20), cursor: { next: null }, total: 20 }),
|
|
189
|
-
})
|
|
227
|
+
O Opus não decide onde os cenários ficam nem se entram no repositório. Eles não fazem parte da
|
|
228
|
+
aplicação: podem ficar numa pasta ignorada pelo git e ser escritos durante a sessão de design,
|
|
229
|
+
quando forem necessários.
|
|
190
230
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
})
|
|
196
|
-
```
|
|
231
|
+
`mockHandler` está obsoleto desde a 22.1.0 e sai na próxima major. Enquanto existir, ele ainda
|
|
232
|
+
responde no modo design, abaixo do cenário e acima da resposta gerada, e o `opus check` conta
|
|
233
|
+
quantas actions ainda o declaram. Para migrar, apague o `mockHandler`; se a resposta dele importar
|
|
234
|
+
para o design, transforme-a em cenário.
|
|
197
235
|
|
|
198
|
-
O `
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
|
|
236
|
+
O plugin `opusDesign()`, de `@softize/opus/vite`, monta o runtime do Opus dentro do dev server e
|
|
237
|
+
responde `/api` no próprio processo. No modo design, qualquer `server.proxy` para um backend externo
|
|
238
|
+
fica desligado: um prefixo que ia para o proxy e não tem action correspondente responde 503 em
|
|
239
|
+
envelope, sem alcançar o backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
|
|
203
240
|
|
|
204
241
|
```ts
|
|
205
242
|
// vite.config.ts — o plugin só carrega no modo design, por import dinâmico.
|
|
@@ -233,9 +270,7 @@ nem reiniciar o dev server. Quando uma action não é encontrada, o plugin recon
|
|
|
233
270
|
vez por ciclo de mudança e tenta de novo; se a action continuar ausente, a resposta indica o
|
|
234
271
|
`entry` a conferir.
|
|
235
272
|
|
|
236
|
-
|
|
237
|
-
chamado. Não o condicione com `import.meta.env` no contrato: essa variável não existe no Node e
|
|
238
|
-
quebraria o servidor.
|
|
273
|
+
Os cenários não entram no bundle da web: eles ficam fora do contrato, e só o plugin os carrega.
|
|
239
274
|
|
|
240
275
|
## Consumir o contrato na interface
|
|
241
276
|
|
|
@@ -56,6 +56,12 @@ entrada. Em `_meta`, cada tool traz os
|
|
|
56
56
|
[Produtos de Dados](/docs/data-products) relacionados: `com.softize.opus/data-products` lista os
|
|
57
57
|
identificadores e `com.softize.opus/data-product-labels`, o nome legível de cada um.
|
|
58
58
|
|
|
59
|
+
Quando a action declara o que faz com os dados, a tool traz também as anotações padrão do MCP:
|
|
60
|
+
`readOnlyHint` vem de `effect`. Numa action que escreve, `destructiveHint` vai sempre explícito, a
|
|
61
|
+
partir de `ai.destructive`, porque o MCP trata a ausência numa escrita como destrutiva.
|
|
62
|
+
Em `_meta`, `com.softize.opus/effect` repete o efeito e `com.softize.opus/personal-data` diz se a
|
|
63
|
+
resposta contém dado pessoal. Veja [Actions](/docs/actions).
|
|
64
|
+
|
|
59
65
|
`CallTool` recusa com erro qualquer nome fora dessa lista; os demais seguem por `runtime.execute`.
|
|
60
66
|
A exposição declarada em `ai` decide o que o cliente MCP pode executar, e a autorização do contexto
|
|
61
67
|
continua valendo sobre ela. Para um acesso somente de leitura, exponha apenas actions de leitura.
|
|
@@ -15,8 +15,8 @@ pulada, porque o schema descreve o item e o handler devolve a página. Falhas us
|
|
|
15
15
|
`ActionError` tipado do runtime, como `validation.invalid_input`, `auth.unauthenticated`,
|
|
16
16
|
`auth.system_forbidden` e `auth.forbidden`.
|
|
17
17
|
|
|
18
|
-
Passe a action ligada ao handler, produzida por `bindAction`. O contrato puro não tem handler e
|
|
19
|
-
|
|
18
|
+
Passe a action ligada ao handler, produzida por `bindAction`. O contrato puro não tem handler e
|
|
19
|
+
falha com `testing.no_handler`.
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
22
|
import { runAction } from '@softize/opus/testing'
|
|
@@ -86,29 +86,13 @@ const { output, emitted, logged } = await runAction(taskDone, { id: '1' }, { use
|
|
|
86
86
|
expect(emitted).toEqual([{ event: 'task.done', data: { id: '1' } }])
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
-
##
|
|
89
|
+
## Dados do modo design
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
import { afterEach, it } from 'vitest'
|
|
97
|
-
import { runAction } from '@softize/opus/testing'
|
|
98
|
-
import { Task, taskList } from './list.ts'
|
|
99
|
-
|
|
100
|
-
afterEach(() => {
|
|
101
|
-
// A variável vale para o processo inteiro; limpe para não afetar outros testes.
|
|
102
|
-
delete process.env['OPUS_MODE']
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('o mock devolve itens válidos para o schema', async () => {
|
|
106
|
-
process.env['OPUS_MODE'] = 'design'
|
|
107
|
-
const { output } = await runAction(taskList, {}, { user: { id: 'u1' } })
|
|
108
|
-
// `list` não valida a saída no pipeline: confira item a item.
|
|
109
|
-
for (const item of output.items) expect(Task.safeParse(item).success).toBe(true)
|
|
110
|
-
})
|
|
111
|
-
```
|
|
91
|
+
Os dados que o modo design mostra não são comportamento da action e não precisam de teste de
|
|
92
|
+
unidade: o plugin `opusDesign()` os gera a partir do contrato (ver
|
|
93
|
+
[Actions](/docs/actions)). Enquanto o `mockHandler` existir, obsoleto desde a 22.1.0,
|
|
94
|
+
`runAction` com `OPUS_MODE=design` ainda o executa, como o runtime. Teste a action ligada ao
|
|
95
|
+
handler em vez dele.
|
|
112
96
|
|
|
113
97
|
## memStorage
|
|
114
98
|
|
|
@@ -126,7 +110,7 @@ expect(storage.keys()).toEqual(['notas/9.pdf'])
|
|
|
126
110
|
## fake — dados gerados pelo schema
|
|
127
111
|
|
|
128
112
|
`fake(schema)` e `fakeMany(schema, n)` geram dados sintéticos determinísticos que satisfazem o
|
|
129
|
-
schema zod. Servem
|
|
113
|
+
schema zod. Servem à resposta gerada do modo design e a fixtures de teste. A mesma `seed` produz a
|
|
130
114
|
mesma saída, sem `Math.random`. Os valores seguem as validações do zod (email, uuid, url, datetime)
|
|
131
115
|
e o nome do campo (email, id, `*At` como data, telefone, CPF/CNPJ, nome…), com conteúdo em pt-BR.
|
|
132
116
|
|
package/src/vite/design.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Antes, o dev server proxeava `/api` pra um backend real — o que vaza escrita
|
|
7
7
|
* pra prod e exige infra de pé pra desenhar tela. Com o plugin, `vite --mode
|
|
8
8
|
* design` monta o runtime (driver `nodeServer`) no próprio dev server: os
|
|
9
|
-
*
|
|
9
|
+
* actions respondem pelos dados de design (ADR 0037), qualquer proxy
|
|
10
10
|
* configurado é DESLIGADO (prefixo ex-proxy sem cobertura responde 503 em
|
|
11
11
|
* envelope, nunca vaza), e o Maestro sonda `GET /__opus/design` pra saber que
|
|
12
12
|
* o preview está montado.
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
isDomainConfig,
|
|
45
45
|
isReaction,
|
|
46
46
|
isSchedule,
|
|
47
|
+
type ActionContext,
|
|
47
48
|
type ActionDef,
|
|
48
49
|
type AuthAdapter,
|
|
49
50
|
type ErrorInput,
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
type User,
|
|
54
55
|
} from '../core/index.ts'
|
|
55
56
|
import { readPackageVersion } from '../core/package-version.ts'
|
|
57
|
+
import { designResponse, isDesignScenario, type DesignScenario } from '../design/index.ts'
|
|
56
58
|
import { mittEvents } from '../events/drivers/mitt.ts'
|
|
57
59
|
import { nodeServer, type NodeServerHandle } from '../server/drivers/node.ts'
|
|
58
60
|
import { serializeResult } from '../server/index.ts'
|
|
@@ -84,6 +86,14 @@ export interface OpusDesignOptions {
|
|
|
84
86
|
*/
|
|
85
87
|
entry?: string | string[]
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Cenários de design (ADR 0037): módulos que exportam `defineDesignScenario(contrato, resposta)`.
|
|
91
|
+
* Aceita caminhos e globs, como `entry`. O cenário vence o `mockHandler` e a resposta padrão
|
|
92
|
+
* gerada pelo `fake`; cenário de action inexistente impede o boot, com a lista no erro.
|
|
93
|
+
* Sem default: o projeto decide onde os cenários ficam e se são versionados.
|
|
94
|
+
*/
|
|
95
|
+
scenarios?: string | string[]
|
|
96
|
+
|
|
87
97
|
/** Prefixo do path. Default '/api'. */
|
|
88
98
|
apiPrefix?: string
|
|
89
99
|
|
|
@@ -145,10 +155,14 @@ const PROBE_PATH = '/__opus/design'
|
|
|
145
155
|
// deixá-los chegar no handler (moram fora do apiPrefix).
|
|
146
156
|
const RUNTIME_ENDPOINT_PATHS = new Set(['/health', '/ready', '/openapi.json'])
|
|
147
157
|
|
|
158
|
+
/** De onde vem a resposta de cada action no modo design, na ordem de precedência. */
|
|
159
|
+
type DesignSource = 'scenario' | 'mock' | 'handler' | 'generated'
|
|
160
|
+
|
|
148
161
|
interface DesignState {
|
|
149
162
|
handle: NodeServerHandle
|
|
150
163
|
runtime: Runtime
|
|
151
164
|
actions: ActionDef[]
|
|
165
|
+
sources: Map<string, DesignSource>
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
@@ -156,6 +170,12 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
156
170
|
const entries = Array.isArray(options.entry)
|
|
157
171
|
? options.entry
|
|
158
172
|
: [options.entry ?? './opus.config.ts']
|
|
173
|
+
const scenarioEntries =
|
|
174
|
+
options.scenarios === undefined
|
|
175
|
+
? []
|
|
176
|
+
: Array.isArray(options.scenarios)
|
|
177
|
+
? options.scenarios
|
|
178
|
+
: [options.scenarios]
|
|
159
179
|
const devBackend = options.devBackend === true
|
|
160
180
|
// Mais-específico primeiro: prefixo mais longo vence.
|
|
161
181
|
const stubEntries = Object.entries(options.stubs ?? {}).sort(
|
|
@@ -194,8 +214,8 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
194
214
|
exProxyPrefixes = Object.keys(config.server?.proxy ?? {})
|
|
195
215
|
// 2) Mata o proxy: fecha o vazamento preview→prod pra QUALQUER prefixo.
|
|
196
216
|
if (config.server?.proxy !== undefined) config.server.proxy = undefined
|
|
197
|
-
// 3) O execute() roteia
|
|
198
|
-
//
|
|
217
|
+
// 3) O execute() roteia pro mockHandler lendo OPUS_MODE (isOpusDesignMode); o boot
|
|
218
|
+
// liga em cada action a resposta de design, e o preview fica autossuficiente.
|
|
199
219
|
process.env.OPUS_MODE = 'design'
|
|
200
220
|
},
|
|
201
221
|
|
|
@@ -215,7 +235,14 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
215
235
|
registrables.push(...(await loadEntry(server, entry)))
|
|
216
236
|
}
|
|
217
237
|
}
|
|
218
|
-
const
|
|
238
|
+
const collected = collectActions(registrables)
|
|
239
|
+
const { dropped } = collected
|
|
240
|
+
const scenarios = isDesign
|
|
241
|
+
? await loadScenarios(server, scenarioEntries, collected.actions)
|
|
242
|
+
: new Map<string, DesignScenario>()
|
|
243
|
+
const { actions, sources } = isDesign
|
|
244
|
+
? prepareDesignActions(collected.actions, scenarios)
|
|
245
|
+
: { actions: collected.actions, sources: new Map<string, DesignSource>() }
|
|
219
246
|
|
|
220
247
|
const handle = nodeServer({ apiPrefix })
|
|
221
248
|
const runtime = createRuntime({
|
|
@@ -223,8 +250,8 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
223
250
|
auth: designAuth({ user: options.user, tenantId: options.tenantId }),
|
|
224
251
|
eventBus: mittEvents(),
|
|
225
252
|
storage: memStorage(),
|
|
226
|
-
// SEM `data` de propósito: design não toca ctx.db
|
|
227
|
-
//
|
|
253
|
+
// SEM `data` de propósito: design não toca ctx.db. Uma action ligada a handler real
|
|
254
|
+
// que dependa do banco falha aqui; o caminho é um cenário (ADR 0037).
|
|
228
255
|
...options.runtime,
|
|
229
256
|
config: { env: 'development', dev: true, ...options.runtime?.config },
|
|
230
257
|
})
|
|
@@ -236,19 +263,9 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
236
263
|
`[opus-design] ${dropped} reaction(s)/schedule(s) ignorada(s) — o runtime de design é request/response.`,
|
|
237
264
|
)
|
|
238
265
|
}
|
|
239
|
-
if (isDesign) {
|
|
240
|
-
for (const action of actions) {
|
|
241
|
-
if (action.mockHandler === undefined) {
|
|
242
|
-
logger.warn(
|
|
243
|
-
`[opus-design] Action "${action.name}" sem mockHandler — em design cai no handler real e pode falhar sem adapters.`,
|
|
244
|
-
)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
266
|
await runtime.start()
|
|
250
267
|
handle.adapter.mountEndpoints({ health: true, ready: true, openapi: true })
|
|
251
|
-
return { handle, runtime, actions }
|
|
268
|
+
return { handle, runtime, actions, sources }
|
|
252
269
|
}
|
|
253
270
|
|
|
254
271
|
// Promise-mutex: requests em voo aguardam o MESMO boot; rebuild (stale)
|
|
@@ -285,8 +302,9 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
285
302
|
// add/unlink: QUALQUER arquivo sob o root (fora node_modules/.git) —
|
|
286
303
|
// arquivo novo nunca importado não está no graph, e é exatamente ele
|
|
287
304
|
// que um entry glob precisa enxergar. Over-invalidation é barata.
|
|
305
|
+
const watchedEntries = [...entries, ...scenarioEntries]
|
|
288
306
|
const entryPaths = new Set(
|
|
289
|
-
|
|
307
|
+
watchedEntries
|
|
290
308
|
.filter((entry) => !isGlobPattern(entry))
|
|
291
309
|
.map((entry) => path.resolve(server.config.root, entry)),
|
|
292
310
|
)
|
|
@@ -294,7 +312,7 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
294
312
|
// pode apontar pra FORA dele (ex.: '../../services/*/contract.ts', caso GB):
|
|
295
313
|
// mandamos o chokidar observar o PREFIXO ESTÁTICO de cada glob — o mesmo que
|
|
296
314
|
// o expandGlob anda, nunca o mundo — pra change/unlink lá também invalidar.
|
|
297
|
-
const globSpecs =
|
|
315
|
+
const globSpecs = watchedEntries
|
|
298
316
|
.filter(isGlobPattern)
|
|
299
317
|
.map((pattern) => globSpec(server.config.root, pattern))
|
|
300
318
|
for (const { prefixDir } of globSpecs) server.watcher.add(prefixDir)
|
|
@@ -341,12 +359,21 @@ export function opusDesign(options: OpusDesignOptions = {}): Plugin {
|
|
|
341
359
|
}
|
|
342
360
|
try {
|
|
343
361
|
const state = await ensureBoot()
|
|
344
|
-
const
|
|
362
|
+
const named = (source: DesignSource): string[] =>
|
|
363
|
+
state.actions.filter((a) => state.sources.get(a.name) === source).map((a) => a.name)
|
|
364
|
+
const scenarios = named('scenario')
|
|
365
|
+
const mocks = named('mock')
|
|
345
366
|
payload.runtime = {
|
|
346
367
|
prefix: apiPrefix,
|
|
347
368
|
actions: state.actions.length,
|
|
348
|
-
mocked:
|
|
349
|
-
|
|
369
|
+
// `mocked`/`unmocked` mantêm o formato anterior da sonda: resposta escrita à mão
|
|
370
|
+
// (cenário ou mockHandler) contra o resto, que responde por handler real ou `fake`.
|
|
371
|
+
mocked: scenarios.length + mocks.length,
|
|
372
|
+
unmocked: state.actions
|
|
373
|
+
.filter((a) => !['scenario', 'mock'].includes(state.sources.get(a.name) ?? ''))
|
|
374
|
+
.map((a) => a.name),
|
|
375
|
+
scenarios,
|
|
376
|
+
generated: named('generated'),
|
|
350
377
|
}
|
|
351
378
|
} catch (err) {
|
|
352
379
|
payload.error = String(err)
|
|
@@ -605,6 +632,89 @@ function registrablesOfModule(mod: Record<string, unknown>): unknown[] {
|
|
|
605
632
|
return [...out]
|
|
606
633
|
}
|
|
607
634
|
|
|
635
|
+
// =============================================================================
|
|
636
|
+
// Dados de design (ADR 0037)
|
|
637
|
+
// =============================================================================
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Carrega os cenários dos módulos casados por `scenarios`. Cenário de action inexistente ou dois
|
|
641
|
+
* cenários para a mesma action impedem o boot: o preview mostra o erro em vez de silenciar.
|
|
642
|
+
*/
|
|
643
|
+
async function loadScenarios(
|
|
644
|
+
server: ViteDevServer,
|
|
645
|
+
patterns: string[],
|
|
646
|
+
actions: ActionDef[],
|
|
647
|
+
): Promise<Map<string, DesignScenario>> {
|
|
648
|
+
const scenarios = new Map<string, DesignScenario>()
|
|
649
|
+
const known = new Set(actions.map((action) => action.name))
|
|
650
|
+
const unknown: string[] = []
|
|
651
|
+
const duplicated: string[] = []
|
|
652
|
+
for (const pattern of patterns) {
|
|
653
|
+
const files = isGlobPattern(pattern) ? expandGlob(server.config.root, pattern) : [normalizeEntryUrl(pattern)]
|
|
654
|
+
for (const file of files) {
|
|
655
|
+
const mod = (await server.ssrLoadModule(file)) as Record<string, unknown>
|
|
656
|
+
for (const scenario of scenariosOfModule(mod)) {
|
|
657
|
+
if (!known.has(scenario.action)) unknown.push(scenario.action)
|
|
658
|
+
else if (scenarios.has(scenario.action) && scenarios.get(scenario.action) !== scenario) {
|
|
659
|
+
duplicated.push(scenario.action)
|
|
660
|
+
} else scenarios.set(scenario.action, scenario)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (unknown.length > 0 || duplicated.length > 0) {
|
|
665
|
+
const names = (list: string[]): string => [...new Set(list)].join(', ')
|
|
666
|
+
const problems = [
|
|
667
|
+
unknown.length > 0 ? `sem action registrada: ${names(unknown)}` : '',
|
|
668
|
+
duplicated.length > 0 ? `mais de um cenário para: ${names(duplicated)}` : '',
|
|
669
|
+
].filter(Boolean)
|
|
670
|
+
throw error({
|
|
671
|
+
code: 'design.invalid_scenario',
|
|
672
|
+
category: 'internal',
|
|
673
|
+
message: `Cenários de design inválidos (${problems.join('; ')}).`,
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
return scenarios
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function scenariosOfModule(mod: Record<string, unknown>): DesignScenario[] {
|
|
680
|
+
const out = new Set<DesignScenario>()
|
|
681
|
+
const add = (value: unknown): void => {
|
|
682
|
+
if (Array.isArray(value)) value.forEach(add)
|
|
683
|
+
else if (isDesignScenario(value)) out.add(value)
|
|
684
|
+
}
|
|
685
|
+
Object.values(mod).forEach(add)
|
|
686
|
+
return [...out]
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Liga a cada action a resposta de design, nesta precedência: cenário, `mockHandler` declarado,
|
|
691
|
+
* handler real (`bindAction`) e, por fim, a resposta padrão gerada pelo `fake`.
|
|
692
|
+
*/
|
|
693
|
+
function prepareDesignActions(
|
|
694
|
+
actions: ActionDef[],
|
|
695
|
+
scenarios: Map<string, DesignScenario>,
|
|
696
|
+
): { actions: ActionDef[]; sources: Map<string, DesignSource> } {
|
|
697
|
+
const sources = new Map<string, DesignSource>()
|
|
698
|
+
const prepared = actions.map((action): ActionDef => {
|
|
699
|
+
const scenario = scenarios.get(action.name)
|
|
700
|
+
if (scenario !== undefined) {
|
|
701
|
+
sources.set(action.name, 'scenario')
|
|
702
|
+
return { ...action, mockHandler: (ctx: ActionContext, input: unknown) => scenario.respond(input, ctx) } as ActionDef
|
|
703
|
+
}
|
|
704
|
+
if (action.mockHandler !== undefined) {
|
|
705
|
+
sources.set(action.name, 'mock')
|
|
706
|
+
return action
|
|
707
|
+
}
|
|
708
|
+
if (typeof (action as { handler?: unknown }).handler === 'function') {
|
|
709
|
+
sources.set(action.name, 'handler')
|
|
710
|
+
return action
|
|
711
|
+
}
|
|
712
|
+
sources.set(action.name, 'generated')
|
|
713
|
+
return { ...action, mockHandler: async (_ctx: ActionContext, input: unknown) => designResponse(action, input) } as ActionDef
|
|
714
|
+
})
|
|
715
|
+
return { actions: prepared, sources }
|
|
716
|
+
}
|
|
717
|
+
|
|
608
718
|
/** Achata os registráveis em actions; reactions/schedules ficam de fora (contados). */
|
|
609
719
|
function collectActions(items: unknown[]): { actions: ActionDef[]; dropped: number } {
|
|
610
720
|
const actions: ActionDef[] = []
|
|
@@ -621,7 +731,7 @@ function collectActions(items: unknown[]): { actions: ActionDef[]; dropped: numb
|
|
|
621
731
|
dropped += 1
|
|
622
732
|
continue
|
|
623
733
|
}
|
|
624
|
-
// ActionDef ou contrato (sem handler): em design
|
|
734
|
+
// ActionDef ou contrato (sem handler): em design, a resposta de design responde.
|
|
625
735
|
actions.push(item as ActionDef)
|
|
626
736
|
}
|
|
627
737
|
return { actions, dropped }
|