@softize/opus 24.0.1 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -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/0050-markdown-does-not-load-external-images.md +64 -0
- package/docs/adr/0051-soft-delete-declares-retention-and-purge.md +224 -0
- package/docs/data-layer.md +31 -3
- package/docs/protocol.md +98 -2
- package/package.json +1 -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/markdown.tsx +17 -0
- package/src/ui/docs/content/cli.md +5 -3
- package/src/ui/docs/content/data.md +42 -0
- package/src/ui/docs/content/scheduler.md +4 -0
package/bin/lib/gen-runner.mjs
CHANGED
|
@@ -90,7 +90,7 @@ async function main() {
|
|
|
90
90
|
const opusCoreUrl = pathToFileURL(
|
|
91
91
|
path.join(PACKAGE_ROOT, "src", "core", "index.ts"),
|
|
92
92
|
).href;
|
|
93
|
-
const { flattenDomain } = await import(opusCoreUrl);
|
|
93
|
+
const { flattenDomain, domainPurges, declaredJsonSchema } = await import(opusCoreUrl);
|
|
94
94
|
|
|
95
95
|
const opusSeedUrl = pathToFileURL(
|
|
96
96
|
path.join(PACKAGE_ROOT, "src", "seed", "index.ts"),
|
|
@@ -137,7 +137,10 @@ async function main() {
|
|
|
137
137
|
|
|
138
138
|
const ctx = {
|
|
139
139
|
flattenDomain,
|
|
140
|
+
domainPurges,
|
|
141
|
+
// Standard Schema que declara o próprio JSON Schema (ADR 0051) dispensa o zod.
|
|
140
142
|
toJsonSchema: (schema) =>
|
|
143
|
+
declaredJsonSchema(schema, { target: "openapi-3.0" }) ??
|
|
141
144
|
zodToJsonSchema(schema, { target: "openApi3", $refStrategy: "none" }),
|
|
142
145
|
sourceConfigDir: path.dirname(configAbs),
|
|
143
146
|
};
|
|
@@ -150,7 +153,7 @@ async function main() {
|
|
|
150
153
|
}
|
|
151
154
|
const actionRegistry = Object.fromEntries(
|
|
152
155
|
cfg.domains
|
|
153
|
-
.flatMap((domain) => collectDomainActions(domain))
|
|
156
|
+
.flatMap((domain) => collectDomainActions(domain, ctx))
|
|
154
157
|
.map((action) => [action.name, action]),
|
|
155
158
|
);
|
|
156
159
|
const presentations = validatePresentations(
|
|
@@ -185,12 +188,15 @@ async function main() {
|
|
|
185
188
|
emit({ ok: true, payload });
|
|
186
189
|
}
|
|
187
190
|
|
|
188
|
-
function collectDomainActions(domain) {
|
|
191
|
+
function collectDomainActions(domain, ctx, parentPath = []) {
|
|
189
192
|
const actions = Array.from(iterateActions(domain.actions));
|
|
193
|
+
for (const purge of ctx.domainPurges(domain, parentPath)) actions.push(purge.action);
|
|
190
194
|
for (const subdomain of Array.isArray(domain.subdomains)
|
|
191
195
|
? domain.subdomains
|
|
192
196
|
: []) {
|
|
193
|
-
actions.push(
|
|
197
|
+
actions.push(
|
|
198
|
+
...collectDomainActions(subdomain, ctx, [...parentPath, domain.name]),
|
|
199
|
+
);
|
|
194
200
|
}
|
|
195
201
|
return actions;
|
|
196
202
|
}
|
|
@@ -206,8 +212,11 @@ function serializeSeed(seed, isSeedDefinition, publicSeedDefinition) {
|
|
|
206
212
|
// Serialização
|
|
207
213
|
// =============================================================================
|
|
208
214
|
|
|
209
|
-
function serializeDomain(domain, ctx) {
|
|
215
|
+
function serializeDomain(domain, ctx, parentPath = []) {
|
|
210
216
|
const entityList = serializeEntities(domain.entities);
|
|
217
|
+
// Expurgo derivado das entidades com retenção (ADR 0051): a action e a rotina entram no
|
|
218
|
+
// manifest no domínio que lista a entidade, como as declaradas.
|
|
219
|
+
const purges = ctx.domainPurges(domain, parentPath);
|
|
211
220
|
return {
|
|
212
221
|
name: domain.name,
|
|
213
222
|
description: nullable(domain.description),
|
|
@@ -216,17 +225,21 @@ function serializeDomain(domain, ctx) {
|
|
|
216
225
|
entities: entityList,
|
|
217
226
|
dataProducts: serializeDataProducts(domain.dataProducts),
|
|
218
227
|
dicts: serializeDicts(domain.dicts),
|
|
219
|
-
actions:
|
|
220
|
-
|
|
221
|
-
|
|
228
|
+
actions: [
|
|
229
|
+
...iterateActions(domain.actions),
|
|
230
|
+
...purges.map((purge) => purge.action),
|
|
231
|
+
].map((a) => serializeAction(a, ctx)),
|
|
222
232
|
reactions: Array.isArray(domain.reactions)
|
|
223
233
|
? domain.reactions.map(serializeReaction)
|
|
224
234
|
: [],
|
|
225
|
-
schedules:
|
|
226
|
-
? domain.schedules
|
|
227
|
-
|
|
235
|
+
schedules: [
|
|
236
|
+
...(Array.isArray(domain.schedules) ? domain.schedules : []),
|
|
237
|
+
...purges.map((purge) => purge.schedule),
|
|
238
|
+
].map(serializeSchedule),
|
|
228
239
|
subdomains: Array.isArray(domain.subdomains)
|
|
229
|
-
? domain.subdomains.map((s) =>
|
|
240
|
+
? domain.subdomains.map((s) =>
|
|
241
|
+
serializeDomain(s, ctx, [...parentPath, domain.name]),
|
|
242
|
+
)
|
|
230
243
|
: [],
|
|
231
244
|
};
|
|
232
245
|
}
|
package/bin/lib/mcp.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export function buildServer() {
|
|
|
42
42
|
server.registerTool(
|
|
43
43
|
'opus_check',
|
|
44
44
|
{
|
|
45
|
-
description: 'Valida as convenções das actions (régua) — defineAction e o split defineContract+bindAction: naming, kind, ordem dos campos, export, requires-sem-authorize, ignored-by-kind. Retorna findings.',
|
|
45
|
+
description: 'Valida as convenções das actions (régua) — defineAction e o split defineContract+bindAction: naming, kind, ordem dos campos, export, requires-sem-authorize, ignored-by-kind, soft-delete-retention. Retorna findings.',
|
|
46
46
|
inputSchema: { dir: z.string().optional() },
|
|
47
47
|
},
|
|
48
48
|
async ({ dir }) => asText(await scanDir(dirOf(dir))),
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# ADR 0050 — Markdown não carrega imagem externa
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita.
|
|
4
|
+
- **Data:** 2026-10-02.
|
|
5
|
+
- **Complementa:** a decisão registrada em `markdown.tsx` de renderizar com `html: false`, porque o
|
|
6
|
+
conteúdo vem de pessoas e de modelos.
|
|
7
|
+
|
|
8
|
+
## Contexto
|
|
9
|
+
|
|
10
|
+
O `Markdown` é o renderer único do pacote: o chat, os painéis e a documentação usam a mesma
|
|
11
|
+
instância do markdown-it. Ele já escapa HTML do fonte e o markdown-it já recusa links
|
|
12
|
+
`javascript:` e `data:text/html`. Faltava uma terceira via: `` virava um
|
|
13
|
+
`<img>` que o navegador busca sozinho, sem nenhum clique.
|
|
14
|
+
|
|
15
|
+
Isso importa porque boa parte desse conteúdo é escrita por um modelo. Um modelo induzido por
|
|
16
|
+
injeção de prompt (um documento, uma resposta de ferramenta, uma mensagem de terceiro) pode montar
|
|
17
|
+
o endereço de uma imagem com dados da conversa e deixar que o navegador de quem lê os entregue ao
|
|
18
|
+
host. Num relatório salvo, isso acontece também com quem abre o relatório depois.
|
|
19
|
+
|
|
20
|
+
Nenhum consumidor conhecido depende de imagem externa no Markdown: a documentação do pacote não usa
|
|
21
|
+
imagem em Markdown, e a plataforma Grand Brasil não tem imagem externa em documentos, Base de
|
|
22
|
+
conhecimento nem relatórios.
|
|
23
|
+
|
|
24
|
+
## Decisão
|
|
25
|
+
|
|
26
|
+
1. O `Markdown` não renderiza `<img>` para endereço absoluto, com esquema (`https:`, `http:`) ou
|
|
27
|
+
relativo ao protocolo (`//host`). No lugar da imagem fica o texto alternativo dela, num
|
|
28
|
+
`span` com `data-slot="markdown-external-image"`, sem link: seguir o endereço exigiria um
|
|
29
|
+
clique, mas um link montado pelo modelo não deve ser oferecido como se fosse da imagem.
|
|
30
|
+
2. Endereço relativo (`/caminho`, `./arquivo.png`) continua renderizando a imagem, porque é servido
|
|
31
|
+
pela própria aplicação. As imagens em `data:` que o markdown-it já aceita (`png`, `gif`, `jpeg`,
|
|
32
|
+
`webp`) também continuam: não fazem requisição.
|
|
33
|
+
3. A regra vale para a instância compartilhada `markdownIt`, então chat, painéis e documentação
|
|
34
|
+
seguem o mesmo comportamento. Não há opção para liberar imagem externa; um consumidor que
|
|
35
|
+
precise disso traz o caso concreto para uma decisão nova.
|
|
36
|
+
|
|
37
|
+
## Consequências
|
|
38
|
+
|
|
39
|
+
- Uma resposta ou um relatório com imagem externa mostra o texto alternativo no lugar da imagem.
|
|
40
|
+
- A defesa não depende de cada aplicação configurar uma Content Security Policy com `img-src`,
|
|
41
|
+
embora uma CSP continue sendo uma segunda camada recomendada.
|
|
42
|
+
- Imagem absoluta da própria origem também deixa de carregar. Não há uso conhecido; se aparecer,
|
|
43
|
+
o caminho é usar o endereço relativo.
|
|
44
|
+
- A imagem relativa continua fazendo uma requisição à própria origem, com os cookies de quem lê. Se
|
|
45
|
+
a aplicação tiver um redirecionamento aberto ou um proxy de imagem, o mesmo vetor volta por ali.
|
|
46
|
+
É mais um motivo para manter a CSP com `img-src` como segunda camada.
|
|
47
|
+
|
|
48
|
+
## Alternativas consideradas
|
|
49
|
+
|
|
50
|
+
- **Bloquear toda imagem.** Fecharia também o resíduo da imagem relativa, mas tiraria imagens que a
|
|
51
|
+
própria aplicação serve (anexos, ilustrações de documentação) sem um vetor conhecido que o
|
|
52
|
+
justifique hoje.
|
|
53
|
+
- **Transformar a imagem externa em link.** Mantém o endereço acessível, mas oferece com aparência
|
|
54
|
+
legítima um link montado pelo modelo; o texto alternativo basta para a leitura.
|
|
55
|
+
- **Lista de origens permitidas ou proxy de imagem.** Exigiriam configuração por aplicação e um
|
|
56
|
+
serviço novo para um uso que não existe. Ficam para quando um consumidor trouxer o caso.
|
|
57
|
+
- **Só CSP na aplicação.** Depende de cada consumidor configurar e não vale fora do navegador da
|
|
58
|
+
aplicação (por exemplo, na documentação publicada). Fica como segunda camada, não como a defesa.
|
|
59
|
+
|
|
60
|
+
## Verificação
|
|
61
|
+
|
|
62
|
+
- Teste do `Markdown`: imagem `https:`, `//host`, com maiúsculas, com esquema em entidade, por
|
|
63
|
+
referência, dentro de link e em célula de tabela não geram `<img>` e mostram o texto alternativo;
|
|
64
|
+
imagem relativa e `data:image/png` continuam gerando `<img>`.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# ADR 0051 — Exclusão lógica declara retenção e expurgo
|
|
2
|
+
|
|
3
|
+
- **Status:** aceita.
|
|
4
|
+
- **Data:** 2026-10-02.
|
|
5
|
+
- **Complementa:** ADR 0028, que roda actions agendadas como o sistema, e ADR 0002, cujo par
|
|
6
|
+
declaração e binding serve de molde.
|
|
7
|
+
|
|
8
|
+
## Contexto
|
|
9
|
+
|
|
10
|
+
`defineEntity({ softDelete: true })` injeta a coluna `deletedAt`. Pelo `kyselyRepo`, excluir passa a
|
|
11
|
+
marcar a data, e `findById`, `all` e `list` deixam os registros excluídos de fora. O ciclo fica pela
|
|
12
|
+
metade: o Opus sabe excluir de forma lógica, mas nada diz quando o registro sai de vez.
|
|
13
|
+
|
|
14
|
+
Sem esse fechamento, cada consumidor resolve sozinho. A plataforma Grand Brasil é o caso concreto:
|
|
15
|
+
|
|
16
|
+
- os fluxos de automação fazem exclusão lógica e expurgo à mão, com coluna própria e uma action
|
|
17
|
+
`automation.purge-deleted` escrita no domínio. Ela é restrita ao sistema e à administração, é
|
|
18
|
+
interna e roda numa rotina diária;
|
|
19
|
+
- os relatórios ainda apagam de vez. Por isso, excluir um relatório cuja conversa de IA registrou
|
|
20
|
+
uma autorização de extração de dado pessoal esbarra numa escolha ruim: apagar a autorização
|
|
21
|
+
junto, contra a regra que manda guardá-la, ou impedir a exclusão.
|
|
22
|
+
|
|
23
|
+
A forma escolhida para os relatórios é a mesma dos fluxos: excluir de forma lógica, manter o
|
|
24
|
+
histórico durante um prazo e expurgar depois, numa rotina. Com dois casos, o padrão já se repete.
|
|
25
|
+
No terceiro, quem esquecer o expurgo deixa registros excluídos acumulando para sempre, inclusive
|
|
26
|
+
dado pessoal, sem que nada avise.
|
|
27
|
+
|
|
28
|
+
O que precisa ser verdadeiro:
|
|
29
|
+
|
|
30
|
+
- toda entidade com exclusão lógica tem um prazo de retenção declarado ou declara, com motivo, que
|
|
31
|
+
guarda para sempre;
|
|
32
|
+
- o expurgo existe, roda sozinho, só o sistema o executa e deixa rastro do que apagou;
|
|
33
|
+
- o prazo prometido é cumprido: nada expurga antes dele;
|
|
34
|
+
- a ordem do que se apaga e o que se preserva continuam sendo decisão do domínio, porque variam:
|
|
35
|
+
um fluxo leva junto as execuções, e um relatório precisa apagar conversas em outro serviço e
|
|
36
|
+
preservar o consumo de IA;
|
|
37
|
+
- a falta de declaração quebra a verificação, em vez de depender de alguém lembrar.
|
|
38
|
+
|
|
39
|
+
## Decisão
|
|
40
|
+
|
|
41
|
+
### 1. A retenção é declarada na entidade
|
|
42
|
+
|
|
43
|
+
`softDelete` passa a aceitar um objeto, numa de duas formas mutuamente exclusivas:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
type SoftDelete =
|
|
47
|
+
| true // aceito pelo tipo; reprovado pelo `opus check` (seção 6)
|
|
48
|
+
| {
|
|
49
|
+
retainDays: number // inteiro positivo; conta a partir de `deletedAt`
|
|
50
|
+
timezone: string // IANA, obrigatório: a rotina não depende do fuso da máquina
|
|
51
|
+
label: string // título da action, completo: 'Expurgar relatórios excluídos'
|
|
52
|
+
description: string // descrição da action, completa
|
|
53
|
+
schedule?: { cron?: string; enabled?: boolean } // padrão: '30 3 * * *', habilitada
|
|
54
|
+
batchSize?: number // padrão: 200 linhas por lote; no máximo 10_000
|
|
55
|
+
maxDurationMs?: number // padrão: 60_000 por execução
|
|
56
|
+
}
|
|
57
|
+
| { retain: 'forever'; reason: string }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Toda forma diferente de `true` mantém a exclusão lógica. Os pontos do pacote que hoje testam
|
|
61
|
+
`softDelete === true` passam por uma única função, `hasSoftDelete(entity)`, que reconhece as três:
|
|
62
|
+
`entityColumns` (injeção de `deletedAt`), o tipo `SoftDeletePart` e o `kyselyRepo` (filtro de
|
|
63
|
+
leitura e exclusão lógica). Sem isso, a forma de objeto deixaria de injetar a coluna e de filtrar,
|
|
64
|
+
sem erro nenhum.
|
|
65
|
+
|
|
66
|
+
### 2. Uma entidade com retenção pertence a exatamente um domínio
|
|
67
|
+
|
|
68
|
+
A action e a rotina do expurgo nascem no domínio que lista a entidade em `entities`.
|
|
69
|
+
|
|
70
|
+
- A mesma entidade com retenção em dois domínios, ou num domínio e num subdomínio dele, falha com
|
|
71
|
+
`domain.entity_registered_twice`: no `validateDomain`, dentro da árvore de um domínio, e no
|
|
72
|
+
`register` do runtime, entre domínios registrados separadamente. A comparação é pela tabela, para
|
|
73
|
+
pegar também duas declarações `defineEntity` da mesma tabela, que gerariam dois expurgos dela.
|
|
74
|
+
- Entidade com `retainDays` que não aparece em nenhum `entities` de domínio é reprovada pelo
|
|
75
|
+
`opus check`. O check liga a entidade ao domínio pelo identificador exportado, como já liga um
|
|
76
|
+
`bindAction` ao seu contrato. Assim, uma entidade usada só por repositório não fica sem expurgo.
|
|
77
|
+
|
|
78
|
+
### 3. O que o Opus gera
|
|
79
|
+
|
|
80
|
+
`flattenDomain` deriva, de cada entidade com `retainDays`, um contrato e uma rotina, e a checagem de
|
|
81
|
+
duplicidade do `validateDomain` passa a incluí-los. A derivação é pura e isomorfa, como o resto do
|
|
82
|
+
`flattenDomain`.
|
|
83
|
+
|
|
84
|
+
**Nomes.** A action se chama `<caminho-do-domínio>.<entidade-em-kebab>.purge-deleted`. Quando a
|
|
85
|
+
entidade em kebab é igual ao último segmento do domínio, o segmento não se repete: a entidade
|
|
86
|
+
`automation` no domínio `automation` gera `automation.purge-deleted`, e a entidade `reportShare` no
|
|
87
|
+
domínio `report` gera `report.report-share.purge-deleted`. A rotina se chama `<action>.daily`. O
|
|
88
|
+
caminho do domínio entra como está, sem conversão, como nas demais actions do domínio.
|
|
89
|
+
|
|
90
|
+
**Contrato da action:**
|
|
91
|
+
|
|
92
|
+
- entrada `{ olderThanDays?: number }`: inteiro, no mínimo o `retainDays` e no máximo 3650. O
|
|
93
|
+
padrão é o `retainDays`. Expurgar antes do prazo é recusado, para cumprir a retenção declarada;
|
|
94
|
+
- saída `{ purged: number }`;
|
|
95
|
+
- `effect: 'write'`, `automatable: true`, `internal: true` (não fica exposta por REST),
|
|
96
|
+
`personalData: false` e `ai: false`. O motivo de ficar fora da IA (é operação do sistema, não
|
|
97
|
+
pedido de uma pessoa) fica registrado no fonte do SDK, porque a `description` é do consumidor;
|
|
98
|
+
- autorização: só o sistema (`isSystemActor`). Não há execução manual por pessoa nesta decisão;
|
|
99
|
+
- textos: o `label` e a `description` declarados na retenção, completos e literais. O Opus não
|
|
100
|
+
monta frase a partir do nome do recurso, porque um modelo fixo erra a concordância (um recurso
|
|
101
|
+
feminino viraria "conversas excluídos"). O `opus copy` passa a ler esses dois campos no
|
|
102
|
+
`defineEntity`, como já lê os textos de `defineContract`.
|
|
103
|
+
|
|
104
|
+
**Rotina:** `defineSchedule` com a action, `cron` (padrão `'30 3 * * *'`), o `timezone` declarado e
|
|
105
|
+
`enabled` (padrão verdadeiro). Ela roda como o sistema, conforme a ADR 0028. Como qualquer rotina,
|
|
106
|
+
exige um `SchedulerAdapter` no `start()`, mesmo com `enabled: false`.
|
|
107
|
+
|
|
108
|
+
### 4. Quem executa: handler padrão ou `bindPurge`
|
|
109
|
+
|
|
110
|
+
O contrato é gerado no core. O handler é anexado no servidor:
|
|
111
|
+
|
|
112
|
+
- **Padrão:** quando o runtime tem um `DataAdapter` Kysely, o `register` anexa o handler padrão.
|
|
113
|
+
Ele apaga as linhas com `deletedAt` anterior ao corte, das mais antigas para as mais novas, em
|
|
114
|
+
lotes de `batchSize`. Repete os lotes até não sobrar nada ou até esgotar `maxDurationMs`.
|
|
115
|
+
- **Próprio:** quando apagar a linha não basta, o domínio liga `bindPurge(entity, handler)` e o
|
|
116
|
+
registra em `purges` no `defineDomain`. `isDomainConfig` passa a reconhecer `purges`. O handler
|
|
117
|
+
recebe `{ ctx, cutoff, batchSize }`, apaga um lote e devolve `{ ids: string[] }`. O Opus repete os
|
|
118
|
+
lotes enquanto houver ids e tempo, e continua dono do contrato, da rotina, dos lotes e do registro.
|
|
119
|
+
Respeitar o `cutoff` é obrigação do handler: o Opus recusa um `olderThanDays` abaixo do prazo, mas
|
|
120
|
+
só o handler decide o que apaga. Um lote que devolve um id já removido na mesma execução, ou mais
|
|
121
|
+
ids que o `batchSize`, interrompe o expurgo com `purge.no_progress`; sem essa guarda, um handler
|
|
122
|
+
que não avança repetiria lotes até o fim do tempo. Antes de parar, o Opus registra os ids novos
|
|
123
|
+
daquele lote, que o handler já apagou. Entre um lote e outro, o Opus cede a vez ao event loop.
|
|
124
|
+
Ele decide a ordem, o que fica e o que sai em outro serviço. É a separação da ADR 0002: a entidade,
|
|
125
|
+
com a retenção, fica no contrato; o handler fica no servidor.
|
|
126
|
+
- **Sem handler padrão aplicável e sem `bindPurge`**, por exemplo sem `DataAdapter` ou com um que não
|
|
127
|
+
seja Kysely: o `start()` recusa com `runtime.purge_handler_missing`, nomeando a entidade.
|
|
128
|
+
- **No modo design**, a action gerada se comporta como as demais: entra no catálogo, e o handler é o
|
|
129
|
+
do design. As rotinas não são iniciadas.
|
|
130
|
+
|
|
131
|
+
**Dependências no banco:** o schema do Opus não declara cascata; ela depende da DDL do consumidor.
|
|
132
|
+
Se uma chave estrangeira restritiva impedir a exclusão, a execução agendada falha com o erro do
|
|
133
|
+
banco no log e na auditoria da execução, e nada é apagado daquele lote. Como a linha travada está
|
|
134
|
+
entre as mais antigas, ela volta ao primeiro lote em toda execução: o expurgo da entidade fica
|
|
135
|
+
parado até a chave ser ajustada. A saída é ajustar a chave, com cascata na DDL, ou usar `bindPurge`
|
|
136
|
+
para apagar os dependentes primeiro.
|
|
137
|
+
|
|
138
|
+
### 5. O registro do que saiu
|
|
139
|
+
|
|
140
|
+
Cada lote que apaga algo registra a chave `opus.purge`, com a entidade, a action, o corte, o
|
|
141
|
+
`olderThanDays`, o número do lote e os ids removidos, em blocos de no máximo 500 ids. Assim, um
|
|
142
|
+
acúmulo grande vira vários registros de tamanho limitado, nunca uma linha sem teto. No fim da
|
|
143
|
+
execução que apagou algo, `opus.purge.total` traz a quantidade, o número de lotes e se restou
|
|
144
|
+
trabalho: verdadeiro quando a execução parou por tempo com lote cheio ou foi interrompida por erro.
|
|
145
|
+
Os registros vão para o `ctx.log` quando existe e, senão, para o logger do runtime. Os únicos rastros
|
|
146
|
+
de um expurgo são esses registros e a auditoria da execução agendada.
|
|
147
|
+
|
|
148
|
+
### 6. O `opus check` cobra a regra
|
|
149
|
+
|
|
150
|
+
A regra `soft-delete-retention` reprova:
|
|
151
|
+
|
|
152
|
+
- entidade com `softDelete: true`, sem retenção;
|
|
153
|
+
- `retain: 'forever'` sem `reason`;
|
|
154
|
+
- `retainDays` sem `timezone`, `label` ou `description`, ou com valor que não é inteiro positivo
|
|
155
|
+
ou passa de 3650, e `batchSize` que não é inteiro positivo ou passa de 10.000;
|
|
156
|
+
- entidade com `retainDays` fora de qualquer `entities` de domínio;
|
|
157
|
+
- `bindPurge` para uma entidade sem `retainDays`;
|
|
158
|
+
- `bindPurge` exportado que não aparece no `purges` de nenhum domínio, porque cairia em silêncio no
|
|
159
|
+
handler padrão.
|
|
160
|
+
|
|
161
|
+
### 7. Documentação
|
|
162
|
+
|
|
163
|
+
`docs/protocol.md` e `docs/data-layer.md` passam a mostrar a forma com retenção e deixam de
|
|
164
|
+
apresentar `softDelete: true` como suficiente. O trecho de `protocol.md` que promete uma "opção
|
|
165
|
+
explícita" para incluir excluídos na leitura é corrigido: essa opção não existe hoje.
|
|
166
|
+
|
|
167
|
+
### 8. O que fica fora
|
|
168
|
+
|
|
169
|
+
- Restaurar um registro excluído dentro do prazo. É útil, mas independente do expurgo, e entra
|
|
170
|
+
quando houver o caso.
|
|
171
|
+
- Execução manual do expurgo por uma pessoa.
|
|
172
|
+
- Um motor de retenção para o que não é entidade com exclusão lógica, como logs e trilhas de
|
|
173
|
+
auditoria. Eles têm política própria.
|
|
174
|
+
- A introspecção estática usada pelo MCP de desenvolvimento ainda não mostra a action e a rotina
|
|
175
|
+
geradas; o manifest mostra.
|
|
176
|
+
|
|
177
|
+
## Consequências
|
|
178
|
+
|
|
179
|
+
- Quem usa `softDelete: true` precisa declarar a retenção ou o motivo para guardar. O `opus check`
|
|
180
|
+
passa a cobrar isso, então a versão é major, com a migração no changelog.
|
|
181
|
+
- O expurgo deixa de ser código repetido em cada domínio. O terceiro caso já nasce com prazo,
|
|
182
|
+
rotina, lotes, autorização e registro, e o esquecimento quebra a verificação.
|
|
183
|
+
- A action de expurgo aparece no manifest e na documentação, e a rotina na lista de agendamentos.
|
|
184
|
+
Fica visível o que cada projeto apaga e quando.
|
|
185
|
+
- O expurgo é irreversível: depois dele, restam só o registro `opus.purge` e a auditoria.
|
|
186
|
+
|
|
187
|
+
## Alternativas consideradas
|
|
188
|
+
|
|
189
|
+
- **Manter o expurgo no consumidor, com uma convenção escrita.** É o estado de hoje. Não há
|
|
190
|
+
garantia: o próximo domínio pode esquecer, e nada avisa.
|
|
191
|
+
- **Contrato e teste de gate só no consumidor.** Resolveria a Grand Brasil, mas deixaria o
|
|
192
|
+
padrão fora do lugar onde a exclusão lógica já mora, e cada projeto repetiria o gate.
|
|
193
|
+
- **Expurgo implícito, sem declarar prazo.** Um padrão único, por exemplo 30 dias, para toda
|
|
194
|
+
entidade com `softDelete`. Esconderia uma decisão de retenção, que pode envolver dado pessoal,
|
|
195
|
+
atrás de um padrão silencioso.
|
|
196
|
+
- **Fuso padrão do projeto em vez de fuso por entidade.** Seria menos repetitivo, mas o pacote não
|
|
197
|
+
tem esse padrão hoje, e o check, que é estático, não conseguiria verificá-lo. Fica para quando
|
|
198
|
+
houver uma configuração de projeto para agendamentos.
|
|
199
|
+
- **Um motor genérico de retenção para qualquer tabela.** Cobriria logs e trilhas, mas mistura
|
|
200
|
+
políticas diferentes e é cedo demais com os casos que existem.
|
|
201
|
+
|
|
202
|
+
## Verificação
|
|
203
|
+
|
|
204
|
+
- Testes do SDK:
|
|
205
|
+
- a forma de objeto mantém a exclusão lógica: injeta `deletedAt` e o `kyselyRepo` filtra e marca;
|
|
206
|
+
- a entidade com `retainDays` gera a action e a rotina com os nomes da seção 3, incluindo o caso de
|
|
207
|
+
entidade em kebab diferente do domínio, e com as declarações de contrato e autorização;
|
|
208
|
+
- entrada abaixo do `retainDays` é recusada, e chamador que não é o sistema também;
|
|
209
|
+
- o handler padrão apaga só os excluídos antes do corte, em lotes, do mais antigo para o mais novo,
|
|
210
|
+
para no tempo e registra `opus.purge` com os ids, em blocos limitados, e `opus.purge.total`;
|
|
211
|
+
- uma linha restaurada ou excluída de novo entre a seleção e a exclusão não é apagada;
|
|
212
|
+
- um handler que repete ids ou excede o `batchSize` falha com `purge.no_progress`, rápido e sem
|
|
213
|
+
inflar o registro, e os ids novos do lote que parou entram no registro e no total;
|
|
214
|
+
- o `bindPurge` substitui o padrão e mantém contrato, lotes e registro;
|
|
215
|
+
- entidade em dois domínios, ou duas entidades da mesma tabela, falham com
|
|
216
|
+
`domain.entity_registered_twice`;
|
|
217
|
+
- o `start()` sem handler falha com `runtime.purge_handler_missing`;
|
|
218
|
+
- chave estrangeira restritiva faz a execução falhar sem apagar o lote, e a seguinte também;
|
|
219
|
+
- o core (`src/core/index.ts`) continua sem pacote externo em tempo de execução: os schemas da
|
|
220
|
+
action gerada são Standard Schema com o JSON Schema anexado, sem zod.
|
|
221
|
+
- Testes do `opus check`: os seis casos da seção 6 reprovam, e a declaração completa passa.
|
|
222
|
+
- Teste do `opus copy`: o `label` e a `description` da retenção entram no inventário.
|
|
223
|
+
- Na Grand Brasil, a adoção migra os fluxos de automação para `softDelete` com retenção e cria a
|
|
224
|
+
exclusão lógica dos relatórios com expurgo próprio.
|
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/package.json
CHANGED
|
@@ -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
|
|