@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.
Files changed (286) hide show
  1. package/CHANGELOG.md +1616 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -0
  4. package/bin/cli.mjs +528 -0
  5. package/bin/lib/check.mjs +307 -0
  6. package/bin/lib/components.mjs +151 -0
  7. package/bin/lib/create.mjs +208 -0
  8. package/bin/lib/db-check-runner.mjs +86 -0
  9. package/bin/lib/db-migrate-runner.mjs +89 -0
  10. package/bin/lib/db-scaffold-runner.mjs +84 -0
  11. package/bin/lib/db.mjs +261 -0
  12. package/bin/lib/docs-include.mjs +48 -0
  13. package/bin/lib/gen-dicts.mjs +134 -0
  14. package/bin/lib/gen-docs.mjs +288 -0
  15. package/bin/lib/gen-manifest.mjs +102 -0
  16. package/bin/lib/gen-openapi.mjs +195 -0
  17. package/bin/lib/gen-runner.mjs +472 -0
  18. package/bin/lib/gen-stubs.mjs +463 -0
  19. package/bin/lib/gen.mjs +311 -0
  20. package/bin/lib/init.mjs +514 -0
  21. package/bin/lib/introspect.mjs +107 -0
  22. package/bin/lib/mcp.mjs +85 -0
  23. package/bin/lib/postinstall.mjs +56 -0
  24. package/docs/chat-event-protocol.md +85 -0
  25. package/docs/code-style.md +16 -0
  26. package/docs/data-layer.md +246 -0
  27. package/docs/ownership-vs-shadcn-lock.md +102 -0
  28. package/docs/protocol.md +2053 -0
  29. package/docs/releasing.md +110 -0
  30. package/docs/shellnav.md +131 -0
  31. package/package.json +338 -0
  32. package/registry/hooks/hooks.json +26 -0
  33. package/registry/hooks/link-memory-on-start.mjs +46 -0
  34. package/registry/hooks/opus-check-on-stop.mjs +114 -0
  35. package/registry/skills/create-action/SKILL.md +49 -0
  36. package/registry/skills/create-action/scaffold.mjs +122 -0
  37. package/registry/templates/app/_gitignore +3 -0
  38. package/registry/templates/app/_npmrc +1 -0
  39. package/registry/templates/app/_opus/_gitignore +5 -0
  40. package/registry/templates/app/_prettierrc.json +6 -0
  41. package/registry/templates/app/index.html +13 -0
  42. package/registry/templates/app/opus.config.ts +16 -0
  43. package/registry/templates/app/package.json +43 -0
  44. package/registry/templates/app/pnpm-workspace.yaml +11 -0
  45. package/registry/templates/app/public/favicon.svg +4 -0
  46. package/registry/templates/app/src/App.tsx +37 -0
  47. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
  48. package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
  49. package/registry/templates/app/src/domains/tasks/index.ts +13 -0
  50. package/registry/templates/app/src/index.css +18 -0
  51. package/registry/templates/app/src/main.tsx +25 -0
  52. package/registry/templates/app/tsconfig.json +20 -0
  53. package/registry/templates/app/vite.config.ts +46 -0
  54. package/registry/templates/monorepo/_gitignore +3 -0
  55. package/registry/templates/monorepo/_npmrc +1 -0
  56. package/registry/templates/monorepo/package.json +9 -0
  57. package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
  58. package/src/ai/ask.ts +64 -0
  59. package/src/ai/drivers/anthropic.ts +309 -0
  60. package/src/ai/index.ts +17 -0
  61. package/src/audit/drivers/console.ts +117 -0
  62. package/src/audit/drivers/pg.ts +172 -0
  63. package/src/audit/index.ts +51 -0
  64. package/src/auth/drivers/better-auth.ts +103 -0
  65. package/src/auth/drivers/jwt.ts +188 -0
  66. package/src/auth/index.ts +9 -0
  67. package/src/client/drivers/fetch.ts +202 -0
  68. package/src/client/index.ts +22 -0
  69. package/src/core/actions.ts +110 -0
  70. package/src/core/audit.ts +239 -0
  71. package/src/core/contracts.ts +137 -0
  72. package/src/core/domain.ts +310 -0
  73. package/src/core/errors.ts +181 -0
  74. package/src/core/index.ts +174 -0
  75. package/src/core/logical-type.ts +31 -0
  76. package/src/core/reactions.ts +81 -0
  77. package/src/core/runtime.ts +1167 -0
  78. package/src/core/schedules.ts +41 -0
  79. package/src/core/types.ts +1356 -0
  80. package/src/data/drivers/kysely.ts +389 -0
  81. package/src/data/index.ts +10 -0
  82. package/src/data/readonly-pool.ts +160 -0
  83. package/src/dsl/eval.ts +136 -0
  84. package/src/dsl/index.ts +29 -0
  85. package/src/dsl/kysely.ts +230 -0
  86. package/src/dsl/loads.ts +123 -0
  87. package/src/dsl/parser.ts +423 -0
  88. package/src/dsl/types.ts +113 -0
  89. package/src/events/drivers/mitt.ts +70 -0
  90. package/src/events/index.ts +9 -0
  91. package/src/log/drivers/pino.ts +57 -0
  92. package/src/log/index.ts +9 -0
  93. package/src/mcp/index.ts +62 -0
  94. package/src/queue/drivers/bullmq.ts +190 -0
  95. package/src/queue/index.ts +9 -0
  96. package/src/scheduler/drivers/node-cron.ts +93 -0
  97. package/src/scheduler/every.ts +45 -0
  98. package/src/scheduler/index.ts +9 -0
  99. package/src/schema/drivers/zod.ts +765 -0
  100. package/src/schema/entity.ts +439 -0
  101. package/src/schema/format/locale.ts +144 -0
  102. package/src/schema/index.ts +65 -0
  103. package/src/schema/openapi.ts +302 -0
  104. package/src/schema/scaffold.ts +160 -0
  105. package/src/server/drivers/fastify.ts +224 -0
  106. package/src/server/drivers/node.ts +386 -0
  107. package/src/server/index.ts +142 -0
  108. package/src/storage/drivers/fs.ts +90 -0
  109. package/src/storage/drivers/s3.ts +117 -0
  110. package/src/storage/index.ts +27 -0
  111. package/src/testing/fake.ts +298 -0
  112. package/src/testing/index.ts +324 -0
  113. package/src/ui/components/patterns/action-form-card.tsx +48 -0
  114. package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
  115. package/src/ui/components/patterns/app-shell.tsx +227 -0
  116. package/src/ui/components/patterns/confirm.tsx +226 -0
  117. package/src/ui/components/patterns/data-state.tsx +75 -0
  118. package/src/ui/components/patterns/form-dialog.tsx +64 -0
  119. package/src/ui/components/patterns/form.tsx +584 -0
  120. package/src/ui/components/patterns/list.tsx +1488 -0
  121. package/src/ui/components/patterns/page.tsx +46 -0
  122. package/src/ui/components/patterns/section-shell.tsx +246 -0
  123. package/src/ui/components/patterns/shell-nav.tsx +150 -0
  124. package/src/ui/components/patterns/sidebar.tsx +89 -0
  125. package/src/ui/components/patterns/split.tsx +93 -0
  126. package/src/ui/components/patterns/trigger.tsx +196 -0
  127. package/src/ui/components/patterns/view.tsx +84 -0
  128. package/src/ui/components/primitives/accordion.tsx +64 -0
  129. package/src/ui/components/primitives/alert-dialog.tsx +190 -0
  130. package/src/ui/components/primitives/alert.tsx +116 -0
  131. package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
  132. package/src/ui/components/primitives/avatar.tsx +107 -0
  133. package/src/ui/components/primitives/badge.tsx +37 -0
  134. package/src/ui/components/primitives/breadcrumb.tsx +109 -0
  135. package/src/ui/components/primitives/button-group.tsx +83 -0
  136. package/src/ui/components/primitives/button.tsx +102 -0
  137. package/src/ui/components/primitives/calendar.tsx +218 -0
  138. package/src/ui/components/primitives/card.tsx +56 -0
  139. package/src/ui/components/primitives/carousel.tsx +239 -0
  140. package/src/ui/components/primitives/chat.tsx +407 -0
  141. package/src/ui/components/primitives/checkbox.tsx +30 -0
  142. package/src/ui/components/primitives/collapsible.tsx +31 -0
  143. package/src/ui/components/primitives/command.tsx +182 -0
  144. package/src/ui/components/primitives/composer.tsx +121 -0
  145. package/src/ui/components/primitives/copyable.tsx +50 -0
  146. package/src/ui/components/primitives/dialog.tsx +147 -0
  147. package/src/ui/components/primitives/drawer.tsx +141 -0
  148. package/src/ui/components/primitives/empty.tsx +104 -0
  149. package/src/ui/components/primitives/field.tsx +246 -0
  150. package/src/ui/components/primitives/icon-picker.tsx +180 -0
  151. package/src/ui/components/primitives/input-group.tsx +168 -0
  152. package/src/ui/components/primitives/input-otp.tsx +75 -0
  153. package/src/ui/components/primitives/input.tsx +72 -0
  154. package/src/ui/components/primitives/item.tsx +193 -0
  155. package/src/ui/components/primitives/kbd.tsx +28 -0
  156. package/src/ui/components/primitives/label.tsx +22 -0
  157. package/src/ui/components/primitives/markdown.tsx +35 -0
  158. package/src/ui/components/primitives/menu.tsx +255 -0
  159. package/src/ui/components/primitives/pagination.tsx +127 -0
  160. package/src/ui/components/primitives/popover.tsx +87 -0
  161. package/src/ui/components/primitives/progress.tsx +29 -0
  162. package/src/ui/components/primitives/radio-group.tsx +43 -0
  163. package/src/ui/components/primitives/resizable.tsx +51 -0
  164. package/src/ui/components/primitives/scroll-area.tsx +56 -0
  165. package/src/ui/components/primitives/select.tsx +479 -0
  166. package/src/ui/components/primitives/separator.tsx +26 -0
  167. package/src/ui/components/primitives/skeleton.tsx +13 -0
  168. package/src/ui/components/primitives/slider.tsx +61 -0
  169. package/src/ui/components/primitives/sonner.tsx +46 -0
  170. package/src/ui/components/primitives/spinner.tsx +29 -0
  171. package/src/ui/components/primitives/switch.tsx +33 -0
  172. package/src/ui/components/primitives/table.tsx +114 -0
  173. package/src/ui/components/primitives/tabs.tsx +104 -0
  174. package/src/ui/components/primitives/textarea.tsx +18 -0
  175. package/src/ui/components/primitives/toggle-group.tsx +81 -0
  176. package/src/ui/components/primitives/toggle.tsx +45 -0
  177. package/src/ui/components/primitives/tooltip.tsx +55 -0
  178. package/src/ui/components/primitives/truncate.tsx +49 -0
  179. package/src/ui/docs/DocBrowser.tsx +90 -0
  180. package/src/ui/docs/changelog.tsx +80 -0
  181. package/src/ui/docs/content/accordion.md +86 -0
  182. package/src/ui/docs/content/action-form-card.md +24 -0
  183. package/src/ui/docs/content/action-form-dialog.md +30 -0
  184. package/src/ui/docs/content/action-form.md +125 -0
  185. package/src/ui/docs/content/action-list-dialog.md +68 -0
  186. package/src/ui/docs/content/action-list.md +194 -0
  187. package/src/ui/docs/content/action-trigger.md +72 -0
  188. package/src/ui/docs/content/action-view.md +47 -0
  189. package/src/ui/docs/content/actions.md +138 -0
  190. package/src/ui/docs/content/ai.md +112 -0
  191. package/src/ui/docs/content/alert-dialog.md +73 -0
  192. package/src/ui/docs/content/alert.md +69 -0
  193. package/src/ui/docs/content/app-shell.md +155 -0
  194. package/src/ui/docs/content/aspect-ratio.md +66 -0
  195. package/src/ui/docs/content/audit.md +84 -0
  196. package/src/ui/docs/content/auth.md +70 -0
  197. package/src/ui/docs/content/avatar.md +94 -0
  198. package/src/ui/docs/content/badge.md +48 -0
  199. package/src/ui/docs/content/breadcrumb.md +87 -0
  200. package/src/ui/docs/content/button-group.md +71 -0
  201. package/src/ui/docs/content/button.md +60 -0
  202. package/src/ui/docs/content/calendar.md +62 -0
  203. package/src/ui/docs/content/card.md +49 -0
  204. package/src/ui/docs/content/carousel.md +85 -0
  205. package/src/ui/docs/content/chat.md +69 -0
  206. package/src/ui/docs/content/checkbox.md +75 -0
  207. package/src/ui/docs/content/cli.md +58 -0
  208. package/src/ui/docs/content/collapsible.md +64 -0
  209. package/src/ui/docs/content/command.md +56 -0
  210. package/src/ui/docs/content/composer.md +50 -0
  211. package/src/ui/docs/content/confirm.md +120 -0
  212. package/src/ui/docs/content/copyable.md +30 -0
  213. package/src/ui/docs/content/customization.md +110 -0
  214. package/src/ui/docs/content/cycle.md +34 -0
  215. package/src/ui/docs/content/data-state.md +47 -0
  216. package/src/ui/docs/content/data.md +99 -0
  217. package/src/ui/docs/content/dialog.md +60 -0
  218. package/src/ui/docs/content/drawer.md +55 -0
  219. package/src/ui/docs/content/empty.md +66 -0
  220. package/src/ui/docs/content/events.md +61 -0
  221. package/src/ui/docs/content/field.md +58 -0
  222. package/src/ui/docs/content/getting-started.md +109 -0
  223. package/src/ui/docs/content/icon-picker.md +51 -0
  224. package/src/ui/docs/content/input-group.md +78 -0
  225. package/src/ui/docs/content/input-otp.md +72 -0
  226. package/src/ui/docs/content/input.md +78 -0
  227. package/src/ui/docs/content/item.md +84 -0
  228. package/src/ui/docs/content/kbd.md +62 -0
  229. package/src/ui/docs/content/label.md +32 -0
  230. package/src/ui/docs/content/log.md +55 -0
  231. package/src/ui/docs/content/markdown.md +41 -0
  232. package/src/ui/docs/content/mcp.md +44 -0
  233. package/src/ui/docs/content/menu.md +114 -0
  234. package/src/ui/docs/content/microcopy.md +83 -0
  235. package/src/ui/docs/content/page.md +34 -0
  236. package/src/ui/docs/content/pagination.md +99 -0
  237. package/src/ui/docs/content/popover.md +49 -0
  238. package/src/ui/docs/content/progress.md +69 -0
  239. package/src/ui/docs/content/queue.md +62 -0
  240. package/src/ui/docs/content/radio-group.md +77 -0
  241. package/src/ui/docs/content/resizable.md +86 -0
  242. package/src/ui/docs/content/router.md +56 -0
  243. package/src/ui/docs/content/runtime.md +77 -0
  244. package/src/ui/docs/content/scheduler.md +66 -0
  245. package/src/ui/docs/content/scroll-area.md +89 -0
  246. package/src/ui/docs/content/section-shell.md +121 -0
  247. package/src/ui/docs/content/select.md +342 -0
  248. package/src/ui/docs/content/separator.md +33 -0
  249. package/src/ui/docs/content/sidebar.md +38 -0
  250. package/src/ui/docs/content/skeleton.md +34 -0
  251. package/src/ui/docs/content/slider.md +64 -0
  252. package/src/ui/docs/content/spinner.md +37 -0
  253. package/src/ui/docs/content/split.md +33 -0
  254. package/src/ui/docs/content/storage.md +69 -0
  255. package/src/ui/docs/content/switch.md +69 -0
  256. package/src/ui/docs/content/table.md +102 -0
  257. package/src/ui/docs/content/tabs.md +94 -0
  258. package/src/ui/docs/content/testing.md +89 -0
  259. package/src/ui/docs/content/textarea.md +30 -0
  260. package/src/ui/docs/content/toast.md +67 -0
  261. package/src/ui/docs/content/toggle-group.md +81 -0
  262. package/src/ui/docs/content/toggle.md +72 -0
  263. package/src/ui/docs/content/tokens.md +171 -0
  264. package/src/ui/docs/content/tooltip.md +50 -0
  265. package/src/ui/docs/content/truncate.md +37 -0
  266. package/src/ui/docs/content/ui.md +40 -0
  267. package/src/ui/docs/content/upgrading.md +48 -0
  268. package/src/ui/docs/doc-client.tsx +214 -0
  269. package/src/ui/docs/doc.tsx +301 -0
  270. package/src/ui/docs/folder.tsx +149 -0
  271. package/src/ui/docs/index.ts +21 -0
  272. package/src/ui/docs/markdown.tsx +130 -0
  273. package/src/ui/docs/md-raw.d.ts +4 -0
  274. package/src/ui/docs/plugin.ts +104 -0
  275. package/src/ui/docs/registry.tsx +424 -0
  276. package/src/ui/docs/standalone.tsx +107 -0
  277. package/src/ui/drivers/react.tsx +627 -0
  278. package/src/ui/index.ts +92 -0
  279. package/src/ui/lib/cn.ts +10 -0
  280. package/src/ui/lib/zod-pt-br.ts +38 -0
  281. package/src/ui/meta.ts +412 -0
  282. package/src/ui/react.tsx +235 -0
  283. package/src/ui/router.ts +96 -0
  284. package/src/ui/theme.css +234 -0
  285. package/src/vite/design.ts +652 -0
  286. package/src/vite/index.ts +8 -0
@@ -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).