@softize/opus 22.0.0 → 22.1.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 CHANGED
@@ -7,6 +7,30 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 22.1.0 — 2026-09-28
11
+
12
+ Os dados do modo design saem do contrato (ADR 0037). No modo design, toda action passa a responder
13
+ sem que o contrato declare nada: a resposta é gerada pelo `fake` a partir do `output`, a mesma
14
+ requisição recebe a mesma resposta, uma `list` devolve três páginas do tamanho pedido em `limit` (20
15
+ por padrão) pela paginação declarada, e a saída repete o `id` da entrada quando o schema aceita.
16
+ Quando o design precisa de coerência, um cenário escrito com `defineDesignScenario`, do novo
17
+ `@softize/opus/design`, responde no lugar da resposta gerada. O plugin encontra os cenários pela
18
+ opção `scenarios` de `opusDesign()`, com caminhos ou globs; um cenário de action inexistente, ou
19
+ dois cenários para a mesma action, impedem o runtime de design de subir, e o erro lista os nomes.
20
+
21
+ `mockHandler` fica obsoleto. Ele ainda responde no modo design, abaixo do cenário e acima da
22
+ resposta gerada, e sai na 23.0.0. Os tipos o marcam com `@deprecated`. O `opus check` passa a
23
+ contar, numa linha de aviso que não reprova, quantas actions ainda o declaram. A sonda
24
+ `/__opus/design` ganha `scenarios` e `generated`; `mocked` e `unmocked` mantêm o formato anterior.
25
+
26
+ O template de `opus create` e as skills `create-opus-action` e `test-opus-action` deixam de ensinar
27
+ `mockHandler`.
28
+
29
+ **O que fazer:** nada é obrigatório nesta versão. Para migrar, apague os `mockHandler` dos
30
+ contratos; se a resposta de algum importar para o design, transforme-a em cenário e aponte a opção
31
+ `scenarios` do plugin para o lugar onde os cenários ficam. Testes que usavam `runAction` com
32
+ `OPUS_MODE=design` para exercitar o mock devem testar a action ligada ao handler.
33
+
10
34
  ## 22.0.0 — 2026-09-28
11
35
 
12
36
  As superfícies passam a usar uma introdução única e ganham composições explícitas para conteúdo
package/bin/cli.mjs CHANGED
@@ -40,7 +40,15 @@ async function cmdCheck(dir) {
40
40
  for (const error of materialization.errors) log('error', `✗ opus check: ${error}`)
41
41
  process.exit(1)
42
42
  }
43
- const { files, actions, contracts, findings, hasMarker } = await scanDir(root)
43
+ const { files, actions, contracts, deprecatedMockHandlers, findings, hasMarker } = await scanDir(root)
44
+ // Aviso de migração, não violação: uma linha só, para não poluir o gate a cada execução.
45
+ if (deprecatedMockHandlers > 0) {
46
+ log(
47
+ 'warn',
48
+ `⚠ opus check: ${deprecatedMockHandlers} action(s) ainda declaram \`mockHandler\`, que sai na próxima major. ` +
49
+ 'No modo design, a action sem mock já responde com dado gerado; use `defineDesignScenario` quando o design precisar de um cenário (ADR 0037).',
50
+ )
51
+ }
44
52
 
45
53
  if (actions === 0 && findings.length === 0) {
46
54
  const verdict = emptyScanVerdict({ hasMarker, contracts })
package/bin/lib/check.mjs CHANGED
@@ -642,6 +642,8 @@ export function checkProject(sources) {
642
642
  const productDeclarations = [];
643
643
  const entityNames = new Set();
644
644
  const actionNames = new Set();
645
+ // `mockHandler` obsoleto (ADR 0037): contado, não reprovado — a minor ainda o aceita.
646
+ let deprecatedMockHandlers = 0;
645
647
 
646
648
  for (const { file, text } of sources) {
647
649
  const declarations = parseDataProducts(file, text);
@@ -666,6 +668,7 @@ export function checkProject(sources) {
666
668
  if (item.name !== null) actionNames.add(item.name);
667
669
  }
668
670
  for (const f of lintAction(item)) findings.push({ ...f, file });
671
+ if (item.keys.includes("mockHandler")) deprecatedMockHandlers += 1;
669
672
  }
670
673
  }
671
674
 
@@ -771,6 +774,7 @@ export function checkProject(sources) {
771
774
  actions,
772
775
  contracts,
773
776
  dataProducts: productDeclarations.length,
777
+ deprecatedMockHandlers,
774
778
  findings,
775
779
  };
776
780
  }
@@ -1921,7 +1925,7 @@ async function walkUiFiles(dir, acc = []) {
1921
1925
  return acc;
1922
1926
  }
1923
1927
 
1924
- /** Escaneia um diretório → { files, actions, contracts, findings, hasMarker }.
1928
+ /** Escaneia um diretório → { files, actions, contracts, deprecatedMockHandlers, findings, hasMarker }.
1925
1929
  * `hasMarker` = o diretório é um projeto opus (tem opus.json na raiz). */
