@softize/opus 24.1.0 → 25.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +1 -1
  3. package/bin/cli.mjs +9 -0
  4. package/bin/lib/check.mjs +390 -1
  5. package/bin/lib/copy.mjs +39 -2
  6. package/bin/lib/gen-runner.mjs +25 -12
  7. package/bin/lib/mcp.mjs +1 -1
  8. package/docs/adr/0051-soft-delete-declares-retention-and-purge.md +224 -0
  9. package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
  10. package/docs/data-layer.md +31 -3
  11. package/docs/protocol.md +98 -2
  12. package/docs/releasing.md +8 -2
  13. package/docs/stacking-scale.md +53 -0
  14. package/package.json +6 -1
  15. package/src/ai/drivers/anthropic.ts +3 -0
  16. package/src/core/domain.ts +189 -7
  17. package/src/core/index.ts +19 -2
  18. package/src/core/retention.ts +493 -0
  19. package/src/core/runtime.ts +93 -29
  20. package/src/core/system-actor.ts +33 -0
  21. package/src/core/types.ts +12 -0
  22. package/src/data/drivers/kysely.ts +47 -5
  23. package/src/data/index.ts +24 -6
  24. package/src/mcp/index.ts +3 -2
  25. package/src/schema/entity.ts +30 -9
  26. package/src/schema/index.ts +4 -0
  27. package/src/schema/openapi.ts +4 -1
  28. package/src/ui/components/primitives/dialog.tsx +285 -26
  29. package/src/ui/components/primitives/drawer.tsx +104 -13
  30. package/src/ui/components/primitives/floating-layer.tsx +79 -0
  31. package/src/ui/components/primitives/menu.tsx +17 -2
  32. package/src/ui/components/primitives/popover.tsx +6 -1
  33. package/src/ui/components/primitives/tooltip.tsx +6 -1
  34. package/src/ui/docs/content/cli.md +5 -3
  35. package/src/ui/docs/content/data.md +42 -0
  36. package/src/ui/docs/content/dialog.md +3 -0
  37. package/src/ui/docs/content/drawer.md +3 -0
  38. package/src/ui/docs/content/floating-layer.md +178 -0
  39. package/src/ui/docs/content/scheduler.md +4 -0
  40. package/src/ui/docs/registry.tsx +6 -0
  41. package/src/ui/lib/dismiss-on-iframe.ts +4 -1
  42. package/src/ui/lib/modal-layers.ts +547 -0
  43. package/src/ui/meta.ts +6 -0
  44. package/src/ui/react.tsx +4 -0
@@ -137,7 +137,13 @@ export const Deal = defineEntity({
137
137
  },
138
138
  relations: { owner: belongsTo('user', { from: 'ownerId' }) },
139
139
  timestamps: true,
140
- softDelete: true,
140
+ // Exclusão lógica com prazo: o Opus gera a action e a rotina de expurgo (ADR 0051).
141
+ softDelete: {
142
+ retainDays: 90,
143
+ timezone: 'America/Sao_Paulo',
144
+ label: 'Expurgar negócios excluídos',
145
+ description: 'Apaga de vez os negócios excluídos há mais de 90 dias.',
146
+ },
141
147
  })
142
148
  ```
143
149
 
@@ -194,6 +200,25 @@ defineAction({
194
200
  })
195
201
  ```
196
202
 
203
+ ### 6. Expurgo dos excluídos
204
+ Com `retainDays`, `remove` marca `deletedAt`, e o domínio que lista a entidade em `entities`
205
+ ganha a action `deal.purge-deleted` e a rotina `deal.purge-deleted.daily` (ADR 0051). Com
206
+ `kyselyData` no runtime, o handler padrão apaga em lotes, do mais antigo para o mais novo, as
207
+ linhas excluídas há mais de `retainDays`. A rotina exige um `SchedulerAdapter`.
208
+
209
+ ```ts
210
+ import { defineDomain } from '@softize/opus'
211
+
212
+ export const deal = defineDomain({ name: 'deal', entities: { Deal } })
213
+ // runtime.register([deal]) → deal.purge-deleted + deal.purge-deleted.daily
214
+ ```
215
+
216
+ Quando apagar a linha não basta, registre um `bindPurge` (de `@softize/opus/data`) em `purges`
217
+ no mesmo domínio. Uma chave estrangeira restritiva faz a execução falhar sem apagar o lote, e como a
218
+ linha travada está entre as mais antigas, toda execução seguinte falha do mesmo jeito: o expurgo da
219
+ entidade fica parado até a chave ser ajustada (cascata na DDL) ou um `bindPurge` apagar os
220
+ dependentes primeiro. Detalhes em `protocol.md`, §15, "Retenção e expurgo".
221
+
197
222
  ## Naming (camel ↔ snake)
198
223
 
199
224
  O backend é **100% camelCase** (TS idiomático); o snake_case mora só no banco. Quem
