@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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,127 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 25.1.0 — 2026-10-03
11
+
12
+ `FloatingLayer`, de `@softize/opus/ui/react`, é uma camada que continua utilizável por cima de
13
+ qualquer `Dialog` ou `Drawer` modal, como o botão e o painel de um assistente (ADR 0052). Quem está
14
+ preenchendo um formulário em um modal pode usar a camada sem fechar o modal nem perder o que digitou.
15
+ Os filhos vão para um hospedeiro fixo no `body`, `div[data-slot="floating-layer"]`, e cada um se
16
+ posiciona contra a janela. Com um `Dialog` ou `Drawer` aberto:
17
+
18
+ - clicar na camada não fecha o modal, mesmo que o elemento clicado suma no próprio clique, e o véu
19
+ continua fechando, respeitando `closeDisabled`;
20
+ - o foco pode ir para a camada por clique ou por código sem ser puxado de volta, e o Tab passa do
21
+ último elemento do modal para a camada e dela de volta ao primeiro; quando o elemento focado da
22
+ camada some, o foco volta ao campo do modal em que a pessoa estava, e um modal que fecha com a
23
+ pessoa na camada não leva o foco ao gatilho dele;
24
+ - o Esc age onde a pessoa está: na camada, nenhum modal fecha e `onEscapeKeyDown` é chamado uma vez
25
+ por tecla; no modal, o modal fecha;
26
+ - a camada rola e continua visível ao leitor de tela, enquanto a página atrás segue travada e
27
+ escondida.
28
+
29
+ O hospedeiro publica o `Dialog` ou `Drawer` do topo em `data-modal` (`none`, `dialog` ou `drawer`) e o
30
+ lado do Drawer em `data-modal-side`, para o consumidor tirar o painel de cima do modal. O Opus não
31
+ desloca nem redimensiona modais. Os patterns construídos sobre `Dialog` e `Drawer`, como
32
+ `ActionFormDialog`, `ActionListDialog`, `Presentation` e `CommandDialog`, convivem com a camada sem
33
+ mudança.
34
+
35
+ Outras mudanças:
36
+
37
+ - Popover, Menu, Tooltip, Select, Dialog e Drawer abertos de dentro de uma `FloatingLayer` vão para o
38
+ hospedeiro dela, e não para o `body`; fora da camada, nada muda;
39
+ - `Dialog mode="alert"`, que também serve às confirmações do `DialogHost`, passa à faixa `z-70`,
40
+ acima da camada (`z-60`) e dos demais modais (`z-50`), e cobre a camada até a resposta, inclusive
41
+ enquanto um `Dialog` aberto de dentro dele estiver aberto. Popover, Menu e Tooltip do Opus abertos
42
+ de dentro de um alerta acompanham a faixa dele. Dois efeitos para conferir no seu código: um
43
+ elemento próprio com `z-index` entre 51 e 69, que antes ficava acima do alerta, agora fica abaixo
44
+ dele; e uma sobreposição de outra biblioteca com `z-50`, aberta de dentro de um alerta, fica
45
+ abaixo do véu dele. Durante a animação de saída, o alerta deixa de receber clique. A escala está
46
+ em `docs/stacking-scale.md`;
47
+ - `DialogPortal` e `MenuPortal` sem `container` usam o hospedeiro da camada quando abertos de
48
+ dentro dela;
49
+ - a `ref` de `DialogContent` e `DrawerContent` continua recebendo a limpeza das callback refs do
50
+ React 19, agora composta com a ref interna da camada;
51
+ - o pacote ganha testes em navegador real (Chromium, pelo modo navegador do Vitest com Playwright)
52
+ para o foco da camada: `pnpm test:browser` roda à parte do `pnpm test` e entra no gate de release.
53
+
54
+ ## 25.0.0 — 2026-10-02
55
+
56
+ A exclusão lógica passa a declarar quando o registro excluído sai de vez (ADR 0051). `softDelete`
57
+ aceita a retenção com prazo — `retainDays`, `timezone` e os textos `label` e `description` da action
58
+ de expurgo, com `schedule`, `batchSize` e `maxDurationMs` opcionais — ou `{ retain: 'forever',
59
+ reason }`. Para cada entidade com `retainDays`, o domínio que a lista em `entities` ganha a action
60
+ `<caminho-do-domínio>.<entidade-em-kebab>.purge-deleted` e a rotina `<action>.daily`, que roda às
61
+ 3h30 no fuso declarado, como o sistema. A action aceita `olderThanDays` entre o `retainDays` e
62
+ 3650 e nunca expurga antes do prazo; só o sistema a executa, e ela fica fora do REST e da IA. Com
63
+ `kyselyData`, o handler padrão apaga em lotes, das linhas mais antigas para as mais novas, até
64
+ acabar ou esgotar `maxDurationMs`. Quando apagar a linha não basta, `bindPurge`, de
65
+ `@softize/opus/data`, liga um handler próprio, registrado em `purges` no `defineDomain`; ele
66
+ precisa respeitar o `cutoff` que recebe, e um lote que repete ids ou excede o `batchSize` interrompe
67
+ o expurgo com `purge.no_progress`. Cada lote que apaga algo registra `opus.purge` no log, com os ids
68
+ removidos em blocos de no máximo 500, e a execução termina com `opus.purge.total`, que traz a
69
+ quantidade, os lotes e se restou trabalho. Os schemas da action gerada são Standard Schema com o
70
+ JSON Schema anexado, sem zod: `@softize/opus` continua carregando sem zod instalado, e o manifest, o
71
+ OpenAPI e o MCP leem esse JSON Schema antes de recorrer ao zod.
72
+
73
+ Outras mudanças:
74
+
75
+ - `hasSoftDelete(entity)`, de `@softize/opus/schema`, reconhece as três formas de `softDelete`.
76
+ `entityColumns`, o tipo `EntityRow` e o `kyselyRepo` passam por ela; um teste que comparava
77
+ `entity.softDelete === true` deve usá-la;
78
+ - `defineEntity` recusa retenção inválida com `entity.invalid_soft_delete`: `retainDays` que não é
79
+ inteiro de 1 a 3650, `batchSize` acima de 10.000, fuso IANA inválido, texto vazio ou
80
+ `retain: 'forever'` sem `reason`;
81
+ - `defineDomain` passa a recusar schedules com o mesmo nome na árvore (`domain.duplicate_schedule`),
82
+ como já fazia com actions; antes, o conflito só aparecia no `register`;
83
+ - `FlattenedDomain` ganha `purges`, e `DataAdapter` ganha o método opcional `purgeDeleted`, que o
84
+ driver Kysely implementa;
85
+ - `opus gen` projeta a action e a rotina de expurgo no manifest: rode-o para que o `manifest:check`
86
+ volte a passar;
87
+ - `opus copy` lê o `label` e a `description` da retenção no `defineEntity`: rode-o para atualizar
88
+ o inventário;
89
+ - `docs/protocol.md` deixa de prometer uma opção para incluir excluídos na leitura, que não existe.
90
+
91
+ ### Breaking
92
+
93
+ **`softDelete: true` reprova no `opus check`.** A regra `soft-delete-retention` reprova a exclusão
94
+ lógica sem retenção, `retain: 'forever'` sem `reason`, `retainDays` sem `timezone`, `label` ou
95
+ `description` ou que não é inteiro positivo, `batchSize` acima de 10.000, entidade com `retainDays`
96
+ fora de qualquer `entities` de domínio, `bindPurge` de entidade sem `retainDays` e `bindPurge` fora
97
+ de qualquer `purges`.
98
+ Migração: em cada `softDelete: true`, declare a retenção ou o motivo para guardar:
99
+
100
+ ```ts
101
+ softDelete: {
102
+ retainDays: 90,
103
+ timezone: 'America/Sao_Paulo',
104
+ label: 'Expurgar relatórios excluídos',
105
+ description: 'Apaga de vez os relatórios excluídos há mais de 90 dias.',
106
+ }
107
+ // ou
108
+ softDelete: { retain: 'forever', reason: 'Exigência legal de guarda do histórico.' }
109
+ ```
110
+
111
+ Se o domínio já tem um expurgo escrito à mão, remova a action e o schedule próprios ao adotar
112
+ `retainDays`: o nome gerado pode coincidir com o declarado (`domain.duplicate_action`). Para manter
113
+ a ordem própria do que se apaga, mova o corpo para um `bindPurge`.
114
+
115
+ **Entidade com `retainDays` exige quem apague e quem agende.** Sem `bindPurge` no domínio e sem um
116
+ `DataAdapter` com o handler padrão (`kyselyData`), o `start()` recusa com
117
+ `runtime.purge_handler_missing`, nomeando a entidade, antes de inicializar os adapters. Como toda
118
+ rotina, a de expurgo exige um `SchedulerAdapter`; sem ele, o `start()` falha com
119
+ `runtime.scheduler_required`. A mesma entidade com retenção em dois domínios, ou num domínio e num
120
+ subdomínio dele, falha com `domain.entity_registered_twice`, assim como duas declarações com
121
+ retenção da mesma tabela. Migração: configure `data` e
122
+ `scheduler` no runtime que registra o domínio, ou registre o `bindPurge`; mantenha cada entidade
123
+ com retenção num único domínio.
124
+
125
+ **Chave estrangeira restritiva para o expurgo.** O schema do Opus não declara cascata. Se uma chave
126
+ estrangeira recusar a exclusão, a execução falha com o erro do banco no log e na auditoria, e nada
127
+ daquele lote é apagado. Como a linha travada está entre as mais antigas, ela volta ao primeiro lote
128
+ em toda execução: o expurgo da entidade fica parado até a chave ser ajustada. Migração: antes de declarar `retainDays`, confira as chaves que
129
+ apontam para a tabela; use cascata na DDL ou um `bindPurge` que apague os dependentes primeiro.
130
+
10
131
  ## 24.1.0 — 2026-10-02