1926
1930
  export async function scanDir(rootDir) {
1927
1931
  rootDir = canonicalProjectDirectory(rootDir);
@@ -1951,6 +1955,7 @@ export async function scanDir(rootDir) {
1951
1955
  actions: checked.actions,
1952
1956
  contracts: checked.contracts,
1953
1957
  dataProducts: checked.dataProducts,
1958
+ deprecatedMockHandlers: checked.deprecatedMockHandlers,
1954
1959
  findings: [...checked.findings, ...uiFindings],
1955
1960
  hasMarker,
1956
1961
  };
@@ -0,0 +1,117 @@
1
+ # ADR 0037 — Dados do modo design ficam fora do contrato
2
+
3
+ - **Status:** aceita; a primeira etapa saiu na 22.1.0, e a remoção fica para a 23.0.0.
4
+ - **Data:** 2026-09-28.
5
+ - **Relacionadas:** ADR 0002 (seeds estruturados).
6
+
7
+ ## Contexto
8
+
9
+ O modo design serve para desenhar e revisar telas sem backend: `opusDesign()` monta o runtime
10
+ dentro do dev server, e cada action responde pelo `mockHandler` declarado no contrato. O preview de
11
+ sessão do Maestro depende desse modo e não tem outro propósito além de design.
12
+
13
+ Em setembro de 2026, o único consumidor, a Grand Brasil, tinha 233 `mockHandler` para 256
14
+ contratos, cerca de 3.200 linhas entre handlers, fixtures e estado simulado. A medição mostrou três
15
+ problemas:
16
+
17
+ - **O mock viaja com o contrato.** O contrato é a fatia compartilhada com a interface, então o
18
+ build de produção da aplicação leva os handlers simulados e suas fixtures. Um build de produção
19
+ da Grand Brasil continha textos das fixtures e os próprios handlers.
20
+ - **Metade dos mocks não acrescenta nada ao schema.** Cerca de 110 handlers devolvem um literal
21
+ fixo (`{ id: 'mock-x' }`) ou uma lista vazia, o que o `fake` já gera a partir do `output`.
22
+ - **A outra metade replica o domínio.** Cerca de 95 handlers simulam um CRUD em memória e
23
+ reimplementam regras do handler real, como o significado de cada filtro operacional de uma
24
+ listagem. Em setembro, 19 commits de funcionalidade tiveram de alterar mocks junto com o código
25
+ real, e nada garante que as duas versões da regra continuem iguais.
26
+
27
+ Forças em tensão:
28
+
29
+ - a aplicação real deve carregar o mínimo possível de algo que só existe para design;
30
+ - o preview precisa responder a qualquer action, inclusive às criadas durante a sessão;
31
+ - às vezes o design precisa de coerência entre respostas, como um item em cada situação ou algo
32
+ criado que aparece na lista em seguida;
33
+ - no preview, variar entre sessões é aceitável, porque dado de design não é evidência. Os seeds da
34
+ ADR 0002 são evidência e continuam determinísticos;
35
+ - o Opus é protocolo e SDK: onde os cenários ficam e se são versionados é escolha do projeto.
36
+
37
+ ## Decisão
38
+
39
+ 1. **O contrato deixa de declarar dados de design.** `mockHandler` sai de `ActionBase`, do
40
+ contrato, do `ActionDef` e do `runAction`. O runtime de produção não lê mais `OPUS_MODE` nem
41
+ tem ramo de design. Toda a lógica de design fica em `@softize/opus/vite`.
42
+ 2. **Toda action responde por padrão no modo design.** O plugin registra cada contrato com um
43
+ executor de design que, na ausência de cenário, devolve dado gerado pelo `fake` a partir do
44
+ `output`:
45
+ - `list` devolve três páginas geradas por `fakeMany`, com `items`, `cursor` e `total`, do
46
+ tamanho pedido em `limit` (20 quando a entrada não pede, até 100), e respeita a paginação
47
+ declarada. Busca e filtros não filtram o dado gerado, porque sobre dado
48
+ sorteado o resultado não mostraria nada útil; quando o filtro importar para o design, o
49
+ caminho é um cenário;
50
+ - a semente é derivada do nome da action e da entrada canônica, então a mesma requisição recebe
51
+ a mesma resposta;
52
+ - quando a entrada e a saída têm `id`, a saída ecoa o `id` da entrada, desde que o schema da
53
+ saída aceite esse valor, para que abrir um item listado mostre o mesmo identificador;
54
+ - uma action ligada a um handler real (`bindAction`) mantém esse handler, como hoje.
55
+ 3. **Cenários são registrados fora do contrato, pelo nome da action.** O plugin ganha a opção
56
+ `scenarios`, com um ou mais globs. Os módulos casados exportam cenários criados por
57
+ `defineDesignScenario(contrato, resposta)`, e a resposta pode ser um valor ou uma função tipada
58
+ pela entrada e pela saída do contrato. O cenário vence a resposta padrão. Um cenário para uma
59
+ action inexistente é erro visível no preview, não um silêncio.
60
+ 4. **O Opus não decide onde os cenários ficam.** A opção não tem default dentro do código da
61
+ aplicação. O consumidor escolhe, por exemplo, uma pasta ignorada pelo git que o agente da sessão
62
+ preenche sob demanda. Cenários começam descartáveis: só vale discutir versionar um cenário no
63
+ repositório do consumidor quando o mesmo for recriado em sessões diferentes.
64
+ 5. **Stubs de rotas externas continuam como estão.** A opção `stubs` segue atendendo superfícies
65
+ fora do protocolo de actions, como autenticação e motores externos.
66
+
67
+ ## Consequências
68
+
69
+ - A aplicação deixa de carregar mocks. O contrato volta a descrever só a capacidade, e o build de
70
+ produção não inclui dados de design.
71
+ - Uma action nova aparece no preview sem nenhuma linha de mock.
72
+ - O dado padrão tem o formato certo, mas não a regra de negócio: valores de enum são sorteados, e
73
+ campos de uma listagem e de um detalhe só coincidem no `id`. Quando isso atrapalhar o design, o
74
+ caminho é um cenário.
75
+ - Sem cenário, não há estado entre chamadas: criar um item não o faz aparecer na lista.
76
+ - É uma versão major. Quem declara `mockHandler` precisa remover a declaração ou transformá-la em
77
+ cenário, e testes que usam `OPUS_MODE=design` com `runAction` precisam de outra forma de
78
+ exercitar o contrato.
79
+ - O template de `opus create`, a documentação "Prototipar com dados simulados", as skills
80
+ `create-opus-action` e `test-opus-action` e o gate de ordem de campos (`check.mjs`) mudam junto.
81
+ - No Maestro, que fica fora deste pacote, o preview continua igual. O agente da sessão passa a
82
+ escrever cenários quando precisar, orientado por uma skill própria.
83
+
84
+ ## Alternativas descartadas
85
+
86
+ - **Manter o estado atual.** Preserva cenários prontos entre sessões, mas mantém o mock no bundle,
87
+ a regra de negócio duplicada e o custo de cada funcionalidade nova.
88
+ - **Remover `mockHandler` apenas no build de produção.** Resolve o bundle e nada mais. O contrato
89
+ continua misturando capacidade e simulação, e a duplicação de regra permanece.
90
+ - **Preview com backend real e dados dos seeds.** Mostra o comportamento verdadeiro e elimina os
91
+ mocks, mas exige banco, API e isolamento por sessão num preview que só serve a design, além de
92
+ rever as decisões do Maestro de manter o preview isolado e sem Postgres.
93
+ - **CRUD em memória genérico a partir de metadado novo no contrato.** Exigiria declarar entidade e
94
+ operação em cada action e, mesmo assim, não cobriria regras de negócio, que continuariam
95
+ escritas à mão.
96
+ - **Somente a resposta padrão, sem cenários.** É o mínimo absoluto, mas deixaria o design sem saída
97
+ quando a coerência importa. O custo do mecanismo de cenários é pequeno perto disso.
98
+
99
+ ## Transição
100
+
101
+ A saída acontece em duas versões. A 22.1.0 adiciona a resposta padrão e os cenários, com
102
+ `mockHandler` ainda aceito e precedendo a resposta gerada, e o `check` passa a contar o campo como
103
+ obsoleto. O consumidor remove seus mocks domínio a domínio. A 23.0.0 remove o campo e o ramo de
104
+ design do runtime.
105
+
106
+ ## Verificação
107
+
108
+ - testes do plugin: resposta padrão por `kind` (`view`, `list`, `form`, `simple`), páginas do
109
+ tamanho pedido em `limit` com a paginação declarada, mesma resposta para a mesma entrada, eco do
110
+ `id`, precedência do cenário e erro para cenário sem action ou duplicado;
111
+ - teste de tipos: `defineDesignScenario` infere entrada e saída do contrato e rejeita resposta
112
+ incompatível;
113
+ - busca estática: nenhum `OPUS_MODE` nem `mockHandler` em `src/core` e `src/testing` depois da
114
+ major;
115
+ - no consumidor: nenhum `mockHandler` nos contratos, e o build de produção sem textos das fixtures
116
+ antigas;
117
+ - typecheck, suíte do pacote, `opus check` e os gates de materialização verdes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "22.0.0",
3
+ "version": "22.1.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -173,6 +173,10 @@
173
173
  "types": "./src/seed/index.ts",
174
174
  "default": "./src/seed/index.ts"
175
175
  },
