@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,389 @@
1
+ /**
2
+ * @softize/opus/data/kysely — Kysely driver
3
+ *
4
+ * Implementa `DataAdapter` envolvendo uma instância `Kysely<DB>`.
5
+ * Expõe `ctx.db` tipado pros handlers e implementa `healthCheck()`
6
+ * via `SELECT 1`.
7
+ *
8
+ * Uso:
9
+ * import { Kysely, PostgresDialect } from 'kysely'
10
+ * import { kyselyData } from '@softize/opus/data/kysely'
11
+ *
12
+ * const db = new Kysely<DB>({ dialect: new PostgresDialect({ pool }) })
13
+ *
14
+ * createRuntime({
15
+ * data: kyselyData({ db }),
16
+ * ...
17
+ * })
18
+ *
19
+ * // No handler:
20
+ * handler: async (ctx) => {
21
+ * const deals = await (ctx.db as Kysely<DB>).selectFrom('deals')...
22
+ * }
23
+ */
24
+
25
+ import { randomUUID } from 'node:crypto'
26
+
27
+ import { z } from 'zod'
28
+ import { defineAction, error, type ActionDef, type AuthorizeFn } from '../../core/index.ts'
29
+ import type { DataAdapter, HealthStatus, Paginated } from '../../core/index.ts'
30
+ import {
31
+ desiredColumns,
32
+ diffColumns,
33
+ entityTable,
34
+ entityPk,
35
+ entityRowSchema,
36
+ entityInsertSchema,
37
+ entityUpdateSchema,
38
+ resolveNaming,
39
+ type DriftFinding,
40
+ type EntityConfig,
41
+ type EntityRow,
42
+ type EntityInsert,
43
+ type EntityUpdate,
44
+ type NamingStrategy,
45
+ } from '../../schema/entity.ts'
46
+ import { type Kysely, sql } from 'kysely'
47
+
48
+ export interface KyselyDataOptions<DB> {
49
+ /** Instância Kysely já configurada com dialect e pool. */
50
+ db: Kysely<DB>
51
+
52
+ /**
53
+ * Query usada pelo healthCheck. Default `SELECT 1`.
54
+ * Útil customizar pra dialects exóticos ou pra checks mais profundos.
55
+ */
56
+ healthQuery?: string
57
+ }
58
+
59
+ export function kyselyData<DB>(options: KyselyDataOptions<DB>): DataAdapter {
60
+ const { db, healthQuery = 'SELECT 1' } = options
61
+
62
+ return {
63
+ name: 'kysely',
64
+ kind: 'data',
65
+
66
+ contextExtension() {
67
+ return { db }
68
+ },
69
+
70
+ async healthCheck(): Promise<HealthStatus> {
71
+ try {
72
+ const start = performance.now()
73
+ await sql.raw(healthQuery).execute(db)
74
+ const latencyMs = performance.now() - start
75
+ return { ok: true, details: { latencyMs: round(latencyMs) } }
76
+ } catch (err) {
77
+ return { ok: false, details: { error: String(err) } }
78
+ }
79
+ },
80
+
81
+ async dispose() {
82
+ await db.destroy()
83
+ },
84
+ }
85
+ }
86
+
87
+ function round(n: number): number {
88
+ return Math.round(n * 100) / 100
89
+ }
90
+
91
+ // =============================================================================
92
+ // Repo tipado a partir de uma entidade
93
+ // =============================================================================
94
+
95
+ /** Params de busca: paginação offset + filtros de igualdade (v1). */
96
+ export interface ListParams {
97
+ limit?: number
98
+ offset?: number
99
+ /** Filtros de igualdade por coluna: `{ status: 'open' }`. */
100
+ where?: Record<string, unknown>
101
+ }
102
+
103
+ /** CRUD tipado sobre uma entidade, projetado nos tipos do `defineEntity`. */
104
+ export interface Repo<E extends EntityConfig> {
105
+ insert(values: EntityInsert<E>): Promise<EntityRow<E>>
106
+ findById(id: string): Promise<EntityRow<E> | null>
107
+ update(id: string, patch: EntityUpdate<E>): Promise<EntityRow<E>>
108
+ /** Soft-delete (seta `deletedAt`) se a entidade tem `softDelete`; senão DELETE. */
109
+ remove(id: string): Promise<void>
110
+ /** Lista tudo, cru (exclui soft-deleted quando aplicável). */
111
+ all(): Promise<EntityRow<E>[]>
112
+ /** A listagem paginada (offset) com filtros de igualdade. Retorna `Paginated`. */
113
+ list(params?: ListParams): Promise<Paginated<EntityRow<E>>>
114
+ }
115
+
116
+ export interface KyselyRepoOptions {
117
+ /** Relógio injetável (testes). Default `() => new Date().toISOString()`. */
118
+ now?: () => string
119
+ /** Gerador de id pra pk auto. Default `randomUUID`. */
120
+ genId?: () => string
121
+ }
122
+
123
+ /**
124
+ * Constrói um `Repo` tipado sobre Kysely a partir de uma entidade. Casca fina —
125
+ * não é ORM: gera id (pk auto), preenche timestamps, respeita soft-delete,
126
+ * marshalla campo `t.json()` (objeto→jsonb, guiado pela declaração), e deixa o
127
+ * resto pro Kysely. Tipos vêm do `defineEntity` (`EntityRow/Insert/Update`).
128
+ */
129
+ export function kyselyRepo<E extends EntityConfig>(
130
+ db: Kysely<unknown>,
131
+ entity: E,
132
+ options: KyselyRepoOptions = {},
133
+ ): Repo<E> {
134
+ const table = entityTable(entity)
135
+ const pk = entityPk(entity)
136
+ const now = options.now ?? (() => new Date().toISOString())
137
+ const genId = options.genId ?? (() => randomUUID())
138
+ const hasTimestamps = entity.timestamps === true
139
+ const hasSoftDelete = entity.softDelete === true
140
+ // Campos t.json(): o repo serializa NA ESCRITA (insert/update). Sem isso, o pg
141
+ // até stringifica objeto plano sozinho — mas ARRAY vira literal de array PG
142
+ // (errado pra jsonb) sem quebrar typecheck. A declaração já sabe o tipo; a
143
+ // dupla ColumnType<unknown, string, never> + JSON.stringify na mão morre aqui.
144
+ // String passa DIRETO (quem já mandava pré-serializado segue valendo — sem
145
+ // double-encode na migração). Na leitura o pg devolve objeto — nada a fazer.
146
+ const jsonFields = Object.entries(entity.fields)
147
+ .filter(([, f]) => f.meta?.logicalType === 'json')
148
+ .map(([name]) => name)
149
+ const marshalJson = (row: Record<string, unknown>): Record<string, unknown> => {
150
+ for (const name of jsonFields) {
151
+ const v = row[name]
152
+ if (v !== undefined && v !== null && typeof v !== 'string') row[name] = JSON.stringify(v)
153
+ }
154
+ return row
155
+ }
156
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
157
+ const qb = db as Kysely<any>
158
+
159
+ return {
160
+ async insert(values) {
161
+ const row: Record<string, unknown> = marshalJson({ ...(values as Record<string, unknown>) })
162
+ if (pk.auto && row[pk.name] === undefined) row[pk.name] = genId()
163
+ if (hasTimestamps) {
164
+ const ts = now()
165
+ row.createdAt = ts
166
+ row.updatedAt = ts
167
+ }
168
+ const inserted = await qb
169
+ .insertInto(table)
170
+ .values(row)
171
+ .returningAll()
172
+ .executeTakeFirstOrThrow()
173
+ return inserted as EntityRow<E>
174
+ },
175
+
176
+ async findById(id) {
177
+ let q = qb.selectFrom(table).selectAll().where(pk.name, '=', id)
178
+ if (hasSoftDelete) q = q.where('deletedAt', 'is', null)
179
+ const found = await q.executeTakeFirst()
180
+ return (found ?? null) as EntityRow<E> | null
181
+ },
182
+
183
+ async update(id, patch) {
184
+ const values: Record<string, unknown> = marshalJson({ ...(patch as Record<string, unknown>) })
185
+ if (hasTimestamps) values.updatedAt = now()
186
+ const updated = await qb
187
+ .updateTable(table)
188
+ .set(values)
189
+ .where(pk.name, '=', id)
190
+ .returningAll()
191
+ .executeTakeFirstOrThrow()
192
+ return updated as EntityRow<E>
193
+ },
194
+
195
+ async remove(id) {
196
+ if (hasSoftDelete) {
197
+ await qb.updateTable(table).set({ deletedAt: now() }).where(pk.name, '=', id).execute()
198
+ return
199
+ }
200
+ await qb.deleteFrom(table).where(pk.name, '=', id).execute()
201
+ },
202
+
203
+ async all() {
204
+ let q = qb.selectFrom(table).selectAll()
205
+ if (hasSoftDelete) q = q.where('deletedAt', 'is', null)
206
+ const rows = await q.execute()
207
+ return rows as EntityRow<E>[]
208
+ },
209
+
210
+ async list(params = {}) {
211
+ const limit = params.limit ?? 50
212
+ const offset = params.offset ?? 0
213
+ const applyFilters = (query: any): any => {
214
+ let out = hasSoftDelete ? query.where('deletedAt', 'is', null) : query
215
+ if (params.where !== undefined) {
216
+ for (const [col, value] of Object.entries(params.where)) {
217
+ out = out.where(col, '=', value)
218
+ }
219
+ }
220
+ return out
221
+ }
222
+
223
+ const countRow = await applyFilters(
224
+ qb.selectFrom(table).select((eb: any) => eb.fn.countAll().as('count')),
225
+ ).executeTakeFirstOrThrow()
226
+ const total = Number((countRow as { count: number | string | bigint }).count)
227
+
228
+ const items = await applyFilters(qb.selectFrom(table).selectAll())
229
+ .limit(limit)
230
+ .offset(offset)
231
+ .execute()
232
+
233
+ const next = offset + limit < total ? String(offset + limit) : null
234
+ return { items: items as EntityRow<E>[], cursor: { next }, total }
235
+ },
236
+ }
237
+ }
238
+
239
+ // =============================================================================
240
+ // crudActions — gera as actions de CRUD a partir de uma entidade
241
+ // =============================================================================
242
+
243
+ export type CrudOp = 'view' | 'create' | 'update' | 'delete' | 'list'
244
+
245
+ /* eslint-disable @typescript-eslint/no-explicit-any */
246
+
247
+ /** Authorize por operação. Só as operações declaradas são geradas (fail-closed). */
248
+ export type CrudAuthorize = { [K in CrudOp]?: AuthorizeFn<any, any> }
249
+
250
+ /**
251
+ * Gera as opus actions de CRUD a partir de uma entidade, com handlers backed por
252
+ * `kyselyRepo(ctx.db, entity)` e input/output derivados do `defineEntity`. Só
253
+ * gera as ops cujo `authorize` foi fornecido (fail-closed). Nomes seguem
254
+ * `<entity>.<verb>` (passam no `opus check`). Composição, não core — vive no
255
+ * driver Kysely (ver `docs/data-layer.md`, peça 7).
256
+ */
257
+ export function crudActions<E extends EntityConfig>(
258
+ entity: E,
259
+ authorize: CrudAuthorize,
260
+ ): Record<string, ActionDef> {
261
+ const name = entity.name
262
+ const pk = entityPk(entity)
263
+ const repoOf = (ctx: { db: unknown }): Repo<E> => kyselyRepo(ctx.db as Kysely<unknown>, entity)
264
+ const idInput = z.object({ [pk.name]: z.string() })
265
+ const labelFields = (): Record<string, { label: string }> => {
266
+ const f: Record<string, { label: string }> = {}
267
+ for (const key of Object.keys(entity.fields)) f[key] = { label: key }
268
+ return f
269
+ }
270
+ const out: Record<string, ActionDef> = {}
271
+
272
+ if (authorize.view !== undefined) {
273
+ out.view = defineAction({
274
+ name: `${name}.view`,
275
+ kind: 'view',
276
+ input: idInput,
277
+ output: entityRowSchema(entity),
278
+ authorize: authorize.view,
279
+ projection: [],
280
+ handler: async (ctx: any, input: any) => {
281
+ const row = await repoOf(ctx).findById(input[pk.name])
282
+ if (row === null) {
283
+ throw error({
284
+ code: `${name}.view.notFound`,
285
+ category: 'not_found',
286
+ message: `${name} não encontrado`,
287
+ })
288
+ }
289
+ return row
290
+ },
291
+ } as any)
292
+ }
293
+
294
+ if (authorize.create !== undefined) {
295
+ out.create = defineAction({
296
+ name: `${name}.create`,
297
+ kind: 'form',
298
+ input: entityInsertSchema(entity),
299
+ output: entityRowSchema(entity),
300
+ authorize: authorize.create,
301
+ fields: labelFields(),
302
+ handler: async (ctx: any, input: any) => repoOf(ctx).insert(input),
303
+ } as any)
304
+ }
305
+
306
+ if (authorize.update !== undefined) {
307
+ out.update = defineAction({
308
+ name: `${name}.update`,
309
+ kind: 'form',
310
+ input: entityUpdateSchema(entity).extend({ [pk.name]: z.string() }),
311
+ output: entityRowSchema(entity),
312
+ authorize: authorize.update,
313
+ fields: labelFields(),
314
+ handler: async (ctx: any, input: any) => {
315
+ const { [pk.name]: id, ...patch } = input
316
+ return repoOf(ctx).update(id, patch as EntityUpdate<E>)
317
+ },
318
+ } as any)
319
+ }
320
+
321
+ if (authorize.list !== undefined) {
322
+ out.list = defineAction({
323
+ name: `${name}.list`,
324
+ kind: 'list',
325
+ input: z.object({
326
+ limit: z.number().int().positive().optional(),
327
+ offset: z.number().int().nonnegative().optional(),
328
+ where: z.record(z.unknown()).optional(),
329
+ }),
330
+ output: z.object({
331
+ items: z.array(entityRowSchema(entity)),
332
+ cursor: z.object({ next: z.string().nullable() }),
333
+ total: z.number(),
334
+ }),
335
+ authorize: authorize.list,
336
+ handler: async (ctx: any, input: any) => repoOf(ctx).list(input),
337
+ } as any)
338
+ }
339
+
340
+ if (authorize.delete !== undefined) {
341
+ out.delete = defineAction({
342
+ name: `${name}.delete`,
343
+ kind: 'simple',
344
+ input: idInput,
345
+ output: z.object({ ok: z.literal(true) }),
346
+ authorize: authorize.delete,
347
+ handler: async (ctx: any, input: any) => {
348
+ await repoOf(ctx).remove(input[pk.name])
349
+ return { ok: true as const }
350
+ },
351
+ } as any)
352
+ }
353
+
354
+ return out
355
+ }
356
+
357
+ /* eslint-enable @typescript-eslint/no-explicit-any */
358
+
359
+ /**
360
+ * Drift-check: compara as entidades (`defineEntity`) com o schema real do banco
361
+ * via introspecção do Kysely. Read-only — não muta nada. Pensado pra rodar no
362
+ * `opus check` e falhar o build se a migration divergir do `defineEntity`.
363
+ *
364
+ * v1: nível-coluna (presença + nullable). O `naming` (default `snake`) mapeia os
365
+ * nomes desejados pra bater com a introspecção crua — o `CamelCasePlugin` do
366
+ * Kysely não toca introspecção. Ver `docs/data-layer.md`.
367
+ */
368
+ export async function kyselyDriftCheck<DB>(
369
+ db: Kysely<DB>,
370
+ entities: EntityConfig[],
371
+ options: { naming?: NamingStrategy } = {},
372
+ ): Promise<DriftFinding[]> {
373
+ const naming = options.naming ?? 'snake'
374
+ const map = resolveNaming(naming)
375
+ const tables = await db.introspection.getTables()
376
+ const byName = new Map(tables.map((meta) => [meta.name, meta]))
377
+
378
+ const findings: DriftFinding[] = []
379
+ for (const entity of entities) {
380
+ const table = map(entityTable(entity))
381
+ const meta = byName.get(table)
382
+ const actual =
383
+ meta === undefined
384
+ ? null
385
+ : meta.columns.map((c) => ({ name: c.name, nullable: c.isNullable }))
386
+ findings.push(...diffColumns(table, desiredColumns(entity, naming), actual))
387
+ }
388
+ return findings
389
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @softize/opus/data — shared helpers/types for data drivers.
3
+ *
4
+ * Empty for now — drivers (kysely, drizzle) são thin wrappers que apenas
5
+ * implementam o contrato `DataAdapter` do core. Quando aparecer código
6
+ * compartilhado (helpers de transação, observability hooks, etc),
7
+ * mora aqui.
8
+ */
9
+
10
+ export {}
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @softize/opus/data/readonly-pool — `readOnlyContextPool`: SQL escrito por LLM
3
+ * executado com as QUATRO defesas do padrão da casa (ADRs 0003/0007 do 1º app real),
4
+ * todas no BANCO — não em regex sobre o texto da query:
5
+ *
6
+ * 1. **Pool com teto** — crie o `Pool` com `max` (e/ou use `maxConcurrent` aqui):
7
+ * query de LLM não esgota as conexões do app.
8
+ * 2. **Transação READ ONLY** — `BEGIN TRANSACTION READ ONLY`: escrita morre no
9
+ * servidor, qualquer que seja o SQL.
10
+ * 3. **`SET LOCAL ROLE` por transação** — o alcance é do contexto: com grants
11
+ * default-fechado, `sales_read` não enxerga `hr_employees.salary` nem por
12
+ * SELECT criativo. (Criar os roles/grants é migração sua; aqui só se assume.)
13
+ * 4. **Protocolo estendido contra multi-sentença** — o SQL do LLM roda SEMPRE
14
+ * com array de valores (protocolo estendido do PG, que recusa `;` composto)
15
+ * — `SELECT 1; DROP TABLE x` nem chega a executar.
16
+ *
17
+ * Saindo, `DISCARD ALL` devolve a conexão limpa ao pool (role/settings zerados);
18
+ * se a limpeza falhar, a conexão é DESTRUÍDA (release com erro), nunca reusada suja.
19
+ *
20
+ * Uso (o caso action `ai: true` que executa SQL livre):
21
+ *
22
+ * import { Pool } from 'pg'
23
+ * import { readOnlyContextPool } from '@softize/opus/data/readonly-pool'
24
+ *
25
+ * const bi = readOnlyContextPool({ pool: new Pool({ connectionString, max: 4 }) })
26
+ * const { rows } = await bi.query(sqlDoLlm, { role: roleFor(ctx), params: [] })
27
+ */
28
+
29
+ // =============================================================================
30
+ // Tipos estruturais (sem dep direta de `pg` — aceita pool-like)
31
+ // =============================================================================
32
+
33
+ /** Cliente dedicado obtido do pool (subset do `pg.PoolClient`). */
34
+ export interface ReadOnlyPoolClient {
35
+ query(text: string, values?: unknown[]): Promise<unknown>
36
+ /** `release(err)` com erro DESTRÓI a conexão em vez de devolvê-la ao pool. */
37
+ release(err?: Error | boolean): void
38
+ }
39
+
40
+ /** Subset de `pg.Pool` que precisamos: só `connect()`. */
41
+ export interface ReadOnlyConnectPool {
42
+ connect(): Promise<ReadOnlyPoolClient>
43
+ }
44
+
45
+ export interface ReadOnlyContextPoolOptions {
46
+ /** Pool pg-compatível. Crie COM teto (`new Pool({ max })`) — 1ª defesa. */
47
+ pool: ReadOnlyConnectPool
48
+ /**
49
+ * Teto de queries simultâneas DESTE wrapper (as demais enfileiram). Além do
50
+ * `max` do pool: segura a fila aqui em vez de disputar conexão com o app.
51
+ */
52
+ maxConcurrent?: number
53
+ /** `SET LOCAL statement_timeout` por transação (ms). Default 15000. */
54
+ statementTimeoutMs?: number
55
+ }
56
+
57
+ export interface ReadOnlyQueryOptions {
58
+ /**
59
+ * Role de ALCANCE da transação (`SET LOCAL ROLE`) — derive do contexto
60
+ * (ex.: `sales_read`). Sem role, a query roda com o alcance da conexão.
61
+ */
62
+ role?: string
63
+ /** Parâmetros posicionais (`$1`, `$2`…). Sempre enviados (mesmo vazios) — 4ª defesa. */
64
+ params?: unknown[]
65
+ }
66
+
67
+ /** Resultado mínimo (shape do `pg.Result`). */
68
+ export interface ReadOnlyQueryResult {
69
+ rows: unknown[]
70
+ rowCount?: number | null
71
+ }
72
+
73
+ // =============================================================================
74
+ // Fábrica
75
+ // =============================================================================
76
+
77
+ // Identificador de role: sem quoting exótico, minúsculo/underscore, limite do PG.
78
+ const ROLE_RE = /^[a-z_][a-z0-9_]{0,62}$/
79
+
80
+ export function readOnlyContextPool(options: ReadOnlyContextPoolOptions): {
81
+ query(sql: string, opts?: ReadOnlyQueryOptions): Promise<ReadOnlyQueryResult>
82
+ } {
83
+ const { pool, maxConcurrent, statementTimeoutMs = 15_000 } = options
84
+ if (maxConcurrent !== undefined && (!Number.isInteger(maxConcurrent) || maxConcurrent < 1)) {
85
+ throw new Error(`readOnlyContextPool: maxConcurrent inválido (${maxConcurrent}) — inteiro ≥ 1.`)
86
+ }
87
+ if (!Number.isInteger(statementTimeoutMs) || statementTimeoutMs < 1) {
88
+ throw new Error(`readOnlyContextPool: statementTimeoutMs inválido (${statementTimeoutMs}).`)
89
+ }
90
+ const acquireSlot = maxConcurrent === undefined ? null : makeSemaphore(maxConcurrent)
91
+
92
+ return {
93
+ async query(sql, opts = {}) {
94
+ const { role, params = [] } = opts
95
+ // Valida ANTES de tomar conexão — role ruim nem toca o pool.
96
+ if (role !== undefined && !ROLE_RE.test(role)) {
97
+ throw new Error(
98
+ `readOnlyContextPool: role "${role}" inválido (esperado /${ROLE_RE.source}/ — o alcance vem de roles seus, não de texto livre).`,
99
+ )
100
+ }
101
+
102
+ const releaseSlot = acquireSlot === null ? null : await acquireSlot()
103
+ try {
104
+ const client = await pool.connect()
105
+ let inTx = false
106
+ try {
107
+ await client.query('BEGIN TRANSACTION READ ONLY')
108
+ inTx = true
109
+ // SET não aceita parâmetro — os dois valores são validados acima.
110
+ await client.query(`SET LOCAL statement_timeout = ${statementTimeoutMs}`)
111
+ if (role !== undefined) await client.query(`SET LOCAL ROLE "${role}"`)
112
+ // O SQL do LLM: SEMPRE com values → protocolo estendido → sem multi-sentença.
113
+ const result = (await client.query(sql, params)) as ReadOnlyQueryResult
114
+ await client.query('COMMIT')
115
+ inTx = false
116
+ return { rows: result.rows ?? [], rowCount: result.rowCount ?? null }
117
+ } finally {
118
+ // Limpeza best-effort; se falhar, release COM erro → a conexão morre
119
+ // em vez de voltar suja (com role/settings trocados) pro pool.
120
+ let cleanupError: Error | undefined
121
+ try {
122
+ if (inTx) await client.query('ROLLBACK')
123
+ await client.query('DISCARD ALL')
124
+ } catch (err) {
125
+ cleanupError = err instanceof Error ? err : new Error(String(err))
126
+ }
127
+ client.release(cleanupError)
128
+ }
129
+ } finally {
130
+ releaseSlot?.()
131
+ }
132
+ },
133
+ }
134
+ }
135
+
136
+ // =============================================================================
137
+ // Semáforo (fila FIFO, zero-dep)
138
+ // =============================================================================
139
+
140
+ function makeSemaphore(limit: number): () => Promise<() => void> {
141
+ let active = 0
142
+ const waiting: Array<() => void> = []
143
+ const release = (): void => {
144
+ active -= 1
145
+ const next = waiting.shift()
146
+ if (next !== undefined) next()
147
+ }
148
+ return function acquire(): Promise<() => void> {
149
+ if (active < limit) {
150
+ active += 1
151
+ return Promise.resolve(release)
152
+ }
153
+ return new Promise((resolve) => {
154
+ waiting.push(() => {
155
+ active += 1
156
+ resolve(release)
157
+ })
158
+ })
159
+ }
160
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @softize/opus/dsl — Runtime evaluator.
3
+ *
4
+ * Avalia AST contra um contexto (`{ user, input, loaded, ... }`) e retorna
5
+ * o resultado. Usado pra `authorize` (boolean) e em outros casos onde
6
+ * precisa rodar a expressão direto em JS.
7
+ */
8
+
9
+ import type { AstNode } from './types.ts'
10
+
11
+ export type EvalContext = Record<string, unknown>
12
+
13
+ export function evalExpression(node: AstNode, ctx: EvalContext): unknown {
14
+ switch (node.kind) {
15
+ case 'literal':
16
+ return node.value
17
+ case 'array':
18
+ return node.items.map((i) => evalExpression(i, ctx))
19
+ case 'identifier':
20
+ return ctx[node.name]
21
+ case 'member': {
22
+ const obj = evalExpression(node.object, ctx)
23
+ if (obj === null || obj === undefined) return undefined
24
+ return (obj as Record<string, unknown>)[node.property]
25
+ }
26
+ case 'unary': {
27
+ const v = evalExpression(node.operand, ctx)
28
+ return node.op === 'NOT' ? !v : -(v as number)
29
+ }
30
+ case 'binary': {
31
+ // Curto-circuito pra AND/OR
32
+ if (node.op === 'AND') {
33
+ const left = evalExpression(node.left, ctx)
34
+ if (!left) return false
35
+ return Boolean(evalExpression(node.right, ctx))
36
+ }
37
+ if (node.op === 'OR') {
38
+ const left = evalExpression(node.left, ctx)
39
+ if (left) return true
40
+ return Boolean(evalExpression(node.right, ctx))
41
+ }
42
+ const left = evalExpression(node.left, ctx) as number | string | null
43
+ const right = evalExpression(node.right, ctx) as number | string | null
44
+ switch (node.op) {
45
+ case '==':
46
+ return left === right
47
+ case '!=':
48
+ return left !== right
49
+ case '<':
50
+ return (left as number) < (right as number)
51
+ case '<=':
52
+ return (left as number) <= (right as number)
53
+ case '>':
54
+ return (left as number) > (right as number)
55
+ case '>=':
56
+ return (left as number) >= (right as number)
57
+ case '+':
58
+ return (left as number) + (right as number)
59
+ case '-':
60
+ return (left as number) - (right as number)
61
+ case '*':
62
+ return (left as number) * (right as number)
63
+ case '/':
64
+ return (left as number) / (right as number)
65
+ /* v8 ignore next 2 */
66
+ default:
67
+ throw new Error(`DSL eval: unknown binary op ${node.op as string}`)
68
+ }
69
+ }
70
+ case 'in': {
71
+ const v = evalExpression(node.left, ctx)
72
+ const arr = evalExpression(node.values, ctx) as unknown[]
73
+ const found = Array.isArray(arr) && arr.includes(v)
74
+ return node.negated ? !found : found
75
+ }
76
+ case 'between': {
77
+ const v = evalExpression(node.subject, ctx) as number
78
+ const lo = evalExpression(node.low, ctx) as number
79
+ const hi = evalExpression(node.high, ctx) as number
80
+ const within = v >= lo && v <= hi
81
+ return node.negated ? !within : within
82
+ }
83
+ case 'like': {
84
+ const v = evalExpression(node.subject, ctx)
85
+ const pat = evalExpression(node.pattern, ctx) as string
86
+ const matched = sqlLikeMatch(
87
+ String(v ?? ''),
88
+ pat,
89
+ node.caseInsensitive,
90
+ )
91
+ return node.negated ? !matched : matched
92
+ }
93
+ case 'isNull': {
94
+ const v = evalExpression(node.subject, ctx)
95
+ const isNullish = v === null || v === undefined
96
+ return node.negated ? !isNullish : isNullish
97
+ }
98
+ case 'call':
99
+ return callFunction(node.name, node.args.map((a) => evalExpression(a, ctx)))
100
+ }
101
+ }
102
+
103
+ // =============================================================================
104
+ // SQL LIKE matcher
105
+ // =============================================================================
106
+
107
+ function sqlLikeMatch(s: string, pat: string, ci: boolean): boolean {
108
+ const regexSrc = pat.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/%/g, '.*').replace(/_/g, '.')
109
+ const regex = new RegExp(`^${regexSrc}$`, ci ? 'i' : undefined)
110
+ return regex.test(s)
111
+ }
112
+
113
+ // =============================================================================
114
+ // Built-in functions
115
+ // =============================================================================
116
+
117
+ function callFunction(name: string, args: unknown[]): unknown {
118
+ switch (name) {
119
+ case 'NOW':
120
+ return new Date().toISOString()
121
+ case 'TODAY':
122
+ return new Date().toISOString().slice(0, 10)
123
+ case 'LOWER':
124
+ return String(args[0] ?? '').toLowerCase()
125
+ case 'UPPER':
126
+ return String(args[0] ?? '').toUpperCase()
127
+ case 'LENGTH':
128
+ return String(args[0] ?? '').length
129
+ case 'COALESCE':
130
+ for (const a of args) {
131
+ if (a !== null && a !== undefined) return a
132
+ }
133
+ return null
134
+ }
135
+ throw new Error(`DSL eval: unknown function '${name}'`)
136
+ }