11
132
 
12
133
  O `Markdown` deixa de carregar imagem com endereço absoluto (`https:`, `http:` ou `//host`). No
package/README.md CHANGED
@@ -28,7 +28,7 @@ Um pacote (`@softize/opus`), uma versão. Cada categoria abaixo é um **subpath
28
28
  | `@softize/opus/server` | `/fastify`, `/node` | Transporte HTTP e montagem de endpoints |
29
29
  | `@softize/opus/client` | `/fetch` | Adapter de cliente para invocar actions |
30
30
  | `@softize/opus/ui` | `/react`, `/meta`, `/docs` | Componentes, Provider e hooks (`useAction`, `useListAction`) |
31
- | `@softize/opus/data` | `/kysely`, `/readonly-pool` | Adapter de dados (`ctx.db`, `ctx.repo`) |
31
+ | `@softize/opus/data` | `/kysely`, `/readonly-pool` | Adapter de dados (`ctx.db`, `ctx.repo`) e expurgo próprio (`bindPurge`) |
32
32
  | `@softize/opus/auth` | `/jwt`, `/better-auth` | Adapter de autenticação (`ctx.user`, `ctx.can`) |
33
33
  | `@softize/opus/audit` | `/console`, `/pg` | Destinos de auditoria |
34
34
  | `@softize/opus/log` | `/pino` | Adapter de log (console é o default no core) |