176
+ "./design": {
177
+ "types": "./src/design/index.ts",
178
+ "default": "./src/design/index.ts"
179
+ },
176
180
  "./presentation": {
177
181
  "types": "./src/presentation/index.ts",
178
182
  "default": "./src/presentation/index.ts"
@@ -1,12 +1,14 @@
1
1
  # Contrato e binding
2
2
 
3
- O contrato contém `name`, `kind`, documentação, schemas, metadata de UI, autorização
4
- declarativa compartilhável e `mockHandler` de design quando aplicável.
3
+ O contrato contém `name`, `kind`, documentação, schemas, metadata de UI e autorização
4
+ declarativa compartilhável. Ele não declara dados de design: no modo design, a action sem mock já
5
+ responde com dado gerado a partir do `output`, e um cenário específico, quando necessário, fica
6
+ fora do contrato em `defineDesignScenario` (ADR 0037). `mockHandler` está obsoleto.
5
7
 
6
8
  O binding contém `handler`, `loads`, autorização dependente de dados carregados, execução
7
9
  em background, eventos e idempotência. `bindAction(contract, binding)` produz o `ActionDef`
8
10
  registrado no runtime.
9
11
 
10
12
  Ordem cobrada pelo check: identidade → documentação → input/output → autorização →
11
- handler/mock → comportamento. Campos específicos do kind permanecem junto do grupo ao qual
13
+ handler → comportamento. Campos específicos do kind permanecem junto do grupo ao qual
12
14
  servem e o scaffold acompanha a forma atual do SDK.
@@ -18,7 +18,6 @@ ${common}
18
18
  input: z.object({}),
19
19
  output: z.object({ id: z.string() }),
20
20
  paginate: 'cursor',
21
- mockHandler: async () => ({ items: [], cursor: { next: null }, total: 0 }),
22
21
  })`
23
22
  }
24
23
  if (kind === 'form') {
@@ -30,7 +29,6 @@ ${common}
30
29
  input: z.object({ id: z.string() }),
31
30
  output: z.object({ id: z.string() }),
32
31
  fields: { id: { label: 'Identificador' } },
33
- mockHandler: async (_ctx, input) => ({ id: input.id }),
34
32
  })`
35
33
  }