@@ -233,7 +258,7 @@ export default {
233
258
  2. ✅ `diffColumns` (puro) + `desiredColumns` + `kyselyDriftCheck`.
234
259
  3. ✅ Grupo CLI `db` + `opus db check` (carrega config via tsx, factory `database()`).
235
260
  4. ✅ `kyselyRepo(db, entity)` — CRUD tipado (insert/findById/update/remove/list),
236
- gera id (pk auto), preenche timestamps, respeita soft-delete. Falta o açúcar
261
+ gera id (pk auto), preenche timestamps, respeita a exclusão lógica. Falta o açúcar
237
262
  `ctx.repo(Entity)` (wiring na ActionContext).
238
263
  5. ✅ `opus db migrate` — schema idempotente evolutivo (SQL, config `schema`) + drift-check
239
264
  embutido. (A 1ª versão era Kysely Migrator; aposentada — o formato nunca pegou.)
@@ -252,4 +277,7 @@ export default {
252
277
  Esquecer o plugin → o `db check` falha e expõe (footgun auto-denunciado).
253
278
  10. Aprofundar o drift (índices/FK/tipos/defaults).
254
279
  11. Cursor pagination + `FilterSpec` ricos no search (hoje offset + igualdade).
255
- 12. (Opt-in) drivers de auto-gen: drizzle-kit / Atlas.
280
+ 12. ✅ Retenção da exclusão lógica (ADR 0051): `softDelete` declara `retainDays` ou
281
+ `retain: 'forever'`; o domínio gera a action e a rotina de expurgo, com o handler padrão no
282
+ `kyselyData` ou o próprio em `bindPurge`. Restaurar um excluído dentro do prazo fica fora.
283
+ 13. (Opt-in) drivers de auto-gen: drizzle-kit / Atlas.
package/docs/protocol.md CHANGED
@@ -1942,7 +1942,12 @@ export const Deal = defineEntity({
1942
1942
  ],
1943
1943
 
1944
1944
  timestamps: true,
1945
- softDelete: true,
1945
+ softDelete: {
1946
+ retainDays: 90,
1947
+ timezone: 'America/Sao_Paulo',
1948
+ label: 'Expurgar negócios excluídos',
1949
+ description: 'Apaga de vez os negócios excluídos há mais de 90 dias.',
1950
+ },
1946
1951
  })
1947
1952
  ```
1948
1953
 
@@ -1964,7 +1969,7 @@ fields: { code: t.string().pk(), /* ... */ } // sem id automático
1964
1969
 
1965
1970
  **Timestamps.** `timestamps: true` injeta `createdAt` e `updatedAt` (geridos pelo runtime — o handler nunca seta na mão). Default é `false`.
1966
1971
 
1967
- **Soft delete.** `softDelete: true` injeta `deletedAt`. Actions `search`/`view` filtram registros deletados por default; passe a opção explícita quando quiser incluí-los.
1972
+ **Exclusão lógica.** `softDelete` injeta `deletedAt`. Pelo `kyselyRepo`, `remove` marca a data em vez de apagar, e `findById`, `all` e `list` deixam os excluídos de fora; não há opção para incluí-los na leitura, e uma consulta escrita à mão aplica o filtro por conta própria. A declaração diz também quando o registro excluído sai de vez: `retainDays` com o fuso e os textos da action de expurgo, ou `{ retain: 'forever', reason }`. `softDelete: true` ainda é aceito pelo tipo, mas o `opus check` o reprova. Veja [Retenção e expurgo](#retenção-e-expurgo).
1968
1973
 
1969
1974
  ### Campos
1970
1975
 
@@ -2118,6 +2123,97 @@ export const crm = defineDomain({
2118
2123
  })
2119
2124
  ```
2120
2125
 
2126
+ ### Retenção e expurgo
2127
+
2128
+ Uma entidade com exclusão lógica declara por quanto tempo guarda o registro excluído. Sem esse
2129
+ prazo, os excluídos se acumulam para sempre, inclusive dado pessoal, sem que nada avise. Há duas
2130
+ formas, e a escolha é do domínio (ADR 0051):
2131
+
2132
+ ```ts
2133
+ // Prazo: o registro excluído sai de vez depois de 90 dias.
2134
+ softDelete: {
2135
+ retainDays: 90, // inteiro de 1 a 3650, contado a partir de deletedAt
2136
+ timezone: 'America/Sao_Paulo', // fuso da rotina; obrigatório
2137
+ label: 'Expurgar negócios excluídos', // título da action de expurgo, completo
2138
+ description: 'Apaga de vez os negócios excluídos há mais de 90 dias.',
2139
+ schedule: { cron: '30 3 * * *', enabled: true }, // opcional; estes são os padrões
2140
+ batchSize: 200, // opcional; linhas por lote, até 10_000
2141
+ maxDurationMs: 60_000, // opcional; tempo máximo de uma execução
2142
+ }
2143
+
2144
+ // Guarda para sempre, com o motivo.
2145
+ softDelete: { retain: 'forever', reason: 'Exigência legal de guarda do histórico.' }
2146
+ ```
2147
+
2148
+ O `label` e a `description` entram na action como estão: o Opus não monta a frase a partir do nome
2149
+ do recurso, porque um modelo fixo erraria a concordância. O `opus copy` os lê no `defineEntity`.
2150
+
2151
+ **O que o Opus gera.** Para cada entidade com `retainDays`, o domínio que a lista em `entities`
2152
+ ganha uma action e uma rotina. A action se chama `<caminho-do-domínio>.<entidade-em-kebab>.purge-deleted`;
2153
+ quando a entidade em kebab é igual ao último segmento do domínio, ele não se repete. A entidade
2154
+ `deal` no domínio `deal` gera `deal.purge-deleted`, e `dealNote` no domínio `crm` gera
2155
+ `crm.deal-note.purge-deleted`. A rotina se chama `<action>.daily`.
2156
+
2157
+ | Peça | Declaração |
2158
+ |---|---|
2159
+ | Entrada | `{ olderThanDays?: number }`: inteiro entre o `retainDays` e 3650, com o `retainDays` como padrão. Expurgar antes do prazo é recusado. |
2160
+ | Saída | `{ purged: number }` |
2161
+ | Natureza | `kind: 'simple'`, `effect: 'write'`, `personalData: false` |
2162
+ | Acesso | `automatable: true` e `authorize` que aceita só o sistema (`isSystemActor`); `internal: true` (fora do REST) e `ai: false` (é operação do sistema, não pedido de uma pessoa) |
2163
+ | Rotina | `cron` declarado ou `'30 3 * * *'`, no `timezone` declarado, executada como o sistema (ADR 0028) |
2164
+
2165
+ A mesma entidade com retenção em dois domínios, ou num domínio e num subdomínio dele, falha com
2166
+ `domain.entity_registered_twice`; a comparação é pela tabela, então duas declarações da mesma
2167
+ tabela também falham. A checagem de nomes duplicados do domínio inclui a action e a
2168
+ rotina geradas.
2169
+
2170
+ **Quem apaga.** O contrato nasce no core; o handler é anexado no servidor, no `register`:
2171
+
2172
+ - com o `DataAdapter` do driver Kysely, vale o handler padrão. Ele apaga as linhas com `deletedAt`
2173
+ anterior ao corte, das mais antigas para as mais novas, em lotes de `batchSize`, até não sobrar
2174
+ nada ou até esgotar `maxDurationMs`;
2175
+ - quando apagar a linha não basta, o domínio liga um handler próprio com `bindPurge`, de
2176
+ `@softize/opus/data`, e o registra em `purges` no mesmo `defineDomain`. O handler recebe
2177
+ `{ ctx, cutoff, batchSize }`, apaga um lote e devolve `{ ids }`; o Opus repete os lotes enquanto
2178
+ houver ids e tempo. O handler precisa respeitar o `cutoff`: a garantia de nunca expurgar antes do
2179
+ prazo depende dele. Um id repetido na mesma execução, ou mais ids que o `batchSize`, interrompe o
2180
+ expurgo com `purge.no_progress`, para que um handler que não avança não repita lotes até o fim do
2181
+ tempo. Os ids novos desse lote, que o handler já apagou, entram no registro antes da parada;
2182
+ - sem nenhum dos dois, o `start()` recusa com `runtime.purge_handler_missing`, nomeando a entidade.
2183
+
2184
+ ```ts
2185
+ import { bindPurge } from '@softize/opus/data'
2186
+
2187
+ export const dealPurge = bindPurge(Deal, async ({ ctx, cutoff, batchSize }) => {
2188
+ // apaga os anexos em outro serviço e depois as linhas; devolve os ids que saíram
2189
+ const ids = await purgeDealsWithAttachments(ctx, cutoff, batchSize)
2190
+ return { ids }
2191
+ })
2192
+
2193
+ export const crm = defineDomain({ name: 'crm', entities: { Deal }, purges: [dealPurge] })
2194
+ ```
2195
+
2196
+ A rotina exige um `SchedulerAdapter`, como qualquer schedule. No modo design, a action entra no
2197
+ catálogo, sem rota HTTP porque é `internal`, e a rotina não inicia.
2198
+
2199
+ Os schemas da action são Standard Schema escritos pelo Opus, sem zod, com o JSON Schema anexado
2200
+ em `~standard.jsonSchema`. O manifest, o OpenAPI e o MCP leem esse JSON Schema antes de recorrer
2201
+ ao zod, então `@softize/opus` continua carregando sem zod instalado.
2202
+
2203
+ **Dependências no banco.** O schema do Opus não declara cascata. Se uma chave estrangeira
2204
+ restritiva recusar a exclusão, a execução falha com o erro do banco no log e na auditoria, e nada
2205
+ daquele lote é apagado. Como a linha travada está entre as mais antigas, ela volta ao primeiro lote
2206
+ em toda execução: o expurgo da entidade fica parado até a chave ser ajustada. Antes de declarar
2207
+ `retainDays`, confira as chaves que apontam para a tabela; use cascata na DDL ou um `bindPurge`
2208
+ que apague os dependentes primeiro.
2209
+
2210
+ **O registro.** Cada lote que apaga algo registra `opus.purge` com a entidade, a action, o corte,
2211
+ o `olderThanDays`, o número do lote (`batch`) e os ids removidos, em blocos de no máximo 500 ids.
2212
+ No fim da execução que apagou algo, `opus.purge.total` traz `purged`, `batches` e `remaining`,
2213
+ verdadeiro quando a execução parou por tempo com o lote cheio ou foi interrompida por erro. Os
2214
+ registros vão para o `ctx.log` e, na falta dele, para o logger do runtime. Depois do expurgo, só
2215
+ eles e a auditoria da execução contam o que saiu.
2216
+
2121
2217
  ### O que **não** entra
2122
2218
 
2123
2219
  - **ORM / Active Record.** Sem `deal.save()`, sem estado na instância. Entidade é descrição, não objeto vivo.
package/docs/releasing.md CHANGED
@@ -59,14 +59,20 @@ O token usado para publicar é exclusivamente o token do npm no `~/.npmrc` globa
59
59
  pra testar publicação seria a guarda atrapalhando quem está experimentando.
60
60
 
61
61
  **Gate de qualidade**: depois do bump e da materialização, mas antes de publicar, roda
62
- `pnpm typecheck` + `pnpm test` + `pnpm copy:check` + `base copy check` e **aborta a release
63
- se qualquer um falhar**. O Opus declara `@softize/base ^2.4.0` em `dependencies`, pois usa
62
+ `pnpm typecheck` + `pnpm test` + `pnpm test:browser` + `pnpm copy:check` + `base copy check` e
63
+ **aborta a release se qualquer um falhar**. O Opus declara `@softize/base ^2.4.0` em `dependencies`, pois usa
64
64
  suas APIs públicas de filesystem em runtime, e materializa os artefatos Base no próprio repo;
65
65
  a release não baixa uma política ad hoc. Como o
66
66
  Opus **ship source** (`.ts`, sem build), essa é a última barreira antes do tarball — sem ela,
67
67
  um `tsc` vermelho, inventário desatualizado ou violação da política vaza pro cliente. Emergência (evite):
68
68
  `pnpm release --skip-checks`.
69
69
 
70
+ `pnpm test:browser` roda em Chromium real, pelo modo navegador do Vitest com Playwright, os testes
71
+ que o `happy-dom` não reproduz com fidelidade, como o foco e o Tab da camada flutuante (ADR 0052).
72
+ Ele fica fora do `pnpm test` para que o ciclo comum não dependa de navegador. O Chromium é o da
73
+ revisão fixada pela versão exata do `playwright` nas devDependencies; se a estação não o tiver, o
74
+ comando falha dizendo isso, e `pnpm exec playwright install chromium` o instala.
75
+
70
76
  O gate roda `pnpm test`, não `pnpm test:cov`: o threshold de 100% de cobertura em
71
77
  `vitest.config.ts` é uma meta aspiracional e **não** faz parte do gate de release. Uma release
72
78
  não é bloqueada por cobertura abaixo de 100%; a cobertura é acompanhada com `pnpm test:cov`.
@@ -0,0 +1,53 @@
1
+ # Escala de empilhamento
2
+
3
+ > **Status: decidido (out/2026).** As sobreposições do Opus ocupam quatro faixas de `z-index`,
4
+ > definidas pela ADR 0052. Os valores são detalhe de implementação; a ordem entre as faixas é o
5
+ > contrato.
6
+
7
+ ## Contexto
8
+
9
+ Até a camada flutuante, toda sobreposição do Opus usava `z-50`: véu e conteúdo de Dialog e Drawer,
10
+ Popover, Menu e Tooltip. A ordem no DOM desempatava, e o que abria depois ficava por cima. Isso
11
+ bastava enquanto só existiam modais e sobreposições presas a um gatilho.
12
+
13
+ A camada flutuante (`FloatingLayer`) precisa ficar acima de Dialog e Drawer e, ao mesmo tempo,
14
+ abaixo dos diálogos de resposta obrigatória, que a cobrem com o véu (decisão 4 da seção 9 da ADR
15
+ 0052). A ordem no DOM não garante isso: o hospedeiro da camada é montado uma vez e continua no
16
+ `body` enquanto modais abrem e fecham depois dele.
17
+
18
+ ## Decisão
19
+
20
+ Do fundo para a frente:
21
+
22
+ | Faixa | `z-index` | Quem ocupa |
23
+ | ------------------------- | ------------- | ---------------------------------------------------------------------------------------------- |
24
+ | Conteúdo | `auto` | A página. |
25
+ | Modal | `50` | Dialog, Drawer, seus véus e Popover, Menu e Tooltip abertos fora da camada. |
26
+ | Camada flutuante | `60` | O hospedeiro `div[data-slot="floating-layer"]`. |
27
+ | Resposta obrigatória | `70` | Véu e conteúdo de `Dialog mode="alert"`, que também serve às confirmações do `DialogHost`. |
28
+ | Avisos | `999999999` | O `Toaster`, com o valor do Sonner. |
29
+
30
+ - Dentro de uma faixa, a ordem no DOM continua desempatando, como antes.
31
+ - O hospedeiro da camada cria um contexto de empilhamento próprio. Popover, Menu, Tooltip, Dialog e
32
+ Drawer abertos de dentro dela são portados para o hospedeiro e mantêm o `z-50` lá dentro, acima
33
+ do painel que os abriu.
34
+ - Popover, Menu e Tooltip abertos de dentro de um alerta herdam a faixa do alerta (`z-70`); sem
35
+ isso, ficariam por baixo do véu que o próprio alerta desenha.
36
+ - Componente novo que abre uma sobreposição usa as faixas acima e lê o contêiner da camada; o
37
+ teste `tests/architecture/floating-layer.test.ts` cobra o contêiner de todo portal.
38
+ - Um alerta em animação de saída deixa de receber clique (`pointer-events: none`). Sem isso, o véu
39
+ dele, ainda em `z-70`, cobriria por cerca de 150 ms o Dialog que a resposta abre na faixa de
40
+ baixo.
41
+
42
+ ## Consequências
43
+
44
+ - Um consumidor que posiciona elementos próprios com `z-index` acima de `50` passa a competir com a
45
+ camada. Use a camada para o que precisa ficar acima dos modais, em vez de subir o `z-index`.
46
+ - Um elemento do consumidor com `z-index` entre `51` e `69` ficava acima de um alerta, que estava em
47
+ `z-50`, e agora fica abaixo dele.
48
+ - Uma sobreposição de outra biblioteca que fixa `z-50` e é aberta de dentro de um alerta fica abaixo
49
+ do véu do alerta, porque não lê a faixa que os primitives do Opus herdam. Suba a faixa dela para
50
+ `z-70` ou troque-a por um primitive do Opus.
51
+ - Mudar um valor exige mudar todos os pontos que dependem dele: `overlayClasses` e o conteúdo em
52
+ `dialog.tsx` e `drawer.tsx`, o hospedeiro em `floating-layer.tsx` e a faixa do alerta em
53
+ `modal-layers.ts`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "24.1.0",
3
+ "version": "25.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",
@@ -362,6 +362,7 @@
362
362
  "@opentelemetry/api": "^1.9.1",
363
363
  "@opentelemetry/context-async-hooks": "^2.10.0",
364
364
  "@opentelemetry/sdk-trace-base": "^2.10.0",
365
+ "@tailwindcss/vite": "^4.3.2",
365
366
  "@tanstack/react-query": "^5.62.0",
366
367
  "@testing-library/dom": "^10.4.0",
367
368
  "@testing-library/react": "^16.1.0",
@@ -371,6 +372,7 @@
371
372
  "@types/pg": "^8.11.0",
372
373
  "@types/react": "^19.0.0",
373
374
  "@types/react-dom": "^19.0.0",
375
+ "@vitest/browser": "2.1.9",
374
376
  "@vitest/coverage-v8": "^2.1.0",
375
377
  "better-sqlite3": "^11.0.0",
376
378
  "bullmq": "^5.0.0",
@@ -381,9 +383,11 @@
381
383
  "node-cron": "^3.0.3",
382
384
  "pg": "^8.13.0",
383
385
  "pino": "^9.5.0",
386
+ "playwright": "1.62.1",
384
387
  "react": "^19.0.0",
385
388
  "react-dom": "^19.0.0",
386
389
  "react-hook-form": "^7.54.0",
390
+ "tailwindcss": "^4.3.2",
387
391
  "tsx": "^4.19.0",
388
392
  "vite": "^6.0.0",
389
393
  "vitest": "^2.1.0",
@@ -400,6 +404,7 @@
400
404
  "copy:check": "node ./bin/cli.mjs copy --check",
401
405
  "typecheck": "tsc --noEmit",
402
406
  "test": "vitest run",
407
+ "test:browser": "vitest run --config vitest.browser.config.ts",
403
408
  "test:watch": "vitest",
404
409
  "test:cov": "vitest run --coverage",
405
410
  "registry:up": "npx -y verdaccio --config ~/.config/verdaccio/config.yaml",
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { zodToJsonSchema } from 'zod-to-json-schema'
16
+ import { declaredJsonSchema } from '../../core/index.ts'
16
17
  import type {
17
18
  AiAdapter,
18
19
  AiCompleteOptions,
@@ -95,6 +96,8 @@ function sdk(): Promise<typeof import('@anthropic-ai/sdk')> {
95
96
  }
96
97
 
97
98
  function defaultToJsonSchema(schema: unknown): unknown {
99
+ const declared = declaredJsonSchema(schema)
100
+ if (declared !== null) return declared
98
101
  return zodToJsonSchema(schema as Parameters<typeof zodToJsonSchema>[0], { $refStrategy: 'none' })
99
102
  }
100
103
 
@@ -9,8 +9,11 @@
9
9
  *
10
10
  * Validações em runtime (em `defineDomain`):
11
11
  * - `name` é string não-vazia no formato `[a-z][a-zA-Z0-9_]*`.
12
- * - Actions têm nomes únicos dentro do domínio (incluindo subdomains).
12
+ * - Actions e schedules têm nomes únicos dentro do domínio (incluindo subdomains e as peças de
13
+ * expurgo derivadas das entidades com retenção — ADR 0051).
13
14
  * - Subdomains têm nomes únicos entre siblings.
15
+ * - Uma entidade com retenção aparece em um só domínio da árvore, e cada `bindPurge` em `purges`
16
+ * aponta para uma entidade com `retainDays` do próprio domínio.
14
17
  *
15
18
  * Warnings (em `Runtime.register`, via `collectDomainWarnings`):
16
19
  * - Actions cujo `name` não casa com `<root>(\.<sub>)*\.<verb>` emitem
@@ -24,6 +27,16 @@
24
27
  import type { ActionContract } from './contracts.ts'
25
28
  import type { DataProductConfig } from './data-product.ts'
26
29
  import { error } from './errors.ts'
30
+ import {
31
+ entityTableName,
32
+ purgeActionName,
33
+ purgeContract,
34
+ purgeSchedule,
35
+ softDeleteRetention,
36
+ type PurgeBinding,
37
+ type SoftDeleteEntity,
38
+ type SoftDeleteRetention,
39
+ } from './retention.ts'
27
40
  import type { ActionDef, ReactionDef, ScheduleDef } from './types.ts'
28
41
 
29
42
  /* eslint-disable @typescript-eslint/no-explicit-any */
@@ -83,6 +96,13 @@ export interface DomainConfig {
83
96
  /** Schedules registrados no domínio. */
84
97
  schedules?: ScheduleDef[]
85
98
 
99
+ /**
100
+ * Handlers próprios de expurgo (`bindPurge`, server-only) das entidades com `retainDays` listadas
101
+ * em `entities` deste mesmo domínio. Sem um deles, o runtime usa o handler padrão do driver de
102
+ * dados (ADR 0051).
103
+ */
104
+ purges?: Array<PurgeBinding<any>>
105
+
86
106
  /** Subdomínios (recursivo). */
87
107
  subdomains?: DomainConfig[]
88
108
  }
@@ -95,6 +115,26 @@ export interface FlattenedDomain {
95
115
  reactions: Array<ReactionDef<any>>
96
116
  schedules: ScheduleDef[]
97
117
  dataProducts: DataProductConfig[]
118
+ /**
119
+ * O expurgo derivado de cada entidade com `retainDays` (ADR 0051). A action e o schedule também
120
+ * estão em `actions` e `schedules`; aqui ficam ligados à entidade e ao `bindPurge`, se houver,
121
+ * para o runtime anexar o handler.
122
+ */
123
+ purges: DerivedPurge[]
124
+ }
125
+
126
+ /** O expurgo que o `flattenDomain` deriva de uma entidade com retenção. */
127
+ export interface DerivedPurge {
128
+ /** Caminho completo do domínio que lista a entidade, como `report.share`. */
129
+ domain: string
130
+ entity: SoftDeleteEntity
131
+ retention: SoftDeleteRetention
132
+ /** O contrato da action, sem handler — o mesmo objeto que está em `actions`. */
133
+ action: ActionDef
134
+ /** A rotina diária — o mesmo objeto que está em `schedules`. */
135
+ schedule: ScheduleDef
136
+ /** O handler próprio, quando o domínio registrou um `bindPurge` para a entidade. */
137
+ binding?: PurgeBinding<any>
98
138
  }
99
139
 
100
140
  // =============================================================================
@@ -156,6 +196,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
156
196
  'dicts' in v ||
157
197
  'entities' in v ||
158
198
  'dataProducts' in v ||
199
+ 'purges' in v ||
159
200
  'repository' in v ||
160
201
  'service' in v
161
202
  )
@@ -166,8 +207,33 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
166
207
  * pro `Runtime.register`.
167
208
  */
168
209
  export function flattenDomain(domain: DomainConfig): FlattenedDomain {
169
- const out: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
170
- collect(domain, out)
210
+ const out = emptyFlattened()
211
+ collect(domain, out, [])
212
+ return out
213
+ }
214
+
215
+ /**
216
+ * O expurgo derivado das entidades com `retainDays` de UM nível do domínio, sem descer aos
217
+ * subdomínios. `parentPath` é a trilha dos ancestrais, que entra no nome da action. Puro e isomorfo,
218
+ * como o `flattenDomain`; o gerador do manifest o usa para projetar cada domínio no seu nível.
219
+ */
220
+ export function domainPurges(domain: DomainConfig, parentPath: readonly string[] = []): DerivedPurge[] {
221
+ const path = [...parentPath, domain.name]
222
+ const out: DerivedPurge[] = []
223
+ for (const entity of retainedEntities(domain)) {
224
+ const retention = softDeleteRetention(entity) as SoftDeleteRetention
225
+ const name = purgeActionName(path, entity.name)
226
+ const binding = (domain.purges ?? []).find((item) => item?.entity === entity)
227
+ out.push({
228
+ domain: path.join('.'),
229
+ entity,
230
+ retention,
231
+ // Contrato puro: o runtime anexa o handler no `register` (ADR 0051).
232
+ action: purgeContract(name, retention) as unknown as ActionDef,
233
+ schedule: purgeSchedule(name, retention),
234
+ ...(binding !== undefined ? { binding } : {}),
235
+ })
236
+ }
171
237
  return out
172
238
  }
173
239
 
@@ -175,7 +241,11 @@ export function flattenDomain(domain: DomainConfig): FlattenedDomain {
175
241
  // Internos
176
242
  // =============================================================================
177
243
 
178
- function collect(domain: DomainConfig, out: FlattenedDomain): void {
244
+ function emptyFlattened(): FlattenedDomain {
245
+ return { actions: [], reactions: [], schedules: [], dataProducts: [], purges: [] }
246
+ }
247
+
248
+ function collect(domain: DomainConfig, out: FlattenedDomain, parentPath: readonly string[]): void {
179
249
  for (const action of iterateActions(domain.actions)) {
180
250
  out.actions.push(action)
181
251
  }
@@ -185,6 +255,11 @@ function collect(domain: DomainConfig, out: FlattenedDomain): void {
185
255
  if (domain.schedules !== undefined) {
186
256
  for (const schedule of domain.schedules) out.schedules.push(schedule)
187
257
  }
258
+ for (const purge of domainPurges(domain, parentPath)) {
259
+ out.actions.push(purge.action)
260
+ out.schedules.push(purge.schedule)
261
+ out.purges.push(purge)
262
+ }
188
263
  if (domain.dataProducts !== undefined) {
189
264
  const products = Array.isArray(domain.dataProducts)
190
265
  ? domain.dataProducts
@@ -192,10 +267,29 @@ function collect(domain: DomainConfig, out: FlattenedDomain): void {
192
267
  for (const product of products) out.dataProducts.push(product)
193
268
  }
194
269
  if (domain.subdomains !== undefined) {
195
- for (const sub of domain.subdomains) collect(sub, out)
270
+ const path = [...parentPath, domain.name]
271
+ for (const sub of domain.subdomains) collect(sub, out, path)
196
272
  }
197
273
  }
198
274
 
275
+ /** Entidades de `entities` com `retainDays`, sem repetir a mesma referência. */
276
+ function retainedEntities(domain: DomainConfig): SoftDeleteEntity[] {
277
+ const out: SoftDeleteEntity[] = []
278
+ for (const raw of Object.values(domain.entities ?? {})) {
279
+ if (!isNamedEntity(raw) || softDeleteRetention(raw) === null) continue
280
+ if (!out.includes(raw)) out.push(raw)
281
+ }
282
+ return out
283
+ }
284
+
285
+ function isNamedEntity(value: unknown): value is SoftDeleteEntity {
286
+ return (
287
+ typeof value === 'object' &&
288
+ value !== null &&
289
+ typeof (value as { name?: unknown }).name === 'string'
290
+ )
291
+ }
292
+
199
293
  /**
200
294
  * Normaliza `actions` (record | array | record-of-array) em iterável plano
201
295
  * de `ActionDef`.
@@ -269,8 +363,11 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
269
363
  // — actions: nomes únicos no domínio (e subdomínios) —
270
364
  // (Roda *depois* dos subdomains: assim erros de nome em subs aparecem
271
365
  // primeiro, antes de detectar duplicatas cruzadas.)
272
- const collected: FlattenedDomain = { actions: [], reactions: [], schedules: [], dataProducts: [] }
273
- collect(domain, collected)
366
+ assertRetainedEntitiesOnce(domain, path)
367
+ assertPurgeBindings(domain, fullPathStr)
368
+
369
+ const collected = emptyFlattened()
370
+ collect(domain, collected, path)
274
371
 
275
372
  const actionNames = new Set<string>()
276
373
  for (const action of collected.actions) {
@@ -284,6 +381,18 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
284
381
  actionNames.add(action.name)
285
382
  }
286
383
 
384
+ const scheduleNames = new Set<string>()
385
+ for (const schedule of collected.schedules) {
386
+ if (scheduleNames.has(schedule.name)) {
387
+ throw error({
388
+ code: 'domain.duplicate_schedule',
389
+ category: 'internal',
390
+ message: `Schedule "${schedule.name}" duplicado em "${fullPathStr}"`,
391
+ })
392
+ }
393
+ scheduleNames.add(schedule.name)
394
+ }
395
+
287
396
  const entityNames = new Set<string>()
288
397
  const collectEntityNames = (current: DomainConfig): void => {
289
398
  for (const [key, raw] of Object.entries(current.entities ?? {})) {
@@ -327,6 +436,79 @@ function validateDomain(domain: DomainConfig, path: string[]): void {
327
436
  }
328
437
  }
329
438
 
439
+ /**
440
+ * A action e a rotina do expurgo nascem no domínio que lista a entidade: a mesma entidade com
441
+ * retenção em dois pontos da árvore geraria dois expurgos da mesma tabela (ADR 0051). A comparação
442
+ * é pela tabela, para pegar também duas declarações `defineEntity` da mesma tabela.
443
+ */
444
+ function assertRetainedEntitiesOnce(domain: DomainConfig, path: string[]): void {
445
+ const owners = new Map<string, string>()
446
+ const walk = (current: DomainConfig, parent: string[]): void => {
447
+ const here = [...parent, current.name].join('.')
448
+ const listed = Object.values(current.entities ?? {}).filter(
449
+ (raw): raw is SoftDeleteEntity => isNamedEntity(raw) && softDeleteRetention(raw) !== null,
450
+ )
451
+ for (const entity of listed) {
452
+ const table = entityTableName(entity)
453
+ const owner = owners.get(table)
454
+ if (owner !== undefined) {
455
+ throw entityRegisteredTwice(entity.name, table, owner, here)
456
+ }
457
+ owners.set(table, here)
458
+ }
459
+ for (const sub of current.subdomains ?? []) walk(sub, [...parent, current.name])
460
+ }
461
+ walk(domain, path)
462
+ }
463
+
464
+ /** Erro compartilhado com o `register`, que confere o mesmo entre domínios separados. */
465
+ export function entityRegisteredTwice(
466
+ entity: string,
467
+ table: string,
468
+ first: string,
469
+ second: string,
470
+ ): ReturnType<typeof error> {
471
+ const where = first === second ? `duas vezes em "${first}"` : `em "${first}" e em "${second}"`
472
+ return error({
473
+ code: 'domain.entity_registered_twice',
474
+ category: 'internal',
475
+ message:
476
+ `A entidade "${entity}" (tabela "${table}") tem retenção e está registrada ${where}. O ` +
477
+ 'expurgo nasce no domínio que lista a entidade em `entities`, e duas declarações da mesma ' +
478
+ 'tabela gerariam dois expurgos dela; mantenha uma só entidade com retenção por tabela, num só domínio.',
479
+ meta: { entity, table, domains: [first, second] },
480
+ })
481
+ }
482
+
483
+ /** Cada `bindPurge` em `purges` aponta para uma entidade com `retainDays` do próprio domínio. */
484
+ function assertPurgeBindings(domain: DomainConfig, domainPath: string): void {
485
+ const retained = retainedEntities(domain)
486
+ const listed = Object.values(domain.entities ?? {})
487
+ const bound = new Set<unknown>()
488
+ for (const binding of domain.purges ?? []) {
489
+ const entity = binding?.entity as SoftDeleteEntity | undefined
490
+ const name = typeof entity?.name === 'string' ? entity.name : '(sem nome)'
491
+ if (entity === undefined || !retained.includes(entity)) {
492
+ const reason = entity !== undefined && listed.includes(entity)
493
+ ? 'não declara `softDelete` com `retainDays`: sem prazo, não há expurgo para o handler executar'
494
+ : 'não está em `entities` deste domínio. Registre o bindPurge no domínio que lista a entidade'
495
+ throw error({
496
+ code: 'domain.invalid_purge',
497
+ category: 'internal',
498
+ message: `O bindPurge de "${name}" em "${domainPath}" aponta para uma entidade que ${reason}.`,
499
+ })
500
+ }
501
+ if (bound.has(entity)) {
502
+ throw error({
503
+ code: 'domain.duplicate_purge',
504
+ category: 'internal',
505
+ message: `A entidade "${name}" tem mais de um bindPurge em "${domainPath}".`,
506
+ })
507
+ }
508
+ bound.add(entity)
509
+ }
510
+ }
511
+
330
512
  /**
331
513
  * Caminha a árvore emitindo warnings de nomenclatura. `path` é a trilha de
332
514
  * ancestrais (excluindo o próprio `domain`).
package/src/core/index.ts CHANGED
@@ -198,8 +198,25 @@ export type { ReactionContract, ReactionBinding } from './reactions.ts'
198
198
  export { defineSchedule, isSchedule } from './schedules.ts'
199
199
 
200
200
  // — Domain ————————————————————————————————————————————————————————————————————
201
- export { defineDomain, flattenDomain, isDomainConfig } from './domain.ts'
202
- export type { DomainConfig, FlattenedDomain } from './domain.ts'
201
+ export { defineDomain, domainPurges, flattenDomain, isDomainConfig } from './domain.ts'
202
+ export type { DerivedPurge, DomainConfig, FlattenedDomain } from './domain.ts'
203
+
204
+ // — Retenção da exclusão lógica (ADR 0051) ——————————————————————————————————————
205
+ // `hasSoftDelete` mora em `@softize/opus/schema`, com a entidade; `bindPurge`, em
206
+ // `@softize/opus/data`, porque o handler é server-only.
207
+ export { declaredJsonSchema } from './retention.ts'
208
+ export type {
209
+ PurgeBatchArgs,
210
+ PurgeBinding,
211
+ PurgeHandler,
212
+ PurgeInput,
213
+ PurgeOutput,
214
+ SoftDelete,
215
+ SoftDeleteForever,
216
+ SoftDeleteRetention,
217
+ SoftDeleteSchedule,
218
+ StandardJsonSchema,
219
+ } from './retention.ts'
203
220
 
204
221
  // — Produtos de Dados ————————————————————————————————————————————————————————
205
222
  export { defineDataProduct, isDataProduct } from './data-product.ts'