package/bin/cli.mjs CHANGED
@@ -446,6 +446,15 @@ Regras dos Produtos de Dados (defineDataProduct):
446
446
  data-product-* — declaração exportada, id e versão válidos, sem duplicata,
447
447
  interfaces e entidades que existem no domínio
448
448
 
449
+ Regras da exclusão lógica (defineEntity, ADR 0051):
450
+ soft-delete-retention — a exclusão lógica declara retenção ou motivo: reprova
451
+ \`softDelete: true\`, \`retain: 'forever'\` sem \`reason\`,
452
+ \`retainDays\` sem \`timezone\`, \`label\` ou \`description\`
453
+ ou que não é inteiro positivo, \`batchSize\` acima de 10.000,
454
+ entidade com \`retainDays\` fora de qualquer \`entities\` de
455
+ domínio, \`bindPurge\` de entidade sem \`retainDays\` e
456
+ \`bindPurge\` fora de \`purges\`
457
+
449
458
  Exit ≠ 0 se houver violação. Sem nenhuma action: projeto marcado (opus.json) passa
450
459
  vacuamente; sem marcador, falha — gate vazio não é aprovação. Default dir: cwd.
451
460
  `)
package/bin/lib/check.mjs CHANGED
@@ -15,6 +15,15 @@
15
15
  * ignored-by-kind — `emits`/`background` em action list/view: o runtime e o manifest
16
16
  * só os aplicam em simple/form. No bindAction, o kind vem do
17
17
  * contrato pelo mesmo join por identificador exportado.
18
+ * soft-delete-retention — toda exclusão lógica declara retenção ou motivo para guardar
19
+ * (ADR 0051): reprova `softDelete: true`, `retain: 'forever'` sem
20
+ * `reason`, `retainDays` incompleto ou inválido, entidade com
21
+ * `retainDays` fora de qualquer `entities` de domínio, `bindPurge`
22
+ * de entidade sem `retainDays` e `bindPurge` fora de qualquer
23
+ * `purges`. A entidade e o `bindPurge` se ligam ao domínio pelo
24
+ * identificador exportado; um `entities` ou `purges` que o check não
25
+ * consegue ler (spread, variável) suspende a regra de ausência
26
+ * correspondente, sem falso positivo.
18
27
  *
19
28
  * A checagem cross-file de registro no runtime (graph) fica pra v2 (introspecção/MCP).
20
29
  * Saída: findings + exit ≠ 0 se houver erro. Zero actions num diretório NÃO-marcado
