@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,302 @@
1
+ /**
2
+ * @softize/opus/schema/openapi — OpenAPI 3.1 spec generator.
3
+ *
4
+ * Varre lista de `ActionDef` e emite documento OpenAPI completo. Usa
5
+ * `zod-to-json-schema` pra converter schemas Zod em JSON Schema; consumers
6
+ * que rodam outro schema lib (ArkType, Valibot) devem passar um converter
7
+ * próprio via `options.toJsonSchema`.
8
+ *
9
+ * Convenção de rota espelha o que `@softize/opus/server` aplica:
10
+ * - simple/form → POST /api/{name-com-slashes}
11
+ * - search/view → GET /api/{name-com-slashes}
12
+ *
13
+ * Endpoints opcionais (/health, /ready, /openapi.json) não são incluídos
14
+ * — apenas actions registradas viram operations OpenAPI.
15
+ */
16
+
17
+ import type { ActionDef, ErrorSpec } from '../core/index.ts'
18
+ import { zodToJsonSchema } from 'zod-to-json-schema'
19
+
20
+ // =============================================================================
21
+ // Tipos OpenAPI 3.1 (subset usado)
22
+ // =============================================================================
23
+
24
+ export interface OpenAPIInfo {
25
+ title: string
26
+ version: string
27
+ description?: string
28
+ }
29
+
30
+ export interface OpenAPIServer {
31
+ url: string
32
+ description?: string
33
+ }
34
+
35
+ export interface OpenAPISpec {
36
+ openapi: '3.1.0'
37
+ info: OpenAPIInfo
38
+ servers?: OpenAPIServer[]
39
+ paths: Record<string, Record<string, OpenAPIOperation>>
40
+ components: { schemas: Record<string, unknown> }
41
+ }
42
+
43
+ export interface OpenAPIOperation {
44
+ operationId: string
45
+ summary?: string
46
+ description?: string
47
+ tags?: string[]
48
+ deprecated?: boolean
49
+ requestBody?: {
50
+ required: true
51
+ content: { 'application/json': { schema: unknown } }
52
+ }
53
+ parameters?: Array<{
54
+ name: string
55
+ in: 'query'
56
+ required: boolean
57
+ schema: unknown
58
+ }>
59
+ responses: Record<string, OpenAPIResponse>
60
+ security?: Array<Record<string, string[]>>
61
+ }
62
+
63
+ export interface OpenAPIResponse {
64
+ description: string
65
+ content?: { 'application/json': { schema: unknown } }
66
+ }
67
+
68
+ // =============================================================================
69
+ // Options
70
+ // =============================================================================
71
+
72
+ export interface ToOpenAPIOptions {
73
+ info: OpenAPIInfo
74
+ servers?: OpenAPIServer[]
75
+ apiPrefix?: string
76
+ /**
77
+ * Converter custom (caso schemas não sejam Zod). Default usa
78
+ * `zod-to-json-schema`. Recebe o schema e devolve JSON Schema.
79
+ */
80
+ toJsonSchema?: (schema: unknown) => unknown
81
+ }
82
+
83
+ // =============================================================================
84
+ // Generator
85
+ // =============================================================================
86
+
87
+ export function toOpenAPISpec(
88
+ actions: ActionDef[],
89
+ options: ToOpenAPIOptions,
90
+ ): OpenAPISpec {
91
+ const {
92
+ info,
93
+ servers,
94
+ apiPrefix = '/api',
95
+ toJsonSchema = defaultToJsonSchema,
96
+ } = options
97
+
98
+ const paths: OpenAPISpec['paths'] = {}
99
+
100
+ for (const action of actions) {
101
+ if (action.internal === true) continue
102
+ const { path, method } = routeFor(action, apiPrefix)
103
+ const operation = buildOperation(action, method, toJsonSchema)
104
+ paths[path] = paths[path] ?? {}
105
+ paths[path]![method] = operation
106
+ }
107
+
108
+ return {
109
+ openapi: '3.1.0',
110
+ info,
111
+ ...(servers !== undefined && servers.length > 0 ? { servers } : {}),
112
+ paths,
113
+ components: { schemas: {} },
114
+ }
115
+ }
116
+
117
+ // =============================================================================
118
+ // Internos
119
+ // =============================================================================
120
+
121
+ function defaultToJsonSchema(schema: unknown): unknown {
122
+ return zodToJsonSchema(schema as Parameters<typeof zodToJsonSchema>[0], {
123
+ target: 'openApi3',
124
+ $refStrategy: 'none',
125
+ })
126
+ }
127
+
128
+ function routeFor(
129
+ action: ActionDef,
130
+ apiPrefix: string,
131
+ ): { path: string; method: 'get' | 'post' } {
132
+ const segments = action.name.split('.').map(encodeURIComponent)
133
+ const path = `${apiPrefix}/${segments.join('/')}`
134
+ const method = action.kind === 'simple' || action.kind === 'form' ? 'post' : 'get'
135
+ return { path, method }
136
+ }
137
+
138
+ function buildOperation(
139
+ action: ActionDef,
140
+ method: 'get' | 'post',
141
+ toJsonSchema: (s: unknown) => unknown,
142
+ ): OpenAPIOperation {
143
+ const op: OpenAPIOperation = {
144
+ operationId: action.name,
145
+ responses: buildResponses(action, toJsonSchema),
146
+ }
147
+
148
+ if (action.summary !== undefined) op.summary = action.summary
149
+ if (action.description !== undefined) op.description = action.description
150
+ if (action.tags !== undefined && action.tags.length > 0) op.tags = action.tags
151
+
152
+ const inputSchema = toJsonSchema(action.input)
153
+
154
+ if (method === 'post') {
155
+ op.requestBody = {
156
+ required: true,
157
+ content: { 'application/json': { schema: inputSchema } },
158
+ }
159
+ } else {
160
+ const params = buildQueryParams(inputSchema)
161
+ if (params.length > 0) op.parameters = params
162
+ }
163
+
164
+ if (action.public !== true) {
165
+ op.security = [{ bearerAuth: [] }]
166
+ }
167
+
168
+ return op
169
+ }
170
+
171
+ function buildResponses(
172
+ action: ActionDef,
173
+ toJsonSchema: (s: unknown) => unknown,
174
+ ): Record<string, OpenAPIResponse> {
175
+ const successStatus = String(action.successStatus ?? 200)
176
+ const responses: Record<string, OpenAPIResponse> = {
177
+ [successStatus]: {
178
+ description: 'Success',
179
+ content: {
180
+ 'application/json': {
181
+ schema: wrapEnvelope(toJsonSchema(action.output), action.kind),
182
+ },
183
+ },
184
+ },
185
+ '4XX': errorResponse('Client error', action.errors),
186
+ '5XX': errorResponse('Server error'),
187
+ }
188
+ return responses
189
+ }
190
+
191
+ function wrapEnvelope(outputSchema: unknown, kind: ActionDef['kind']): unknown {
192
+ // Search retorna Paginated<Output>; outros kinds retornam Output direto.
193
+ const data =
194
+ kind === 'list'
195
+ ? {
196
+ type: 'object',
197
+ properties: {
198
+ items: { type: 'array', items: outputSchema },
199
+ cursor: {
200
+ type: 'object',
201
+ properties: {
202
+ next: { type: ['string', 'null'] },
203
+ prev: { type: ['string', 'null'] },
204
+ },
205
+ required: ['next'],
206
+ },
207
+ total: { type: 'integer' },
208
+ },
209
+ required: ['items', 'cursor'],
210
+ }
211
+ : outputSchema
212
+
213
+ return {
214
+ type: 'object',
215
+ properties: {
216
+ ok: { const: true },
217
+ data,
218
+ meta: metaSchema(),
219
+ },
220
+ required: ['ok', 'data', 'meta'],
221
+ }
222
+ }
223
+
224
+ function errorResponse(description: string, errors?: ErrorSpec[]): OpenAPIResponse {
225
+ const errorCodes =
226
+ errors !== undefined && errors.length > 0
227
+ ? { enum: errors.map((e) => e.code) }
228
+ : { type: 'string' }
229
+
230
+ return {
231
+ description,
232
+ content: {
233
+ 'application/json': {
234
+ schema: {
235
+ type: 'object',
236
+ properties: {
237
+ ok: { const: false },
238
+ error: {
239
+ type: 'object',
240
+ properties: {
241
+ code: errorCodes,
242
+ category: { type: 'string' },
243
+ message: { type: 'string' },
244
+ severity: { enum: ['warning', 'error', 'fatal'] },
245
+ retriable: { type: 'boolean' },
246
+ },
247
+ required: ['code', 'category', 'message', 'severity', 'retriable'],
248
+ },
249
+ meta: metaSchema(),
250
+ },
251
+ required: ['ok', 'error', 'meta'],
252
+ },
253
+ },
254
+ },
255
+ }
256
+ }
257
+
258
+ function metaSchema(): unknown {
259
+ return {
260
+ type: 'object',
261
+ properties: {
262
+ actionId: { type: 'string' },
263
+ action: { type: 'string' },
264
+ durationMs: { type: 'number' },
265
+ requestId: { type: 'string' },
266
+ cached: { type: 'boolean' },
267
+ },
268
+ required: ['actionId', 'action', 'durationMs'],
269
+ }
270
+ }
271
+
272
+ interface QueryParam {
273
+ name: string
274
+ in: 'query'
275
+ required: boolean
276
+ schema: unknown
277
+ }
278
+
279
+ function buildQueryParams(inputSchema: unknown): QueryParam[] {
280
+ // Flatten top-level object properties em query params individuais.
281
+ if (
282
+ typeof inputSchema !== 'object' ||
283
+ inputSchema === null ||
284
+ !('properties' in inputSchema) ||
285
+ typeof (inputSchema as { properties: unknown }).properties !== 'object'
286
+ ) {
287
+ return []
288
+ }
289
+
290
+ const props = (inputSchema as { properties: Record<string, unknown> }).properties
291
+ const requiredRaw = (inputSchema as { required?: unknown }).required
292
+ const required = new Set<string>(
293
+ Array.isArray(requiredRaw) ? (requiredRaw as string[]) : [],
294
+ )
295
+
296
+ return Object.entries(props).map(([name, schema]) => ({
297
+ name,
298
+ in: 'query' as const,
299
+ required: required.has(name),
300
+ schema,
301
+ }))
302
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @softize/opus/schema — scaffold de migration a partir do diff entidade↔banco.
3
+ *
4
+ * Puro: recebe entidades + `DriftFinding[]` (do drift-check) e emite o **código**
5
+ * de uma migration Kysely (`up`/`down`) como string. Não toca banco nem fs.
6
+ *
7
+ * Filosofia (ver `docs/data-layer.md`): best-effort, **humano revisa**.
8
+ * - `missing_table` → `createTable` completo / `dropTable` (caso limpo, gerado)
9
+ * - `missing_column` → `addColumn` / `dropColumn` (caso limpo, gerado)
10
+ * - `nullable_mismatch` / `extra_column` → vira **nota `// TODO`**, nunca um
11
+ * ALTER/DROP automático (arriscado, dialect-específico).
12
+ */
13
+
14
+ import {
15
+ entityColumns,
16
+ entityTable,
17
+ type DriftFinding,
18
+ type EntityConfig,
19
+ type ResolvedColumn,
20
+ } from './entity.ts'
21
+
22
+ // =============================================================================
23
+ // logicalType → tipo de coluna Kysely (Postgres-leaning)
24
+ // =============================================================================
25
+
26
+ const SQL_TYPE: Record<string, string> = {
27
+ uuid: 'uuid',
28
+ int: 'integer',
29
+ bigint: 'bigint',
30
+ money: 'bigint',
31
+ decimal: 'numeric',
32
+ boolean: 'boolean',
33
+ datetime: 'timestamptz',
34
+ date: 'date',
35
+ json: 'jsonb',
36
+ string: 'text',
37
+ text: 'text',
38
+ email: 'text',
39
+ phone: 'text',
40
+ url: 'text',
41
+ slug: 'text',
42
+ currency: 'text',
43
+ country: 'text',
44
+ locale: 'text',
45
+ markdown: 'text',
46
+ html: 'text',
47
+ timezone: 'text',
48
+ time: 'text',
49
+ duration: 'text',
50
+ enum: 'text',
51
+ }
52
+
53
+ /** Tipo de coluna Kysely pro tipo lógico. Fallback `text`. */
54
+ export function kyselyColumnType(logicalType: string | undefined): string {
55
+ if (logicalType === undefined) return 'text'
56
+ return SQL_TYPE[logicalType] ?? 'text'
57
+ }
58
+
59
+ // =============================================================================
60
+ // Scaffold
61
+ // =============================================================================
62
+
63
+ export interface ScaffoldResult {
64
+ up: string
65
+ down: string
66
+ /** Divergências que exigem revisão humana (não geradas automaticamente). */
67
+ notes: string[]
68
+ /** Quantidade de statements gerados automaticamente. */
69
+ statements: number
70
+ }
71
+
72
+ function columnLine(col: ResolvedColumn): string {
73
+ const type = kyselyColumnType(col.logicalType)
74
+ const mods: string[] = []
75
+ if (col.column.pk === true) {
76
+ mods.push('primaryKey()', 'notNull()')
77
+ } else if (col.column.nullable !== true) {
78
+ mods.push('notNull()')
79
+ }
80
+ const builder = mods.length > 0 ? `, (c) => c.${mods.join('.')}` : ''
81
+ return ` .addColumn('${col.name}', '${type}'${builder})`
82
+ }
83
+
84
+ /**
85
+ * Gera o corpo de uma migration a partir do diff. Retorna `null` se não há nada
86
+ * a fazer (sem statements e sem notas).
87
+ */
88
+ export function scaffoldMigration(
89
+ entities: EntityConfig[],
90
+ findings: DriftFinding[],
91
+ ): ScaffoldResult | null {
92
+ const byTable = new Map<string, DriftFinding[]>()
93
+ for (const f of findings) {
94
+ const list = byTable.get(f.table) ?? []
95
+ list.push(f)
96
+ byTable.set(f.table, list)
97
+ }
98
+
99
+ const up: string[] = []
100
+ const down: string[] = []
101
+ const notes: string[] = []
102
+ let statements = 0
103
+
104
+ for (const entity of entities) {
105
+ const table = entityTable(entity)
106
+ const list = byTable.get(table)
107
+ if (list === undefined) continue
108
+ const cols = entityColumns(entity)
109
+
110
+ if (list.some((f) => f.kind === 'missing_table')) {
111
+ const lines = cols.map(columnLine).join('\n')
112
+ up.push(` await db.schema.createTable('${table}')\n${lines}\n .execute()`)
113
+ down.push(` await db.schema.dropTable('${table}').execute()`)
114
+ statements++
115
+ continue
116
+ }
117
+
118
+ const missing = new Set(
119
+ list.filter((f) => f.kind === 'missing_column').map((f) => f.column),
120
+ )
121
+ for (const col of cols) {
122
+ if (missing.has(col.name)) {
123
+ up.push(` await db.schema.alterTable('${table}')\n${columnLine(col)}\n .execute()`)
124
+ down.push(
125
+ ` await db.schema.alterTable('${table}').dropColumn('${col.name}').execute()`,
126
+ )
127
+ statements++
128
+ }
129
+ }
130
+
131
+ for (const f of list) {
132
+ if (f.kind === 'nullable_mismatch' || f.kind === 'extra_column') {
133
+ notes.push(`${f.table}.${f.column ?? ''}: ${f.kind} — ${f.message}`)
134
+ }
135
+ }
136
+ }
137
+
138
+ if (statements === 0 && notes.length === 0) return null
139
+ return { up: up.join('\n\n'), down: down.join('\n\n'), notes, statements }
140
+ }
141
+
142
+ /** Monta o arquivo `.ts` completo (imports + up/down) a partir do resultado. */
143
+ export function scaffoldFileContent(result: ScaffoldResult): string {
144
+ const todo =
145
+ result.notes.length > 0
146
+ ? `\n // TODO (revisar à mão — arriscado/dialect-específico, não gerado):\n` +
147
+ result.notes.map((n) => ` // - ${n}`).join('\n') +
148
+ '\n'
149
+ : ''
150
+ return `import type { Kysely } from 'kysely'
151
+
152
+ export async function up(db: Kysely<any>): Promise<void> {
153
+ ${result.up}${todo}
154
+ }
155
+
156
+ export async function down(db: Kysely<any>): Promise<void> {
157
+ ${result.down}
158
+ }
159
+ `
160
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * @softize/opus/server/fastify — Fastify driver
3
+ *
4
+ * Implementa `ServerAdapter` em cima do Fastify 5.x. Auto-monta actions
5
+ * como rotas REST, expõe endpoints opcionais (/health, /ready, ...),
6
+ * resolve contexto via AuthAdapter, mapeia errors → HTTP status.
7
+ *
8
+ * Uso:
9
+ * import Fastify from 'fastify'
10
+ * import { createRuntime } from '@softize/opus/core'
11
+ * import { fastifyServer } from '@softize/opus/server/fastify'
12
+ *
13
+ * const app = Fastify()
14
+ * const runtime = createRuntime({
15
+ * server: fastifyServer({ app }),
16
+ * auth: ...,
17
+ * })
18
+ * runtime.register([...])
19
+ * await runtime.start()
20
+ * await app.listen({ port: 3000 })
21
+ */
22
+
23
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
24
+ import type {
25
+ ActionDef,
26
+ ContextBase,
27
+ EndpointSpec,
28
+ RuntimeRef,
29
+ ServerAdapter,
30
+ } from '../../core/index.ts'
31
+ import {
32
+ toOpenAPISpec,
33
+ type OpenAPIInfo,
34
+ type OpenAPIServer,
35
+ } from '../../schema/openapi.ts'
36
+ import {
37
+ httpStatusFor,
38
+ inputSourceFor,
39
+ methodFor,
40
+ pathFor,
41
+ serializeResult,
42
+ shouldMountPublicly,
43
+ successStatusFor,
44
+ } from '../index.ts'
45
+
46
+ // =============================================================================
47
+ // Options
48
+ // =============================================================================
49
+
50
+ export interface FastifyServerOptions {
51
+ /** Instância Fastify onde rotas serão registradas. */
52
+ app: FastifyInstance
53
+
54
+ /** Prefixo do path. Default '/api'. */
55
+ apiPrefix?: string
56
+
57
+ /**
58
+ * Info do OpenAPI document (title, version, description). Quando ausente,
59
+ * usa um default genérico — sobrescreva pra publicar API decente.
60
+ */
61
+ openapiInfo?: OpenAPIInfo
62
+
63
+ /** Servers do OpenAPI document. */
64
+ openapiServers?: OpenAPIServer[]
65
+ }
66
+
67
+ // =============================================================================
68
+ // Adapter factory
69
+ // =============================================================================
70
+
71
+ export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
72
+ const {
73
+ app,
74
+ apiPrefix = '/api',
75
+ openapiInfo = { title: 'tbdlib API', version: '0.0.0' },
76
+ openapiServers,
77
+ } = options
78
+ let runtime: RuntimeRef | undefined
79
+ const mountedActions: ActionDef[] = []
80
+
81
+ const requireRuntime = (): RuntimeRef => {
82
+ /* v8 ignore next 3 — defensivo; init() é sempre chamado antes de mount/etc */
83
+ if (runtime === undefined) {
84
+ throw new Error('fastifyServer: runtime not initialized (init not called)')
85
+ }
86
+ return runtime
87
+ }
88
+
89
+ return {
90
+ name: 'fastify',
91
+ kind: 'server',
92
+
93
+ init(rt) {
94
+ runtime = rt
95
+ },
96
+
97
+ mount(action) {
98
+ if (!shouldMountPublicly(action)) return
99
+ mountedActions.push(action)
100
+
101
+ const method = methodFor(action)
102
+ const path = pathFor(action, apiPrefix)
103
+ const source = inputSourceFor(action)
104
+
105
+ const handler = async (req: FastifyRequest, reply: FastifyReply) => {
106
+ const rt = requireRuntime()
107
+ const ctx = await resolveCtx(rt, req)
108
+ const input = source === 'body' ? req.body : req.query
109
+
110
+ const result = await rt.execute(action.name, input, ctx)
111
+ const status = result.ok
112
+ ? successStatusFor(action)
113
+ : httpStatusFor(result.error)
114
+
115
+ reply.code(status).send(serializeResult(result))
116
+ }
117
+
118
+ if (method === 'POST') app.post(path, handler)
119
+ else app.get(path, handler)
120
+ },
121
+
122
+ mountEndpoints(spec) {
123
+ mountHealth(app, spec)
124
+ mountReady(app, spec, requireRuntime)
125
+ mountOpenAPI(app, spec, mountedActions, {
126
+ info: openapiInfo,
127
+ ...(openapiServers !== undefined ? { servers: openapiServers } : {}),
128
+ apiPrefix,
129
+ })
130
+ },
131
+ }
132
+ }
133
+
134
+ // =============================================================================
135
+ // Context resolution
136
+ // =============================================================================
137
+
138
+ async function resolveCtx(
139
+ rt: RuntimeRef,
140
+ req: FastifyRequest,
141
+ ): Promise<ContextBase> {
142
+ const base = await resolveAuthBase(rt, req)
143
+ // Fastify sempre popula req.id (auto-gen ou via genReqId opt). Não é optional.
144
+ return {
145
+ ...base,
146
+ requestId: req.id,
147
+ provenance: {
148
+ kind: 'http',
149
+ userId: base.user?.id ?? null,
150
+ requestId: req.id,
151
+ },
152
+ }
153
+ }
154
+
155
+ async function resolveAuthBase(
156
+ rt: RuntimeRef,
157
+ req: FastifyRequest,
158
+ ): Promise<{ user: ContextBase['user']; tenantId: string | null; can: ContextBase['can'] }> {
159
+ if (rt.auth === undefined) {
160
+ return { user: null, tenantId: null, can: () => false }
161
+ }
162
+ const resolved = await rt.auth.resolveContext(req)
163
+ return {
164
+ user: resolved.user,
165
+ tenantId: resolved.tenantId ?? null,
166
+ can: resolved.can,
167
+ }
168
+ }
169
+
170
+ // =============================================================================
171
+ // Endpoints
172
+ // =============================================================================
173
+
174
+ function mountHealth(app: FastifyInstance, spec: EndpointSpec): void {
175
+ const cfg = spec.health
176
+ if (cfg === false) return
177
+ const path = pathFromSpec(cfg, '/health')
178
+ app.get(path, async () => ({ ok: true }))
179
+ }
180
+
181
+ function mountReady(
182
+ app: FastifyInstance,
183
+ spec: EndpointSpec,
184
+ getRuntime: () => RuntimeRef,
185
+ ): void {
186
+ const cfg = spec.ready
187
+ if (cfg === false) return
188
+ const path = pathFromSpec(cfg, '/ready')
189
+ app.get(path, async (_req, reply) => {
190
+ const status = await getRuntime().healthCheck()
191
+ reply.code(status.ok ? 200 : 503).send(status)
192
+ })
193
+ }
194
+
195
+ function pathFromSpec(
196
+ cfg: boolean | { path?: string } | undefined,
197
+ fallback: string,
198
+ ): string {
199
+ if (typeof cfg === 'object' && cfg.path !== undefined) return cfg.path
200
+ return fallback
201
+ }
202
+
203
+ function mountOpenAPI(
204
+ app: FastifyInstance,
205
+ spec: EndpointSpec,
206
+ actions: ActionDef[],
207
+ options: {
208
+ info: OpenAPIInfo
209
+ servers?: OpenAPIServer[]
210
+ apiPrefix: string
211
+ },
212
+ ): void {
213
+ const cfg = spec.openapi
214
+ if (cfg === false) return
215
+ const path =
216
+ typeof cfg === 'object' && cfg.path !== undefined ? cfg.path : '/openapi.json'
217
+ app.get(path, async () =>
218
+ toOpenAPISpec(actions, {
219
+ info: options.info,
220
+ ...(options.servers !== undefined ? { servers: options.servers } : {}),
221
+ apiPrefix: options.apiPrefix,
222
+ }),
223
+ )
224
+ }