36
34
  if (kind === 'view') {
@@ -40,7 +38,6 @@ ${common}
40
38
  description: 'Descreva o comportamento de negócio e seus limites.',
41
39
  input: z.object({ id: z.string() }),
42
40
  output: z.object({ id: z.string() }),
43
- mockHandler: async (_ctx, input) => ({ id: input.id }),
44
41
  })`
45
42
  }
46
43
  return `export const ${identifier} = defineContract({
@@ -50,7 +47,6 @@ ${common}
50
47
  messages: { success: 'Operação concluída.', error: 'Não foi possível concluir.' },
51
48
  input: z.object({ id: z.string() }),
52
49
  output: z.object({ ok: z.boolean() }),
53
- mockHandler: async () => ({ ok: true }),
54
50
  })`
55
51
  }
56
52
 
@@ -12,8 +12,8 @@ infraestrutura real quando a unidade é o contrato.
12
12
 
13
13
  ## Procedimento
14
14
 
15
- 1. Importar o `ActionDef` produzido pelo binding; para contrato puro, testar `mockHandler`
16
- apenas no modo design.
15
+ 1. Importar o `ActionDef` produzido pelo binding. Dados do modo design não são comportamento da
16
+ action e não entram nestes testes.
17
17
  2. Executar com `runAction` e fornecer `user`, `can`, `loaded`, storage ou outros adapters
18
18
  pelas opções do harness.
19
19
  3. Cobrir sucesso, input inválido, autorização negada e erros de negócio materiais.
@@ -6,5 +6,5 @@
6
6
  - `memStorage()`: adapter de storage em memória com inspeção de chaves.
7
7
  - `fake` e `fakeMany`: dados determinísticos derivados de schema.
8
8
 
9
- Prefira o binding completo em testes de execução. Um contrato sem handler só executa via
10
- `mockHandler` quando `OPUS_MODE=design`.
9
+ Teste o binding completo. Um contrato sem handler falha com `testing.no_handler`: os dados do modo
10
+ design são gerados pelo plugin `opusDesign()` e não são comportamento da action.
@@ -9,7 +9,7 @@ type TaskItem = z.infer<typeof Task>
9
9
  export function App(): React.ReactElement {
10
10
  // O front consome o CONTRATO via /api — o plugin opusDesign (vite.config.ts)
11
11
  // serve o backend no próprio dev server: dev normal executa o handler real;
12
- // `pnpm dev:design` responde com o mockHandler, isolado de qualquer backend.
12
+ // `pnpm dev:design` responde com dado gerado a partir do contrato, isolado de qualquer backend.
13
13
  const { items, isLoading, error, refetch } = useListAction<TaskItem>(taskList)
14
14
 
15
15
  return (
@@ -1,6 +1,6 @@
1
- import { afterEach, describe, expect, it } from 'vitest'
1
+ import { describe, expect, it } from 'vitest'
2
2
  import { runAction } from '@softize/opus/testing'
3
- import { Task, taskList, taskListImpl } from './list.ts'
3
+ import { taskListImpl } from './list.ts'
4
4
 
5
5
  // O harness roda o MESMO pipeline do runtime (valida input, cobra auth, valida
6
6
  // output) — teste pelo contrato, não chamando o handler à mão.
@@ -14,21 +14,4 @@ describe('task.list', () => {
14
14
  it('sem usuário, barra: a action não é pública', async () => {
15
15
  await expect(runAction(taskListImpl, {})).rejects.toMatchObject({ code: 'auth.unauthenticated' })
16
16
  })
17
-
18
- describe('modo design (o que o `pnpm dev:design` serve)', () => {
19
- afterEach(() => {
20
- // OPUS_MODE é global do processo — limpa pra não vazar entre testes.
21
- delete process.env['OPUS_MODE']
22
- })
23
-
24
- it('o contrato puro responde pelo mockHandler, com dado válido pro schema', async () => {
25
- process.env['OPUS_MODE'] = 'design'
26
- const { output } = await runAction(taskList, {}, { user: { id: 'u1' } })
27
- expect(output.items.length).toBeGreaterThan(0)
28
- // `list` pula a validação de output no pipeline — afirme item a item.
29
- for (const item of output.items) {
30
- expect(Task.safeParse(item).success).toBe(true)
31
- }
32
- })
33
- })
34
17
  })
@@ -1,14 +1,13 @@
1
1
  import { z } from 'zod'
2
2
  import { bindAction, defineContract } from '@softize/opus/core'
3
- import { fakeMany } from '@softize/opus/testing'
4
3
 
5
- /** Item do domínio-exemplo — o schema alimenta contrato, mock e testes. */
4
+ /** Item do domínio-exemplo — o schema alimenta contrato, dados de design e testes. */
6
5
  export const Task = z.object({ id: z.string(), title: z.string(), done: z.boolean() })
7
6
 
8
7
  // O split canônico contrato↔handler: o CONTRATO é declarativo e roda nos dois
9
8
  // lados (a SPA consome via useListAction); o BINDING é server-only. Em
10
- // `pnpm dev:design` o mockHandler responde (dado fake do próprio schema);
11
- // no dev normal e em prod, o handler real do binding assume.
9
+ // `pnpm dev:design` o plugin responde com dado gerado a partir do `output`
10
+ // (ADR 0037); no dev normal e em prod, o handler real do binding assume.
12
11
  export const taskList = defineContract({
13
12
  name: 'task.list',
14
13
  kind: 'list',
@@ -19,7 +18,6 @@ export const taskList = defineContract({
19
18
  input: z.object({}),
20
19
  output: Task,
21
20
  paginate: 'cursor',
22
- mockHandler: async () => ({ items: fakeMany(Task, 5), cursor: { next: null }, total: 5 }),
23
21
  })
24
22
 
25
23
  // Fixture em memória — o domínio-exemplo não tem banco; troque pelo seu repositório.
@@ -5,11 +5,12 @@ import { fileURLToPath } from 'node:url'
5
5
 
6
6
  /**
7
7
  * Modo design (`vite --mode design`, script dev:design): o runtime Opus sobe DENTRO do
8
- * dev server e os mockHandler dos contratos respondem o `/api` — preview autossuficiente,
8
+ * dev server e as actions respondem o `/api` com dado gerado a partir do `output` do contrato
9
+ * (ADR 0037; cenários específicos entram pela opção `scenarios`) — preview autossuficiente,
9
10
  * isolado de prod (o plugin desliga o `server.proxy` sozinho; prefixo ex-proxy sem
10
11
  * cobertura responde 503, nunca vaza pra porta real). Quando o app ganhar um backend real,
11
12
  * o `dev` normal proxeia pra ele (adicione `server.proxy`); o preview de design segue nos
12
- * mocks. O entry default do plugin é `./opus.config.ts` (a fonte da spec deste app).
13
+ * dados de design. O entry default do plugin é `./opus.config.ts` (a fonte da spec deste app).
13
14
  *
14
15
  * Import DINÂMICO NO TOP-LEVEL, de propósito, e em duas condições que andam juntas: (1) o
15
16
  * @softize/opus embarca TS source e o config loader padrão (esbuild bundle — o que build e
@@ -36,7 +36,7 @@ import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
36
36
  * Um registrável de action num domínio: BOUND (`ActionDef`, com handler) ou
37
37
  * contrato PURO (`ActionContract`, sem handler). Um domínio de design — a
38
38
  * fatia isomorfa (ADR 0004) — registra só contratos, e em modo design o
39
- * `mockHandler` responde. Fora de design, contrato sem handler falha no
39
+ * plugin `opusDesign()` responde por eles (ADR 0037). Fora de design, contrato sem handler falha no
40
40
  * `execute` (comportamento INTENCIONAL); o tipo só deixa de exigir o cast.
41
41
  */
42
42
  export type DomainAction = ActionDef | ActionContract
@@ -205,7 +205,7 @@ function* iterateActions(
205
205
  ): IterableIterator<ActionDef> {
206
206
  if (actions === undefined) return
207
207
  // Um contrato puro (`ActionContract`, sem handler) entra como `ActionDef` no
208
- // runtime — em design o `mockHandler` responde; fora de design, a ausência de
208
+ // runtime — em design o plugin `opusDesign()` responde; fora de design, a ausência de
209
209
  // handler falha no `execute` (intencional). O cast reconcilia o tipo neste
210
210
  // boundary de normalização; ver `DomainAction`.
211
211
  if (Array.isArray(actions)) {
package/src/core/types.ts CHANGED
@@ -821,27 +821,11 @@ export interface ActionBase<In, Out, ParsedIn = In> {
821
821
 
822
822
  /**
823
823
  * Handler alternativo executado quando `process.env.OPUS_MODE === 'design'`.
824
- * Use pra prototipar UI com dados realistas sem precisar do backend pronto.
825
824
  *
826
- * Em modo design: runtime tenta `mockHandler` primeiro; se ausente, cai
827
- * no `handler` normal (permitindo testar pontualmente real ao lado de
828
- * mocks).
829
- *
830
- * Em modo default (sem env): `mockHandler` é ignorado. Útil pra apagar
831
- * em revisão ou manter pro Designer reusar.
832
- *
833
- * Pattern recomendado pra ações que só têm mock durante dev:
834
- *
835
- * ```ts
836
- * defineAction({
837
- * name: 'ticket.list',
838
- * // ...
839
- * mockHandler: async () => MOCK_TICKETS,
840
- * handler: () => {
841
- * throw new Error('handler real ainda não implementado — use OPUS_MODE=design')
842
- * },
843
- * })
844
- * ```
825
+ * @deprecated Desde a 22.1.0 (ADR 0037); sai na próxima major. No modo design, a action sem
826
+ * mock já responde com dado gerado a partir do `output`; quando o design precisar de uma resposta
827
+ * específica, declare um cenário fora do contrato com `defineDesignScenario`, de
828
+ * `@softize/opus/design`, e aponte a opção `scenarios` do `opusDesign()` para ele.
845
829
  */
846
830
  mockHandler?: HandlerFn<ParsedIn, Out>
847
831
 
@@ -919,6 +903,8 @@ export interface ListAction<In, Out, ParsedIn = In>
919
903
  /**
920
904
  * Mesma semântica de `ActionBase.mockHandler`, mas devolve `Paginated<Out>`
921
905
  * (search retorna lista paginada, não item solto).
906
+ *
907
+ * @deprecated Desde a 22.1.0 (ADR 0037); ver `ActionBase.mockHandler`.
922
908
  */
923
909
  mockHandler?: (
924
910
  ctx: ActionContext,
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Dados do modo design fora do contrato (ADR 0037).
3
+ *
4
+ * No modo design, toda action responde: sem cenário, a resposta é gerada pelo `fake` a partir do
5
+ * `output` do contrato. Quando o design precisa de coerência (um item em cada situação, algo criado
6
+ * que aparece na lista), um cenário registrado pelo nome da action vence a resposta padrão.
7
+ *
8
+ * O módulo é isomórfico e não conhece Vite: o plugin `opusDesign()` carrega os cenários e liga as
9
+ * respostas ao runtime de design.
10
+ */
11
+
12
+ import { z } from 'zod'
13
+ import type { ActionContext, InferOutput, Paginated } from '../core/types.ts'
14
+ import { fake, fakeMany } from '../testing/fake.ts'
15
+
16
+ // =============================================================================
17
+ // Cenários
18
+ // =============================================================================
19
+
20
+ // `Symbol.for`: o plugin roda no processo do Vite e carrega os cenários pelo pipeline SSR, então
21
+ // a marca precisa sobreviver a duas instâncias do módulo.
22
+ const SCENARIO = Symbol.for('@softize/opus/design-scenario')
23
+
24
+ type ContractKind = 'simple' | 'form' | 'list' | 'view'
25
+
26
+ interface ScenarioContract {
27
+ name: string
28
+ kind: ContractKind
29
+ input: unknown
30
+ output: unknown
31
+ }
32
+
33
+ type ScenarioInput<C> = C extends { input: infer S } ? InferOutput<S> : never
34
+
35
+ type ScenarioOutput<C> = C extends { output: infer S }
36
+ ? C extends { kind: 'list' }
37
+ ? Paginated<InferOutput<S>>
38
+ : InferOutput<S>
39
+ : never
40
+
41
+ /** Resposta de um cenário: o valor devolvido, ou uma função da entrada já validada. */
42
+ export type DesignScenarioResponse<C> =
43
+ | ScenarioOutput<C>
44
+ | ((input: ScenarioInput<C>, ctx: ActionContext) => ScenarioOutput<C> | Promise<ScenarioOutput<C>>)
45
+
46
+ /** Cenário de design ligado a uma action pelo nome. */
47
+ export interface DesignScenario {
48
+ readonly [SCENARIO]: true
49
+ readonly action: string
50
+ respond(input: unknown, ctx: ActionContext): Promise<unknown>
51
+ }
52
+
53
+ /**
54
+ * Declara a resposta de uma action no modo design, fora do contrato. A resposta é tipada pela
55
+ * entrada e pela saída do contrato; em `list`, é a página completa (`items`, `cursor`, `total`).
56
+ */
57
+ export function defineDesignScenario<C extends ScenarioContract>(
58
+ contract: C,
59
+ // NoInfer: o contrato é inferido só do primeiro argumento. Sem isso, a resposta entra na
60
+ // inferência e os literais do retorno de uma função alargam (`'closed'` vira `string`).
61
+ response: NoInfer<DesignScenarioResponse<C>>,
62
+ ): DesignScenario {
63
+ const respond =
64
+ typeof response === 'function'
65
+ ? async (input: unknown, ctx: ActionContext): Promise<unknown> =>
66
+ (response as (input: unknown, ctx: ActionContext) => unknown)(input, ctx)
67
+ : async (): Promise<unknown> => response
68
+ return { [SCENARIO]: true, action: contract.name, respond }
69
+ }
70
+
71
+ /** `true` quando o valor foi criado por `defineDesignScenario`. */
72
+ export function isDesignScenario(value: unknown): value is DesignScenario {
73
+ return typeof value === 'object' && value !== null && (value as Record<symbol, unknown>)[SCENARIO] === true
74
+ }
75
+
76
+ // =============================================================================
77
+ // Resposta padrão
78
+ // =============================================================================
79
+
80
+ /** Itens por página da listagem padrão quando a entrada não pede `limit`. */
81
+ export const DESIGN_PAGE_SIZE = 20
82
+
83
+ // Teto do `limit` atendido: o maior tamanho de página que o `ActionList` oferece.
84
+ const DESIGN_MAX_PAGE_SIZE = 100
85
+
86
+ // Três páginas: o bastante para a paginação do preview ter para onde ir.
87
+ const DESIGN_PAGES = 3
88
+
89
+ const PAGINATION_KEYS = new Set(['page', 'cursor', 'offset', 'limit'])
90
+
91
+ interface DesignActionShape {
92
+ name: string
93
+ kind: ContractKind
94
+ output?: unknown
95
+ paginate?: 'cursor' | 'offset' | false
96
+ }
97
+
98
+ /**
99
+ * Resposta padrão do modo design: dado gerado pelo `fake` a partir do `output`, com semente
100
+ * derivada do nome da action e da entrada. A mesma requisição recebe a mesma resposta.
101
+ *
102
+ * - `list` devolve três páginas do tamanho pedido em `limit` ({@link DESIGN_PAGE_SIZE} quando a
103
+ * entrada não pede) e respeita a paginação declarada; busca e filtros não filtram o dado gerado.
104
+ * - Nas demais, a saída ecoa o `id` da entrada quando o schema da saída aceita esse valor.
105
+ * - Saída que não é schema zod não tem gerador: a resposta é `null`.
106
+ */
107
+ export function designResponse(action: DesignActionShape, input: unknown): unknown {
108
+ const output = action.output
109
+ if (!(output instanceof z.ZodType)) return null
110
+ const seed = `${action.name}:${canonicalJson(withoutPagination(input))}`
111
+
112
+ if (action.kind === 'list') {
113
+ const paginate = action.paginate ?? 'cursor'
114
+ const size = pageSize(input)
115
+ if (paginate === false) {
116
+ return { items: fakeMany(output, size, { seed }), cursor: { next: null }, total: size }
117
+ }
118
+ // O `limit` fica fora da semente: mudar o tamanho da página redistribui os mesmos itens.
119
+ const all = fakeMany(output, size * DESIGN_PAGES, { seed })
120
+ const page = requestedPage(input, size)
121
+ const start = (page - 1) * size
122
+ const next = paginate === 'cursor' && page < DESIGN_PAGES ? `design:${page + 1}` : null
123
+ const prev = paginate === 'cursor' && page > 1 ? `design:${page - 1}` : null
124
+ return {
125
+ items: all.slice(start, start + size),
126
+ cursor: { next, prev },
127
+ total: all.length,
128
+ } satisfies Paginated<unknown>
129
+ }
130
+
131
+ const value = fake(output, { seed })
132
+ const inputId = isRecord(input) ? input.id : undefined
133
+ if (inputId !== undefined && isRecord(value) && 'id' in value) {
134
+ // Só ecoa quando a saída continua válida: `id` numérico ou uuid na saída não aceita qualquer
135
+ // valor da entrada, e a validação de output do runtime recusaria a resposta.
136
+ const echoed = { ...value, id: inputId }
137
+ if (output.safeParse(echoed).success) return echoed
138
+ }
139
+ return value
140
+ }
141
+
142
+ function pageSize(input: unknown): number {
143
+ const limit = isRecord(input) && typeof input.limit === 'number' ? Math.trunc(input.limit) : DESIGN_PAGE_SIZE
144
+ return Math.min(Math.max(1, limit), DESIGN_MAX_PAGE_SIZE)
145
+ }
146
+
147
+ /** Página pedida, de 1 em diante; além da última página, a lista vem vazia. */
148
+ function requestedPage(input: unknown, size: number): number {
149
+ if (!isRecord(input)) return 1
150
+ const cursor = typeof input.cursor === 'string' ? /^design:(\d+)$/.exec(input.cursor) : null
151
+ const fromCursor = cursor === null ? undefined : Number(cursor[1])
152
+ const fromOffset = typeof input.offset === 'number' ? Math.floor(input.offset / size) + 1 : undefined
153
+ const fromPage = typeof input.page === 'number' ? input.page : undefined
154
+ return Math.max(1, Math.trunc(fromCursor ?? fromPage ?? fromOffset ?? 1))
155
+ }
156
+
157
+ function withoutPagination(input: unknown): unknown {
158
+ if (!isRecord(input)) return input
159
+ return Object.fromEntries(Object.entries(input).filter(([key]) => !PAGINATION_KEYS.has(key)))
160
+ }
161
+
162
+ /** JSON com chaves ordenadas: a mesma entrada produz a mesma semente, qualquer que seja a ordem. */
163
+ function canonicalJson(value: unknown): string {
164
+ if (typeof value === 'bigint') return `${value}n`
165
+ if (value instanceof Date) return JSON.stringify(value.toISOString())
166
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
167
+ if (isRecord(value)) {
168
+ const entries = Object.keys(value)
169
+ .filter((key) => value[key] !== undefined)
170
+ .sort()
171
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
172
+ return `{${entries.join(',')}}`
173
+ }
174
+ return JSON.stringify(value) ?? 'null'
175
+ }
176
+
177
+ function isRecord(value: unknown): value is Record<string, unknown> {
178
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
179
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `fake` — dado sintético DETERMINÍSTICO a partir de um schema zod. É a matéria-prima do
3
- * modo design: um `mockHandler` devolve `fake(entity)`/`fakeMany(entity, n)` e o preview mostra
4
- * a UI com dado realista, sem backend nem banco (o design-mode do runtime nem toca `ctx.db`).
3
+ * modo design: a resposta gerada pelo plugin `opusDesign()` (ADR 0037) sai de `fake`/`fakeMany`
4
+ * sobre o `output` do contrato, e o preview mostra a UI com dado realista, sem backend nem banco.
5
5
  *
6
6
  * DETERMINISMO: PRNG próprio semeado (mulberry32) — mesma `seed` → mesma saída, sempre. Nunca
7
7
  * `Math.random` (o preview tem que ser reproduzível entre execuções). O default é semeado por
@@ -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 via `mockHandler` em modo design. */
209
+ /** Opcional: contrato puro (sem handler) só executa pelo `mockHandler` obsoleto. */
210
210
  handler?: HandlerFn<ParsedIn, Out>
211
- /** Em `OPUS_MODE=design` vence o handler real — mesma régua do runtime. */
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 — contrato puro roda em modo design (OPUS_MODE=design) com mockHandler; fora dele, teste o ActionDef do bindAction`,
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)
@@ -152,54 +152,62 @@ A autorização é fechada por padrão: sem `public: true`, a action exige usuá
152
152
 
