@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,627 @@
1
+ /**
2
+ * @softize/opus/ui/react — React driver
3
+ *
4
+ * Hooks + Provider pra invocar actions client-side.
5
+ * - `TbdlibProvider` — injeta ClientAdapter no contexto
6
+ * - `useAction(action)` — invoca actions simple/form/view
7
+ * - `useLookupAction(action)` — variante pra search (output Paginated)
8
+ *
9
+ * Compatível com React 18 e 19. Não dona estado global de cache —
10
+ * cada hook mantém o seu (suficiente pra v0). Integração com
11
+ * TanStack Query fica em `@softize/opus/client/tanstack` no futuro.
12
+ */
13
+
14
+ import {
15
+ createContext,
16
+ useCallback,
17
+ useContext,
18
+ useMemo,
19
+ useState,
20
+ type ReactNode,
21
+ } from 'react'
22
+ import {
23
+ useForm,
24
+ type DefaultValues,
25
+ type FieldValues,
26
+ type UseFormReturn,
27
+ } from 'react-hook-form'
28
+ import { zodResolver } from '@hookform/resolvers/zod'
29
+ import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
30
+ import type { ZodType } from 'zod'
31
+ import type {
32
+ ActionContract,
33
+ ActionDef,
34
+ ActionError,
35
+ ClientAdapter,
36
+ FormContract,
37
+ Paginated,
38
+ } from '../../core/index.ts'
39
+ import {
40
+ type ActionState,
41
+ type ListState,
42
+ idleAction,
43
+ idleLookup,
44
+ } from '../index.ts'
45
+ import { zodErrorMapPtBr } from '../lib/zod-pt-br.ts'
46
+
47
+ // =============================================================================
48
+ // Provider
49
+ // =============================================================================
50
+
51
+ /**
52
+ * Dicionário estrutural pro resolvedor de `options: { kind: 'dictionary', ref }`:
53
+ * qualquer coisa com `options()` → `{ value, label }`. O `DictType` do `t.dict`
54
+ * satisfaz direto — sem este driver importar o driver de schema.
55
+ */
56
+ export interface DictLike {
57
+ options(): Array<{ value: string; label: string }>
58
+ }
59
+
60
+ interface TbdlibContextValue {
61
+ client: ClientAdapter
62
+ dicts: Record<string, DictLike>
63
+ }
64
+
65
+ const TbdlibContext = createContext<TbdlibContextValue | null>(null)
66
+
67
+ export interface TbdlibProviderProps {
68
+ client: ClientAdapter
69
+ /** Dicionários por ref — resolvem `options: { kind: 'dictionary', ref }` de
70
+ * filters/fields (ActionList/ActionForm). Campo de dict sem registro aqui ainda
71
+ * resolve pelo fallback (a meta do `t.dict` viaja no schema do contrato). */
72
+ dicts?: Record<string, DictLike>
73
+ children: ReactNode
74
+ }
75
+
76
+ export function TbdlibProvider({
77
+ client,
78
+ dicts,
79
+ children,
80
+ }: TbdlibProviderProps): ReactNode {
81
+ const value = useMemo<TbdlibContextValue>(() => ({ client, dicts: dicts ?? {} }), [client, dicts])
82
+ return <TbdlibContext.Provider value={value}>{children}</TbdlibContext.Provider>
83
+ }
84
+
85
+ function useClient(): ClientAdapter {
86
+ const ctx = useContext(TbdlibContext)
87
+ if (ctx === null) {
88
+ throw new Error(
89
+ 'useAction/useLookupAction must be used inside <TbdlibProvider>',
90
+ )
91
+ }
92
+ return ctx.client
93
+ }
94
+
95
+ const NO_DICTS: Record<string, DictLike> = {}
96
+
97
+ /** Dicts do provider. Default {} — inclusive fora do provider: o resolvedor cai
98
+ * nos fallbacks (estáticas do spec, meta do `t.dict`) sem exigir registry. */
99
+ export function useDicts(): Record<string, DictLike> {
100
+ return useContext(TbdlibContext)?.dicts ?? NO_DICTS
101
+ }
102
+
103
+ // =============================================================================
104
+ // useAction
105
+ // =============================================================================
106
+
107
+ export interface UseActionResult<TInput, TData> extends ActionState<TData> {
108
+ run: (input: TInput) => Promise<ActionState<TData>>
109
+ reset: () => void
110
+ }
111
+
112
+ export function useAction<TInput = unknown, TData = unknown>(
113
+ action: ActionDef,
114
+ ): UseActionResult<TInput, TData> {
115
+ const client = useClient()
116
+ const [state, setState] = useState<ActionState<TData>>(() => idleAction<TData>())
117
+
118
+ const run = useCallback(
119
+ async (input: TInput): Promise<ActionState<TData>> => {
120
+ setState({
121
+ data: undefined,
122
+ error: undefined,
123
+ isIdle: false,
124
+ isLoading: true,
125
+ isSuccess: false,
126
+ isError: false,
127
+ })
128
+
129
+ const result = await client.run<TData>(action, input)
130
+
131
+ const next: ActionState<TData> = result.ok
132
+ ? {
133
+ data: result.data,
134
+ error: undefined,
135
+ isIdle: false,
136
+ isLoading: false,
137
+ isSuccess: true,
138
+ isError: false,
139
+ }
140
+ : {
141
+ data: undefined,
142
+ error: result.error,
143
+ isIdle: false,
144
+ isLoading: false,
145
+ isSuccess: false,
146
+ isError: true,
147
+ }
148
+
149
+ setState(next)
150
+ return next
151
+ },
152
+ [client, action],
153
+ )
154
+
155
+ const reset = useCallback(() => {
156
+ setState(idleAction<TData>())
157
+ }, [])
158
+
159
+ return { ...state, run, reset }
160
+ }
161
+
162
+ // =============================================================================
163
+ // useLookupAction
164
+ // =============================================================================
165
+
166
+ export interface UseLookupActionResult<TInput, TItem>
167
+ extends ListState<TItem> {
168
+ run: (input: TInput) => Promise<ListState<TItem>>
169
+ reset: () => void
170
+ }
171
+
172
+ export function useLookupAction<TInput = unknown, TItem = unknown>(
173
+ action: ActionDef,
174
+ ): UseLookupActionResult<TInput, TItem> {
175
+ const client = useClient()
176
+ const [state, setState] = useState<ListState<TItem>>(() => idleLookup<TItem>())
177
+
178
+ const run = useCallback(
179
+ async (input: TInput): Promise<ListState<TItem>> => {
180
+ setState((prev) => ({
181
+ ...prev,
182
+ error: undefined,
183
+ isIdle: false,
184
+ isLoading: true,
185
+ isSuccess: false,
186
+ isError: false,
187
+ }))
188
+
189
+ const result = await client.run<Paginated<TItem>>(action, input)
190
+
191
+ const next: ListState<TItem> = result.ok
192
+ ? {
193
+ items: result.data.items,
194
+ cursor: result.data.cursor,
195
+ total: result.data.total,
196
+ error: undefined,
197
+ isIdle: false,
198
+ isLoading: false,
199
+ isSuccess: true,
200
+ isError: false,
201
+ }
202
+ : {
203
+ items: [],
204
+ cursor: { next: null },
205
+ total: undefined,
206
+ error: result.error,
207
+ isIdle: false,
208
+ isLoading: false,
209
+ isSuccess: false,
210
+ isError: true,
211
+ }
212
+
213
+ setState(next)
214
+ return next
215
+ },
216
+ [client, action],
217
+ )
218
+
219
+ const reset = useCallback(() => {
220
+ setState(idleLookup<TItem>())
221
+ }, [])
222
+
223
+ return { ...state, run, reset }
224
+ }
225
+
226
+ // =============================================================================
227
+ // useListAction
228
+ // =============================================================================
229
+ //
230
+ // Lista QUERY-BACKED de uma search action: busca no mount e cacheia em
231
+ // queryKey [action.name, input]. É o par do `action.invalidates` — quando um
232
+ // form/trigger invalida 'x.list', toda lista montada com este hook refaz
233
+ // sozinha (invalidateQueries casa por prefixo do queryKey).
234
+ //
235
+ // Difere do useLookupAction (imperativo, run() sob demanda, pro ActionList):
236
+ // aqui o fetch é declarativo — montou, buscou. Pre-requisito: <QueryClientProvider>.
237
+
238
+ export interface UseListActionResult<TItem> {
239
+ items: TItem[]
240
+ total: number | undefined
241
+ error: ActionError | undefined
242
+ /** Primeiro load (sem dado nenhum ainda) — o skeleton. */
243
+ isLoading: boolean
244
+ /** Qualquer busca em andamento (inclui refetch com dados na tela). */
245
+ isFetching: boolean
246
+ isSuccess: boolean
247
+ isError: boolean
248
+ /** Força re-fetch (além da invalidação declarativa via action.invalidates). */
249
+ refetch: () => Promise<void>
250
+ }
251
+
252
+ // O param é estrutural (name + kind) de propósito: ListContract é invariante no
253
+ // input, o que quebraria a chamada `useListAction<Row>(contract)` quando o contrato
254
+ // tem filtros opcionais. O hook só usa name/kind; o item é tipado pelo caller.
255
+ export function useListAction<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
256
+ action: { name: string; kind: 'list' },
257
+ input?: TInput,
258
+ ): UseListActionResult<TItem> {
259
+ const client = useClient()
260
+
261
+ const query = useQuery<Paginated<TItem>, ActionError>({
262
+ queryKey: [action.name, input ?? {}],
263
+ queryFn: async () => {
264
+ const result = await client.run<Paginated<TItem>>(action as unknown as ActionDef, input ?? {})
265
+ if (!result.ok) throw result.error
266
+ return result.data
267
+ },
268
+ // Input mudou (filtro/página) → mantém os itens na tela enquanto busca: o
269
+ // ActionList esmaece em vez de piscar skeleton. isPending = só o 1º load.
270
+ placeholderData: keepPreviousData,
271
+ })
272
+
273
+ const refetch = useCallback(async () => {
274
+ await query.refetch()
275
+ }, [query])
276
+
277
+ const isLoading = query.isPending
278
+ return {
279
+ items: query.data?.items ?? [],
280
+ total: query.data?.total,
281
+ error: query.error ?? undefined,
282
+ isLoading,
283
+ // Busca em andamento COM dados na tela (troca de recorte, invalidates, refetch).
284
+ isFetching: query.isFetching,
285
+ isSuccess: query.isSuccess,
286
+ isError: query.isError,
287
+ refetch,
288
+ }
289
+ }
290
+
291
+ // =============================================================================
292
+ // useFormAction
293
+ // =============================================================================
294
+ //
295
+ // Pre-requisito: app está envolvido em `<QueryClientProvider>` do TanStack
296
+ // Query. Sem ele, useFormAction lança no primeiro render.
297
+ //
298
+ // Usa react-hook-form internamente. Se `action.input` é um Zod schema,
299
+ // validation client-side roda automaticamente via zodResolver. Submit
300
+ // invoca `client.run(action, input)`; em sucesso, invalida queryKeys
301
+ // derivadas de `action.invalidates` automaticamente.
302
+
303
+ export interface UseFormActionOptions<TInput extends FieldValues, TData> {
304
+ /** Valores iniciais do form. Mesmo shape do input da action. */
305
+ defaultValues?: DefaultValues<TInput>
306
+ /** Callback chamado ao sucesso. Cache invalidation acontece antes do callback. */
307
+ onSuccess?: (data: TData) => void | Promise<void>
308
+ /** Callback chamado em erro (validation client-side passou mas servidor rejeitou). */
309
+ onError?: (error: ActionError) => void
310
+ }
311
+
312
+ export interface UseFormActionResult<TInput extends FieldValues, TData>
313
+ extends ActionState<TData> {
314
+ /** RHF form methods — passe `{...form.formMethods}` no `<Form>` do shadcn. */
315
+ form: UseFormReturn<TInput>
316
+ /** Handler pronto pra usar em `<form onSubmit={submit}>`. */
317
+ submit: (event?: React.BaseSyntheticEvent) => Promise<void>
318
+ /** Reseta form + estado de execução. */
319
+ reset: () => void
320
+ }
321
+
322
+ /**
323
+ * Normaliza strings vazias do form conforme o schema — reproduz o `trim() || null`
324
+ * dos forms à mão: campo NULLABLE → null (limpa no servidor); só OPTIONAL → undefined
325
+ * (omite, não mexe); obrigatório → mantém '' (o zod acusa). Anda pelos wrappers via
326
+ * _def.typeName (string, sem instanceof — atravessa zod v3/v4; se não reconhecer,
327
+ * passa reto, fail-open).
328
+ */
329
+ function normalizeEmptyStrings(schema: unknown, values: Record<string, unknown>): Record<string, unknown> {
330
+ const shape = (schema as { shape?: Record<string, unknown> } | null)?.shape
331
+ if (shape === undefined || shape === null) return values
332
+ const out: Record<string, unknown> = { ...values }
333
+ for (const [key, value] of Object.entries(out)) {
334
+ if (value !== '') continue
335
+ let node = shape[key] as { _def?: { typeName?: string; innerType?: unknown } } | undefined
336
+ let nullable = false
337
+ let optional = false
338
+ while (
339
+ node?._def?.typeName === 'ZodOptional' ||
340
+ node?._def?.typeName === 'ZodNullable' ||
341
+ node?._def?.typeName === 'ZodDefault'
342
+ ) {
343
+ if (node._def.typeName === 'ZodNullable') nullable = true
344
+ else optional = true
345
+ node = node._def.innerType as typeof node
346
+ }
347
+ if (nullable) out[key] = null
348
+ else if (optional) delete out[key]
349
+ }
350
+ return out
351
+ }
352
+
353
+ export function useFormAction<
354
+ TInput extends FieldValues = FieldValues,
355
+ TData = unknown,
356
+ >(
357
+ action: FormContract<TInput, TData>,
358
+ options: UseFormActionOptions<TInput, TData> = {},
359
+ ): UseFormActionResult<TInput, TData> {
360
+ const client = useClient()
361
+ const queryClient = useQueryClient()
362
+
363
+ const resolver = useMemo(() => {
364
+ // action.input é Schema<TInput>. Se for Zod, embrulhamos com zodResolver.
365
+ // Caso seja outra implementação de StandardSchemaV1, fica sem resolver
366
+ // (servidor ainda valida no fail-closed pipeline).
367
+ const schema = action.input as unknown as ZodType<TInput>
368
+ if (
369
+ schema !== null &&
370
+ typeof schema === 'object' &&
371
+ '_def' in schema &&
372
+ typeof (schema as { parse?: unknown }).parse === 'function'
373
+ ) {
374
+ // Cast version-agnostic: há zod v3 e v4 no monorepo e qual o Opus resolve varia
375
+ // por install (passava local, quebrava na VPS). O guard de runtime acima já
376
+ // garante que é um schema Zod; o tipo exato não importa pro zodResolver.
377
+ // errorMap = dicionário pt-BR (mensagem específica no schema tem precedência).
378
+ return zodResolver(
379
+ schema as unknown as Parameters<typeof zodResolver>[0],
380
+ { errorMap: zodErrorMapPtBr } as never,
381
+ )
382
+ }
383
+ return undefined
384
+ }, [action.input])
385
+
386
+ const form = useForm<TInput>({
387
+ ...(resolver !== undefined ? { resolver } : {}),
388
+ ...(options.defaultValues !== undefined
389
+ ? { defaultValues: options.defaultValues }
390
+ : {}),
391
+ })
392
+
393
+ const [state, setState] = useState<ActionState<TData>>(() =>
394
+ idleAction<TData>(),
395
+ )
396
+
397
+ const submit = useMemo(
398
+ () =>
399
+ form.handleSubmit(async (input: TInput) => {
400
+ setState({
401
+ data: undefined,
402
+ error: undefined,
403
+ isIdle: false,
404
+ isLoading: true,
405
+ isSuccess: false,
406
+ isError: false,
407
+ })
408
+
409
+ // O ClientAdapter tipa `run` com ActionDef (action ligado, com handler), mas o
410
+ // cliente só tem o FormContract — e em runtime o adapter só usa `action.name`.
411
+ const payload = normalizeEmptyStrings(action.input, input as Record<string, unknown>) as TInput
412
+ const result = await client.run<TData>(action as unknown as ActionDef, payload)
413
+
414
+ if (result.ok) {
415
+ // Invalidação cache declarativa via action.invalidates
416
+ const invalidates =
417
+ typeof action.invalidates === 'function'
418
+ ? action.invalidates(input)
419
+ : action.invalidates
420
+ if (invalidates !== undefined && invalidates.length > 0) {
421
+ await Promise.all(
422
+ invalidates.map((key) =>
423
+ queryClient.invalidateQueries({ queryKey: [key] }),
424
+ ),
425
+ )
426
+ }
427
+
428
+ const next: ActionState<TData> = {
429
+ data: result.data,
430
+ error: undefined,
431
+ isIdle: false,
432
+ isLoading: false,
433
+ isSuccess: true,
434
+ isError: false,
435
+ }
436
+ setState(next)
437
+ await options.onSuccess?.(result.data)
438
+ } else {
439
+ const next: ActionState<TData> = {
440
+ data: undefined,
441
+ error: result.error,
442
+ isIdle: false,
443
+ isLoading: false,
444
+ isSuccess: false,
445
+ isError: true,
446
+ }
447
+ setState(next)
448
+ options.onError?.(result.error)
449
+ }
450
+ }),
451
+ [form, client, action, queryClient, options],
452
+ )
453
+
454
+ const reset = useCallback(() => {
455
+ form.reset()
456
+ setState(idleAction<TData>())
457
+ }, [form])
458
+
459
+ return { ...state, form, submit, reset }
460
+ }
461
+
462
+ // =============================================================================
463
+ // useViewAction
464
+ // =============================================================================
465
+ //
466
+ // Auto-roda action.run(input) no mount + quando input muda. Pensado pra
467
+ // view actions (kind=view) que carregam um recurso por id, mas funciona
468
+ // pra qualquer action determinística por input.
469
+ //
470
+ // Pre-requisito: app está envolvido em `<QueryClientProvider>` — cache é
471
+ // armazenado em queryKey [action.name, input]. Múltiplos componentes que
472
+ // usam mesmo action+input compartilham o cache automaticamente.
473
+
474
+ export interface UseViewActionResult<TData> extends ActionState<TData> {
475
+ /** Força re-fetch (invalida o cache desta queryKey + refetch). */
476
+ refetch: () => Promise<void>
477
+ }
478
+
479
+ // O param é estrutural (name + kind) de propósito, como no useListAction: no
480
+ // client só existe o CONTRATO (sem handler) — exigir ViewAction barraria todo
481
+ // callsite `useViewAction(contract, input)`. O hook só usa name/kind.
482
+ export function useViewAction<TInput = unknown, TData = unknown>(
483
+ action: { name: string; kind: 'view' },
484
+ input: TInput,
485
+ ): UseViewActionResult<TData> {
486
+ const client = useClient()
487
+
488
+ // TanStack Query serializa queryKey → dedupe automático mesmo com input
489
+ // inline (referência nova a cada render). Sem isso entraria em loop.
490
+ const query = useQuery<TData, ActionError>({
491
+ queryKey: [action.name, input],
492
+ queryFn: async () => {
493
+ const result = await client.run<TData>(action as unknown as ActionDef, input)
494
+ if (!result.ok) {
495
+ throw result.error
496
+ }
497
+ return result.data
498
+ },
499
+ })
500
+
501
+ const refetch = useCallback(async () => {
502
+ await query.refetch()
503
+ }, [query])
504
+
505
+ // Mapeia 4 estados do TanStack Query (pending/success/error/idle) pros
506
+ // 5 flags do ActionState. Pending pré-data = loading; sem fetch ainda = idle.
507
+ const isLoading = query.isPending || query.isFetching
508
+ const isSuccess = query.isSuccess && !isLoading
509
+ const isError = query.isError
510
+ const isIdle = !isLoading && !isSuccess && !isError
511
+
512
+ return {
513
+ data: query.data,
514
+ error: query.error ?? undefined,
515
+ isIdle,
516
+ isLoading,
517
+ isSuccess,
518
+ isError,
519
+ refetch,
520
+ }
521
+ }
522
+
523
+ // =============================================================================
524
+ // useTriggerAction
525
+ // =============================================================================
526
+ //
527
+ // Dispara QUALQUER contrato via `trigger(input)` — o caso típico é simple
528
+ // (kind=simple), mas um FormContract submetido fora de form (modal composto)
529
+ // roda igual: o hook só usa name/kind e lê `action.invalidates`.
530
+ //
531
+ // Diferença pra useAction (genérico):
532
+ // - Auto-invalidação via action.invalidates
533
+ // - Callbacks declarativos (onSuccess/onError)
534
+ // - Foco em mutation sem form
535
+ //
536
+ // Pre-requisito: <QueryClientProvider> no app.
537
+
538
+ export interface UseTriggerActionOptions<TInput, TData> {
539
+ onSuccess?: (data: TData, input: TInput) => void | Promise<void>
540
+ onError?: (error: ActionError, input: TInput) => void
541
+ }
542
+
543
+ export interface UseTriggerActionResult<TInput, TData>
544
+ extends ActionState<TData> {
545
+ trigger: (input: TInput) => Promise<ActionState<TData>>
546
+ reset: () => void
547
+ }
548
+
549
+ export function useTriggerAction<TInput = unknown, TData = unknown>(
550
+ action: ActionContract<TInput, TData>,
551
+ options: UseTriggerActionOptions<TInput, TData> = {},
552
+ ): UseTriggerActionResult<TInput, TData> {
553
+ const client = useClient()
554
+ const queryClient = useQueryClient()
555
+ const [state, setState] = useState<ActionState<TData>>(() =>
556
+ idleAction<TData>(),
557
+ )
558
+
559
+ const trigger = useCallback(
560
+ async (input: TInput): Promise<ActionState<TData>> => {
561
+ setState({
562
+ data: undefined,
563
+ error: undefined,
564
+ isIdle: false,
565
+ isLoading: true,
566
+ isSuccess: false,
567
+ isError: false,
568
+ })
569
+
570
+ // Contrato (sem handler) no cliente; o adapter só usa `action.name`/`kind`.
571
+ const result = await client.run<TData>(action as unknown as ActionDef, input)
572
+
573
+ if (result.ok) {
574
+ // Cast: o braço FormContract do union restringe o input a `& Record<...>`,
575
+ // mas o TInput do chamador JÁ é o In do contrato passado.
576
+ const invalidates =
577
+ typeof action.invalidates === 'function'
578
+ ? (action.invalidates as (input: TInput) => string[])(input)
579
+ : action.invalidates
580
+ if (invalidates !== undefined && invalidates.length > 0) {
581
+ await Promise.all(
582
+ invalidates.map((key) =>
583
+ queryClient.invalidateQueries({ queryKey: [key] }),
584
+ ),
585
+ )
586
+ }
587
+
588
+ const next: ActionState<TData> = {
589
+ data: result.data,
590
+ error: undefined,
591
+ isIdle: false,
592
+ isLoading: false,
593
+ isSuccess: true,
594
+ isError: false,
595
+ }
596
+ setState(next)
597
+ await options.onSuccess?.(result.data, input)
598
+ return next
599
+ }
600
+
601
+ const next: ActionState<TData> = {
602
+ data: undefined,
603
+ error: result.error,
604
+ isIdle: false,
605
+ isLoading: false,
606
+ isSuccess: false,
607
+ isError: true,
608
+ }
609
+ setState(next)
610
+ options.onError?.(result.error, input)
611
+ return next
612
+ },
613
+ [client, action, queryClient, options],
614
+ )
615
+
616
+ const reset = useCallback(() => {
617
+ setState(idleAction<TData>())
618
+ }, [])
619
+
620
+ return { ...state, trigger, reset }
621
+ }
622
+
623
+ // =============================================================================
624
+ // Re-exports
625
+ // =============================================================================
626
+
627
+ export type { ActionError, ActionState, ListState }