@@ -467,6 +476,377 @@ const UI_LEGACY_VARIANTS = new Map([
467
476
  /** Os três nomes que denotam action/contrato — o arquivo sem nenhum deles é pulado. */
468
477
  export const ACTION_MARKERS = ["defineAction", "defineContract", "bindAction"];
469
478
  export const DATA_PRODUCT_MARKERS = ["defineDataProduct", "defineEntity"];
479
+ export const RETENTION_MARKERS = ["defineDomain", "bindPurge"];
480
+
481
+ /** Teto do `retainDays`, o mesmo do `olderThanDays` da action de expurgo (src/core/retention.ts). */
482
+ const MAX_RETAIN_DAYS = 3650;
483
+ /** Teto do `batchSize` (PURGE_MAX_BATCH_SIZE em src/core/retention.ts). */
484
+ const MAX_PURGE_BATCH_SIZE = 10000;
485
+
486
+ /**
487
+ * Extrai a retenção das entidades, os `entities`/`purges` dos domínios e os `bindPurge` de um
488
+ * source (ADR 0051). Puro/sintático — a ligação entre arquivos fica no `checkProject`.
489
+ */
490
+ export function parseRetention(fileName, sourceText) {
491
+ const sf = ts.createSourceFile(
492
+ fileName,
493
+ sourceText,
494
+ ts.ScriptTarget.Latest,
495
+ true,
496
+ ts.ScriptKind.TS,
497
+ );
498
+ const entities = [];
499
+ const domains = [];
500
+ const purges = [];
501
+ const lineOf = (node) =>
502
+ sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
503
+ // `import { ReportEntity as Report }`: a referência no domínio usa o nome local; a ligação
504
+ // com a declaração precisa do nome exportado.
505
+ const aliases = new Map();
506
+ for (const statement of sf.statements) {
507
+ const bindings = ts.isImportDeclaration(statement)
508
+ ? statement.importClause?.namedBindings
509
+ : undefined;
510
+ if (bindings === undefined || !ts.isNamedImports(bindings)) continue;
511
+ for (const element of bindings.elements) {
512
+ if (element.propertyName !== undefined)
513
+ aliases.set(element.name.text, element.propertyName.text);
514
+ }
515
+ }
516
+ const refName = (node) => {
517
+ if (ts.isIdentifier(node)) return aliases.get(node.text) ?? node.text;
518
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
519
+ return null;
520
+ };
521
+ const isCallTo = (node, name) =>
522
+ ts.isCallExpression(node) &&
523
+ ts.isIdentifier(node.expression) &&
524
+ node.expression.text === name;
525
+ const prop = (obj, key) =>
526
+ obj.properties.find(
527
+ (item) =>
528
+ (ts.isPropertyAssignment(item) ||
529
+ ts.isShorthandPropertyAssignment(item)) &&
530
+ item.name?.getText(sf) === key,
531
+ );
532
+ const constIdent = (call) => {
533
+ const declaration = call.parent;
534
+ return declaration &&
535
+ ts.isVariableDeclaration(declaration) &&
536
+ ts.isIdentifier(declaration.name)
537
+ ? declaration.name.text
538
+ : null;
539
+ };
540
+ const isExported = (call) => {
541
+ const declaration = call.parent;
542
+ const statement =
543
+ declaration && ts.isVariableDeclaration(declaration)
544
+ ? declaration.parent?.parent
545
+ : undefined;
546
+ return (
547
+ statement !== undefined &&
548
+ ts.isVariableStatement(statement) &&
549
+ (statement.modifiers ?? []).some(
550
+ (item) => item.kind === ts.SyntaxKind.ExportKeyword,
551
+ )
552
+ );
553
+ };
554
+ const inlineEntities = new Set();
555
+ const inlinePurges = new Set();
556
+
557
+ /** Lê a forma de `softDelete` sem executar: literal reconhecido ou `opaque`. */
558
+ const softDeleteOf = (obj) => {
559
+ const entry = prop(obj, "softDelete");
560
+ if (entry === undefined) return { form: "none" };
561
+ if (!ts.isPropertyAssignment(entry)) return { form: "opaque" };
562
+ const value = entry.initializer;
563
+ if (value.kind === ts.SyntaxKind.TrueKeyword) return { form: "true" };
564
+ if (value.kind === ts.SyntaxKind.FalseKeyword) return { form: "none" };
565
+ if (!ts.isObjectLiteralExpression(value)) return { form: "opaque" };
566
+ const keys = new Set(
567
+ value.properties.map((item) => item.name?.getText(sf)).filter(Boolean),
568
+ );
569
+ // Um spread pode trazer qualquer chave: o que não está literal no objeto não é ausência.
570
+ const spread = value.properties.some(ts.isSpreadAssignment);
571
+ const literalText = (key) => {
572
+ const item = prop(value, key);
573
+ if (item === undefined) return undefined;
574
+ if (!ts.isPropertyAssignment(item)) return null;
575
+ const init = item.initializer;
576
+ return ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)
577
+ ? init.text
578
+ : null;
579
+ };
580
+ if (keys.has("retainDays")) {
581
+ const batchProp = prop(value, "batchSize");
582
+ const batchSize =
583
+ batchProp !== undefined &&
584
+ ts.isPropertyAssignment(batchProp) &&
585
+ ts.isNumericLiteral(batchProp.initializer)
586
+ ? Number(batchProp.initializer.text)
587
+ : null;
588
+ const days = prop(value, "retainDays");
589
+ let retainDays = null;
590
+ if (ts.isPropertyAssignment(days)) {
591
+ const init = days.initializer;
592
+ if (ts.isNumericLiteral(init)) retainDays = Number(init.text);
593
+ else if (
594
+ ts.isPrefixUnaryExpression(init) &&
595
+ init.operator === ts.SyntaxKind.MinusToken &&
596
+ ts.isNumericLiteral(init.operand)
597
+ )
598
+ retainDays = -Number(init.operand.text);
599
+ else if (ts.isStringLiteral(init)) retainDays = Number.NaN;
600
+ }
601
+ const missing = ["timezone", "label", "description"].filter((key) => {
602
+ if (!keys.has(key)) return !spread;
603
+ // Texto literal vazio conta como ausente; expressão não literal passa (o runtime confere).
604
+ return literalText(key) === "";
605
+ });
606
+ return {
607
+ form: "days",
608
+ retainDays,
609
+ batchSize,
610
+ missing,
611
+ conflicting: keys.has("retain"),
612
+ };
613
+ }
614
+ if (keys.has("retain")) {
615
+ // Expressão não literal (`null`) conta como presente; o runtime confere o valor.
616
+ const reason = literalText("reason");
617
+ return {
618
+ form: "forever",
619
+ reasonMissing:
620
+ (reason === undefined && !spread) ||
621
+ (typeof reason === "string" && reason.trim() === ""),
622
+ };
623
+ }
624
+ return { form: spread ? "opaque" : "invalid" };
625
+ };
626
+
627
+ /** `entities` e `purges` de um objeto de domínio (defineDomain ou subdomínio inline). */
628
+ const readDomain = (obj) => {
629
+ const domain = {
630
+ entityRefs: [],
631
+ entitiesOpaque: false,
632
+ purgeRefs: [],
633
+ purgesOpaque: false,
634
+ line: lineOf(obj),
635
+ };
636
+ const entitiesProp = prop(obj, "entities");
637
+ if (entitiesProp !== undefined) {
638
+ const value = ts.isPropertyAssignment(entitiesProp)
639
+ ? entitiesProp.initializer
640
+ : null;
641
+ if (value === null || !ts.isObjectLiteralExpression(value)) {
642
+ domain.entitiesOpaque = true;
643
+ } else {
644
+ for (const item of value.properties) {
645
+ if (ts.isShorthandPropertyAssignment(item)) {
646
+ domain.entityRefs.push(refName(item.name));
647
+ } else if (ts.isPropertyAssignment(item)) {
648
+ const init = item.initializer;
649
+ if (isCallTo(init, "defineEntity")) inlineEntities.add(init);
650
+ else {
651
+ const name = refName(init);
652
+ if (name === null) domain.entitiesOpaque = true;
653
+ else domain.entityRefs.push(name);
654
+ }
655
+ } else {
656
+ domain.entitiesOpaque = true;
657
+ }
658
+ }
659
+ }
660
+ }
661
+ const purgesProp = prop(obj, "purges");
662
+ if (purgesProp !== undefined) {
663
+ const value = ts.isPropertyAssignment(purgesProp)
664
+ ? purgesProp.initializer
665
+ : null;
666
+ if (value === null || !ts.isArrayLiteralExpression(value)) {
667
+ domain.purgesOpaque = true;
668
+ } else {
669
+ for (const element of value.elements) {
670
+ if (isCallTo(element, "bindPurge")) inlinePurges.add(element);
671
+ else {
672
+ const name = refName(element);
673
+ if (name === null) domain.purgesOpaque = true;
674
+ else domain.purgeRefs.push(name);
675
+ }
676
+ }
677
+ }
678
+ }
679
+ domains.push(domain);
680
+ const subdomains = prop(obj, "subdomains");
681
+ if (
682
+ subdomains !== undefined &&
683
+ ts.isPropertyAssignment(subdomains) &&
684
+ ts.isArrayLiteralExpression(subdomains.initializer)
685
+ ) {
686
+ // Subdomínio em `defineDomain(...)` é visitado como chamada própria; o literal, aqui.
687
+ for (const element of subdomains.initializer.elements) {
688
+ if (ts.isObjectLiteralExpression(element)) readDomain(element);
689
+ }
690
+ }
691
+ };
692
+
693
+ const entityCalls = [];
694
+ const purgeCalls = [];
695
+ const visit = (node) => {
696
+ if (
697
+ ts.isCallExpression(node) &&
698
+ ts.isIdentifier(node.expression) &&
699
+ node.arguments.length > 0
700
+ ) {
701
+ const callee = node.expression.text;
702
+ const first = node.arguments[0];
703
+ if (callee === "defineEntity" && ts.isObjectLiteralExpression(first))
704
+ entityCalls.push(node);
705
+ else if (callee === "defineDomain" && ts.isObjectLiteralExpression(first))
706
+ readDomain(first);
707
+ else if (callee === "bindPurge") purgeCalls.push(node);
708
+ }
709
+ ts.forEachChild(node, visit);
710
+ };
711
+ visit(sf);
712
+
713
+ for (const call of entityCalls) {
714
+ const obj = call.arguments[0];
715
+ const nameProp = prop(obj, "name");
716
+ entities.push({
717
+ ident: constIdent(call),
718
+ name:
719
+ nameProp &&
720
+ ts.isPropertyAssignment(nameProp) &&
721
+ ts.isStringLiteral(nameProp.initializer)
722
+ ? nameProp.initializer.text
723
+ : null,
724
+ softDelete: softDeleteOf(obj),
725
+ inlineInDomain: inlineEntities.has(call),
726
+ line: lineOf(call),
727
+ });
728
+ }
729
+ for (const call of purgeCalls) {
730
+ purges.push({
731
+ ident: constIdent(call),
732
+ exported: isExported(call),
733
+ entityRef: refName(call.arguments[0]),
734
+ inlineInPurges: inlinePurges.has(call),
735
+ line: lineOf(call),
736
+ });
737
+ }
738
+ return { entities, domains, purges };
739
+ }
740
+
741
+ /**
742
+ * Regra `soft-delete-retention` (ADR 0051) sobre o projeto inteiro: a forma da declaração, por
743
+ * entidade, e a ligação entidade ↔ domínio ↔ `bindPurge`, pelo identificador exportado.
744
+ */
745
+ function checkRetention(retention) {
746
+ const findings = [];
747
+ const finding = (file, line, subject, message) =>
748
+ findings.push({
749
+ rule: "soft-delete-retention",
750
+ level: "error",
751
+ action: subject,
752
+ line,
753
+ file,
754
+ message,
755
+ });
756
+ const domains = retention.flatMap(({ parsed }) => parsed.domains);
757
+ const entitiesOpaque = domains.some((domain) => domain.entitiesOpaque);
758
+ const purgesOpaque = domains.some((domain) => domain.purgesOpaque);
759
+ const entityRefs = new Set(domains.flatMap((domain) => domain.entityRefs));
760
+ const purgeRefs = new Set(domains.flatMap((domain) => domain.purgeRefs));
761
+ const entitiesByIdent = new Map(); // ident → entity | 'ambiguous'
762
+
763
+ for (const { file, parsed } of retention) {
764
+ for (const entity of parsed.entities) {
765
+ if (entity.ident !== null) {
766
+ entitiesByIdent.set(
767
+ entity.ident,
768
+ entitiesByIdent.has(entity.ident) ? "ambiguous" : entity,
769
+ );
770
+ }
771
+ const subject = entity.name ?? entity.ident ?? "(entidade sem name)";
772
+ const at = (message) => finding(file, entity.line, subject, message);
773
+ const { softDelete } = entity;
774
+ if (softDelete.form === "true") {
775
+ at(
776
+ "`softDelete: true` não declara por quanto tempo o registro excluído fica guardado. Declare `retainDays`, com `timezone`, `label` e `description`, ou `{ retain: 'forever', reason }`.",
777
+ );
778
+ } else if (softDelete.form === "invalid") {
779
+ at(
780
+ "`softDelete` em objeto precisa de `retainDays` ou de `retain: 'forever'` com `reason`.",
781
+ );
782
+ } else if (softDelete.form === "forever") {
783
+ if (softDelete.reasonMissing)
784
+ at(
785
+ "`retain: 'forever'` precisa de `reason` com o motivo de guardar o registro excluído para sempre.",
786
+ );
787
+ } else if (softDelete.form === "days") {
788
+ if (softDelete.conflicting)
789
+ at(
790
+ "`retainDays` e `retain: 'forever'` são formas excludentes; declare só uma.",
791
+ );
792
+ if (softDelete.missing.length > 0)
793
+ at(
794
+ `\`retainDays\` precisa de ${softDelete.missing.map((key) => `\`${key}\``).join(", ")}: o fuso da rotina e os textos completos da action de expurgo.`,
795
+ );
796
+ const days = softDelete.retainDays;
797
+ if (days !== null && (!Number.isInteger(days) || days < 1))
798
+ at("`retainDays` precisa ser um inteiro positivo.");
799
+ else if (days !== null && days > MAX_RETAIN_DAYS)
800
+ at(`\`retainDays\` aceita no máximo ${MAX_RETAIN_DAYS} dias.`);
801
+ const batch = softDelete.batchSize;
802
+ if (batch !== null && (!Number.isInteger(batch) || batch < 1))
803
+ at("`batchSize` precisa ser um inteiro positivo.");
804
+ else if (batch !== null && batch > MAX_PURGE_BATCH_SIZE)
805
+ at(`\`batchSize\` aceita no máximo ${MAX_PURGE_BATCH_SIZE} linhas por lote.`);
806
+ const inDomain =
807
+ entity.inlineInDomain ||
808
+ (entity.ident !== null && entityRefs.has(entity.ident));
809
+ if (!inDomain && !entitiesOpaque)
810
+ at(
811
+ "Entidade com `retainDays` fora de qualquer `entities` de domínio: o expurgo nasce no domínio que lista a entidade. Registre-a em `entities` no `defineDomain`.",
812
+ );
813
+ }
814
+ }
815
+ }
816
+
817
+ for (const { file, parsed } of retention) {
818
+ for (const purge of parsed.purges) {
819
+ const subject = `bindPurge(${purge.entityRef ?? "?"})`;
820
+ const entity =
821
+ purge.entityRef === null ? undefined : entitiesByIdent.get(purge.entityRef);
822
+ if (
823
+ entity !== undefined &&
824
+ entity !== "ambiguous" &&
825
+ entity.softDelete.form !== "days" &&
826
+ entity.softDelete.form !== "opaque"
827
+ ) {
828
+ finding(
829
+ file,
830
+ purge.line,
831
+ subject,
832
+ "`bindPurge` para uma entidade sem `retainDays`: sem prazo, não há expurgo para o handler executar.",
833
+ );
834
+ }
835
+ const registered =
836
+ purge.inlineInPurges ||
837
+ (purge.ident !== null && purgeRefs.has(purge.ident));
838
+ if (!registered && !purgesOpaque) {
839
+ finding(
840
+ file,
841
+ purge.line,
842
+ subject,
843
+ "`bindPurge` fora de qualquer `purges` de domínio: o runtime usaria o handler padrão em silêncio. Registre-o em `purges` no `defineDomain` que lista a entidade.",
844
+ );
845
+ }
846
+ }
847
+ }
848
+ return findings;
849
+ }
470
850
 