153
153
  ## Prototipar com dados simulados
154
154
 
155
- Para desenhar a interface antes de o backend existir, declare um `mockHandler` no contrato. Com
156
- `OPUS_MODE=design`, o runtime executa o `mockHandler` no lugar do `handler`; sem ele, usa o
157
- `handler` real. Como o mock fica no contrato, a mesma declaração serve ao design e, depois, ao
158
- handler real, e o mock não precisa acessar `ctx.db`.
155
+ Para desenhar a interface antes de o backend existir, rode o app em modo design. Nesse modo, toda
156
+ action responde sem que o contrato declare nada: a resposta é gerada pelo `fake` a partir do
157
+ `output` do contrato (ADR 0037).
159
158
 
160
- `fake` e `fakeMany` geram dados determinísticos a partir do próprio schema (ver
161
- [Testes de action](/docs/testing)). Em `list`, o `mockHandler` devolve a página inteira, com
162
- `items`, `cursor` e `total`:
159
+ - A mesma requisição recebe sempre a mesma resposta.
160
+ - Uma `list` devolve três páginas do tamanho pedido em `limit` (20 quando a entrada não pede, até
161
+ 100) e respeita a paginação declarada. Busca e filtros não filtram o dado gerado.
162
+ - 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
163
+ aceite esse valor. Assim, abrir um item da lista mostra o mesmo identificador.
164
+
165
+ O dado gerado tem o formato certo, mas não segue a regra de negócio: os valores de um enum são
166
+ sorteados, e uma listagem e um detalhe só coincidem no `id`. Quando o design precisa de coerência,
167
+ como um item em cada situação ou algo criado que aparece na lista, escreva um cenário. Ele fica fora
168
+ do contrato e é ligado à action pelo nome:
163
169
 
