@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.
- package/CHANGELOG.md +121 -0
- package/README.md +1 -1
- package/bin/cli.mjs +9 -0
- package/bin/lib/check.mjs +390 -1
- package/bin/lib/copy.mjs +39 -2
- package/bin/lib/gen-runner.mjs +25 -12
- package/bin/lib/mcp.mjs +1 -1
- package/docs/adr/0051-soft-delete-declares-retention-and-purge.md +224 -0
- package/docs/adr/0052-floating-layer-coexists-with-modal-surfaces.md +668 -0
- package/docs/data-layer.md +31 -3
- package/docs/protocol.md +98 -2
- package/docs/releasing.md +8 -2
- package/docs/stacking-scale.md +53 -0
- package/package.json +6 -1
- package/src/ai/drivers/anthropic.ts +3 -0
- package/src/core/domain.ts +189 -7
- package/src/core/index.ts +19 -2
- package/src/core/retention.ts +493 -0
- package/src/core/runtime.ts +93 -29
- package/src/core/system-actor.ts +33 -0
- package/src/core/types.ts +12 -0
- package/src/data/drivers/kysely.ts +47 -5
- package/src/data/index.ts +24 -6
- package/src/mcp/index.ts +3 -2
- package/src/schema/entity.ts +30 -9
- package/src/schema/index.ts +4 -0
- package/src/schema/openapi.ts +4 -1
- package/src/ui/components/primitives/dialog.tsx +285 -26
- package/src/ui/components/primitives/drawer.tsx +104 -13
- package/src/ui/components/primitives/floating-layer.tsx +79 -0
- package/src/ui/components/primitives/menu.tsx +17 -2
- package/src/ui/components/primitives/popover.tsx +6 -1
- package/src/ui/components/primitives/tooltip.tsx +6 -1
- package/src/ui/docs/content/cli.md +5 -3
- package/src/ui/docs/content/data.md +42 -0
- package/src/ui/docs/content/dialog.md +3 -0
- package/src/ui/docs/content/drawer.md +3 -0
- package/src/ui/docs/content/floating-layer.md +178 -0
- package/src/ui/docs/content/scheduler.md +4 -0
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/lib/dismiss-on-iframe.ts +4 -1
- package/src/ui/lib/modal-layers.ts +547 -0
- package/src/ui/meta.ts +6 -0
- package/src/ui/react.tsx +4 -0
package/docs/data-layer.md
CHANGED
|
@@ -137,7 +137,13 @@ export const Deal = defineEntity({
|
|
|
137
137
|
},
|
|
138
138
|
relations: { owner: belongsTo('user', { from: 'ownerId' }) },
|
|
139
139
|
timestamps: true,
|
|
140
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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
|
-
**
|
|
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
|
|
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": "
|
|
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
|
|
package/src/core/domain.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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'
|