@softize/opus 8.6.6
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 +1616 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/cli.mjs +528 -0
- package/bin/lib/check.mjs +307 -0
- package/bin/lib/components.mjs +151 -0
- package/bin/lib/create.mjs +208 -0
- package/bin/lib/db-check-runner.mjs +86 -0
- package/bin/lib/db-migrate-runner.mjs +89 -0
- package/bin/lib/db-scaffold-runner.mjs +84 -0
- package/bin/lib/db.mjs +261 -0
- package/bin/lib/docs-include.mjs +48 -0
- package/bin/lib/gen-dicts.mjs +134 -0
- package/bin/lib/gen-docs.mjs +288 -0
- package/bin/lib/gen-manifest.mjs +102 -0
- package/bin/lib/gen-openapi.mjs +195 -0
- package/bin/lib/gen-runner.mjs +472 -0
- package/bin/lib/gen-stubs.mjs +463 -0
- package/bin/lib/gen.mjs +311 -0
- package/bin/lib/init.mjs +514 -0
- package/bin/lib/introspect.mjs +107 -0
- package/bin/lib/mcp.mjs +85 -0
- package/bin/lib/postinstall.mjs +56 -0
- package/docs/chat-event-protocol.md +85 -0
- package/docs/code-style.md +16 -0
- package/docs/data-layer.md +246 -0
- package/docs/ownership-vs-shadcn-lock.md +102 -0
- package/docs/protocol.md +2053 -0
- package/docs/releasing.md +110 -0
- package/docs/shellnav.md +131 -0
- package/package.json +338 -0
- package/registry/hooks/hooks.json +26 -0
- package/registry/hooks/link-memory-on-start.mjs +46 -0
- package/registry/hooks/opus-check-on-stop.mjs +114 -0
- package/registry/skills/create-action/SKILL.md +49 -0
- package/registry/skills/create-action/scaffold.mjs +122 -0
- package/registry/templates/app/_gitignore +3 -0
- package/registry/templates/app/_npmrc +1 -0
- package/registry/templates/app/_opus/_gitignore +5 -0
- package/registry/templates/app/_prettierrc.json +6 -0
- package/registry/templates/app/index.html +13 -0
- package/registry/templates/app/opus.config.ts +16 -0
- package/registry/templates/app/package.json +43 -0
- package/registry/templates/app/pnpm-workspace.yaml +11 -0
- package/registry/templates/app/public/favicon.svg +4 -0
- package/registry/templates/app/src/App.tsx +37 -0
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
- package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
- package/registry/templates/app/src/domains/tasks/index.ts +13 -0
- package/registry/templates/app/src/index.css +18 -0
- package/registry/templates/app/src/main.tsx +25 -0
- package/registry/templates/app/tsconfig.json +20 -0
- package/registry/templates/app/vite.config.ts +46 -0
- package/registry/templates/monorepo/_gitignore +3 -0
- package/registry/templates/monorepo/_npmrc +1 -0
- package/registry/templates/monorepo/package.json +9 -0
- package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
- package/src/ai/ask.ts +64 -0
- package/src/ai/drivers/anthropic.ts +309 -0
- package/src/ai/index.ts +17 -0
- package/src/audit/drivers/console.ts +117 -0
- package/src/audit/drivers/pg.ts +172 -0
- package/src/audit/index.ts +51 -0
- package/src/auth/drivers/better-auth.ts +103 -0
- package/src/auth/drivers/jwt.ts +188 -0
- package/src/auth/index.ts +9 -0
- package/src/client/drivers/fetch.ts +202 -0
- package/src/client/index.ts +22 -0
- package/src/core/actions.ts +110 -0
- package/src/core/audit.ts +239 -0
- package/src/core/contracts.ts +137 -0
- package/src/core/domain.ts +310 -0
- package/src/core/errors.ts +181 -0
- package/src/core/index.ts +174 -0
- package/src/core/logical-type.ts +31 -0
- package/src/core/reactions.ts +81 -0
- package/src/core/runtime.ts +1167 -0
- package/src/core/schedules.ts +41 -0
- package/src/core/types.ts +1356 -0
- package/src/data/drivers/kysely.ts +389 -0
- package/src/data/index.ts +10 -0
- package/src/data/readonly-pool.ts +160 -0
- package/src/dsl/eval.ts +136 -0
- package/src/dsl/index.ts +29 -0
- package/src/dsl/kysely.ts +230 -0
- package/src/dsl/loads.ts +123 -0
- package/src/dsl/parser.ts +423 -0
- package/src/dsl/types.ts +113 -0
- package/src/events/drivers/mitt.ts +70 -0
- package/src/events/index.ts +9 -0
- package/src/log/drivers/pino.ts +57 -0
- package/src/log/index.ts +9 -0
- package/src/mcp/index.ts +62 -0
- package/src/queue/drivers/bullmq.ts +190 -0
- package/src/queue/index.ts +9 -0
- package/src/scheduler/drivers/node-cron.ts +93 -0
- package/src/scheduler/every.ts +45 -0
- package/src/scheduler/index.ts +9 -0
- package/src/schema/drivers/zod.ts +765 -0
- package/src/schema/entity.ts +439 -0
- package/src/schema/format/locale.ts +144 -0
- package/src/schema/index.ts +65 -0
- package/src/schema/openapi.ts +302 -0
- package/src/schema/scaffold.ts +160 -0
- package/src/server/drivers/fastify.ts +224 -0
- package/src/server/drivers/node.ts +386 -0
- package/src/server/index.ts +142 -0
- package/src/storage/drivers/fs.ts +90 -0
- package/src/storage/drivers/s3.ts +117 -0
- package/src/storage/index.ts +27 -0
- package/src/testing/fake.ts +298 -0
- package/src/testing/index.ts +324 -0
- package/src/ui/components/patterns/action-form-card.tsx +48 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
- package/src/ui/components/patterns/app-shell.tsx +227 -0
- package/src/ui/components/patterns/confirm.tsx +226 -0
- package/src/ui/components/patterns/data-state.tsx +75 -0
- package/src/ui/components/patterns/form-dialog.tsx +64 -0
- package/src/ui/components/patterns/form.tsx +584 -0
- package/src/ui/components/patterns/list.tsx +1488 -0
- package/src/ui/components/patterns/page.tsx +46 -0
- package/src/ui/components/patterns/section-shell.tsx +246 -0
- package/src/ui/components/patterns/shell-nav.tsx +150 -0
- package/src/ui/components/patterns/sidebar.tsx +89 -0
- package/src/ui/components/patterns/split.tsx +93 -0
- package/src/ui/components/patterns/trigger.tsx +196 -0
- package/src/ui/components/patterns/view.tsx +84 -0
- package/src/ui/components/primitives/accordion.tsx +64 -0
- package/src/ui/components/primitives/alert-dialog.tsx +190 -0
- package/src/ui/components/primitives/alert.tsx +116 -0
- package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
- package/src/ui/components/primitives/avatar.tsx +107 -0
- package/src/ui/components/primitives/badge.tsx +37 -0
- package/src/ui/components/primitives/breadcrumb.tsx +109 -0
- package/src/ui/components/primitives/button-group.tsx +83 -0
- package/src/ui/components/primitives/button.tsx +102 -0
- package/src/ui/components/primitives/calendar.tsx +218 -0
- package/src/ui/components/primitives/card.tsx +56 -0
- package/src/ui/components/primitives/carousel.tsx +239 -0
- package/src/ui/components/primitives/chat.tsx +407 -0
- package/src/ui/components/primitives/checkbox.tsx +30 -0
- package/src/ui/components/primitives/collapsible.tsx +31 -0
- package/src/ui/components/primitives/command.tsx +182 -0
- package/src/ui/components/primitives/composer.tsx +121 -0
- package/src/ui/components/primitives/copyable.tsx +50 -0
- package/src/ui/components/primitives/dialog.tsx +147 -0
- package/src/ui/components/primitives/drawer.tsx +141 -0
- package/src/ui/components/primitives/empty.tsx +104 -0
- package/src/ui/components/primitives/field.tsx +246 -0
- package/src/ui/components/primitives/icon-picker.tsx +180 -0
- package/src/ui/components/primitives/input-group.tsx +168 -0
- package/src/ui/components/primitives/input-otp.tsx +75 -0
- package/src/ui/components/primitives/input.tsx +72 -0
- package/src/ui/components/primitives/item.tsx +193 -0
- package/src/ui/components/primitives/kbd.tsx +28 -0
- package/src/ui/components/primitives/label.tsx +22 -0
- package/src/ui/components/primitives/markdown.tsx +35 -0
- package/src/ui/components/primitives/menu.tsx +255 -0
- package/src/ui/components/primitives/pagination.tsx +127 -0
- package/src/ui/components/primitives/popover.tsx +87 -0
- package/src/ui/components/primitives/progress.tsx +29 -0
- package/src/ui/components/primitives/radio-group.tsx +43 -0
- package/src/ui/components/primitives/resizable.tsx +51 -0
- package/src/ui/components/primitives/scroll-area.tsx +56 -0
- package/src/ui/components/primitives/select.tsx +479 -0
- package/src/ui/components/primitives/separator.tsx +26 -0
- package/src/ui/components/primitives/skeleton.tsx +13 -0
- package/src/ui/components/primitives/slider.tsx +61 -0
- package/src/ui/components/primitives/sonner.tsx +46 -0
- package/src/ui/components/primitives/spinner.tsx +29 -0
- package/src/ui/components/primitives/switch.tsx +33 -0
- package/src/ui/components/primitives/table.tsx +114 -0
- package/src/ui/components/primitives/tabs.tsx +104 -0
- package/src/ui/components/primitives/textarea.tsx +18 -0
- package/src/ui/components/primitives/toggle-group.tsx +81 -0
- package/src/ui/components/primitives/toggle.tsx +45 -0
- package/src/ui/components/primitives/tooltip.tsx +55 -0
- package/src/ui/components/primitives/truncate.tsx +49 -0
- package/src/ui/docs/DocBrowser.tsx +90 -0
- package/src/ui/docs/changelog.tsx +80 -0
- package/src/ui/docs/content/accordion.md +86 -0
- package/src/ui/docs/content/action-form-card.md +24 -0
- package/src/ui/docs/content/action-form-dialog.md +30 -0
- package/src/ui/docs/content/action-form.md +125 -0
- package/src/ui/docs/content/action-list-dialog.md +68 -0
- package/src/ui/docs/content/action-list.md +194 -0
- package/src/ui/docs/content/action-trigger.md +72 -0
- package/src/ui/docs/content/action-view.md +47 -0
- package/src/ui/docs/content/actions.md +138 -0
- package/src/ui/docs/content/ai.md +112 -0
- package/src/ui/docs/content/alert-dialog.md +73 -0
- package/src/ui/docs/content/alert.md +69 -0
- package/src/ui/docs/content/app-shell.md +155 -0
- package/src/ui/docs/content/aspect-ratio.md +66 -0
- package/src/ui/docs/content/audit.md +84 -0
- package/src/ui/docs/content/auth.md +70 -0
- package/src/ui/docs/content/avatar.md +94 -0
- package/src/ui/docs/content/badge.md +48 -0
- package/src/ui/docs/content/breadcrumb.md +87 -0
- package/src/ui/docs/content/button-group.md +71 -0
- package/src/ui/docs/content/button.md +60 -0
- package/src/ui/docs/content/calendar.md +62 -0
- package/src/ui/docs/content/card.md +49 -0
- package/src/ui/docs/content/carousel.md +85 -0
- package/src/ui/docs/content/chat.md +69 -0
- package/src/ui/docs/content/checkbox.md +75 -0
- package/src/ui/docs/content/cli.md +58 -0
- package/src/ui/docs/content/collapsible.md +64 -0
- package/src/ui/docs/content/command.md +56 -0
- package/src/ui/docs/content/composer.md +50 -0
- package/src/ui/docs/content/confirm.md +120 -0
- package/src/ui/docs/content/copyable.md +30 -0
- package/src/ui/docs/content/customization.md +110 -0
- package/src/ui/docs/content/cycle.md +34 -0
- package/src/ui/docs/content/data-state.md +47 -0
- package/src/ui/docs/content/data.md +99 -0
- package/src/ui/docs/content/dialog.md +60 -0
- package/src/ui/docs/content/drawer.md +55 -0
- package/src/ui/docs/content/empty.md +66 -0
- package/src/ui/docs/content/events.md +61 -0
- package/src/ui/docs/content/field.md +58 -0
- package/src/ui/docs/content/getting-started.md +109 -0
- package/src/ui/docs/content/icon-picker.md +51 -0
- package/src/ui/docs/content/input-group.md +78 -0
- package/src/ui/docs/content/input-otp.md +72 -0
- package/src/ui/docs/content/input.md +78 -0
- package/src/ui/docs/content/item.md +84 -0
- package/src/ui/docs/content/kbd.md +62 -0
- package/src/ui/docs/content/label.md +32 -0
- package/src/ui/docs/content/log.md +55 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/content/mcp.md +44 -0
- package/src/ui/docs/content/menu.md +114 -0
- package/src/ui/docs/content/microcopy.md +83 -0
- package/src/ui/docs/content/page.md +34 -0
- package/src/ui/docs/content/pagination.md +99 -0
- package/src/ui/docs/content/popover.md +49 -0
- package/src/ui/docs/content/progress.md +69 -0
- package/src/ui/docs/content/queue.md +62 -0
- package/src/ui/docs/content/radio-group.md +77 -0
- package/src/ui/docs/content/resizable.md +86 -0
- package/src/ui/docs/content/router.md +56 -0
- package/src/ui/docs/content/runtime.md +77 -0
- package/src/ui/docs/content/scheduler.md +66 -0
- package/src/ui/docs/content/scroll-area.md +89 -0
- package/src/ui/docs/content/section-shell.md +121 -0
- package/src/ui/docs/content/select.md +342 -0
- package/src/ui/docs/content/separator.md +33 -0
- package/src/ui/docs/content/sidebar.md +38 -0
- package/src/ui/docs/content/skeleton.md +34 -0
- package/src/ui/docs/content/slider.md +64 -0
- package/src/ui/docs/content/spinner.md +37 -0
- package/src/ui/docs/content/split.md +33 -0
- package/src/ui/docs/content/storage.md +69 -0
- package/src/ui/docs/content/switch.md +69 -0
- package/src/ui/docs/content/table.md +102 -0
- package/src/ui/docs/content/tabs.md +94 -0
- package/src/ui/docs/content/testing.md +89 -0
- package/src/ui/docs/content/textarea.md +30 -0
- package/src/ui/docs/content/toast.md +67 -0
- package/src/ui/docs/content/toggle-group.md +81 -0
- package/src/ui/docs/content/toggle.md +72 -0
- package/src/ui/docs/content/tokens.md +171 -0
- package/src/ui/docs/content/tooltip.md +50 -0
- package/src/ui/docs/content/truncate.md +37 -0
- package/src/ui/docs/content/ui.md +40 -0
- package/src/ui/docs/content/upgrading.md +48 -0
- package/src/ui/docs/doc-client.tsx +214 -0
- package/src/ui/docs/doc.tsx +301 -0
- package/src/ui/docs/folder.tsx +149 -0
- package/src/ui/docs/index.ts +21 -0
- package/src/ui/docs/markdown.tsx +130 -0
- package/src/ui/docs/md-raw.d.ts +4 -0
- package/src/ui/docs/plugin.ts +104 -0
- package/src/ui/docs/registry.tsx +424 -0
- package/src/ui/docs/standalone.tsx +107 -0
- package/src/ui/drivers/react.tsx +627 -0
- package/src/ui/index.ts +92 -0
- package/src/ui/lib/cn.ts +10 -0
- package/src/ui/lib/zod-pt-br.ts +38 -0
- package/src/ui/meta.ts +412 -0
- package/src/ui/react.tsx +235 -0
- package/src/ui/router.ts +96 -0
- package/src/ui/theme.css +234 -0
- package/src/vite/design.ts +652 -0
- package/src/vite/index.ts +8 -0
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,2053 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Protocolo
|
|
3
|
+
order: 1
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Opus — Protocolo
|
|
7
|
+
|
|
8
|
+
> **Status:** v0. Esta doc descreve o protocolo do Opus **como ele é** — o código o implementa. Divergência entre código e doc é bug de um dos dois (a corrigir num dos lados), não "evolução natural" sem revisão da doc.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Tese
|
|
13
|
+
|
|
14
|
+
**O Opus é o protocolo de actions de ponta a ponta.** Você declara uma action uma vez — input, autorização, execução, audit, feedback — e a lib materializa em UI, client, server e log via adapters.
|
|
15
|
+
|
|
16
|
+
**Não é framework.** Não dona ciclo de vida, não tem router próprio, não tem ORM, não tem queue. É o contrato comum que todas as camadas falam.
|
|
17
|
+
|
|
18
|
+
**O que o Opus não substitui:**
|
|
19
|
+
- Auth provider (Better Auth, Clerk, próprio) — opus **lê** o contexto autenticado, não autentica.
|
|
20
|
+
- Queue / job runner — opus pode emitir trigger, não executa.
|
|
21
|
+
- ORM / DB client — opus não toca dado direto.
|
|
22
|
+
- Design system / UI framework — Opus expõe metadata da action, UI consome.
|
|
23
|
+
- HTTP framework — Opus gera handler, o framework hospeda.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 2. Action: definição
|
|
28
|
+
|
|
29
|
+
Uma **action** é uma unidade declarativa que flui pelo **pipeline padrão do Opus**: validar input → carregar recursos → autorizar → executar → auditar → retornar. Toda action atravessa esse pipeline, **independente de mudar estado ou não**.
|
|
30
|
+
|
|
31
|
+
O valor da lib não é "escrita declarada" — é **pipeline declarado**. O mesmo pipeline serve write (form, simple) e read estruturado (search, view).
|
|
32
|
+
|
|
33
|
+
### Papéis (todos os kinds cumprem)
|
|
34
|
+
|
|
35
|
+
1. **Declarar** contrato (input, output, efeitos esperados).
|
|
36
|
+
2. **Autorizar** quem pode disparar e sob quais condições.
|
|
37
|
+
3. **Executar** a operação.
|
|
38
|
+
4. **Auditar** o que aconteceu (quem, quando, dados, resultado).
|
|
39
|
+
5. **Propagar** consequências (invalidação de cache, emissão de evento).
|
|
40
|
+
|
|
41
|
+
### Kinds (v0)
|
|
42
|
+
|
|
43
|
+
Toda action tem um `kind` que determina o **shape do contrato** — quais campos extras são válidos, como o backend se comporta, como a UI materializa.
|
|
44
|
+
|
|
45
|
+
| Kind | Quando | Backend | Frontend default |
|
|
46
|
+
|----------|---------------------------------------|----------------------------------------|------------------------|
|
|
47
|
+
| `simple` | Operação direta sem input estruturado | Executa handler, audit | Botão/trigger |
|
|
48
|
+
| `form` | Coleta input do usuário | Valida input, executa, audit | Form com fields |
|
|
49
|
+
| `search` | Lista filtrada/paginada | Constrói query, paginação, sort | Interface de busca |
|
|
50
|
+
| `view` | Detalhe/projeção de entidade | Join, projection, audit de acesso | Display read-only |
|
|
51
|
+
|
|
52
|
+
Kinds são **extensíveis** — projetos podem adicionar (`bulk`, `wizard`, `async`) registrando no adapter UI. v0 do core trava esses quatro como first-class.
|
|
53
|
+
|
|
54
|
+
### Contrato base (comum a todos os kinds)
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
type ActionBase<In, Out> = {
|
|
58
|
+
// — Identidade —
|
|
59
|
+
name: string // único, namespaced ('deal.archive')
|
|
60
|
+
kind: 'simple' | 'form' | 'search' | 'view'
|
|
61
|
+
|
|
62
|
+
// — Documentação / Application Interface (flat, top-level) —
|
|
63
|
+
label?: I18nRef // texto do botão/link
|
|
64
|
+
title?: I18nRef // título de dialog
|
|
65
|
+
summary?: string // ≤ 80 chars; OpenAPI summary, AI tool desc
|
|
66
|
+
description?: string // markdown
|
|
67
|
+
icon?: string // nome do ícone (Tabler, Lucide, etc — adapter resolve)
|
|
68
|
+
color?: 'primary' | 'success' | 'warning' | 'danger' | 'neutral' | string
|
|
69
|
+
messages?: {
|
|
70
|
+
success?: I18nRef
|
|
71
|
+
error?: I18nRef
|
|
72
|
+
confirmation?: I18nRef
|
|
73
|
+
}
|
|
74
|
+
tags?: string[] // ['deals', 'mutations']
|
|
75
|
+
examples?: Example[]
|
|
76
|
+
errors?: ErrorSpec[] // catálogo de erros possíveis
|
|
77
|
+
|
|
78
|
+
// — Contrato —
|
|
79
|
+
input: Schema<In> // StandardSchemaV1-compatible
|
|
80
|
+
output: Schema<Out>
|
|
81
|
+
|
|
82
|
+
// — Autorização (fail-closed default) —
|
|
83
|
+
public?: boolean // default false
|
|
84
|
+
requires?: string | string[] // permissão declarativa (drive OpenAPI/docs)
|
|
85
|
+
authorize?: (ctx, input, loaded?) =>
|
|
86
|
+
boolean | ActionError | Promise<boolean | ActionError>
|
|
87
|
+
loads?: Record<string, (ctx, input) => Promise<unknown>>
|
|
88
|
+
|
|
89
|
+
// — Execução —
|
|
90
|
+
handler: (ctx, input, loaded?) => Out | Promise<Out>
|
|
91
|
+
|
|
92
|
+
// — Comportamento —
|
|
93
|
+
audit?: boolean | AuditConfig // default true
|
|
94
|
+
confirm?: ConfirmSpec
|
|
95
|
+
invalidates?: string[] | ((input: In) => string[])
|
|
96
|
+
rateLimit?: { window: number, max: number, key?: (ctx, input) => string }
|
|
97
|
+
idempotency?: (input: In) => string
|
|
98
|
+
|
|
99
|
+
// — Integração —
|
|
100
|
+
ai?: boolean | AIConfig // default true (opt-out via false)
|
|
101
|
+
automatable?: boolean // sistema/cron/workflow pode disparar
|
|
102
|
+
internal?: boolean // só service-to-service; não expõe REST
|
|
103
|
+
successStatus?: number // HTTP code de sucesso (default 200)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// simple/form ganham extra:
|
|
107
|
+
type SimpleAction<In, Out> = ActionBase<In, Out> & {
|
|
108
|
+
kind: 'simple'
|
|
109
|
+
background?: BackgroundConfig // executa em worker
|
|
110
|
+
emits?: string[] // eventos que handler pode emitir
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type FormAction<In, Out> = ActionBase<In, Out> & {
|
|
114
|
+
kind: 'form'
|
|
115
|
+
fields: Record<keyof In & string, FieldSpec>
|
|
116
|
+
background?: BackgroundConfig
|
|
117
|
+
emits?: string[]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// search/view não suportam background/emits em v0:
|
|
121
|
+
type ListAction<In, Out> = ActionBase<In, Paginated<Out>> & {
|
|
122
|
+
kind: 'list'
|
|
123
|
+
filters?: Record<string, FilterSpec>
|
|
124
|
+
sort?: { fields: string[], default?: SortSpec[] }
|
|
125
|
+
paginate?: 'cursor' | 'offset' | false
|
|
126
|
+
text?: { fields: string[] }
|
|
127
|
+
periods?: { value: string, label: I18nRef }[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type ViewAction<In, Out> = ActionBase<In, Out> & {
|
|
131
|
+
kind: 'view'
|
|
132
|
+
projection: string[]
|
|
133
|
+
expand?: Record<string, ExpandSpec>
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Campos por kind (detalhe dos sub-tipos)
|
|
138
|
+
|
|
139
|
+
**`simple`**
|
|
140
|
+
- `background?: BackgroundConfig` — executa em worker (ver §11).
|
|
141
|
+
- `emits?: string[]` — eventos que handler pode emitir (ver §12).
|
|
142
|
+
|
|
143
|
+
**`form`**
|
|
144
|
+
- `fields: Record<string, FieldSpec>` — descrição de cada campo de input.
|
|
145
|
+
- `background?`, `emits?` — idem simple.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
type FieldSpec = {
|
|
149
|
+
// Display
|
|
150
|
+
label: I18nRef
|
|
151
|
+
placeholder?: I18nRef
|
|
152
|
+
hint?: I18nRef
|
|
153
|
+
|
|
154
|
+
// Behavior
|
|
155
|
+
default?: unknown
|
|
156
|
+
mask?: string // input mask (CPF, telefone, ...)
|
|
157
|
+
|
|
158
|
+
// Conditional
|
|
159
|
+
depends?: string[] // re-eval quando esses campos mudam
|
|
160
|
+
showWhen?: (input) => boolean
|
|
161
|
+
requireWhen?: (input) => boolean
|
|
162
|
+
|
|
163
|
+
// Layout
|
|
164
|
+
group?: string // seção visual
|
|
165
|
+
order?: number
|
|
166
|
+
|
|
167
|
+
// UI override
|
|
168
|
+
widget?: string // 'text' | 'select' | 'date' | 'rich' | custom
|
|
169
|
+
|
|
170
|
+
// Options (select/radio/lookup)
|
|
171
|
+
options?: OptionsSpec
|
|
172
|
+
|
|
173
|
+
// AI guidance
|
|
174
|
+
aiDescription?: string
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type OptionsSpec =
|
|
178
|
+
| { kind: 'static', items: { value: string, label: I18nRef }[] }
|
|
179
|
+
| { kind: 'dictionary', ref: string } // catálogo nomeado
|
|
180
|
+
| { kind: 'lookup', source: string, depends?: string[] } // async
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**`search`**
|
|
184
|
+
- `filters?: Record<string, FilterSpec>` — filtros disponíveis (chave é nome).
|
|
185
|
+
- `sort?`, `paginate?`, `text?`, `periods?` — ver tipos abaixo.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
type FilterSpec = {
|
|
189
|
+
// Display
|
|
190
|
+
label: I18nRef
|
|
191
|
+
placeholder?: I18nRef
|
|
192
|
+
|
|
193
|
+
// Behavior
|
|
194
|
+
type: 'text' | 'select' | 'lookup' | 'date' | 'number'
|
|
195
|
+
multiple?: boolean
|
|
196
|
+
mode?: 'server' | 'client' // default 'server'
|
|
197
|
+
operators?: FilterOp[]
|
|
198
|
+
path?: string // dot path quando nome ≠ campo (filter.name → contact.name)
|
|
199
|
+
|
|
200
|
+
// Layout
|
|
201
|
+
section?: string // agrupamento UI ("Dados", "Período")
|
|
202
|
+
depends?: string[] // outros filtros que precisam estar preenchidos
|
|
203
|
+
|
|
204
|
+
// Options
|
|
205
|
+
options?: OptionsSpec
|
|
206
|
+
|
|
207
|
+
aiDescription?: string
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
type FilterOp = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte'
|
|
211
|
+
| 'in' | 'nin' | 'contains' | 'startsWith' | 'endsWith'
|
|
212
|
+
| 'between' | 'null' | 'notNull'
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Output de search action é **sempre** `Paginated<Output>` (ver §6).
|
|
216
|
+
|
|
217
|
+
**`view`**
|
|
218
|
+
- `projection: string[]` — campos/relações sempre carregados (`['owner', 'company.address']`).
|
|
219
|
+
- `expand?: Record<string, ExpandSpec>` — relações opt-in por request.
|
|
220
|
+
|
|
221
|
+
### Exemplos por kind
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// simple
|
|
225
|
+
defineAction({
|
|
226
|
+
name: 'deal.archive',
|
|
227
|
+
kind: 'simple',
|
|
228
|
+
input: z.object({ dealId: z.string() }),
|
|
229
|
+
output: z.object({ archivedAt: t.datetime() }),
|
|
230
|
+
authorize: (ctx, input) => ctx.can('deal:archive', input.dealId),
|
|
231
|
+
handler: async (ctx, input) => { /* ... */ },
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// form
|
|
235
|
+
defineAction({
|
|
236
|
+
name: 'deal.create',
|
|
237
|
+
kind: 'form',
|
|
238
|
+
input: z.object({ title: z.string(), amount: t.money() }),
|
|
239
|
+
output: dealSchema,
|
|
240
|
+
authorize: (ctx) => ctx.can('deal:create'),
|
|
241
|
+
handler: async (ctx, input) => { /* ... */ },
|
|
242
|
+
fields: {
|
|
243
|
+
title: { label: 'Título' },
|
|
244
|
+
amount: { label: 'Valor', currency: 'BRL' },
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// search
|
|
249
|
+
defineAction({
|
|
250
|
+
name: 'deal.search',
|
|
251
|
+
kind: 'list',
|
|
252
|
+
input: searchInputSchema,
|
|
253
|
+
output: paginatedDealsSchema,
|
|
254
|
+
authorize: (ctx) => ctx.can('deal:read'),
|
|
255
|
+
handler: async (ctx, input) => { /* ... */ },
|
|
256
|
+
filter: { fields: ['title', 'status', 'amount'] },
|
|
257
|
+
sort: { fields: ['createdAt', 'amount'], default: { createdAt: 'desc' } },
|
|
258
|
+
paginate: 'cursor',
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// view
|
|
262
|
+
defineAction({
|
|
263
|
+
name: 'deal.view',
|
|
264
|
+
kind: 'view',
|
|
265
|
+
input: z.object({ dealId: z.string() }),
|
|
266
|
+
output: dealDetailSchema,
|
|
267
|
+
authorize: (ctx, input) => ctx.can('deal:read', input.dealId),
|
|
268
|
+
handler: async (ctx, input) => { /* ... */ },
|
|
269
|
+
projection: ['owner', 'company', 'attachments'],
|
|
270
|
+
})
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Discriminated union
|
|
274
|
+
|
|
275
|
+
`ActionDef` é uma união discriminada indexada por `kind`:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
type ActionDef =
|
|
279
|
+
| SimpleAction
|
|
280
|
+
| FormAction
|
|
281
|
+
| ListAction
|
|
282
|
+
| ViewAction
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
TS narrowing dá autocomplete dos campos certos no editor. Adapters em runtime narrowam via guards (`isFormAction(a)`, `isListAction(a)`, ...).
|
|
286
|
+
|
|
287
|
+
### O que ainda **não** é action
|
|
288
|
+
|
|
289
|
+
Action ≠ qualquer leitura. Action = operação **estruturada** que cumpre os 5 papéis pelo pipeline.
|
|
290
|
+
|
|
291
|
+
Fica **fora** do core:
|
|
292
|
+
- Query SQL custom, agregação ad-hoc, ETL — não passam pelo pipeline.
|
|
293
|
+
- Endpoints HTTP arbitrários — transporte é adapter.
|
|
294
|
+
- Handler isolado sem schema/auth — função normal, não action.
|
|
295
|
+
|
|
296
|
+
Critério prático: se você não consegue declarar `input` schematizado, `authorize`, e dizer qual kind se aplica, não é action.
|
|
297
|
+
|
|
298
|
+
### Decisões de API
|
|
299
|
+
|
|
300
|
+
- **Schema via `StandardSchemaV1`.** Default Zod; ArkType/Valibot são swap trivial.
|
|
301
|
+
- **`kind` é discriminator obrigatório.** Não tem default — explicit > implicit.
|
|
302
|
+
- **Estilo híbrido fn × OO** — ver §9 "Estilo de código". `defineAction`, `error()`, `t.*` são factory functions; `Runtime`, `Adapter`, `AuditSink`, `ActionPipeline` são classes.
|
|
303
|
+
|
|
304
|
+
### Split contrato ↔ handler (`defineContract` / `bindAction`)
|
|
305
|
+
|
|
306
|
+
O `defineAction` junta contrato (declarativo) + handler (server-only) num objeto
|
|
307
|
+
só — ótimo num app mono-runtime, mas impede o **frontend** de importar a action
|
|
308
|
+
sem arrastar db/segredos. Pra apps com superfícies separadas (web + api), o Opus
|
|
309
|
+
parte isso em dois:
|
|
310
|
+
|
|
311
|
+
- **`defineContract`** (roda nos dois lados → mora no `shared`): identidade,
|
|
312
|
+
`input`/`output`, `fields`/`filters`/`projection`, docs (`label`/`messages`/
|
|
313
|
+
`confirm`), e a regra `authorize` **action-level**. É `ActionDef` **sem** os
|
|
314
|
+
campos de execução.
|
|
315
|
+
- **`bindAction(contract, binding)`** (server-only → mora na `api`): amarra
|
|
316
|
+
`handler`, `loads`, `background`, `emits`, `idempotency`, e um `authorize`
|
|
317
|
+
**row-level** (com `loaded`) que **sobrescreve** o do contrato. Produz um
|
|
318
|
+
`ActionDef` normal — o runtime não distingue.
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
// shared/ — o app (importável por web E api)
|
|
322
|
+
export const archiveClient = defineContract({
|
|
323
|
+
name: 'client.archive', kind: 'form',
|
|
324
|
+
input: z.object({ id: z.string(), reason: z.string() }),
|
|
325
|
+
output: clientRowSchema,
|
|
326
|
+
fields: { reason: { label: 'Motivo' } },
|
|
327
|
+
authorize: (ctx) => ctx.can('client:archive'), // regra → web usa pra gating de UX
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
// api/ — casca: só o server-only
|
|
331
|
+
export const archiveClientAction = bindAction(archiveClient, {
|
|
332
|
+
loads: (ctx, i) => ctx.db...,
|
|
333
|
+
authorize: (ctx, i, { client }) => ctx.can('client:archive', client), // override row-level
|
|
334
|
+
handler: (ctx, i, { client }) => ...,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// web/ — render + call do MESMO contrato
|
|
338
|
+
useAction(archiveClient) // tipos, fields, confirm, authorize(UX) — tudo do contrato
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Por quê:** front e back não são domínios diferentes — são superfícies forçadas
|
|
342
|
+
pela web. O contrato é a app; api e web são cascas. Fail-closed preservado: o
|
|
343
|
+
runtime checa o `ActionDef` **já bindado** (sem `authorize` em nenhum dos dois +
|
|
344
|
+
sem `public` → negado). Para CRUD, o mesmo padrão já é feito pela entidade
|
|
345
|
+
(`shared`) + `crudActions` (`api`); o `defineContract` generaliza pras actions
|
|
346
|
+
com lógica própria. Ver `docs/data-layer.md`.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## 3. Contrato de erro
|
|
351
|
+
|
|
352
|
+
Todo erro emitido por uma action — seja por schema rejection, autorização negada, regra de negócio, ou falha de dependência — segue o **mesmo shape**. Adapters consomem esse shape; UI renderiza baseado nele; audit registra.
|
|
353
|
+
|
|
354
|
+
### Shape
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
type ActionError = {
|
|
358
|
+
code: string; // "deal.archive.alreadyArchived"
|
|
359
|
+
category: ErrorCategory; // ver enum abaixo
|
|
360
|
+
message: string; // default em EN, humano
|
|
361
|
+
severity: 'warning' | 'error' | 'fatal';
|
|
362
|
+
retriable: boolean; // hint pro client decidir retry
|
|
363
|
+
|
|
364
|
+
i18nKey?: string; // chave para tradução (resolver é externo)
|
|
365
|
+
i18nParams?: Record<string, unknown>;
|
|
366
|
+
|
|
367
|
+
field?: string; // path do campo, para erros single-field
|
|
368
|
+
issues?: ValidationIssue[]; // múltiplos issues (typicamente validation)
|
|
369
|
+
|
|
370
|
+
cause?: unknown; // erro original encadeado
|
|
371
|
+
meta?: Record<string, unknown>; // dados auxiliares opcionais
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
type ValidationIssue = {
|
|
375
|
+
path: string; // "items.0.qty"
|
|
376
|
+
code: string; // "required", "out_of_range", ...
|
|
377
|
+
message: string;
|
|
378
|
+
i18nKey?: string;
|
|
379
|
+
i18nParams?: Record<string, unknown>;
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Categorias
|
|
384
|
+
|
|
385
|
+
| Categoria | Quando | Default HTTP | Retriable |
|
|
386
|
+
|-------------------|-------------------------------------------|--------------|-----------|
|
|
387
|
+
| `validation` | Input falhou schema ou regra de negócio | 400 | false |
|
|
388
|
+
| `authentication` | Não autenticado | 401 | false |
|
|
389
|
+
| `authorization` | Autenticado mas sem permissão | 403 | false |
|
|
390
|
+
| `not_found` | Recurso não existe | 404 | false |
|
|
391
|
+
| `conflict` | Colisão de estado (versão, idempotência) | 409 | false |
|
|
392
|
+
| `rate_limit` | Throttled | 429 | true |
|
|
393
|
+
| `dependency` | Upstream falhou (DB, API externa) | 502 | true |
|
|
394
|
+
| `internal` | Bug — não-categorizável | 500 | false |
|
|
395
|
+
|
|
396
|
+
**Mapeamento HTTP é responsabilidade do adapter**, não do contrato. Tabela acima é o default sugerido para o adapter `@softize/opus/server-*`.
|
|
397
|
+
|
|
398
|
+
### Códigos
|
|
399
|
+
|
|
400
|
+
Strings namespaced. Convenção:
|
|
401
|
+
- Erros de uma action: `<action.name>.<error>` — ex: `deal.archive.alreadyArchived`.
|
|
402
|
+
- Erros transversais: `<dominio>.<error>` — ex: `auth.forbidden`, `validation.required`.
|
|
403
|
+
|
|
404
|
+
Códigos são **estáveis** — mudar código é breaking change. Mensagem é livre.
|
|
405
|
+
|
|
406
|
+
### Como emitir
|
|
407
|
+
|
|
408
|
+
Dentro do handler:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
import { error } from '@softize/opus/core'
|
|
412
|
+
|
|
413
|
+
handler: async (ctx, input) => {
|
|
414
|
+
const deal = await ctx.db.deals.findById(input.dealId)
|
|
415
|
+
if (!deal) throw error({
|
|
416
|
+
code: 'deal.archive.notFound',
|
|
417
|
+
category: 'not_found',
|
|
418
|
+
message: `Deal ${input.dealId} not found`,
|
|
419
|
+
i18nKey: 'deal.error.notFound',
|
|
420
|
+
i18nParams: { id: input.dealId },
|
|
421
|
+
})
|
|
422
|
+
if (deal.archivedAt) throw error({
|
|
423
|
+
code: 'deal.archive.alreadyArchived',
|
|
424
|
+
category: 'conflict',
|
|
425
|
+
message: 'Deal already archived',
|
|
426
|
+
retriable: false,
|
|
427
|
+
})
|
|
428
|
+
// ...
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Erros não-categorizados (ex: `throw new Error(...)` cru, ou exceção do driver do DB) são **normalizados** pelo runtime para `{ category: 'internal', code: 'internal.unhandled', severity: 'error', retriable: false, cause: <original> }`.
|
|
433
|
+
|
|
434
|
+
### Validation: caso especial
|
|
435
|
+
|
|
436
|
+
Erros de schema (input que não satisfaz Zod/ArkType) são **automaticamente** transformados em um único `ActionError`:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
{
|
|
440
|
+
code: 'validation.invalid_input',
|
|
441
|
+
category: 'validation',
|
|
442
|
+
message: 'Input validation failed',
|
|
443
|
+
severity: 'warning',
|
|
444
|
+
retriable: false,
|
|
445
|
+
issues: [
|
|
446
|
+
{ path: 'dealId', code: 'required', message: 'dealId is required' },
|
|
447
|
+
{ path: 'priority', code: 'out_of_range', message: 'priority must be 1..5' },
|
|
448
|
+
],
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Handler não vê erro de validation — runtime rejeita antes do handler executar.
|
|
453
|
+
|
|
454
|
+
### Propagação client ↔ server
|
|
455
|
+
|
|
456
|
+
Shape do erro **não muda** no transporte. Server emite `ActionError`; adapter de transporte serializa (JSON-safe); client adapter recebe e devolve o mesmo objeto tipado. `cause` é serializado como string (mensagem original) para evitar vazar stack/internals.
|
|
457
|
+
|
|
458
|
+
### Severidade
|
|
459
|
+
|
|
460
|
+
- `warning` — usuário pode corrigir e tentar (validation, conflict simples).
|
|
461
|
+
- `error` — falha esperada do fluxo (autorização, not_found).
|
|
462
|
+
- `fatal` — algo está quebrado e exige atenção (internal, dependency irrecuperável).
|
|
463
|
+
|
|
464
|
+
Adapter de audit registra todos; sink de alerta (Sentry, etc) é configurado para filtrar por severidade.
|
|
465
|
+
|
|
466
|
+
### O que **não** entra no contrato de erro
|
|
467
|
+
|
|
468
|
+
- Stack traces — adapter de log decide se inclui em dev/prod.
|
|
469
|
+
- Localização (idioma resolvido) — `i18nKey` é entregue; tradução fica no consumer.
|
|
470
|
+
- Sugestões de UI ("clique aqui para tentar novamente") — UI decide com base em `category` + `retriable`.
|
|
471
|
+
|
|
472
|
+
---
|
|
473
|
+
|
|
474
|
+
## 4. Contrato de autorização
|
|
475
|
+
|
|
476
|
+
O Opus **não autentica** e **não implementa RBAC**. O contrato é: action declara quem pode executá-la; contexto fornece os meios de checar; runtime aplica o resultado.
|
|
477
|
+
|
|
478
|
+
### Princípio: fail-closed por default
|
|
479
|
+
|
|
480
|
+
Action **sem** `authorize` declarado é **negada**. Para abrir explicitamente, declara `public: true`.
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
// ✓ acessível por qualquer um, inclusive não-autenticado
|
|
484
|
+
defineAction({ name: 'session.heartbeat', public: true, ... })
|
|
485
|
+
|
|
486
|
+
// ✓ acessível pra quem passa pela função
|
|
487
|
+
defineAction({ name: 'deal.archive', authorize: (ctx, input) => ..., ... })
|
|
488
|
+
|
|
489
|
+
// ✗ negada — autorize esquecido
|
|
490
|
+
defineAction({ name: 'deal.archive', ... })
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Esquecer `authorize` é erro do dev. Default seguro é negar, não falhar aberto.
|
|
494
|
+
|
|
495
|
+
### Contexto
|
|
496
|
+
|
|
497
|
+
`ctx` é o canal que opus provê pra autorização. Shape mínimo:
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
type ActionContext = {
|
|
501
|
+
user: User | null; // null se não autenticado
|
|
502
|
+
tenantId: string | null; // multi-tenant opcional
|
|
503
|
+
can: (permission: string, resource?: unknown) =>
|
|
504
|
+
boolean | Promise<boolean>;
|
|
505
|
+
db: unknown; // injetado pelo DataAdapter
|
|
506
|
+
log: LoggerAdapter; // injetado pelo LoggerAdapter (default console)
|
|
507
|
+
emit: EmitFn; // emite DomainEvent via EventBusAdapter
|
|
508
|
+
storage: StorageAdapter | null; // storage de arquivos (experimental; null sem adapter)
|
|
509
|
+
provenance: Provenance; // quem disparou (http/schedule/reaction/…)
|
|
510
|
+
meta: Record<string, unknown>; // adapter pode estender
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
`ctx.can()` é **delegado**. O Opus não implementa — o adapter ou o consumer plugam o engine de permissões (RBAC, ABAC, custom). Action chama `ctx.can('deal:archive', deal)` e confia que o consumer respondeu corretamente.
|
|
515
|
+
|
|
516
|
+
### Forma do `authorize`
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
authorize: (ctx, input, loaded?) =>
|
|
520
|
+
boolean | ActionError | Promise<boolean | ActionError>
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
- `true` — permitido.
|
|
524
|
+
- `false` — negado com erro genérico (`{ code: 'auth.forbidden', category: 'authorization' }`).
|
|
525
|
+
- `ActionError` — negado com motivo específico (útil quando o "por quê" importa).
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
authorize: async (ctx, input, loaded) => {
|
|
529
|
+
if (!ctx.user) return false // genérico
|
|
530
|
+
if (loaded.deal.locked) return error({ // específico
|
|
531
|
+
code: 'deal.archive.locked',
|
|
532
|
+
category: 'authorization',
|
|
533
|
+
message: 'Locked deals cannot be archived',
|
|
534
|
+
})
|
|
535
|
+
return ctx.can('deal:archive', loaded.deal) // delega
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Row-level via `loads`
|
|
540
|
+
|
|
541
|
+
Para checar permissão sobre **um recurso específico**, action declara `loads` — o runtime carrega antes do `authorize` rodar:
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
defineAction({
|
|
545
|
+
name: 'deal.archive',
|
|
546
|
+
input: z.object({ dealId: z.string() }),
|
|
547
|
+
loads: {
|
|
548
|
+
deal: (ctx, input) => ctx.db.deals.findById(input.dealId),
|
|
549
|
+
},
|
|
550
|
+
authorize: (ctx, input, loaded) =>
|
|
551
|
+
ctx.can('deal:archive', loaded.deal),
|
|
552
|
+
handler: async (ctx, input, loaded) => {
|
|
553
|
+
// loaded.deal já carregado e tipado
|
|
554
|
+
},
|
|
555
|
+
})
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Vantagens:
|
|
559
|
+
- Sem refetch — handler reusa `loaded.deal`.
|
|
560
|
+
- Autorização não-vaza-handler — checagem fora da regra de negócio.
|
|
561
|
+
- `not_found` é detectado antes do authorize (loader retorna null → erro `not_found` automático antes de chamar `authorize`).
|
|
562
|
+
|
|
563
|
+
Loader pode falhar:
|
|
564
|
+
- Retornar `null` → runtime emite `not_found` com código `<action>.<resource>NotFound`.
|
|
565
|
+
- Lançar erro → propaga normalizado (`internal` ou categoria que o loader marcou).
|
|
566
|
+
|
|
567
|
+
### Quando NÃO usar `loads`
|
|
568
|
+
|
|
569
|
+
- Action **cria** recurso (não tem o que carregar antes).
|
|
570
|
+
- Autorização é **action-level pura** (qualquer usuário com a permissão X pode executar, independente do recurso).
|
|
571
|
+
- O recurso só pode ser determinado após executar o handler.
|
|
572
|
+
|
|
573
|
+
Nesses casos, `authorize` recebe `(ctx, input)` e checa só permissão + input.
|
|
574
|
+
|
|
575
|
+
### Composição
|
|
576
|
+
|
|
577
|
+
Não há decorators nem múltiplos `authorize`. Composição é **dentro da função** — é só código TS:
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
const isOwner = (ctx, deal) => deal.ownerId === ctx.user?.id
|
|
581
|
+
const isAdmin = (ctx) => ctx.user?.role === 'admin'
|
|
582
|
+
|
|
583
|
+
defineAction({
|
|
584
|
+
name: 'deal.archive',
|
|
585
|
+
loads: { deal: (ctx, i) => ctx.db.deals.findById(i.dealId) },
|
|
586
|
+
authorize: (ctx, input, loaded) =>
|
|
587
|
+
isOwner(ctx, loaded.deal) || isAdmin(ctx),
|
|
588
|
+
})
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
Não vai ter `compose(authA, authB)` no core. Se sentir falta, é regra de negócio mascarada de framework.
|
|
592
|
+
|
|
593
|
+
### Distinção: `authenticate` vs `authorize`
|
|
594
|
+
|
|
595
|
+
O Opus **não autentica** — não valida token, não emite sessão. O adapter (server) recebe a request, valida o token via auth provider externo (Better Auth, Clerk, próprio), monta `ctx.user`, e entrega pro Opus. A partir daí, `authorize` decide.
|
|
596
|
+
|
|
597
|
+
Se `ctx.user` é `null` e `public !== true`, runtime rejeita com `{ code: 'auth.unauthenticated', category: 'authentication' }` **antes** de chamar `authorize`.
|
|
598
|
+
|
|
599
|
+
---
|
|
600
|
+
|
|
601
|
+
## 5. Contrato de audit
|
|
602
|
+
|
|
603
|
+
Toda action **audita por default**. Para desabilitar (raramente justificável), declara `audit: false`. Para customizar (redação, severidade, sink), declara `audit: { ... }`.
|
|
604
|
+
|
|
605
|
+
> Audit é o **log de negócio** de mudanças de estado. Não confundir com logging de aplicação (debug, info) — esses ficam por conta do logger do projeto.
|
|
606
|
+
|
|
607
|
+
### Shape do registro
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
type AuditRecord = {
|
|
611
|
+
id: string; // UUID, único por evento
|
|
612
|
+
timestamp: string; // ISO 8601 UTC
|
|
613
|
+
action: string; // nome da action ("deal.archive")
|
|
614
|
+
outcome: 'success' | 'error';
|
|
615
|
+
durationMs: number;
|
|
616
|
+
|
|
617
|
+
actor: { // quem disparou
|
|
618
|
+
id: string | null; // null = sistema / não autenticado
|
|
619
|
+
type?: 'user' | 'system' | 'integration';
|
|
620
|
+
meta?: Record<string, unknown>;
|
|
621
|
+
};
|
|
622
|
+
tenant?: string | null;
|
|
623
|
+
|
|
624
|
+
input: unknown; // já redacted (ver §config)
|
|
625
|
+
output?: unknown; // já redacted, presente apenas em success
|
|
626
|
+
error?: ActionError; // presente apenas em outcome=error
|
|
627
|
+
|
|
628
|
+
severity: 'info' | 'warning' | 'error';
|
|
629
|
+
trace?: {
|
|
630
|
+
requestId?: string;
|
|
631
|
+
parentActionId?: string; // quando esta action é disparada por outra
|
|
632
|
+
};
|
|
633
|
+
meta?: Record<string, unknown>;
|
|
634
|
+
}
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Config por action
|
|
638
|
+
|
|
639
|
+
```ts
|
|
640
|
+
defineAction({
|
|
641
|
+
audit: true, // default — captura tudo
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
defineAction({
|
|
645
|
+
audit: false, // desabilita (precisa justificar em PR)
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
defineAction({
|
|
649
|
+
audit: {
|
|
650
|
+
fields?: string[]; // whitelist de campos do input
|
|
651
|
+
redact?: string[]; // blacklist (paths como "payment.cardNumber")
|
|
652
|
+
severity?: 'info' | 'warning' | 'error'; // baseline (default 'info')
|
|
653
|
+
sink?: string; // nome do sink ("primary", "compliance")
|
|
654
|
+
output?: boolean | { fields?, redact? }; // captura output (default true)
|
|
655
|
+
},
|
|
656
|
+
})
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
Regras de captura de input:
|
|
660
|
+
- `fields` definido → captura **só** esses campos.
|
|
661
|
+
- `redact` definido → captura tudo **menos** esses paths.
|
|
662
|
+
- Ambos → primeiro filtra por `fields`, depois aplica `redact`.
|
|
663
|
+
- Nenhum → captura input inteiro (default).
|
|
664
|
+
|
|
665
|
+
Paths usam dot-notation (`payment.cardNumber`, `items.0.qty`). Redação substitui valor por `'[REDACTED]'`.
|
|
666
|
+
|
|
667
|
+
### Severidade
|
|
668
|
+
|
|
669
|
+
Mapeada do outcome + erro:
|
|
670
|
+
|
|
671
|
+
| Outcome | Tipo de erro | Severity default |
|
|
672
|
+
|-----------|------------------------|------------------|
|
|
673
|
+
| success | — | `info` |
|
|
674
|
+
| error | `validation` | `warning` |
|
|
675
|
+
| error | `authentication` | `warning` |
|
|
676
|
+
| error | `authorization` | `warning` |
|
|
677
|
+
| error | `not_found` | `warning` |
|
|
678
|
+
| error | `conflict` | `warning` |
|
|
679
|
+
| error | `rate_limit` | `warning` |
|
|
680
|
+
| error | `dependency` | `error` |
|
|
681
|
+
| error | `internal` | `error` |
|
|
682
|
+
|
|
683
|
+
Action pode forçar severidade via `audit.severity`. Erro `fatal` (severidade do ActionError) sobrescreve para `error` independente do mapping.
|
|
684
|
+
|
|
685
|
+
### Sinks
|
|
686
|
+
|
|
687
|
+
O Opus **não** implementa sink. Adapter ou consumer registra implementações da interface:
|
|
688
|
+
|
|
689
|
+
```ts
|
|
690
|
+
interface AuditSink {
|
|
691
|
+
name: string;
|
|
692
|
+
emit(record: AuditRecord): Promise<void> | void;
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
Sinks são registrados no setup do runtime:
|
|
697
|
+
|
|
698
|
+
```ts
|
|
699
|
+
import { registerAuditSink } from '@softize/opus/core'
|
|
700
|
+
|
|
701
|
+
registerAuditSink({
|
|
702
|
+
name: 'primary',
|
|
703
|
+
emit: async (record) => {
|
|
704
|
+
await db.auditLog.insert(record)
|
|
705
|
+
},
|
|
706
|
+
})
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
Múltiplos sinks são permitidos. Action sem `audit.sink` emite para todos os sinks registrados; com `audit.sink: 'compliance'` emite só pro sink nomeado.
|
|
710
|
+
|
|
711
|
+
### Falha de sink
|
|
712
|
+
|
|
713
|
+
Falha ao emitir audit **não** falha a action. Sink lançou → opus loga `{ code: 'audit.sink.failed', cause: <error> }` no logger configurado e continua. Persistência de audit é responsabilidade do sink (retry, fila, etc).
|
|
714
|
+
|
|
715
|
+
> Exceção: se você precisa de "no audit, no action" (compliance estrita), seu sink deve ser síncrono e opus oferece config `auditMode: 'strict'` no runtime — nesse modo, falha de sink **aborta** a action com `{ code: 'audit.required', category: 'internal' }`. Default é `'lenient'`.
|
|
716
|
+
|
|
717
|
+
### O que **não** é audit
|
|
718
|
+
|
|
719
|
+
- Logs de debug do handler — usa logger do projeto.
|
|
720
|
+
- Métricas (counter de execuções, latência percentile) — usa observabilidade do projeto.
|
|
721
|
+
- Traces distribuídos — usa OpenTelemetry no adapter; opus só carrega `trace.requestId` se o adapter populou.
|
|
722
|
+
- Replay de actions — audit não foi desenhado pra reexecução. Event sourcing é outro contrato.
|
|
723
|
+
|
|
724
|
+
---
|
|
725
|
+
|
|
726
|
+
## 6. Envelope de resposta
|
|
727
|
+
|
|
728
|
+
Toda execução de action — sucesso ou erro — produz um **`ActionResult`** com shape uniforme. Adapters de transporte serializam esse envelope; client adapter desserializa preservando estrutura.
|
|
729
|
+
|
|
730
|
+
### Shape
|
|
731
|
+
|
|
732
|
+
Discriminated union baseado em `ok`:
|
|
733
|
+
|
|
734
|
+
```ts
|
|
735
|
+
type ActionResult<T> =
|
|
736
|
+
| { ok: true; data: T; meta: ResultMeta }
|
|
737
|
+
| { ok: false; error: ActionError; meta: ResultMeta }
|
|
738
|
+
|
|
739
|
+
type ResultMeta = {
|
|
740
|
+
actionId: string; // execução única — bate com AuditRecord.id
|
|
741
|
+
action: string; // nome da action
|
|
742
|
+
durationMs: number;
|
|
743
|
+
requestId?: string; // trace, populado pelo adapter se disponível
|
|
744
|
+
cached?: boolean; // resultado serviu do cache do client
|
|
745
|
+
}
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
Por que discriminated union e não `{ data, error }` separados:
|
|
749
|
+
- Força narrowing no TS — impossível ler `data` em path de erro.
|
|
750
|
+
- Sem estado inconsistente (`data` e `error` simultaneamente populados).
|
|
751
|
+
- Idiomático no ecossistema (Result-like).
|
|
752
|
+
|
|
753
|
+
### Consumo
|
|
754
|
+
|
|
755
|
+
Server adapter retorna `ActionResult<Output>` diretamente. Client adapter expõe o mesmo:
|
|
756
|
+
|
|
757
|
+
```ts
|
|
758
|
+
const r = await client.run(archiveDeal, { dealId: 'd_123' })
|
|
759
|
+
if (r.ok) {
|
|
760
|
+
console.log(r.data.archivedAt) // tipo Output, narrow correto
|
|
761
|
+
} else {
|
|
762
|
+
console.error(r.error.code, r.error.message)
|
|
763
|
+
}
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
`@softize/opus/react` envolve em hook idiomático:
|
|
767
|
+
|
|
768
|
+
```ts
|
|
769
|
+
const { data, error, isLoading, run } = useAction(archiveDeal)
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
`data` e `error` ficam mutuamente exclusivos via o mesmo discriminated union.
|
|
773
|
+
|
|
774
|
+
### Listas e paginação
|
|
775
|
+
|
|
776
|
+
Actions do kind `search` retornam **sempre** `Paginated<T>`. Demais kinds (`simple`, `form`, `view`) retornam um único output (não paginado).
|
|
777
|
+
|
|
778
|
+
```ts
|
|
779
|
+
type Paginated<T> = {
|
|
780
|
+
items: T[];
|
|
781
|
+
cursor: {
|
|
782
|
+
next: string | null;
|
|
783
|
+
prev?: string | null;
|
|
784
|
+
};
|
|
785
|
+
total?: number; // opcional — nem todo backend calcula barato
|
|
786
|
+
}
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
**Default cursor-based.** Cursor escala melhor que offset, evita deslocamento em listas mutáveis, e adapter de DB pode otimizar. Search action pode declarar `paginate: 'offset'` quando o caso exige (admin tables, exports) — runtime traduz cursor opaco em `{ offset, limit }`.
|
|
790
|
+
|
|
791
|
+
> **Nota de escopo:** reads ad-hoc (query SQL custom, agregação) **não** são actions. Search/view cobrem leitura **estruturada** (com schema, autorização, audit); fora desse padrão, consumer usa o que preferir (Drizzle direto, server actions, etc).
|
|
792
|
+
|
|
793
|
+
### Streaming / progresso
|
|
794
|
+
|
|
795
|
+
Versão v0 do protocolo: actions são **request/response**. Sem streaming.
|
|
796
|
+
|
|
797
|
+
Adapters podem implementar streaming (ex: `@softize/opus/server-hono` com SSE pra progresso de action longa), mas o **contrato** de uma action declara `output` único. Se sentir falta, espera virar dor real em mais de um projeto antes de promover.
|
|
798
|
+
|
|
799
|
+
### Meta — o que entra e o que não
|
|
800
|
+
|
|
801
|
+
**Entra:**
|
|
802
|
+
- `actionId`, `action`, `durationMs` — sempre.
|
|
803
|
+
- `requestId` — quando o adapter de transporte forneceu (correlacionamento).
|
|
804
|
+
- `cached` — quando o client adapter serviu do cache.
|
|
805
|
+
|
|
806
|
+
**Não entra:**
|
|
807
|
+
- Stack traces — fica no log/audit, não no envelope.
|
|
808
|
+
- Dados de debug arbitrários — adapter pode adicionar via extension, mas core ignora.
|
|
809
|
+
- Métricas (counters, percentile) — outro canal (observabilidade).
|
|
810
|
+
|
|
811
|
+
---
|
|
812
|
+
|
|
813
|
+
## 7. Catálogo de tipos lógicos
|
|
814
|
+
|
|
815
|
+
Action declara input/output via schema (Zod/etc). Mas muitos campos têm **semântica conhecida** — `email`, `phone`, `money`, `datetime`. Repetir as regras de validação, formatação e mapping em cada projeto é ruído.
|
|
816
|
+
|
|
817
|
+
O Opus oferece um **catálogo de tipos lógicos** — constructors prontos que retornam schemas com metadata anexada. Adapters leem a metadata e adaptam:
|
|
818
|
+
- **DB adapter** sabe mapear pra coluna correta (`datetime` → `timestamp`, `money` → `numeric + currency`).
|
|
819
|
+
- **UI adapter** sabe renderizar default (`email` → input type=email, `phone` → mask).
|
|
820
|
+
- **Validation** já vem embutida (formato, range, normalização).
|
|
821
|
+
|
|
822
|
+
### Uso
|
|
823
|
+
|
|
824
|
+
```ts
|
|
825
|
+
import { z } from 'zod'
|
|
826
|
+
import { t } from '@softize/opus/schema/zod'
|
|
827
|
+
|
|
828
|
+
defineAction({
|
|
829
|
+
input: z.object({
|
|
830
|
+
email: t.email(),
|
|
831
|
+
phone: t.phone({ country: 'BR' }),
|
|
832
|
+
amount: t.money({ currency: 'BRL' }),
|
|
833
|
+
paidAt: t.datetime(),
|
|
834
|
+
notes: t.markdown(),
|
|
835
|
+
}),
|
|
836
|
+
})
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
`t.email()` retorna um schema compatível com `StandardSchemaV1` (Zod por padrão) com metadata `{ logicalType: 'email' }`. Schema bruto (`z.string()`) continua aceito — só perde o benefício de adaptação automática.
|
|
840
|
+
|
|
841
|
+
### Tipos core (v0)
|
|
842
|
+
|
|
843
|
+
| Tipo lógico | TS type | Wire (JSON) | Notas |
|
|
844
|
+
|--------------|--------------------|--------------------|------------------------------------------------|
|
|
845
|
+
| `string` | `string` | string | identidade — quando nenhum tipo específico cabe |
|
|
846
|
+
| `text` | `string` | string | multiline, sem normalização |
|
|
847
|
+
| `int` | `number` | number | inteiro 32-bit; runtime rejeita float |
|
|
848
|
+
| `bigint` | `bigint` | string | wire em string pra não perder precisão JSON |
|
|
849
|
+
| `decimal` | `string` | string | precisão arbitrária; nunca float |
|
|
850
|
+
| `boolean` | `boolean` | boolean | |
|
|
851
|
+
| `datetime` | `string` (ISO) | string | ISO 8601 UTC; handler decide hidratar pra Date |
|
|
852
|
+
| `date` | `string` (ISO) | string | `YYYY-MM-DD` |
|
|
853
|
+
| `time` | `string` | string | `HH:mm:ss` |
|
|
854
|
+
| `duration` | `string` (ISO) | string | ISO 8601 duration (`PT1H`, `P2D`) |
|
|
855
|
+
| `timezone` | `string` | string | IANA (`America/Sao_Paulo`) |
|
|
856
|
+
| `email` | `string` | string | normaliza lowercase, valida RFC |
|
|
857
|
+
| `phone` | `string` | string | E.164 (`+5511999999999`); opção `country` |
|
|
858
|
+
| `url` | `string` | string | valida protocolo http/https por padrão |
|
|
859
|
+
| `uuid` | `string` | string | UUID v4 default |
|
|
860
|
+
| `slug` | `string` | string | `[a-z0-9-]`, normaliza dashes |
|
|
861
|
+
| `money` | `{ amount, currency }` | object | `amount: decimal`, `currency: ISO 4217` |
|
|
862
|
+
| `currency` | `string` | string | ISO 4217 (`BRL`, `USD`) |
|
|
863
|
+
| `country` | `string` | string | ISO 3166 alpha-2 (`BR`, `US`) |
|
|
864
|
+
| `locale` | `string` | string | BCP 47 (`pt-BR`, `en-US`) |
|
|
865
|
+
| `markdown` | `string` | string | semântica de texto markdown |
|
|
866
|
+
| `html` | `string` | string | semântica de HTML; sanitização é do adapter |
|
|
867
|
+
| `json` | `unknown` | object | payload opaco; runtime não estrutura |
|
|
868
|
+
| `enum` | `string` | string | constructor recebe lista (`t.enum(['a','b'])`) |
|
|
869
|
+
| `array` | `T[]` | array | wrapper (`t.array(t.email())`) |
|
|
870
|
+
| `object` | `{ ... }` | object | wrapper (`t.object({ a: t.int() })`) |
|
|
871
|
+
|
|
872
|
+
### Metadata anexada
|
|
873
|
+
|
|
874
|
+
Cada tipo lógico anexa, no mínimo:
|
|
875
|
+
|
|
876
|
+
```ts
|
|
877
|
+
{
|
|
878
|
+
logicalType: string; // 'email', 'money', etc
|
|
879
|
+
params?: Record<string, unknown>; // ex: { country: 'BR' } para phone
|
|
880
|
+
}
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
Adapters consultam essa metadata via API utilitária (`getLogicalType(schema)`).
|
|
884
|
+
|
|
885
|
+
### Extensão
|
|
886
|
+
|
|
887
|
+
Projetos podem registrar tipos lógicos próprios:
|
|
888
|
+
|
|
889
|
+
```ts
|
|
890
|
+
import { defineLogicalType } from '@softize/opus/core'
|
|
891
|
+
|
|
892
|
+
const cpf = defineLogicalType('cpf', {
|
|
893
|
+
schema: () => z.string().regex(/^\d{11}$/),
|
|
894
|
+
meta: { format: 'cpf' },
|
|
895
|
+
})
|
|
896
|
+
|
|
897
|
+
defineAction({
|
|
898
|
+
input: z.object({ taxId: cpf() }),
|
|
899
|
+
})
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
Tipos custom não vêm com adapter automático — quem implementar precisa estender adapter de DB/UI também (ou aceitar fallback para `string`).
|
|
903
|
+
|
|
904
|
+
### O que **não** vira tipo lógico no core
|
|
905
|
+
|
|
906
|
+
- Tipos domain-specific de um projeto (`cpf`, `cnpj`, `placa`) — vivem no projeto, não na lib.
|
|
907
|
+
- Tipos de moeda específicos (`brl`, `usd` separados) — usa `money` com `currency`.
|
|
908
|
+
- Tipos compostos pesados (`address`, `geoPoint`) — projeto define como object com tipos core dentro.
|
|
909
|
+
|
|
910
|
+
Critério: entra no core o que tem **representação universal** e benefício real de mapping cross-adapter. Resto vive no projeto.
|
|
911
|
+
|
|
912
|
+
---
|
|
913
|
+
|
|
914
|
+
## 8. Adapters
|
|
915
|
+
|
|
916
|
+
Core do Opus é **abstrato** — não tem HTTP, não tem DB, não tem framework de UI. Adapters são **superfícies (subpaths) do pacote único `@softize/opus`** que **conectam o protocolo a uma stack concreta**. O Opus é distribuído como **um pacote, uma versão** (`@softize/opus`); cada categoria abaixo é um subpath ESM, não um pacote separado.
|
|
917
|
+
|
|
918
|
+
### Tipos de adapter
|
|
919
|
+
|
|
920
|
+
| Categoria | Superfície | Drivers (subpath) |
|
|
921
|
+
|-----------|---------------------|----------------------------------------------------|
|
|
922
|
+
| Server | `@softize/opus/server` | `/fastify`, `/hono` |
|
|
923
|
+
| Data | `@softize/opus/data` | `/kysely`, `/drizzle` |
|
|
924
|
+
| Auth | `@softize/opus/auth` | `/better-auth`, `/clerk` |
|
|
925
|
+
| Audit | `@softize/opus/audit` | `/pg`, `/sentry`, `/console` |
|
|
926
|
+
| Logger | `@softize/opus/log` | `/pino` (console fica no `core`) |
|
|
927
|
+
| Queue | `@softize/opus/queue` | `/bullmq`, `/inngest`, `/redis` |
|
|
928
|
+
| EventBus | `@softize/opus/events` | `/mitt` (default), `/redis`, `/nats` |
|
|
929
|
+
| Client | `@softize/opus/client` | `/fetch`, `/tanstack` |
|
|
930
|
+
| UI | `@softize/opus/ui` | `/react`, `/vue` (futuro) |
|
|
931
|
+
| Schema | `@softize/opus/schema` | `/zod` (default), `/arktype`, `/valibot` (futuro) |
|
|
932
|
+
|
|
933
|
+
**Convenção:** `@softize/opus/<kind>` é a superfície; `/<driver>` é o driver (subpath aninhado). Padrão estilo Drizzle (`drizzle-orm/pg-core`). Mantém regra única sem exceções (`@softize/opus/queue/redis` e `@softize/opus/events/redis` convivem sem ambiguidade).
|
|
934
|
+
|
|
935
|
+
```bash
|
|
936
|
+
# Instalação — um pacote só; os peers vêm conforme os drivers que você usa
|
|
937
|
+
pnpm add @softize/opus fastify kysely
|
|
938
|
+
|
|
939
|
+
# Uso
|
|
940
|
+
import { fastifyServer } from '@softize/opus/server/fastify'
|
|
941
|
+
import { kyselyData } from '@softize/opus/data/kysely'
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
Drivers são opt-in via subpath: bundle inclui só o que você importar. Peer deps de cada driver (fastify, hono, kysely, drizzle, etc) são declaradas como `peerDependenciesMeta` optional.
|
|
945
|
+
|
|
946
|
+
### Interface base
|
|
947
|
+
|
|
948
|
+
Todo adapter implementa `Adapter`:
|
|
949
|
+
|
|
950
|
+
```ts
|
|
951
|
+
interface Adapter {
|
|
952
|
+
name: string;
|
|
953
|
+
kind: 'server' | 'data' | 'auth' | 'audit' | 'logger'
|
|
954
|
+
| 'queue' | 'eventbus' | 'client' | 'ui' | 'schema';
|
|
955
|
+
init?: (runtime: Runtime) => Promise<void> | void;
|
|
956
|
+
dispose?: () => Promise<void> | void;
|
|
957
|
+
healthCheck?: () => Promise<{ ok: boolean, details?: object }>;
|
|
958
|
+
}
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
Cada `kind` estende com contratos específicos:
|
|
962
|
+
|
|
963
|
+
```ts
|
|
964
|
+
interface ServerAdapter extends Adapter {
|
|
965
|
+
kind: 'server';
|
|
966
|
+
mount(action: ActionDef): void; // expõe action no transporte
|
|
967
|
+
mountEndpoints(spec: EndpointSpec): void; // /health, /ready, /logs, etc
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
type EndpointSpec = {
|
|
971
|
+
health?: boolean | { path?: string } // default true
|
|
972
|
+
ready?: boolean | { path?: string } // default true
|
|
973
|
+
openapi?: boolean | { path?: string, ui?: boolean } // default true
|
|
974
|
+
actions?: boolean | { path?: string, auth?: string } // default false
|
|
975
|
+
reactions?: boolean | { path?: string, auth?: string } // default false
|
|
976
|
+
logs?: boolean | LogsEndpointSpec // default false
|
|
977
|
+
audit?: boolean | AuditEndpointSpec // default false
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
type LogsEndpointSpec = {
|
|
981
|
+
enabled: boolean
|
|
982
|
+
path?: string // default '/logs'
|
|
983
|
+
auth: string | string[] // permission required
|
|
984
|
+
bufferSize?: number // ring buffer, default 1000
|
|
985
|
+
source?: (query: LogQuery) => Promise<LogEntry[]> // custom (Datadog, etc)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
type AuditEndpointSpec = {
|
|
989
|
+
enabled: boolean
|
|
990
|
+
path?: string // default '/audit'
|
|
991
|
+
auth: string | string[]
|
|
992
|
+
source?: (query: AuditQuery) => Promise<AuditRecord[]>
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
interface DataAdapter extends Adapter {
|
|
996
|
+
kind: 'data';
|
|
997
|
+
contextExtension(): { db: unknown }; // entra no ctx
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
interface AuthAdapter extends Adapter {
|
|
1001
|
+
kind: 'auth';
|
|
1002
|
+
resolveContext(req: unknown): Promise<{
|
|
1003
|
+
user: User | null;
|
|
1004
|
+
tenantId?: string | null;
|
|
1005
|
+
can: (perm: string, resource?: unknown) => boolean | Promise<boolean>;
|
|
1006
|
+
}>;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
interface AuditSink extends Adapter {
|
|
1010
|
+
kind: 'audit';
|
|
1011
|
+
emit(record: AuditRecord): Promise<void> | void;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
interface ClientAdapter extends Adapter {
|
|
1015
|
+
kind: 'client';
|
|
1016
|
+
run<T>(action: ActionDef, input: unknown): Promise<ActionResult<T>>;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
interface QueueAdapter extends Adapter {
|
|
1020
|
+
kind: 'queue';
|
|
1021
|
+
enqueue(spec: JobSpec): Promise<JobHandle>;
|
|
1022
|
+
status(jobId: string): Promise<JobHandle | null>;
|
|
1023
|
+
cancel(jobId: string): Promise<boolean>;
|
|
1024
|
+
subscribe?(jobId: string, listener: (h: JobHandle) => void): Unsubscribe;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
interface EventBusAdapter extends Adapter {
|
|
1028
|
+
kind: 'eventbus';
|
|
1029
|
+
publish(event: DomainEvent): Promise<void> | void;
|
|
1030
|
+
subscribe?(pattern: string, listener: (event: DomainEvent) => void): Unsubscribe;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
interface LoggerAdapter extends Adapter {
|
|
1034
|
+
kind: 'logger';
|
|
1035
|
+
trace(msg: string, meta?: object): void;
|
|
1036
|
+
debug(msg: string, meta?: object): void;
|
|
1037
|
+
info (msg: string, meta?: object): void;
|
|
1038
|
+
warn (msg: string, meta?: object): void;
|
|
1039
|
+
error(msg: string, meta?: object): void;
|
|
1040
|
+
fatal(msg: string, meta?: object): void;
|
|
1041
|
+
child(bindings: object): LoggerAdapter; // pre-bind contexto (per-action, per-request)
|
|
1042
|
+
}
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
Ver §10 (Background), §11 (Eventos), §15 (RuntimeConfig) para detalhes de uso.
|
|
1046
|
+
|
|
1047
|
+
### Server endpoints (auto-mount)
|
|
1048
|
+
|
|
1049
|
+
Server adapter monta endpoints fixos + opcionais via `EndpointSpec`:
|
|
1050
|
+
|
|
1051
|
+
| Path | Default | Auth | Propósito |
|
|
1052
|
+
|------------------|---------------|--------|-----------------------------------------------|
|
|
1053
|
+
| `/api/*` | sempre | varia | Execução de actions registradas |
|
|
1054
|
+
| `/health` | enabled | none | Liveness probe — 200 se processo vivo |
|
|
1055
|
+
| `/ready` | enabled | none | Readiness — agrega `adapter.healthCheck()` |
|
|
1056
|
+
| `/openapi.json` | enabled | none | Spec OpenAPI gerada |
|
|
1057
|
+
| `/openapi/docs` | dev only | none | Swagger UI |
|
|
1058
|
+
| `/actions` | disabled | admin | Catálogo de actions (debug) |
|
|
1059
|
+
| `/reactions` | disabled | admin | Catálogo de reactions (debug) |
|
|
1060
|
+
| `/logs` | disabled | admin | Query de logs (in-mem ou source custom) |
|
|
1061
|
+
| `/audit` | disabled | admin | Query de audit records |
|
|
1062
|
+
|
|
1063
|
+
### Health check em duas camadas
|
|
1064
|
+
|
|
1065
|
+
- **`/health`** — liveness probe simples. Responde 200 se o runtime iniciou e não morreu. Não chama dependência externa. Rápido, idempotente.
|
|
1066
|
+
- **`/ready`** — readiness probe. Chama `runtime.healthCheck()` que agrega `adapter.healthCheck?()` de todos os adapters. 200 se todos ok, 503 se algum falha. Toca DB, queue, bus.
|
|
1067
|
+
|
|
1068
|
+
```ts
|
|
1069
|
+
runtime.healthCheck()
|
|
1070
|
+
// → {
|
|
1071
|
+
// ok: true,
|
|
1072
|
+
// adapters: {
|
|
1073
|
+
// data: { ok: true, details: { latencyMs: 3 } },
|
|
1074
|
+
// auth: { ok: true },
|
|
1075
|
+
// queue: { ok: false, details: { error: 'Redis unreachable' } },
|
|
1076
|
+
// eventBus: { ok: true },
|
|
1077
|
+
// }
|
|
1078
|
+
// }
|
|
1079
|
+
```
|
|
1080
|
+
|
|
1081
|
+
`adapter.healthCheck()` é **opcional** — adapter que não implementa é considerado ok.
|
|
1082
|
+
|
|
1083
|
+
Padrão Kubernetes/ECS/Cloud Run-compatible. Liveness mata pod se falhar (reinicia); readiness tira do load balancer se falhar (não mata).
|
|
1084
|
+
|
|
1085
|
+
### Setup do runtime
|
|
1086
|
+
|
|
1087
|
+
```ts
|
|
1088
|
+
import { createRuntime } from '@softize/opus/core'
|
|
1089
|
+
import { honoAdapter } from '@softize/opus/server-hono'
|
|
1090
|
+
import { kyselyAdapter } from '@softize/opus/data-kysely'
|
|
1091
|
+
import { betterAuthAdapter } from '@softize/opus/auth-better-auth'
|
|
1092
|
+
import { pgAuditSink } from '@softize/opus/audit-pg'
|
|
1093
|
+
|
|
1094
|
+
const runtime = createRuntime({
|
|
1095
|
+
server: honoAdapter({ app }),
|
|
1096
|
+
data: kyselyAdapter({ db }),
|
|
1097
|
+
auth: betterAuthAdapter({ client: auth }),
|
|
1098
|
+
audit: [pgAuditSink({ table: 'audit_log' })],
|
|
1099
|
+
queue: bullmqAdapter({ connection }), // opcional — só se houver background action
|
|
1100
|
+
eventBus: mittEventBusAdapter(), // opcional — só se houver emits
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
runtime.register(actions) // monta tudo
|
|
1104
|
+
await runtime.start()
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
### Lifecycle de uma execução (sync)
|
|
1108
|
+
|
|
1109
|
+
```
|
|
1110
|
+
HTTP request → Server adapter
|
|
1111
|
+
↓ (parse + resolve context via Auth adapter)
|
|
1112
|
+
ctx = { user, tenantId, can, db, emit, meta, ... }
|
|
1113
|
+
↓
|
|
1114
|
+
Runtime.execute(action, input, ctx)
|
|
1115
|
+
↓ (1) validate input contra schema
|
|
1116
|
+
↓ (2) load (se action declara `loads`)
|
|
1117
|
+
↓ (3) check public OR authenticate (ctx.user != null)
|
|
1118
|
+
↓ (4) authorize (chama action.authorize)
|
|
1119
|
+
↓ (5) handler executa
|
|
1120
|
+
(durante: handler pode chamar ctx.emit(event, data)
|
|
1121
|
+
→ runtime publica via EventBusAdapter)
|
|
1122
|
+
↓ (6) validate output (em dev)
|
|
1123
|
+
↓ (7) emit audit pra todos AuditSinks
|
|
1124
|
+
↓ (8) build ActionResult
|
|
1125
|
+
↓
|
|
1126
|
+
Server adapter serializa → HTTP response
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
Erro em qualquer step (1-4) → handler **não executa**, audit registra falha, ActionResult.error.
|
|
1130
|
+
Erro no step 5 ou 6 → audit registra com erro, ActionResult.error.
|
|
1131
|
+
Erro em audit sink (step 7) → depende de `auditMode` ('lenient' default, 'strict' aborta).
|
|
1132
|
+
Falha em emit de evento (durante step 5) → depende de `emitMode` ('lenient' default).
|
|
1133
|
+
|
|
1134
|
+
### Lifecycle (background)
|
|
1135
|
+
|
|
1136
|
+
Quando action declara `background.enabled: true`, pipeline bifurca:
|
|
1137
|
+
|
|
1138
|
+
```
|
|
1139
|
+
HTTP request → Server adapter
|
|
1140
|
+
↓ validate + authenticate + authorize (steps 1-4 acima)
|
|
1141
|
+
↓ QueueAdapter.enqueue(jobSpec)
|
|
1142
|
+
↓
|
|
1143
|
+
Server retorna JobHandle síncrono
|
|
1144
|
+
↓
|
|
1145
|
+
[worker, processo separado]
|
|
1146
|
+
↓ load → handler (com ProgressReporter se config) → validate output → audit
|
|
1147
|
+
↓ atualiza JobHandle.status = done/failed
|
|
1148
|
+
```
|
|
1149
|
+
|
|
1150
|
+
Ver §10 (Background) para detalhes.
|
|
1151
|
+
|
|
1152
|
+
### Versionamento de adapter
|
|
1153
|
+
|
|
1154
|
+
Adapter declara `peerDependencies: { "@softize/opus/core": "^X.0.0" }`. Major bump do core = breaking. Patch/minor do adapter pode evoluir independente.
|
|
1155
|
+
|
|
1156
|
+
### Adapters first-party vs comunidade
|
|
1157
|
+
|
|
1158
|
+
O Opus mantém **first-party** as superfícies acima. Drivers iniciais por superfície são escolhidos pelo time core; drivers comunidade entram via PR no repo (são novos subpaths do mesmo pacote, não pacotes separados).
|
|
1159
|
+
|
|
1160
|
+
Atualmente cobertos pelo time core (v0):
|
|
1161
|
+
- `@softize/opus/core`
|
|
1162
|
+
- `@softize/opus/server` (drivers: `/fastify` — `/hono` em v1)
|
|
1163
|
+
- `@softize/opus/data` (drivers: `/kysely`)
|
|
1164
|
+
- `@softize/opus/auth` (drivers: `/better-auth`)
|
|
1165
|
+
- `@softize/opus/client` (drivers: `/fetch`)
|
|
1166
|
+
- `@softize/opus/ui` (drivers: `/react`)
|
|
1167
|
+
- `@softize/opus/schema` (drivers: `/zod`)
|
|
1168
|
+
|
|
1169
|
+
Outros drivers (Drizzle, Vue, Solid, NATS, etc) entram como contribuição quando houver demanda real.
|
|
1170
|
+
|
|
1171
|
+
### O que adapter **não** decide
|
|
1172
|
+
|
|
1173
|
+
- Shape de `ActionError`, `AuditRecord`, `ActionResult` — esses são contrato fechado do core.
|
|
1174
|
+
- Categorias de erro — fixas.
|
|
1175
|
+
- Nome dos campos de action — fixos.
|
|
1176
|
+
|
|
1177
|
+
Adapter implementa o contrato; não modifica.
|
|
1178
|
+
|
|
1179
|
+
---
|
|
1180
|
+
|
|
1181
|
+
## 9. Princípios não-negociáveis
|
|
1182
|
+
|
|
1183
|
+
Filtro pra qualquer adição ao Opus core. A pergunta:
|
|
1184
|
+
|
|
1185
|
+
> *Isso é contrato que padroniza forma, ou é feature que faz trabalho?*
|
|
1186
|
+
|
|
1187
|
+
**Contrato entra. Feature usa lib externa.**
|
|
1188
|
+
|
|
1189
|
+
### Entra no core
|
|
1190
|
+
- Definição declarativa (`defineAction`, `defineEntity`, etc).
|
|
1191
|
+
- Tipos derivados e inferência ponta a ponta.
|
|
1192
|
+
- Contratos de erro, autorização, audit, paginação, envelope.
|
|
1193
|
+
- Catálogo de tipos lógicos.
|
|
1194
|
+
- Hooks/interfaces para adapters implementarem.
|
|
1195
|
+
|
|
1196
|
+
### NÃO entra no core
|
|
1197
|
+
- Implementação de auth (RBAC engine, JWT, sessão).
|
|
1198
|
+
- Implementação de queue, cron, storage.
|
|
1199
|
+
- Implementação de ORM / DB client.
|
|
1200
|
+
- Componentes de UI prontos (são `@softize/opus/react`, separado do core).
|
|
1201
|
+
- Logger concreto (interface sim; implementação é adapter).
|
|
1202
|
+
- Error tracker, observability sink.
|
|
1203
|
+
- HTTP framework.
|
|
1204
|
+
- **Cache** — implementação fica fora. Core apenas declara intenção de invalidação via `invalidates: string[]`. Quem invalida (TanStack Query no client, Redis no server) é adapter/consumer.
|
|
1205
|
+
- **Carregamento de env vars** (`dotenv`, `t3-env` resolvem). Core nunca lê `process.env` direto — recebe valor pronto via `RuntimeConfig`.
|
|
1206
|
+
- **Process management** (PM2, restart loop). É deployment concern (Docker, k8s, systemd resolvem).
|
|
1207
|
+
|
|
1208
|
+
### Adicionar coisa ao core requer
|
|
1209
|
+
1. Justificativa de por que é contrato e não feature.
|
|
1210
|
+
2. Uso real em pelo menos dois projetos distintos (ou um projeto + previsão concreta do segundo).
|
|
1211
|
+
3. Doc desta seção atualizada **antes** do código entrar.
|
|
1212
|
+
|
|
1213
|
+
### Estilo de código
|
|
1214
|
+
|
|
1215
|
+
Premissas transversais a todo código do core e adapters first-party.
|
|
1216
|
+
|
|
1217
|
+
**Clean code, sempre.**
|
|
1218
|
+
- Funções/métodos curtos, uma responsabilidade clara.
|
|
1219
|
+
- Sem comentários óbvios; código autoexplicativo.
|
|
1220
|
+
- Comentário só pra explicar **porquê não-óbvio**, nunca **o que** o código faz.
|
|
1221
|
+
- Sem `any`, sem `// @ts-ignore`, sem `// eslint-disable` sem justificativa em PR.
|
|
1222
|
+
- Erro early-return; nunca pirâmide de `if` aninhado.
|
|
1223
|
+
|
|
1224
|
+
**Naming elegante.**
|
|
1225
|
+
- Nomes revelam intenção, não implementação. `archiveDeal`, não `dealServiceArchiveMethod`.
|
|
1226
|
+
- Verbos pra actions (`archive`, `dispatch`, `validate`). Substantivos pra entidades (`Action`, `Runtime`, `Adapter`).
|
|
1227
|
+
- Sem abreviações herdadas de C (`ctx`, `req`, `res` são exceção convencional; `mgr`, `usr`, `cfg` não).
|
|
1228
|
+
- Plural só quando coleção real (`actions: Action[]`); singular pra factory que produz um (`defineAction`).
|
|
1229
|
+
- Booleanos com prefixo de pergunta: `isRetriable`, `hasIssues`, `canArchive`.
|
|
1230
|
+
|
|
1231
|
+
**Híbrido fn × OO — regra de bolso.**
|
|
1232
|
+
|
|
1233
|
+
| Use **função** quando | Use **classe** quando |
|
|
1234
|
+
|---|---|
|
|
1235
|
+
| Produz dado declarativo (`defineAction`, `error`, `t.email`) | Tem estado interno (`Runtime`, `ActionPipeline`) |
|
|
1236
|
+
| Pura ou determinística (formatador, validador) | Tem lifecycle (`init`, `dispose`) |
|
|
1237
|
+
| Composta como valor de primeira ordem (passar adiante, mapear) | Encapsula identidade (uma instância vive ao longo da app) |
|
|
1238
|
+
| Helper interno de módulo | Implementação de interface adapter |
|
|
1239
|
+
|
|
1240
|
+
**Não-negociáveis:**
|
|
1241
|
+
- Nada de mixins, decorators do TC39 não-stable, ou herança profunda. Herança máxima: 1 nível (interface → classe).
|
|
1242
|
+
- Composição > herança. Sempre.
|
|
1243
|
+
- Sem singletons globais — quem precisa de instância única injeta via `Runtime`.
|
|
1244
|
+
- Imutabilidade por default. Mutação só em coleções internas de classes que **encapsulam** o mutável.
|
|
1245
|
+
- Sem `null` quando `undefined` cabe; sem `undefined` quando o tipo é colecional vazio (`[]`, `{}`).
|
|
1246
|
+
|
|
1247
|
+
**Testes seguem o mesmo padrão.**
|
|
1248
|
+
- Test = mesma linguagem do código (`describe(Runtime, ...)`, não `describe('the runtime stuff', ...)`).
|
|
1249
|
+
- Arrange/act/assert visualmente separados.
|
|
1250
|
+
- Sem `beforeEach` setando state global; cada teste constrói o que precisa.
|
|
1251
|
+
|
|
1252
|
+
---
|
|
1253
|
+
|
|
1254
|
+
## 10. Background handler
|
|
1255
|
+
|
|
1256
|
+
Action `simple` ou `form` pode declarar `background: BackgroundConfig` pra rodar em worker em vez de sync no request. Útil pra operações longas (reports, bulk, AI generation, importações).
|
|
1257
|
+
|
|
1258
|
+
> Restringido a `simple`/`form`. `search`/`view` são reads que respondem direto; background pra eles não casa em v0.
|
|
1259
|
+
|
|
1260
|
+
### Config
|
|
1261
|
+
|
|
1262
|
+
```ts
|
|
1263
|
+
type BackgroundConfig = {
|
|
1264
|
+
enabled: true // explícito (sem default ambíguo)
|
|
1265
|
+
queue?: string // nome da fila (adapter-specific)
|
|
1266
|
+
priority?: 'high' | 'normal' | 'low'
|
|
1267
|
+
retry?: { attempts: number, backoff?: BackoffSpec }
|
|
1268
|
+
timeout?: number // ms; 0 = sem timeout
|
|
1269
|
+
progress?: boolean // handler recebe ProgressReporter
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
type BackoffSpec =
|
|
1273
|
+
| { kind: 'fixed', delayMs: number }
|
|
1274
|
+
| { kind: 'exponential', initialMs: number, multiplier?: number, maxMs?: number }
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
### Handler signature (background)
|
|
1278
|
+
|
|
1279
|
+
```ts
|
|
1280
|
+
// sem progress
|
|
1281
|
+
handler: (ctx, input, loaded?) => Promise<Out>
|
|
1282
|
+
|
|
1283
|
+
// com progress (background.progress: true)
|
|
1284
|
+
handler: (
|
|
1285
|
+
ctx, input, loaded?,
|
|
1286
|
+
progress: ProgressReporter,
|
|
1287
|
+
) => Promise<Out>
|
|
1288
|
+
|
|
1289
|
+
type ProgressReporter = {
|
|
1290
|
+
report(p: {
|
|
1291
|
+
percent?: number // 0..100
|
|
1292
|
+
message?: string
|
|
1293
|
+
data?: unknown
|
|
1294
|
+
}): Promise<void> | void
|
|
1295
|
+
}
|
|
1296
|
+
```
|
|
1297
|
+
|
|
1298
|
+
### Result
|
|
1299
|
+
|
|
1300
|
+
Background action **não retorna `Output` síncrono**. Retorna `JobHandle`:
|
|
1301
|
+
|
|
1302
|
+
```ts
|
|
1303
|
+
type JobStatus = 'queued' | 'running' | 'done' | 'failed' | 'cancelled'
|
|
1304
|
+
|
|
1305
|
+
type JobHandle<T = unknown> = {
|
|
1306
|
+
jobId: string
|
|
1307
|
+
action: string
|
|
1308
|
+
status: JobStatus
|
|
1309
|
+
progress?: { percent?: number, message?: string, data?: unknown }
|
|
1310
|
+
data?: T // populado quando status='done'
|
|
1311
|
+
error?: ActionError // populado quando status='failed'
|
|
1312
|
+
enqueuedAt: string
|
|
1313
|
+
startedAt?: string
|
|
1314
|
+
finishedAt?: string
|
|
1315
|
+
attempts: number
|
|
1316
|
+
}
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
ActionResult de background action:
|
|
1320
|
+
|
|
1321
|
+
```ts
|
|
1322
|
+
type BackgroundResult<T> =
|
|
1323
|
+
| { ok: true, data: JobHandle<T>, meta: ResultMeta }
|
|
1324
|
+
| { ok: false, error: ActionError, meta: ResultMeta }
|
|
1325
|
+
```
|
|
1326
|
+
|
|
1327
|
+
### Pipeline (background)
|
|
1328
|
+
|
|
1329
|
+
```
|
|
1330
|
+
HTTP request → Server adapter
|
|
1331
|
+
↓ (1) validate input
|
|
1332
|
+
↓ (2) authenticate
|
|
1333
|
+
↓ (3) authorize (sem load — load roda no worker)
|
|
1334
|
+
↓ (4) enqueue job via QueueAdapter
|
|
1335
|
+
↓
|
|
1336
|
+
Server retorna JobHandle síncrono → client polla ou subscribe
|
|
1337
|
+
↓
|
|
1338
|
+
Worker (processo separado) consome job:
|
|
1339
|
+
↓ (a) load
|
|
1340
|
+
↓ (b) handler (recebe ProgressReporter se config.progress)
|
|
1341
|
+
↓ (c) validate output
|
|
1342
|
+
↓ (d) audit
|
|
1343
|
+
↓ (e) atualiza JobHandle.status = done/failed
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1346
|
+
### Adapter: `QueueAdapter`
|
|
1347
|
+
|
|
1348
|
+
```ts
|
|
1349
|
+
interface QueueAdapter extends Adapter {
|
|
1350
|
+
kind: 'queue'
|
|
1351
|
+
enqueue(spec: JobSpec): Promise<JobHandle>
|
|
1352
|
+
status(jobId: string): Promise<JobHandle | null>
|
|
1353
|
+
cancel(jobId: string): Promise<boolean>
|
|
1354
|
+
subscribe?(jobId: string, listener: (h: JobHandle) => void): Unsubscribe
|
|
1355
|
+
}
|
|
1356
|
+
```
|
|
1357
|
+
|
|
1358
|
+
Implementações futuras (não core): `@softize/opus/queue-bullmq`, `@softize/opus/queue-inngest`, `@softize/opus/queue-trigger-dev`.
|
|
1359
|
+
|
|
1360
|
+
### Cliente
|
|
1361
|
+
|
|
1362
|
+
`@softize/opus/react` expõe hook separado pra background:
|
|
1363
|
+
|
|
1364
|
+
```ts
|
|
1365
|
+
const { run, job, progress, isRunning, isDone, cancel, error } =
|
|
1366
|
+
useBackgroundAction(reportGenerate)
|
|
1367
|
+
|
|
1368
|
+
await run({ template: 'monthly' })
|
|
1369
|
+
// job.jobId disponível imediato; UI acompanha progress
|
|
1370
|
+
```
|
|
1371
|
+
|
|
1372
|
+
---
|
|
1373
|
+
|
|
1374
|
+
## 11. Eventos de domínio
|
|
1375
|
+
|
|
1376
|
+
Action `simple` ou `form` pode declarar `emits: string[]` — lista do que o handler **pode** emitir. Handler emite manualmente via `ctx.emit()`.
|
|
1377
|
+
|
|
1378
|
+
> Restringido a `simple`/`form` em v0. Read kinds raramente emitem; promove quando aparecer dor.
|
|
1379
|
+
|
|
1380
|
+
### Declaração (action) + emissão (handler)
|
|
1381
|
+
|
|
1382
|
+
```ts
|
|
1383
|
+
defineAction({
|
|
1384
|
+
name: 'deal.archive',
|
|
1385
|
+
kind: 'simple',
|
|
1386
|
+
// ...
|
|
1387
|
+
emits: ['deal.archived', 'deal.statusChanged'],
|
|
1388
|
+
handler: async (ctx, input) => {
|
|
1389
|
+
const deal = await ctx.db.deals.archive(input.dealId)
|
|
1390
|
+
await ctx.emit('deal.archived', { dealId: deal.id, at: deal.archivedAt })
|
|
1391
|
+
if (deal.previousStatus !== 'archived') {
|
|
1392
|
+
await ctx.emit('deal.statusChanged', {
|
|
1393
|
+
dealId: deal.id, from: deal.previousStatus, to: 'archived',
|
|
1394
|
+
})
|
|
1395
|
+
}
|
|
1396
|
+
return deal
|
|
1397
|
+
},
|
|
1398
|
+
})
|
|
1399
|
+
```
|
|
1400
|
+
|
|
1401
|
+
Runtime em dev mode emite warning se handler chamar `ctx.emit` com evento **não declarado** em `emits`. Em prod, runtime ainda emite (sem warning) — dev é fail-soft, contrato é informativo.
|
|
1402
|
+
|
|
1403
|
+
### `ctx.emit`
|
|
1404
|
+
|
|
1405
|
+
```ts
|
|
1406
|
+
type EmitFn = <T = unknown>(
|
|
1407
|
+
event: string,
|
|
1408
|
+
data: T,
|
|
1409
|
+
meta?: { correlation?: string, [k: string]: unknown },
|
|
1410
|
+
) => Promise<void>
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
### Shape do evento
|
|
1414
|
+
|
|
1415
|
+
```ts
|
|
1416
|
+
type DomainEvent<T = unknown> = {
|
|
1417
|
+
type: string // 'deal.archived'
|
|
1418
|
+
id: string // UUID único
|
|
1419
|
+
timestamp: string // ISO 8601
|
|
1420
|
+
actor: { id: string | null, type?: 'user' | 'system' | 'integration' }
|
|
1421
|
+
tenant?: string | null
|
|
1422
|
+
data: T // payload (livre, handler decide)
|
|
1423
|
+
source: {
|
|
1424
|
+
action: string // 'deal.archive'
|
|
1425
|
+
actionId: string // bate com AuditRecord.id
|
|
1426
|
+
correlation?: string // request id / trace
|
|
1427
|
+
}
|
|
1428
|
+
meta?: Record<string, unknown>
|
|
1429
|
+
}
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
### Adapter: `EventBusAdapter`
|
|
1433
|
+
|
|
1434
|
+
```ts
|
|
1435
|
+
interface EventBusAdapter extends Adapter {
|
|
1436
|
+
kind: 'eventbus'
|
|
1437
|
+
publish(event: DomainEvent): Promise<void> | void
|
|
1438
|
+
}
|
|
1439
|
+
```
|
|
1440
|
+
|
|
1441
|
+
Implementações (não core, adapter wrappa lib existente):
|
|
1442
|
+
- In-process: `@softize/opus/events-mitt` (default), `@softize/opus/events-nano`.
|
|
1443
|
+
- Distribuído: `@softize/opus/events-redis`, `@softize/opus/events-nats`, `@softize/opus/events-inngest`.
|
|
1444
|
+
|
|
1445
|
+
### Falha de emit
|
|
1446
|
+
|
|
1447
|
+
Igual audit: comportamento lenient default (warn no logger, action segue), strict configurável (`emitMode: 'strict'` aborta a action com `{ code: 'emit.failed', category: 'internal' }`).
|
|
1448
|
+
|
|
1449
|
+
### Subscribers
|
|
1450
|
+
|
|
1451
|
+
V0: consumer subscreve **imperativamente** no setup:
|
|
1452
|
+
|
|
1453
|
+
```ts
|
|
1454
|
+
const bus = mittEventBusAdapter()
|
|
1455
|
+
bus.subscribe('deal.archived', async (event) => {
|
|
1456
|
+
await sendNotification(event.data.dealId)
|
|
1457
|
+
})
|
|
1458
|
+
```
|
|
1459
|
+
|
|
1460
|
+
`defineEventListener` declarativo entra em v1+ quando dor real de duplicar setup aparecer — subscribers têm dimensões próprias (ordering, dedup, retry, dead-letter) que merecem sub-protocolo dedicado.
|
|
1461
|
+
|
|
1462
|
+
---
|
|
1463
|
+
|
|
1464
|
+
## 12. Reactions
|
|
1465
|
+
|
|
1466
|
+
Action é "ator inicia". Reaction é "sistema reage". Pareamento simétrico — ambos são declarativos, ambos são auto-registrados pelo runtime, ambos cumprem cinco papéis (declarar, autorizar, executar, auditar, propagar).
|
|
1467
|
+
|
|
1468
|
+
### Contrato
|
|
1469
|
+
|
|
1470
|
+
```ts
|
|
1471
|
+
type ReactionDef<Event = unknown> = {
|
|
1472
|
+
// — Identidade —
|
|
1473
|
+
name: string // único, descritivo
|
|
1474
|
+
on: string | string[] // evento(s) a escutar
|
|
1475
|
+
|
|
1476
|
+
// — Documentação —
|
|
1477
|
+
description?: string
|
|
1478
|
+
tags?: string[]
|
|
1479
|
+
|
|
1480
|
+
// — Execução —
|
|
1481
|
+
handler: (ctx: ReactionContext, event: DomainEvent<Event>) =>
|
|
1482
|
+
Promise<void> | void
|
|
1483
|
+
|
|
1484
|
+
// — Resiliência —
|
|
1485
|
+
retry?: { attempts: number, backoff?: BackoffSpec }
|
|
1486
|
+
timeout?: number // ms
|
|
1487
|
+
dedup?: (event: DomainEvent<Event>) => string // chave de idempotência
|
|
1488
|
+
concurrency?: number // máx execuções paralelas (default 1)
|
|
1489
|
+
|
|
1490
|
+
// — Autorização (raro) —
|
|
1491
|
+
authorize?: (ctx: ReactionContext, event: DomainEvent<Event>) =>
|
|
1492
|
+
boolean | Promise<boolean>
|
|
1493
|
+
}
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
### Exemplo
|
|
1497
|
+
|
|
1498
|
+
```ts
|
|
1499
|
+
defineReaction({
|
|
1500
|
+
name: 'hub.onCrmLeadCreated.syncSalesforce',
|
|
1501
|
+
on: 'crm.lead.created',
|
|
1502
|
+
description: 'Sincroniza leads novos do CRM com Salesforce',
|
|
1503
|
+
tags: ['integration', 'salesforce'],
|
|
1504
|
+
|
|
1505
|
+
retry: { attempts: 3, backoff: { kind: 'exponential', initialMs: 1000 } },
|
|
1506
|
+
timeout: 30_000,
|
|
1507
|
+
dedup: (event) => event.id, // dedup at-least-once
|
|
1508
|
+
concurrency: 5,
|
|
1509
|
+
|
|
1510
|
+
handler: async (ctx, event) => {
|
|
1511
|
+
await syncToSalesforce(event.data.leadId)
|
|
1512
|
+
await ctx.emit('hub.lead.synced', { leadId: event.data.leadId })
|
|
1513
|
+
},
|
|
1514
|
+
})
|
|
1515
|
+
|
|
1516
|
+
// Reactions com múltiplos eventos
|
|
1517
|
+
defineReaction({
|
|
1518
|
+
name: 'analytics.onDealLifecycle',
|
|
1519
|
+
on: ['crm.deal.created', 'crm.deal.updated', 'crm.deal.archived'],
|
|
1520
|
+
handler: async (ctx, event) => {
|
|
1521
|
+
await analytics.track(event.type, event.data, event.actor)
|
|
1522
|
+
},
|
|
1523
|
+
})
|
|
1524
|
+
```
|
|
1525
|
+
|
|
1526
|
+
### `ReactionContext`
|
|
1527
|
+
|
|
1528
|
+
Mais enxuto que `ActionContext` (sem input/loaded):
|
|
1529
|
+
|
|
1530
|
+
```ts
|
|
1531
|
+
type ReactionContext = {
|
|
1532
|
+
user: User | null // ator que disparou o evento (pode ser system)
|
|
1533
|
+
tenantId: string | null
|
|
1534
|
+
db: unknown // mesmo data adapter
|
|
1535
|
+
emit: EmitFn // reaction pode emitir novos eventos (chain)
|
|
1536
|
+
log: LoggerAdapter // logger pre-bound com contexto da reaction
|
|
1537
|
+
storage: StorageAdapter | null // storage de arquivos (experimental)
|
|
1538
|
+
provenance: Provenance // { kind: 'reaction', … }
|
|
1539
|
+
meta: Record<string, unknown>
|
|
1540
|
+
}
|
|
1541
|
+
```
|
|
1542
|
+
|
|
1543
|
+
Reaction pode emitir eventos novos via `ctx.emit` — habilita workflows declarativos em cadeia (`lead.created` → reaction → `lead.onboarded` → outra reaction → ...).
|
|
1544
|
+
|
|
1545
|
+
### Setup unificado
|
|
1546
|
+
|
|
1547
|
+
Runtime aceita actions e reactions misturados; distingue pelo discriminator interno:
|
|
1548
|
+
|
|
1549
|
+
```ts
|
|
1550
|
+
runtime.register([
|
|
1551
|
+
// actions
|
|
1552
|
+
archiveDeal,
|
|
1553
|
+
createLead,
|
|
1554
|
+
|
|
1555
|
+
// reactions
|
|
1556
|
+
onCrmLeadCreated,
|
|
1557
|
+
onDealArchivedNotifyOwner,
|
|
1558
|
+
])
|
|
1559
|
+
|
|
1560
|
+
await runtime.start()
|
|
1561
|
+
// — Para cada action → ServerAdapter.mount(action)
|
|
1562
|
+
// — Para cada reaction → EventBusAdapter.subscribe(reaction.on, wrappedHandler)
|
|
1563
|
+
```
|
|
1564
|
+
|
|
1565
|
+
`runtime.start()` falha se houver reaction sem `EventBusAdapter` configurado (fail-fast em setup, não em runtime).
|
|
1566
|
+
|
|
1567
|
+
### Resiliência: quem implementa o quê
|
|
1568
|
+
|
|
1569
|
+
| Campo | Implementação | Sem suporte do adapter |
|
|
1570
|
+
|---------------|------------------------------------------|------------------------------|
|
|
1571
|
+
| `dedup` | Core — cache LRU de `event.id` por reaction (TTL configurável) | Sempre funciona |
|
|
1572
|
+
| `retry` | Adapter preferencial (Inngest, BullMQ); core fallback simples (in-memory backoff) | Warn em setup; core fallback ativo |
|
|
1573
|
+
| `timeout` | Core — AbortController em volta do handler | Sempre funciona |
|
|
1574
|
+
| `concurrency` | Adapter (consumer groups); core fallback (semáforo) | Default `concurrency: 1` |
|
|
1575
|
+
|
|
1576
|
+
Reaction declara intenção; runtime aplica o que core garante + delega o que adapter suporta. Se adapter não cobre algo declarado, **warning visível no boot** (não silencia falha de capability).
|
|
1577
|
+
|
|
1578
|
+
### Falha de reaction não afeta o emissor
|
|
1579
|
+
|
|
1580
|
+
Reaction roda **assíncrono** ao handler que emitiu o evento. Se reaction falha, é problema dela (retry / dead-letter via adapter); a action que emitiu o evento já completou.
|
|
1581
|
+
|
|
1582
|
+
Falhas registradas no log do runtime + audit (entrada do tipo `'reaction.failed'`). Adapter de eventos pode rotear pra dead-letter (v1).
|
|
1583
|
+
|
|
1584
|
+
### Pipeline de uma reaction
|
|
1585
|
+
|
|
1586
|
+
```
|
|
1587
|
+
EventBusAdapter recebe evento (publish externo ou ctx.emit interno)
|
|
1588
|
+
↓
|
|
1589
|
+
matching: encontra reactions registradas pra esse padrão
|
|
1590
|
+
↓ (paralelo, com concurrency limit)
|
|
1591
|
+
para cada reaction:
|
|
1592
|
+
↓ (a) dedup check (se declarado)
|
|
1593
|
+
↓ (b) authorize (se declarado)
|
|
1594
|
+
↓ (c) handler executa (com timeout)
|
|
1595
|
+
↓ (d) handler emite eventos (opcional, via ctx.emit)
|
|
1596
|
+
↓ (e) audit (reaction completed/failed)
|
|
1597
|
+
↓ (f) retry se falhou e config permite
|
|
1598
|
+
```
|
|
1599
|
+
|
|
1600
|
+
### O que **não** entra em v0
|
|
1601
|
+
|
|
1602
|
+
- **`deadLetter: string`** — evento pra publicar em caso de falha total. Útil mas exige sub-protocolo (quem consome dead-letter, retenção, replay).
|
|
1603
|
+
- **`ordering: 'global' | 'by-key'`** — garantia de ordem de processamento. Caro de implementar, raro pra v0.
|
|
1604
|
+
- **Reaction `background`** — bus já é assíncrono; agendar mais um tier não compensa em v0.
|
|
1605
|
+
- **Reaction com input parametrizado pelo subscriber** — `on: { event, filter: ... }` filter inline. v0: filtra dentro do handler.
|
|
1606
|
+
|
|
1607
|
+
---
|
|
1608
|
+
|
|
1609
|
+
## 13. i18n e `I18nRef`
|
|
1610
|
+
|
|
1611
|
+
O Opus é i18n-aware mas **i18n é opt-in**. Projetos mono-idioma (ex: tudo pt-BR) passam strings cruas; projetos multi-idioma passam refs.
|
|
1612
|
+
|
|
1613
|
+
```ts
|
|
1614
|
+
type I18nRef =
|
|
1615
|
+
| string // valor literal (default = chave)
|
|
1616
|
+
| { key: string, default: string } // chave i18n + fallback
|
|
1617
|
+
```
|
|
1618
|
+
|
|
1619
|
+
Resolução:
|
|
1620
|
+
- **Sem UI adapter com i18n configurado**: passthrough — string vira ela mesma; objeto retorna `default`.
|
|
1621
|
+
- **Com i18n configurado**: string é tratada como chave (`label: 'deal.archive'` → resolve `'deal.archive'` no catálogo); objeto resolve `key` com fallback pro `default`.
|
|
1622
|
+
|
|
1623
|
+
Custo zero pra quem não precisa. Adapter UI decide como resolver.
|
|
1624
|
+
|
|
1625
|
+
---
|
|
1626
|
+
|
|
1627
|
+
## 14. Runtime config
|
|
1628
|
+
|
|
1629
|
+
Adapters são "config externa" (você pluga ou não). Mas existem **behavior knobs** que controlam como o runtime se comporta: modo de falha, ambiente, defaults de resiliência, i18n. Esses ficam consolidados em `RuntimeConfig`.
|
|
1630
|
+
|
|
1631
|
+
### Princípio: core nunca lê env
|
|
1632
|
+
|
|
1633
|
+
`RuntimeConfig` recebe **valores literais**, não chaves de env. Consumer lê env (com a ferramenta que preferir) e passa valor pronto. Core fica testável, portável e funciona em ambientes esquisitos (edge runtime, worker sem env, etc).
|
|
1634
|
+
|
|
1635
|
+
```ts
|
|
1636
|
+
// ✓ correto — valor literal
|
|
1637
|
+
auditMode: 'lenient'
|
|
1638
|
+
|
|
1639
|
+
// ✓ correto — consumer resolve env, passa pronto
|
|
1640
|
+
auditMode: process.env.NODE_ENV === 'production' ? 'strict' : 'lenient'
|
|
1641
|
+
|
|
1642
|
+
// ✗ errado — core lendo env
|
|
1643
|
+
auditMode: process.env.AUDIT_MODE // se core fizesse isso, seria acoplamento
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
### Shape
|
|
1647
|
+
|
|
1648
|
+
```ts
|
|
1649
|
+
type RuntimeConfig = {
|
|
1650
|
+
// — Modos de falha —
|
|
1651
|
+
auditMode?: 'lenient' | 'strict' // default 'lenient'
|
|
1652
|
+
emitMode?: 'lenient' | 'strict' // default 'lenient'
|
|
1653
|
+
|
|
1654
|
+
// — Ambiente —
|
|
1655
|
+
env?: 'development' | 'production' | 'test' // informativo
|
|
1656
|
+
dev?: boolean // habilita validações extras
|
|
1657
|
+
|
|
1658
|
+
// — Validação —
|
|
1659
|
+
validateOutputInDev?: boolean // default true se dev
|
|
1660
|
+
warnUndeclaredEmits?: boolean // default true se dev
|
|
1661
|
+
|
|
1662
|
+
// — Resiliência / dedup —
|
|
1663
|
+
dedupCacheTtl?: number // ms, default 60_000
|
|
1664
|
+
defaultRetry?: { attempts: number, backoff?: BackoffSpec }
|
|
1665
|
+
|
|
1666
|
+
// — i18n —
|
|
1667
|
+
i18n?: {
|
|
1668
|
+
resolver?: (ref: I18nRef, locale?: string) => string
|
|
1669
|
+
defaultLocale?: string // ex: 'pt-BR'
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// — Limites —
|
|
1673
|
+
maxConcurrentActions?: number // default Infinity
|
|
1674
|
+
maxConcurrentReactions?: number // default 10
|
|
1675
|
+
}
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
### Setup completo
|
|
1679
|
+
|
|
1680
|
+
```ts
|
|
1681
|
+
import { createRuntime } from '@softize/opus/core'
|
|
1682
|
+
import { fastifyServer } from '@softize/opus/server/fastify'
|
|
1683
|
+
import { kyselyData } from '@softize/opus/data/kysely'
|
|
1684
|
+
import { betterAuth } from '@softize/opus/auth/better-auth'
|
|
1685
|
+
import { pinoLogger } from '@softize/opus/log/pino'
|
|
1686
|
+
import { pgAudit } from '@softize/opus/audit/pg'
|
|
1687
|
+
import { bullmqQueue } from '@softize/opus/queue/bullmq'
|
|
1688
|
+
import { redisEvents } from '@softize/opus/events/redis'
|
|
1689
|
+
|
|
1690
|
+
const runtime = createRuntime({
|
|
1691
|
+
// — adapters —
|
|
1692
|
+
server: fastifyServer({ app }),
|
|
1693
|
+
data: kyselyData({ db }),
|
|
1694
|
+
auth: betterAuth({ client: auth }),
|
|
1695
|
+
logger: pinoLogger({ level: 'info' }),
|
|
1696
|
+
audit: [pgAudit({ table: 'audit_log' })],
|
|
1697
|
+
queue: bullmqQueue({ connection }),
|
|
1698
|
+
eventBus: redisEvents({ connection }),
|
|
1699
|
+
|
|
1700
|
+
// — behavior knobs —
|
|
1701
|
+
config: {
|
|
1702
|
+
auditMode: 'lenient',
|
|
1703
|
+
emitMode: 'strict',
|
|
1704
|
+
env: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
1705
|
+
dev: process.env.NODE_ENV !== 'production',
|
|
1706
|
+
i18n: {
|
|
1707
|
+
resolver: i18next.t,
|
|
1708
|
+
defaultLocale: 'pt-BR',
|
|
1709
|
+
},
|
|
1710
|
+
maxConcurrentReactions: 20,
|
|
1711
|
+
},
|
|
1712
|
+
})
|
|
1713
|
+
|
|
1714
|
+
runtime.register([archiveDeal, createLead, onCrmLeadCreated])
|
|
1715
|
+
await runtime.start()
|
|
1716
|
+
|
|
1717
|
+
// Graceful shutdown
|
|
1718
|
+
process.on('SIGTERM', async () => {
|
|
1719
|
+
await runtime.dispose()
|
|
1720
|
+
process.exit(0)
|
|
1721
|
+
})
|
|
1722
|
+
```
|
|
1723
|
+
|
|
1724
|
+
### Defaults sensatos por config
|
|
1725
|
+
|
|
1726
|
+
Se você não passa `config`, o runtime usa:
|
|
1727
|
+
|
|
1728
|
+
```ts
|
|
1729
|
+
{
|
|
1730
|
+
auditMode: 'lenient',
|
|
1731
|
+
emitMode: 'lenient',
|
|
1732
|
+
env: 'development', // assume dev até prova contrário
|
|
1733
|
+
dev: true,
|
|
1734
|
+
validateOutputInDev: true,
|
|
1735
|
+
warnUndeclaredEmits: true,
|
|
1736
|
+
dedupCacheTtl: 60_000,
|
|
1737
|
+
defaultRetry: { attempts: 3, backoff: { kind: 'exponential', initialMs: 1000 } },
|
|
1738
|
+
maxConcurrentActions: Number.POSITIVE_INFINITY,
|
|
1739
|
+
maxConcurrentReactions: 10,
|
|
1740
|
+
}
|
|
1741
|
+
```
|
|
1742
|
+
|
|
1743
|
+
**Defaults são `dev`-friendly por padrão.** Produção exige passar `env: 'production'` + `dev: false` explicitamente. Postura: se você esqueceu de configurar, o pior caso é "rodando como dev em prod" (vai funcionar, com warnings) — não "rodando como prod em dev sem feedback".
|
|
1744
|
+
|
|
1745
|
+
### Validação da config
|
|
1746
|
+
|
|
1747
|
+
Core valida `RuntimeConfig` na `createRuntime` via schema (Zod interno). Config inválida → erro síncrono no boot, com mensagem clara. Não silencia.
|
|
1748
|
+
|
|
1749
|
+
### O que `RuntimeConfig` **não** controla
|
|
1750
|
+
|
|
1751
|
+
- Detalhes específicos de adapter (`bullmqAdapter({ ... })` config vai pro adapter, não pra RuntimeConfig).
|
|
1752
|
+
- Feature flags (LaunchDarkly/Unleash existem; integre via `meta` em action ou logger).
|
|
1753
|
+
- Carregamento de env (consumer faz; core não toca).
|
|
1754
|
+
|
|
1755
|
+
---
|
|
1756
|
+
|
|
1757
|
+
## 15. Entidades
|
|
1758
|
+
|
|
1759
|
+
### Introdução
|
|
1760
|
+
|
|
1761
|
+
Até aqui o protocolo declarou **ações** — o que *acontece*. Falta declarar o que *persiste*: as **entidades** do domínio (`Deal`, `User`, `Invoice`). Uma entidade é a descrição declarativa de um recorte de dado — seus campos, relações e índices — que serve de **fonte única** para tudo que toca aquele dado.
|
|
1762
|
+
|
|
1763
|
+
Você declara a entidade uma vez. A partir dela, o Opus materializa:
|
|
1764
|
+
|
|
1765
|
+
- O **tipo TypeScript** da linha (inferido, ponta a ponta).
|
|
1766
|
+
- Os **schemas** Zod de input/output que as actions consomem (`entityInsertSchema`, `entityUpdateSchema`, `entityRowSchema`).
|
|
1767
|
+
- A **migration** do banco (via adapter — ver "Materialização").
|
|
1768
|
+
- A **projeção** de `view`/`search`, sem redeclarar o shape na mão.
|
|
1769
|
+
|
|
1770
|
+
> **O Opus não tem ORM.** `defineEntity` é **contrato**, não engine. Ele declara a forma e anexa metadata; quem executa query e roda migration é lib externa (Drizzle, Kysely) via adapter. Mesma regra do §9: *contrato entra, feature usa lib externa.* Igual `defineAction` não hospeda HTTP, `defineEntity` não persiste dado.
|
|
1771
|
+
|
|
1772
|
+
### Definindo uma entidade
|
|
1773
|
+
|
|
1774
|
+
`defineEntity` é puramente declarativo — nenhum efeito colateral acontece ao chamá-lo (igual `defineAction` e `defineDomain`). Em sucesso, devolve o próprio objeto tipado.
|
|
1775
|
+
|
|
1776
|
+
```ts
|
|
1777
|
+
import { defineEntity, belongsTo, hasMany } from '@softize/opus/schema'
|
|
1778
|
+
import { t } from '@softize/opus/schema/zod'
|
|
1779
|
+
|
|
1780
|
+
export const Deal = defineEntity({
|
|
1781
|
+
name: 'deal',
|
|
1782
|
+
|
|
1783
|
+
fields: {
|
|
1784
|
+
title: t.string({ max: 200 }),
|
|
1785
|
+
amount: t.money({ currency: 'BRL' }),
|
|
1786
|
+
status: t.enum(['open', 'won', 'lost']).default('open'),
|
|
1787
|
+
ownerId: t.uuid(),
|
|
1788
|
+
notes: t.text().nullable(),
|
|
1789
|
+
},
|
|
1790
|
+
|
|
1791
|
+
relations: {
|
|
1792
|
+
owner: belongsTo('user', { from: 'ownerId' }),
|
|
1793
|
+
company: belongsTo('company', { from: 'companyId' }),
|
|
1794
|
+
attachments: hasMany('attachment', { to: 'dealId' }),
|
|
1795
|
+
},
|
|
1796
|
+
|
|
1797
|
+
indexes: [
|
|
1798
|
+
{ on: ['ownerId'] },
|
|
1799
|
+
{ on: ['status', 'createdAt'] },
|
|
1800
|
+
],
|
|
1801
|
+
|
|
1802
|
+
timestamps: true,
|
|
1803
|
+
softDelete: true,
|
|
1804
|
+
})
|
|
1805
|
+
```
|
|
1806
|
+
|
|
1807
|
+
### Convenções
|
|
1808
|
+
|
|
1809
|
+
O Opus assume defaults sensatos para você não repetir o óbvio. Toda convenção tem override explícito.
|
|
1810
|
+
|
|
1811
|
+
**Nome e tabela.** `name` é o nome **singular** da entidade (`[a-z][a-zA-Z0-9_]*`, igual domínio — espelha "substantivo pra entidade" do §9). O nome da tabela é o plural de `name` por default; declare `table` para sobrescrever:
|
|
1812
|
+
|
|
1813
|
+
```ts
|
|
1814
|
+
defineEntity({ name: 'person', table: 'people', /* ... */ })
|
|
1815
|
+
```
|
|
1816
|
+
|
|
1817
|
+
**Chave primária.** Se você não declarar uma PK, o Opus injeta `id: t.uuid().pk()`. Para uma PK própria, declare-a:
|
|
1818
|
+
|
|
1819
|
+
```ts
|
|
1820
|
+
fields: { code: t.string().pk(), /* ... */ } // sem id automático
|
|
1821
|
+
```
|
|
1822
|
+
|
|
1823
|
+
**Timestamps.** `timestamps: true` injeta `createdAt` e `updatedAt` (geridos pelo runtime — o handler nunca seta na mão). Default é `false`.
|
|
1824
|
+
|
|
1825
|
+
**Soft delete.** `softDelete: true` injeta `deletedAt`. Actions `search`/`view` filtram registros deletados por default; passe a opção explícita quando quiser incluí-los.
|
|
1826
|
+
|
|
1827
|
+
### Campos
|
|
1828
|
+
|
|
1829
|
+
Campos reusam o **catálogo de tipos lógicos** do §7 (`t.email`, `t.money`, `t.datetime`...). A metadata que cada tipo já carrega (`logicalType`) é o que permite o adapter de DB mapear a coluna certa (`datetime` → `timestamp`, `money` → `numeric + currency`) sem você dizer nada.
|
|
1830
|
+
|
|
1831
|
+
Um campo tem **duas camadas independentes** — não as misture:
|
|
1832
|
+
|
|
1833
|
+
| Camada | Modifiers | Quem consome |
|
|
1834
|
+
|---|---|---|
|
|
1835
|
+
| **Validação** (Zod nativo) | `.max()`, `.min()`, `.nullable()`, `.default()`, `.optional()` | runtime valida input da action |
|
|
1836
|
+
| **Coluna** (metadata opus) | `.pk()`, `.unique()`, `.references()`, `.index()` | adapter de migration / data |
|
|
1837
|
+
|
|
1838
|
+
```ts
|
|
1839
|
+
fields: {
|
|
1840
|
+
id: t.uuid().pk(),
|
|
1841
|
+
email: t.email().unique(), // validação + constraint
|
|
1842
|
+
slug: t.slug().unique().index(),
|
|
1843
|
+
ownerId: t.uuid().references('user'), // FK explícita (sem relation)
|
|
1844
|
+
bio: t.text().nullable(), // coluna NULL-able
|
|
1845
|
+
tier: t.enum(['free', 'pro']).default('free'), // default no schema E no DDL
|
|
1846
|
+
}
|
|
1847
|
+
```
|
|
1848
|
+
|
|
1849
|
+
`t.uuid().pk()` produz `{ logicalType: 'uuid', column: { pk: true } }` — a validação segue sendo Zod puro; o `column` é o canal que o adapter lê. Schema bruto (`z.string()`) continua aceito num campo, só perde a adaptação automática (cai pra `text`).
|
|
1850
|
+
|
|
1851
|
+
### Relações
|
|
1852
|
+
|
|
1853
|
+
Relações são declaradas com helpers puros que referenciam a outra entidade **por string** (não por import), evitando ciclos. O runtime resolve o grafo no `register`, validando que os alvos existem.
|
|
1854
|
+
|
|
1855
|
+
| Helper | Forma | Significado |
|
|
1856
|
+
|---|---|---|
|
|
1857
|
+
| `belongsTo(target, { from })` | FK local | `deal.ownerId` → `user.id` |
|
|
1858
|
+
| `hasOne(target, { to })` | FK no alvo, 1:1 | `user.id` → `profile.userId` |
|
|
1859
|
+
| `hasMany(target, { to })` | FK no alvo, 1:N | `deal.id` → `attachment.dealId` |
|
|
1860
|
+
| `manyToMany(target, { through })` | tabela de junção | `deal` ↔ `tag` via `deal_tags` |
|
|
1861
|
+
|
|
1862
|
+
```ts
|
|
1863
|
+
relations: {
|
|
1864
|
+
owner: belongsTo('user', { from: 'ownerId' }),
|
|
1865
|
+
attachments: hasMany('attachment', { to: 'dealId' }),
|
|
1866
|
+
tags: manyToMany('tag', { through: 'deal_tags' }),
|
|
1867
|
+
}
|
|
1868
|
+
```
|
|
1869
|
+
|
|
1870
|
+
> **Sem lazy-loading mágico.** Relação é *contrato* (quem aponta pra quem), não carregamento automático. O `view`/`search` declara o que expandir via `projection`/`expand` (§2); o adapter de data resolve o join. O Opus nunca dispara query implícita ao acessar uma propriedade.
|
|
1871
|
+
|
|
1872
|
+
### Índices e constraints
|
|
1873
|
+
|
|
1874
|
+
```ts
|
|
1875
|
+
indexes: [
|
|
1876
|
+
{ on: ['ownerId'] }, // índice simples
|
|
1877
|
+
{ on: ['status', 'createdAt'] }, // composto
|
|
1878
|
+
{ on: ['email'], unique: true }, // unique multi-coluna
|
|
1879
|
+
{ on: ['title'], using: 'gin' }, // hint de tipo; adapter resolve
|
|
1880
|
+
]
|
|
1881
|
+
```
|
|
1882
|
+
|
|
1883
|
+
Constraints de coluna única ficam no campo (`.unique()`); constraints multi-coluna ficam em `indexes`. O adapter traduz para o DDL do banco-alvo.
|
|
1884
|
+
|
|
1885
|
+
### Tipo inferido e schemas derivados
|
|
1886
|
+
|
|
1887
|
+
A entidade é a fonte do tipo TS e dos schemas que as actions consomem — declarado uma vez, inferido em todo lugar.
|
|
1888
|
+
|
|
1889
|
+
```ts
|
|
1890
|
+
import type { EntityRow, EntityInsert, EntityUpdate } from '@softize/opus/schema'
|
|
1891
|
+
|
|
1892
|
+
type DealRow = EntityRow<typeof Deal>
|
|
1893
|
+
// { id: string; title: string; amount: bigint;
|
|
1894
|
+
// status: 'open' | 'won' | 'lost'; ownerId: string; companyId: string;
|
|
1895
|
+
// notes: string | null; createdAt: string; updatedAt: string; deletedAt: string | null }
|
|
1896
|
+
```
|
|
1897
|
+
|
|
1898
|
+
Os schemas Zod pra input/output de actions são **funções** sobre a entidade (não
|
|
1899
|
+
props anexadas — mantém o `defineEntity` identity-puro):
|
|
1900
|
+
|
|
1901
|
+
```ts
|
|
1902
|
+
import { entityRowSchema, entityInsertSchema, entityUpdateSchema } from '@softize/opus/schema'
|
|
1903
|
+
|
|
1904
|
+
entityRowSchema(Deal) // Zod da linha completa (output)
|
|
1905
|
+
entityInsertSchema(Deal) // sem campos gerados; nullable/default opcionais (create)
|
|
1906
|
+
entityUpdateSchema(Deal) // partial do insert (update)
|
|
1907
|
+
```
|
|
1908
|
+
|
|
1909
|
+
### Materialização
|
|
1910
|
+
|
|
1911
|
+
A entidade materializa em quatro direções; só a primeira precisa de um adapter externo.
|
|
1912
|
+
|
|
1913
|
+
```
|
|
1914
|
+
defineEntity
|
|
1915
|
+
├─► EntityRow + entityRowSchema/InsertSchema/UpdateSchema (tipos + Zod, schema)
|
|
1916
|
+
├─► kyselyRepo(db, entity): CRUD tipado (driver Kysely)
|
|
1917
|
+
├─► crudActions(entity, {authorize}): actions de CRUD (driver Kysely)
|
|
1918
|
+
└─► entityColumns → drift-check / scaffold / migrate (opus db check/scaffold/migrate)
|
|
1919
|
+
```
|
|
1920
|
+
|
|
1921
|
+
> **O Opus não roda migração própria nem tem ORM.** Engine de query, runner de
|
|
1922
|
+
> migration e introspecção são do **Kysely**; o `defineEntity` é a fonte única; o
|
|
1923
|
+
> ciclo é `up/down` à mão (scaffold opcional) + **drift-check**. Decisão completa
|
|
1924
|
+
> e o porquê de não adotar Drizzle/Atlas como base em `docs/data-layer.md`.
|
|
1925
|
+
|
|
1926
|
+
### Derivando actions da entidade
|
|
1927
|
+
|
|
1928
|
+
O scaffolding entidade→CRUD vive na **camada de composição** (driver Kysely), não
|
|
1929
|
+
no core: `crudActions` gera as actions com input/output derivados e handlers backed
|
|
1930
|
+
por `kyselyRepo`. Decisão (a fronteira schema↔core): pôr `entity:` no `defineAction`
|
|
1931
|
+
e `ctx.repo` no `ActionContext` puxaria tipos de entidade pro core e criaria ciclo —
|
|
1932
|
+
então a composição mora onde core+schema já convivem (ver `data-layer.md`, peça 7).
|
|
1933
|
+
|
|
1934
|
+
```ts
|
|
1935
|
+
import { crudActions, kyselyRepo } from '@softize/opus/data/kysely'
|
|
1936
|
+
|
|
1937
|
+
// gera deal.view/create/update/delete/search (só as ops com authorize)
|
|
1938
|
+
export const dealActions = crudActions(Deal, {
|
|
1939
|
+
view: (ctx) => ctx.can('deal:read'),
|
|
1940
|
+
create: (ctx) => ctx.can('deal:create'),
|
|
1941
|
+
update: (ctx) => ctx.can('deal:update'),
|
|
1942
|
+
delete: (ctx) => ctx.can('deal:delete'),
|
|
1943
|
+
search: (ctx) => ctx.can('deal:read'), // output Paginated; offset + filtros de igualdade (v1)
|
|
1944
|
+
})
|
|
1945
|
+
|
|
1946
|
+
// quando precisa de lógica própria, action manual + repo + schemas da entidade:
|
|
1947
|
+
defineAction({
|
|
1948
|
+
name: 'deal.archive',
|
|
1949
|
+
kind: 'simple',
|
|
1950
|
+
input: z.object({ id: t.uuid().zod() }),
|
|
1951
|
+
output: entityRowSchema(Deal),
|
|
1952
|
+
authorize: (ctx) => ctx.can('deal:archive'),
|
|
1953
|
+
handler: (ctx, input) => kyselyRepo(ctx.db, Deal).update(input.id, { /* ... */ }),
|
|
1954
|
+
})
|
|
1955
|
+
```
|
|
1956
|
+
|
|
1957
|
+
`kyselyRepo(ctx.db, Deal)` é o repositório tipado (insert/findById/update/remove/list)
|
|
1958
|
+
— casca fina sobre Kysely, não ORM com estado.
|
|
1959
|
+
|
|
1960
|
+
**Naming (camel ↔ snake).** O backend é camelCase; o snake mora só no banco. O
|
|
1961
|
+
`CamelCasePlugin` do Kysely faz a ponte de query/DDL; o Opus só mapeia no
|
|
1962
|
+
**drift-check** (a introspecção volta crua). `naming` no `opus.config.ts` (default
|
|
1963
|
+
`snake`). Detalhes e o footgun auto-denunciado em `docs/data-layer.md`.
|
|
1964
|
+
|
|
1965
|
+
### Entidades dentro de um domínio
|
|
1966
|
+
|
|
1967
|
+
`DomainConfig.models` recebe as entidades do recorte (substitui o `unknown` placeholder atual). O domínio agrupa; a entidade declara.
|
|
1968
|
+
|
|
1969
|
+
```ts
|
|
1970
|
+
import { defineDomain } from '@softize/opus'
|
|
1971
|
+
|
|
1972
|
+
export const crm = defineDomain({
|
|
1973
|
+
name: 'crm',
|
|
1974
|
+
models: { Deal, Company, Contact },
|
|
1975
|
+
actions: { /* deal.create, deal.search, ... */ },
|
|
1976
|
+
})
|
|
1977
|
+
```
|
|
1978
|
+
|
|
1979
|
+
### O que **não** entra
|
|
1980
|
+
|
|
1981
|
+
- **ORM / Active Record.** Sem `deal.save()`, sem estado na instância. Entidade é descrição, não objeto vivo.
|
|
1982
|
+
- **Query DSL próprio.** Filtro/sort/join de action saem do contrato (§2); query ad-hoc usa Kysely/Drizzle direto.
|
|
1983
|
+
- **Lazy-loading / N+1 mágico.** Expansão é sempre declarada (`projection`/`expand`).
|
|
1984
|
+
- **Migration runner próprio.** Drizzle-kit roda; o Opus só gera o schema.
|
|
1985
|
+
- **Hooks de ciclo de vida da linha** (`beforeSave`, `afterDelete`). Efeito de negócio é **action** + **reaction** (§12), não gancho escondido no model.
|
|
1986
|
+
|
|
1987
|
+
---
|
|
1988
|
+
|
|
1989
|
+
## 16. Glossário
|
|
1990
|
+
|
|
1991
|
+
- **Action** — unidade declarativa que flui pelo pipeline opus. Ver §2.
|
|
1992
|
+
- **Kind** — discriminator de action: `simple`, `form`, `search`, `view`. Determina shape do contrato.
|
|
1993
|
+
- **`defineAction`** — função que constrói uma action a partir do contrato declarativo. Aceita union de todos os kinds.
|
|
1994
|
+
- **`defineContract`** — constrói só o **contrato** (`ActionDef` sem handler/loads/execução); roda nos dois lados (`shared`). Ver §2.
|
|
1995
|
+
- **`bindAction`** — amarra o server-only (`handler`/`loads`/...) a um contrato, produzindo um `ActionDef`. `authorize` do binding (row-level) sobrescreve o do contrato. Ver §2.
|
|
1996
|
+
- **`ActionContract` / `ActionBinding`** — o contrato declarativo e a parte server-only que o `bindAction` junta.
|
|
1997
|
+
- **`SimpleAction` / `FormAction` / `ListAction` / `ViewAction`** — variantes do `ActionDef` (discriminated union).
|
|
1998
|
+
- **Projection** — campos/relações sempre carregados em uma view action.
|
|
1999
|
+
- **Paginated<T>** — envelope de output de search actions: `{ items, cursor, total? }`.
|
|
2000
|
+
- **Adapter** — código que conecta opus a uma stack concreta (server framework, ORM, UI lib). Ver §8.
|
|
2001
|
+
- **Runtime** — orquestrador no centro: recebe action + input + ctx, executa pipeline (validate → load → authorize → handle → audit → result).
|
|
2002
|
+
- **Contract** — promessa de comportamento declarada na action que um adapter materializa.
|
|
2003
|
+
- **Handler** — função que executa a mudança. Uma das partes da action.
|
|
2004
|
+
- **Loader** — função declarada em `loads` que pré-carrega recurso antes de `authorize` rodar. Ver §4.
|
|
2005
|
+
- **Schema** — descrição de input/output validável compatível com `StandardSchemaV1`. Default: Zod.
|
|
2006
|
+
- **Logical type** — semântica conhecida de um campo (`email`, `money`, `datetime`...) com metadata anexada que adapters consomem. Ver §7.
|
|
2007
|
+
- **`ActionResult<T>`** — envelope discriminated union retornado de toda execução: `{ ok: true, data, meta } | { ok: false, error, meta }`. Ver §6.
|
|
2008
|
+
- **`ActionError`** — shape padronizado de erro emitido por uma action. Ver §3.
|
|
2009
|
+
- **`ValidationIssue`** — item dentro de `ActionError.issues` para erros multi-campo.
|
|
2010
|
+
- **`AuditRecord`** — registro emitido após execução de action. Ver §5.
|
|
2011
|
+
- **Sink** — destino que recebe `AuditRecord`. Múltiplos sinks possíveis. Ver §5.
|
|
2012
|
+
- **Invalidates** — chaves de cache que devem ser marcadas stale após uma action.
|
|
2013
|
+
- **Idempotency key** — string que identifica execução única; repetições com mesma chave retornam o resultado da primeira.
|
|
2014
|
+
- **Fail-closed** — default seguro: action sem `authorize` é negada. Ver §4.
|
|
2015
|
+
- **`FieldSpec`** — descrição declarativa de campo em form action (label, widget, options, condicionais). Ver §2.
|
|
2016
|
+
- **`FilterSpec`** — descrição declarativa de filtro em search action (mode server/client, operators, options). Ver §2.
|
|
2017
|
+
- **`OptionsSpec`** — discriminated union para fonte de opções: `static` (inline), `dictionary` (catálogo nomeado), `lookup` (async). Ver §2.
|
|
2018
|
+
- **`I18nRef`** — `string` (literal/passthrough) ou `{ key, default }` (i18n com fallback). Opt-in. Ver §12.
|
|
2019
|
+
- **`BackgroundConfig`** — configura execução em worker. Apenas `simple`/`form`. Ver §10.
|
|
2020
|
+
- **`ProgressReporter`** — interface injetada como 4º arg do handler quando `background.progress: true`. Ver §10.
|
|
2021
|
+
- **`JobHandle<T>`** — retorno síncrono de background action; representa execução assíncrona. Ver §10.
|
|
2022
|
+
- **`JobStatus`** — `'queued' | 'running' | 'done' | 'failed' | 'cancelled'`. Ver §10.
|
|
2023
|
+
- **`DomainEvent<T>`** — evento de domínio emitido pelo handler via `ctx.emit`. Ver §11.
|
|
2024
|
+
- **`EventBusAdapter`** — adapter que publica `DomainEvent` (in-process ou distribuído). Ver §8/§11.
|
|
2025
|
+
- **`QueueAdapter`** — adapter que executa background jobs. Ver §8/§10.
|
|
2026
|
+
- **`ctx.emit`** — função disponível no contexto do handler para emitir eventos declarados em `emits`. Ver §11.
|
|
2027
|
+
- **`requires`** — permissão declarativa (string ou array) usada por adapters de docs/OpenAPI. Distinta de `authorize` (lógica runtime). Ver §4.
|
|
2028
|
+
- **`automatable`** — flag que indica se a action pode ser disparada por sistema (cron, workflow, trigger). Distinta de `ai.enabled`. Ver §2.
|
|
2029
|
+
- **`internal`** — flag que indica action service-to-service apenas; server adapter não expõe como REST público. Ver §2.
|
|
2030
|
+
- **Reaction** — declaração que escuta evento(s) e executa handler. Auto-registrada pelo runtime via EventBusAdapter. Pareada com Action (ator inicia × sistema reage). Ver §12.
|
|
2031
|
+
- **`defineReaction`** — função que constrói uma reaction declarativa.
|
|
2032
|
+
- **`ReactionContext`** — contexto entregue ao handler de reaction (user, tenant, db, emit, log, meta).
|
|
2033
|
+
- **`dedup`** — função em reaction que produz chave de idempotência; runtime descarta evento já processado. Ver §12.
|
|
2034
|
+
- **`LoggerAdapter`** — interface de logger plugável (levels, structured, child binding). Default: `@softize/opus/log-console`. Ver §8.
|
|
2035
|
+
- **`RuntimeConfig`** — behavior knobs do runtime (auditMode, emitMode, env, i18n, limits). Recebe valores literais; core nunca lê env direto. Ver §14.
|
|
2036
|
+
- **`healthCheck()`** — método opcional em qualquer adapter, retorna `{ ok, details? }`. Agregado por `runtime.healthCheck()` e exposto em `/ready`. Ver §8.
|
|
2037
|
+
- **`/health`** — liveness probe; 200 se processo vivo. Não toca dependência externa. Ver §8.
|
|
2038
|
+
- **`/ready`** — readiness probe; agrega `adapter.healthCheck()`. 503 se algum adapter falha. Ver §8.
|
|
2039
|
+
- **`EndpointSpec`** — config passada ao ServerAdapter pra ligar/desligar endpoints opcionais (`/logs`, `/audit`, `/actions`, `/reactions`). Ver §8.
|
|
2040
|
+
- **Entity** — descrição declarativa de um recorte de dado persistido (campos, relações, índices). Fonte única para tipo, schemas, projeção e migration. Ver §15.
|
|
2041
|
+
- **`defineEntity`** — função que constrói uma entidade a partir do contrato declarativo. Puro, sem efeito colateral.
|
|
2042
|
+
- **Relation** — declaração de vínculo entre entidades (`belongsTo`, `hasOne`, `hasMany`, `manyToMany`), referenciada por string e resolvida no `register`. Ver §15.
|
|
2043
|
+
- **`EntityRow<E>` / `EntityInsert<E>` / `EntityUpdate<E>`** — tipos inferidos da entidade: linha completa, input de create (sem campos gerados), partial de update. Ver §15.
|
|
2044
|
+
- **`kyselyRepo(db, Entity)`** — repositório tipado (CRUD) derivado de uma entidade pelo driver Kysely; casca fina, não ORM. Ver §15.
|
|
2045
|
+
- **`crudActions(Entity, authorize)`** — gera as opus actions de CRUD da entidade (driver Kysely), handlers backed por `kyselyRepo`. Composição, não core. Ver §15.
|
|
2046
|
+
|
|
2047
|
+
---
|
|
2048
|
+
|
|
2049
|
+
## Decisões em aberto
|
|
2050
|
+
|
|
2051
|
+
- Licença (MIT, Apache-2.0, AGPL).
|
|
2052
|
+
- Política de versionamento (semver convencional vs calver).
|
|
2053
|
+
- Estratégia de release (changesets vs custom).
|