164
170
  ```ts
165
- import { bindAction, defineContract } from '@softize/opus/core'
166
- import { entityRowSchema } from '@softize/opus/schema'
167
- import { fake, fakeMany } from '@softize/opus/testing'
168
- import { z } from 'zod'
169
- import { EventEntity } from '../entity.ts'
171
+ import { defineDesignScenario } from '@softize/opus/design'
172
+ import { eventGet, eventList } from '../domains/event/contract.ts'
173
+
174
+ // Valor fixo: o caso mais comum.
175
+ export const eventsInEachState = defineDesignScenario(eventList, {
176
+ items: [
177
+ { id: 'e1', title: 'Revisão agendada', status: 'scheduled' },
178
+ { id: 'e2', title: 'Revisão perdida', status: 'lost' },
179
+ ],
180
+ cursor: { next: null },
181
+ total: 2,
182
+ })
170
183
 
171
- // `entityRowSchema` deriva da entidade o schema de uma linha.
172
- const eventRowSchema = entityRowSchema(EventEntity)
184
+ // Função: quando a resposta depende da entrada.
185
+ export const eventDetail = defineDesignScenario(eventGet, (input) => ({
186
+ id: input.id,
187
+ title: 'Revisão agendada',
188
+ status: 'scheduled',
189
+ }))
190
+ ```
173
191
 