471
851
  /** Extrai Produtos de Dados e entities literais para validar a linhagem sem executar o app. */
472
852
  export function parseDataProducts(fileName, sourceText) {
@@ -745,6 +1125,7 @@ export function checkProject(sources) {
745
1125
  const productDeclarations = [];
746
1126
  const entityNames = new Set();
747
1127
  const actionNames = new Set();
1128
+ const retention = [];
748
1129
  // Natureza da action (ADR 0041): declarações ausentes são contadas, não reprovadas, na minor.
749
1130
  let missingEffect = 0;
750
1131
  let missingPersonalData = 0;
@@ -754,6 +1135,12 @@ export function checkProject(sources) {
754
1135
  for (const entity of declarations.entities) entityNames.add(entity);
755
1136
  for (const product of declarations.products)
756
1137
  productDeclarations.push({ product, file });
1138
+ if (
1139
+ ["defineEntity", ...RETENTION_MARKERS].some((marker) =>
1140
+ text.includes(marker),
1141
+ )
1142
+ )
1143
+ retention.push({ file, parsed: parseRetention(file, text) });
757
1144
  for (const item of parseActions(file, text)) {
758
1145
  if (item.form === "bindAction") {
759
1146
  actions += 1;
@@ -878,6 +1265,8 @@ export function checkProject(sources) {
878
1265
  }
879
1266
  }
880
1267
 
1268
+ findings.push(...checkRetention(retention));
1269
+
881
1270
  return {
882
1271
  actions,
883
1272
  contracts,
@@ -2147,7 +2536,7 @@ export async function scanDir(rootDir) {
2147
2536
  for (const file of files) {
2148
2537
  const text = readProjectFile(rootDir, path.relative(rootDir, file)).content;
2149
2538
  if (
2150
- ![...ACTION_MARKERS, ...DATA_PRODUCT_MARKERS].some((m) =>
2539
+ ![...ACTION_MARKERS, ...DATA_PRODUCT_MARKERS, ...RETENTION_MARKERS].some((m) =>
2151
2540
  text.includes(m),
2152
2541
  )
2153
2542
  )
package/bin/lib/copy.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Inventário semântico de copy dos contratos e dicionários Opus.
3
3
  *
4
- * O Opus conhece o papel dos textos declarados em defineAction/defineContract e t.dict;
4
+ * O Opus conhece o papel dos textos declarados em defineAction/defineContract, t.dict e na
5
+ * retenção de defineEntity (o `label` e a `description` da action de expurgo derivada);
5
6
  * a @softize/base continua dona da política e da validação editorial. Este módulo
6
7
  * apenas projeta o protocolo JSON v2 sem executar código do consumidor.
7
8
  */
@@ -78,6 +79,8 @@ const AUDIT_REFERENCE =
78
79
  /^(?:https?:\/\/\S+|[a-z][a-z0-9-]*:\S+|(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._#/-]+|[A-Za-z0-9._-]+\.(?:md|json|ya?ml)(?:#[^\s]+)?)$/u
79
80
  const CONTRACT_MODULES = new Set(['@softize/opus', '@softize/opus/core'])
80
81
  const CONTRACT_FACTORIES = new Set(['defineAction', 'defineContract'])
82
+ // A retenção da exclusão lógica declara os textos da action de expurgo que o Opus deriva (ADR 0051).
83
+ const ENTITY_MODULES = new Set(['@softize/opus/schema'])
81
84
  const PRESENTATION_MODULES = new Set(['@softize/opus', '@softize/opus/presentation'])
82
85
  const SCHEMA_ZOD_MODULES = new Set(['@softize/opus/schema/zod'])
83
86
  const ACTION_BINDING_KEYS = new Set(['authorize', 'background', 'emits', 'handler', 'idempotency', 'loads'])
@@ -1407,6 +1410,7 @@ export function extractCopyFromSource(file, sourceText) {
1407
1410
  let hasContract = false
1408
1411
  let hasDictionary = false
1409
1412
  let hasPresentation = false
1413
+ let hasEntity = false
1410
1414
  const hasUiImport = sourceFile.statements.some(
1411
1415
  (statement) =>
1412
1416
  ts.isImportDeclaration(statement) &&
@@ -1599,6 +1603,33 @@ export function extractCopyFromSource(file, sourceText) {
1599
1603
  return name !== null && CONTRACT_FACTORIES.has(name) ? name : null
1600
1604
  }
1601
1605
 
1606
+ function entityFactory(expression) {
1607
+ return importedMember(expression, (module) => ENTITY_MODULES.has(module)) === 'defineEntity'
1608
+ }
1609
+
1610
+ /**
1611
+ * `softDelete` com `retainDays` traz o título e a descrição, completos e literais, da action de
1612
+ * expurgo que o `flattenDomain` deriva (ADR 0051). `true`, `false` e `retain: 'forever'` não
1613
+ * têm texto para pessoas.
1614
+ */
1615
+ function extractEntity(object) {
1616
+ const resolved = property(object, 'softDelete', sourceFile, checker)
1617
+ if (resolved.opaque !== undefined) {
1618
+ diagnostics.push(diagnostic(file, sourceFile, resolved.opaque, 'softDelete', 'structure'))
1619
+ return
1620
+ }
1621
+ if (resolved.candidate === undefined) return
1622
+ const valueNode = propertyValue(resolved.candidate)
1623
+ const value = resolveBinding(valueNode, checker)
1624
+ if (value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword) return
1625
+ if (!ts.isObjectLiteralExpression(value)) {
1626
+ diagnostics.push(diagnostic(file, sourceFile, valueNode, 'softDelete', 'structure'))
1627
+ return
1628
+ }
1629
+ addProperty(value, 'label', 'button', 'softDelete.')
1630
+ addProperty(value, 'description', 'description', 'softDelete.')
1631
+ }
1632
+
1602
1633
  function presentationFactory(expression) {
1603
1634
  return importedMember(expression, (module) => PRESENTATION_MODULES.has(module)) === 'definePresentation'
1604
1635
  }
@@ -2582,6 +2613,12 @@ export function extractCopyFromSource(file, sourceText) {
2582
2613
  else diagnostics.push(diagnostic(file, sourceFile, node.arguments[0], `${factory}(...)`, 'structure'))
2583
2614
  }
2584
2615
  }
2616
+ if (ts.isCallExpression(node) && entityFactory(node.expression) && node.arguments.length > 0) {
2617
+ hasEntity = true
2618
+ const object = resolveBinding(node.arguments[0], checker)
2619
+ if (ts.isObjectLiteralExpression(object)) extractEntity(object)
2620
+ else diagnostics.push(diagnostic(file, sourceFile, node.arguments[0], 'defineEntity(...)', 'structure'))
2621
+ }
2585
2622
  if (ts.isCallExpression(node) && presentationFactory(node.expression) && node.arguments.length > 0) {
2586
2623
  hasPresentation = true
2587
2624
  const object = resolveBinding(node.arguments[0], checker)
@@ -2599,7 +2636,7 @@ export function extractCopyFromSource(file, sourceText) {
2599
2636
 
2600
2637
  return {
2601
2638
  hasContract,
2602
- hasCopySurface: hasContract || hasDictionary || hasPresentation || hasUiImport,
2639
+ hasCopySurface: hasContract || hasDictionary || hasPresentation || hasEntity || hasUiImport,
2603
2640
  entries,
2604
2641
  diagnostics,
2605
2642
  }