174
- export const eventGet = defineContract({
175
- name: 'event.get',
176
- kind: 'view',
177
- input: z.object({ id: z.string().uuid() }),
178
- output: eventRowSchema,
179
- mockHandler: () => fake(eventRowSchema),
180
- })
192
+ A resposta é tipada pela entrada e pela saída do contrato; em `list`, é a página inteira. Informe ao
193
+ plugin onde os cenários estão, com caminhos ou globs:
194
+ `opusDesign({ scenarios: 'design/scenarios/*.ts' })`. O cenário vence a resposta gerada. Um cenário
195
+ de uma action que não existe, ou dois cenários para a mesma action, impedem o runtime de design de
196
+ subir, e o erro lista os nomes.
181
197
 
182
- export const eventList = defineContract({
183
- name: 'event.list',
184
- kind: 'list',
185
- input: z.object({}),
186
- output: eventRowSchema,
187
- paginate: 'cursor',
188
- mockHandler: async () => ({ items: fakeMany(eventRowSchema, 20), cursor: { next: null }, total: 20 }),
189
- })
198
+ O Opus não decide onde os cenários ficam nem se entram no repositório. Eles não fazem parte da
199
+ aplicação: podem ficar numa pasta ignorada pelo git e ser escritos durante a sessão de design,
200
+ quando forem necessários.
190
201
 
191
- // Depois, o servidor liga o handler real ao mesmo contrato.
192
- export const eventGetImpl = bindAction(eventGet, {
193
- handler: async (ctx, input) =>
194
- ctx.db.selectFrom('events').selectAll().where('id', '=', input.id).executeTakeFirstOrThrow(),
195
- })
196
- ```
202
+ `mockHandler` está obsoleto desde a 22.1.0 e sai na próxima major. Enquanto existir, ele ainda
203
+ responde no modo design, abaixo do cenário e acima da resposta gerada, e o `opus check` conta
204
+ quantas actions ainda o declaram. Para migrar, apague o `mockHandler`; se a resposta dele importar
205
+ para o design, transforme-a em cenário.
197
206
 
198
- O `mockHandler` roda no servidor, não no navegador. O plugin `opusDesign()`, de
199
- `@softize/opus/vite`, monta o runtime do Opus dentro do dev server e responde `/api` no próprio
200
- processo. No modo design, qualquer `server.proxy` para um backend externo fica desligado: um prefixo
201
- que ia para o proxy e não tem action correspondente responde 503 em envelope, sem alcançar o
202
- backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
207
+ O plugin `opusDesign()`, de `@softize/opus/vite`, monta o runtime do Opus dentro do dev server e
208
+ responde `/api` no próprio processo. No modo design, qualquer `server.proxy` para um backend externo
209
+ fica desligado: um prefixo que ia para o proxy e não tem action correspondente responde 503 em
210
+ envelope, sem alcançar o backend. A aplicação chama `/api` como sempre e recebe os dados simulados.
203
211
 
204
212
  ```ts
205
213
  // vite.config.ts — o plugin só carrega no modo design, por import dinâmico.
@@ -233,9 +241,7 @@ nem reiniciar o dev server. Quando uma action não é encontrada, o plugin recon
233
241
  vez por ciclo de mudança e tenta de novo; se a action continuar ausente, a resposta indica o
234
242
  `entry` a conferir.
235
243
 
236
- Como o contrato vai para os dois lados, o `mockHandler` também entra no bundle da web, onde nunca é
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.
244
+ Os cenários não entram no bundle da web: eles ficam fora do contrato, e só o plugin os carrega.
239
245
 
240
246
  ## Consumir o contrato na interface
241
247
 
@@ -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 só
19
- executa em modo design, descrito abaixo.
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
- ## Testar em modo design
89
+ ## Dados do modo design
90
90
 
91
- Com `OPUS_MODE=design`, `runAction` executa o `mockHandler` quando ele existe, como o runtime do
92
- modo design. Nesse modo, o contrato puro também é testável. Fora dele, um contrato sem handler falha
93
- com `testing.no_handler`.
94
-
95
- ```ts
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 ao `mockHandler` do modo design e a fixtures de teste. A mesma `seed` produz a
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
 
@@ -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
- * `mockHandler` dos contratos respondem (`OPUS_MODE=design`), qualquer proxy
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 handler×mockHandler lendo OPUS_MODE (isOpusDesignMode);
198
- // com o runtime local, o preview fica autossuficiente.
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 { actions, dropped } = collectActions(registrables)
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 — handler real sem
227
- // mock falhando é o sinal certo de que falta mockar.
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
- entries
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 = entries
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 unmocked = state.actions.filter((a) => a.mockHandler === undefined)
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: state.actions.length - unmocked.length,
349
- unmocked: unmocked.map((a) => a.name),
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 o mockHandler responde.
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 }