@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,439 @@
1
+ /**
2
+ * @softize/opus/schema — `defineEntity` + relations + inferência de tipos.
3
+ *
4
+ * Uma entidade é a descrição declarativa de um recorte de dado persistido
5
+ * (campos, relações, índices). Serve de fonte única pra:
6
+ * - tipo TS da linha (`EntityRow`), input de create (`EntityInsert`) e
7
+ * update (`EntityUpdate`);
8
+ * - colunas resolvidas (`entityColumns`) que o data/migration adapter lê;
9
+ * - projeção de view/search (consumido pelas actions com `entity:`).
10
+ *
11
+ * Puro: `defineEntity` valida e devolve o config (identity), sem efeito
12
+ * colateral — igual `defineAction`/`defineDomain`. A materialização (migration,
13
+ * repo tipado) é do adapter, não daqui. Ver §15 do protocolo.
14
+ */
15
+
16
+ import { z } from 'zod'
17
+ import { error } from '../core/index.ts'
18
+ import type { ColumnMeta, LogicalType } from './drivers/zod.ts'
19
+
20
+ /* eslint-disable @typescript-eslint/no-explicit-any */
21
+
22
+ // =============================================================================
23
+ // Relations
24
+ // =============================================================================
25
+
26
+ export type RelationKind = 'belongsTo' | 'hasOne' | 'hasMany' | 'manyToMany'
27
+
28
+ export interface Relation {
29
+ kind: RelationKind
30
+ /** Nome da entidade alvo (referência por string — resolvida no register). */
31
+ target: string
32
+ /** FK local (belongsTo). */
33
+ from?: string
34
+ /** FK no alvo (hasOne/hasMany). */
35
+ to?: string
36
+ /** Tabela de junção (manyToMany). */
37
+ through?: string
38
+ }
39
+
40
+ /** FK local → alvo. Ex: `belongsTo('user', { from: 'ownerId' })`. */
41
+ export const belongsTo = (target: string, opts: { from: string }): Relation => ({
42
+ kind: 'belongsTo',
43
+ target,
44
+ from: opts.from,
45
+ })
46
+
47
+ /** FK no alvo, 1:1. Ex: `hasOne('profile', { to: 'userId' })`. */
48
+ export const hasOne = (target: string, opts: { to: string }): Relation => ({
49
+ kind: 'hasOne',
50
+ target,
51
+ to: opts.to,
52
+ })
53
+
54
+ /** FK no alvo, 1:N. Ex: `hasMany('attachment', { to: 'dealId' })`. */
55
+ export const hasMany = (target: string, opts: { to: string }): Relation => ({
56
+ kind: 'hasMany',
57
+ target,
58
+ to: opts.to,
59
+ })
60
+
61
+ /** N:N via tabela de junção. Ex: `manyToMany('tag', { through: 'deal_tags' })`. */
62
+ export const manyToMany = (target: string, opts: { through: string }): Relation => ({
63
+ kind: 'manyToMany',
64
+ target,
65
+ through: opts.through,
66
+ })
67
+
68
+ // =============================================================================
69
+ // Config
70
+ // =============================================================================
71
+
72
+ export interface IndexSpec {
73
+ on: string[]
74
+ unique?: boolean
75
+ using?: string
76
+ }
77
+
78
+ export type EntityFields = Record<string, LogicalType<any>>
79
+
80
+ export interface EntityConfig<F extends EntityFields = EntityFields> {
81
+ /** Nome singular da entidade. `[a-z][a-zA-Z0-9_]*`. */
82
+ name: string
83
+ /** Doc de negócio da entidade — o que ela é/representa. A fonte de
84
+ * entendimento; flui pro manifest/lente (substitui a prosa do domain.md). */
85
+ description?: string
86
+ /** Nome da tabela. Default: plural simples de `name`. */
87
+ table?: string
88
+ fields: F
89
+ relations?: Record<string, Relation>
90
+ indexes?: IndexSpec[]
91
+ /** Injeta `createdAt`/`updatedAt` (geridos pelo runtime). */
92
+ timestamps?: boolean
93
+ /** Injeta `deletedAt`; search/view filtram deletados por default. */
94
+ softDelete?: boolean
95
+ }
96
+
97
+ // =============================================================================
98
+ // Inferência de tipos
99
+ // =============================================================================
100
+
101
+ type Simplify<T> = { [K in keyof T]: T[K] } & {}
102
+
103
+ type FieldT<X> = X extends LogicalType<infer T> ? T : never
104
+
105
+ type HasPk<F> = true extends {
106
+ [K in keyof F]: F[K] extends { readonly __pk: true } ? true : false
107
+ }[keyof F]
108
+ ? true
109
+ : false
110
+
111
+ type HasDefault<X> = X extends { readonly __hasDefault: true } ? true : false
112
+ type IsNullable<X> = null extends FieldT<X> ? true : false
113
+
114
+ type IdPart<F> = HasPk<F> extends true ? {} : { id: string }
115
+ type TimestampPart<E> = E extends { timestamps: true }
116
+ ? { createdAt: string; updatedAt: string }
117
+ : {}
118
+ type SoftDeletePart<E> = E extends { softDelete: true } ? { deletedAt: string | null } : {}
119
+
120
+ /** Linha completa da entidade (id auto + campos + timestamps + softDelete). */
121
+ export type EntityRow<E extends EntityConfig> = Simplify<
122
+ IdPart<E['fields']> &
123
+ { [K in keyof E['fields']]: FieldT<E['fields'][K]> } &
124
+ TimestampPart<E> &
125
+ SoftDeletePart<E>
126
+ >
127
+
128
+ // Insert: sem colunas geradas; campos com default/nullable são opcionais.
129
+ type OptionalInsertKeys<F> = {
130
+ [K in keyof F]: HasDefault<F[K]> extends true
131
+ ? K
132
+ : IsNullable<F[K]> extends true
133
+ ? K
134
+ : never
135
+ }[keyof F]
136
+ type RequiredInsertKeys<F> = Exclude<keyof F, OptionalInsertKeys<F>>
137
+
138
+ /** Input de create: sem id auto/timestamps; default/nullable opcionais. */
139
+ export type EntityInsert<E extends EntityConfig> = Simplify<
140
+ { [K in RequiredInsertKeys<E['fields']>]: FieldT<E['fields'][K]> } & {
141
+ [K in OptionalInsertKeys<E['fields']>]?: FieldT<E['fields'][K]>
142
+ }
143
+ >
144
+
145
+ /** Input de update: partial do insert. */
146
+ export type EntityUpdate<E extends EntityConfig> = Partial<EntityInsert<E>>
147
+
148
+ // =============================================================================
149
+ // defineEntity
150
+ // =============================================================================
151
+
152
+ const ENTITY_NAME_RE = /^[a-z][a-zA-Z0-9_]*$/
153
+
154
+ /**
155
+ * Constrói e valida uma entidade. Em sucesso devolve o próprio config
156
+ * (identity, pra preservar inferência). Erros são `ActionError` com
157
+ * `category: 'internal'`.
158
+ */
159
+ export function defineEntity<const E extends EntityConfig>(config: E): E {
160
+ validateEntity(config)
161
+ return config
162
+ }
163
+
164
+ function validateEntity(config: EntityConfig): void {
165
+ if (typeof config.name !== 'string' || config.name.length === 0) {
166
+ throw error({
167
+ code: 'entity.invalid_name',
168
+ category: 'internal',
169
+ message: 'Entity "name" deve ser string não-vazia',
170
+ })
171
+ }
172
+ if (!ENTITY_NAME_RE.test(config.name)) {
173
+ throw error({
174
+ code: 'entity.invalid_name_format',
175
+ category: 'internal',
176
+ message: `Entity name "${config.name}" deve casar com [a-z][a-zA-Z0-9_]*`,
177
+ })
178
+ }
179
+
180
+ const pkFields = Object.entries(config.fields).filter(([, f]) => f.column.pk === true)
181
+ if (pkFields.length > 1) {
182
+ throw error({
183
+ code: 'entity.multiple_pk',
184
+ category: 'internal',
185
+ message: `Entity "${config.name}" tem ${pkFields.length} primary keys (${pkFields
186
+ .map(([k]) => k)
187
+ .join(', ')}); declare no máximo uma`,
188
+ })
189
+ }
190
+
191
+ if (config.relations !== undefined) {
192
+ for (const [relName, rel] of Object.entries(config.relations)) {
193
+ if (typeof rel.target !== 'string' || rel.target.length === 0) {
194
+ throw error({
195
+ code: 'entity.invalid_relation_target',
196
+ category: 'internal',
197
+ message: `Relation "${relName}" em "${config.name}" tem target inválido`,
198
+ })
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ // =============================================================================
205
+ // Materialização (lida pelo data/migration adapter)
206
+ // =============================================================================
207
+
208
+ export interface ResolvedColumn {
209
+ name: string
210
+ /** `undefined` em colunas injetadas (id/timestamps/softDelete). */
211
+ logicalType?: string
212
+ column: ColumnMeta
213
+ /** `true` em colunas geridas pelo runtime (id auto, timestamps, deletedAt). */
214
+ generated: boolean
215
+ }
216
+
217
+ /**
218
+ * Resolve as colunas da entidade incluindo as injetadas (id auto quando não há
219
+ * pk declarada, timestamps, deletedAt). É o input do migration adapter.
220
+ */
221
+ export function entityColumns(config: EntityConfig): ResolvedColumn[] {
222
+ const cols: ResolvedColumn[] = []
223
+ const hasPk = Object.values(config.fields).some((f) => f.column.pk === true)
224
+
225
+ if (!hasPk) {
226
+ cols.push({ name: 'id', logicalType: 'uuid', column: { pk: true }, generated: true })
227
+ }
228
+
229
+ for (const [name, field] of Object.entries(config.fields)) {
230
+ cols.push({
231
+ name,
232
+ logicalType: field.meta.logicalType,
233
+ column: field.column,
234
+ generated: false,
235
+ })
236
+ }
237
+
238
+ if (config.timestamps === true) {
239
+ cols.push({ name: 'createdAt', logicalType: 'datetime', column: {}, generated: true })
240
+ cols.push({ name: 'updatedAt', logicalType: 'datetime', column: {}, generated: true })
241
+ }
242
+ if (config.softDelete === true) {
243
+ cols.push({
244
+ name: 'deletedAt',
245
+ logicalType: 'datetime',
246
+ column: { nullable: true },
247
+ generated: true,
248
+ })
249
+ }
250
+
251
+ return cols
252
+ }
253
+
254
+ /**
255
+ * Primary key da entidade. `auto: true` quando é o `id` injetado (nenhum campo
256
+ * declarou `.pk()`) — nesse caso o repo gera o valor (uuid).
257
+ */
258
+ export function entityPk(config: EntityConfig): { name: string; auto: boolean } {
259
+ for (const [name, field] of Object.entries(config.fields)) {
260
+ if (field.column.pk === true) return { name, auto: false }
261
+ }
262
+ return { name: 'id', auto: true }
263
+ }
264
+
265
+ // =============================================================================
266
+ // Schemas Zod derivados (input/output de actions a partir da entidade)
267
+ // =============================================================================
268
+
269
+ /** Schema Zod da linha completa (output): todos os campos + colunas geradas. */
270
+ export function entityRowSchema(config: EntityConfig): z.ZodObject<z.ZodRawShape> {
271
+ const shape: z.ZodRawShape = {}
272
+ for (const col of entityColumns(config)) {
273
+ const field = config.fields[col.name]
274
+ shape[col.name] =
275
+ field !== undefined
276
+ ? field.zod()
277
+ : col.column.nullable === true
278
+ ? z.string().nullable()
279
+ : z.string()
280
+ }
281
+ return z.object(shape)
282
+ }
283
+
284
+ /** Schema Zod de create: só campos declarados; nullable/default viram opcionais. */
285
+ export function entityInsertSchema(config: EntityConfig): z.ZodObject<z.ZodRawShape> {
286
+ const shape: z.ZodRawShape = {}
287
+ for (const [name, field] of Object.entries(config.fields)) {
288
+ const base = field.zod()
289
+ shape[name] =
290
+ field.column.nullable === true || field.column.hasDefault === true
291
+ ? base.optional()
292
+ : base
293
+ }
294
+ return z.object(shape)
295
+ }
296
+
297
+ /** Schema Zod de update: partial do insert. */
298
+ export function entityUpdateSchema(config: EntityConfig): z.ZodObject<z.ZodRawShape> {
299
+ return entityInsertSchema(config).partial()
300
+ }
301
+
302
+ /** Nome da tabela: `table` explícito ou plural simples de `name`. */
303
+ export function entityTable(config: EntityConfig): string {
304
+ if (config.table !== undefined) return config.table
305
+ const n = config.name
306
+ if (n.endsWith('y')) return `${n.slice(0, -1)}ies`
307
+ if (/(s|x|z|ch|sh)$/.test(n)) return `${n}es`
308
+ return `${n}s`
309
+ }
310
+
311
+ /**
312
+ * `true` se `value` parece um `EntityConfig`. Usado pra coletar entidades de
313
+ * `domain.models` (que é `unknown`) sem confundir com action/reaction/model.
314
+ * Heurística: tem `name` string + `fields` objeto e **não** tem `kind`
315
+ * (descarta `ActionDef`).
316
+ */
317
+ export function isEntityConfig(value: unknown): value is EntityConfig {
318
+ if (typeof value !== 'object' || value === null) return false
319
+ const v = value as Record<string, unknown>
320
+ return (
321
+ typeof v.name === 'string' &&
322
+ typeof v.fields === 'object' &&
323
+ v.fields !== null &&
324
+ !('kind' in v)
325
+ )
326
+ }
327
+
328
+ // =============================================================================
329
+ // Drift-check (comparador puro — sem Kysely)
330
+ // =============================================================================
331
+
332
+ /** Shape mínimo de coluna pra comparar entidade ↔ banco. */
333
+ export interface ColumnShape {
334
+ name: string
335
+ nullable: boolean
336
+ }
337
+
338
+ export type DriftKind =
339
+ | 'missing_table'
340
+ | 'missing_column'
341
+ | 'extra_column'
342
+ | 'nullable_mismatch'
343
+
344
+ export interface DriftFinding {
345
+ table: string
346
+ column?: string
347
+ kind: DriftKind
348
+ message: string
349
+ }
350
+
351
+ /**
352
+ * Convenção de nome de coluna. `snake` (default) mapeia camelCase → snake_case
353
+ * (idiomático Postgres); `identity` mantém; ou uma fn custom. Ver `docs/data-layer.md`.
354
+ */
355
+ export type NamingStrategy = 'snake' | 'identity' | ((name: string) => string)
356
+
357
+ /** camelCase → snake_case. `ownerId` → `owner_id`, `id` → `id`. */
358
+ export function snakeCase(name: string): string {
359
+ return name.replace(/([A-Z])/g, '_$1').toLowerCase()
360
+ }
361
+
362
+ /** Resolve a estratégia num mapeador `nome → coluna`. */
363
+ export function resolveNaming(naming: NamingStrategy): (name: string) => string {
364
+ if (naming === 'snake') return snakeCase
365
+ if (naming === 'identity') return (n) => n
366
+ return naming
367
+ }
368
+
369
+ /**
370
+ * Colunas desejadas (do `defineEntity`) no shape de comparação, com os nomes já
371
+ * mapeados pela `naming` (default `snake`) — pra bater com a introspecção crua
372
+ * do banco no drift-check.
373
+ */
374
+ export function desiredColumns(
375
+ config: EntityConfig,
376
+ naming: NamingStrategy = 'snake',
377
+ ): ColumnShape[] {
378
+ const map = resolveNaming(naming)
379
+ return entityColumns(config).map((c) => ({
380
+ name: map(c.name),
381
+ nullable: c.column.nullable === true,
382
+ }))
383
+ }
384
+
385
+ /**
386
+ * Compara o schema desejado (entidade) com o real (banco). Puro: recebe os dois
387
+ * lados já normalizados. `actual: null` significa tabela inexistente.
388
+ *
389
+ * v1: compara presença e nullable. Tipos/defaults/índices/FK ficam pro passo 2.
390
+ */
391
+ export function diffColumns(
392
+ table: string,
393
+ desired: ColumnShape[],
394
+ actual: ColumnShape[] | null,
395
+ ): DriftFinding[] {
396
+ if (actual === null) {
397
+ return [{ table, kind: 'missing_table', message: `Tabela "${table}" não existe` }]
398
+ }
399
+
400
+ const findings: DriftFinding[] = []
401
+ const actualByName = new Map(actual.map((c) => [c.name, c]))
402
+ const desiredNames = new Set(desired.map((c) => c.name))
403
+
404
+ for (const d of desired) {
405
+ const a = actualByName.get(d.name)
406
+ if (a === undefined) {
407
+ findings.push({
408
+ table,
409
+ column: d.name,
410
+ kind: 'missing_column',
411
+ message: `Coluna "${table}.${d.name}" existe na entidade mas não no banco`,
412
+ })
413
+ continue
414
+ }
415
+ if (a.nullable !== d.nullable) {
416
+ findings.push({
417
+ table,
418
+ column: d.name,
419
+ kind: 'nullable_mismatch',
420
+ message: `Coluna "${table}.${d.name}" deveria ser ${
421
+ d.nullable ? 'NULL-able' : 'NOT NULL'
422
+ } mas está ${a.nullable ? 'NULL-able' : 'NOT NULL'} no banco`,
423
+ })
424
+ }
425
+ }
426
+
427
+ for (const a of actual) {
428
+ if (!desiredNames.has(a.name)) {
429
+ findings.push({
430
+ table,
431
+ column: a.name,
432
+ kind: 'extra_column',
433
+ message: `Coluna "${table}.${a.name}" existe no banco mas não na entidade`,
434
+ })
435
+ }
436
+ }
437
+
438
+ return findings
439
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @softize/opus/schema/format/locale — helpers internos de formatação.
3
+ *
4
+ * Wrappa `Intl.*` com defaults pt-BR. Usado pelas factories de `t.*` pra
5
+ * implementar `.format(value, { locale })` sem repetir boilerplate.
6
+ *
7
+ * Não é parte da superfície pública — consumers acessam via `t.money().format(...)`.
8
+ */
9
+
10
+ const DEFAULT_LOCALE = 'pt-BR'
11
+
12
+ // =============================================================================
13
+ // Numbers / decimals / percent
14
+ // =============================================================================
15
+
16
+ /**
17
+ * Formata número decimal seguindo locale. `value` é string canônica
18
+ * (dot como decimal separator) — convertemos pra Number antes do `Intl`.
19
+ */
20
+ export function formatDecimal(
21
+ value: string,
22
+ locale: string = DEFAULT_LOCALE,
23
+ options?: Intl.NumberFormatOptions,
24
+ ): string {
25
+ const n = Number(value)
26
+ return new Intl.NumberFormat(locale, options).format(n)
27
+ }
28
+
29
+ /**
30
+ * Parse string decimal aceitando pt-BR ("1.234,56") ou en ("1234.56").
31
+ * Heurística: se contém vírgula, assume pt-BR (ponto = milhar, vírgula = decimal).
32
+ * Caso contrário, deixa como está. Resultado é string canônica (dot decimal).
33
+ */
34
+ export function parseDecimal(input: string): string {
35
+ const trimmed = input.trim()
36
+ if (trimmed.includes(',')) {
37
+ // pt-BR: remove pontos (milhar), troca vírgula por ponto.
38
+ return trimmed.replace(/\./g, '').replace(',', '.')
39
+ }
40
+ return trimmed
41
+ }
42
+
43
+ // =============================================================================
44
+ // Money — bigint cents
45
+ // =============================================================================
46
+
47
+ /**
48
+ * Formata bigint cents como moeda no locale.
49
+ * Ex: formatMoney(123456n, 'BRL', 'pt-BR') → "R$ 1.234,56"
50
+ */
51
+ export function formatMoney(
52
+ cents: bigint,
53
+ currency: string,
54
+ locale: string = DEFAULT_LOCALE,
55
+ ): string {
56
+ // bigint → number pra Intl. Pra valores acima de Number.MAX_SAFE_INTEGER
57
+ // perdemos precisão, mas é trade-off aceitável pra formatação de display.
58
+ const value = Number(cents) / 100
59
+ return new Intl.NumberFormat(locale, {
60
+ style: 'currency',
61
+ currency,
62
+ }).format(value)
63
+ }
64
+
65
+ /**
66
+ * Parse string de moeda em bigint cents. Aceita "R$ 1.234,56", "1234.56",
67
+ * "1,234.56" (en com milhar), "1234,56" (pt-BR), etc.
68
+ * Retorna `null` se input não tem dígitos.
69
+ */
70
+ export function parseMoney(input: string): bigint | null {
71
+ // Remove símbolos de moeda / espaços / NBSP — sobra dígitos, separadores e sinal.
72
+ const cleaned = input.replace(/[^\d.,\-]/g, '').trim()
73
+ if (cleaned === '' || cleaned === '-') return null
74
+
75
+ const negative = cleaned.startsWith('-')
76
+ const body = negative ? cleaned.slice(1) : cleaned
77
+
78
+ // Determina separador decimal: o último ponto-ou-vírgula é decimal se tiver
79
+ // exatamente 2 dígitos depois. Caso contrário, sem decimal.
80
+ const lastDot = body.lastIndexOf('.')
81
+ const lastComma = body.lastIndexOf(',')
82
+ const lastSep = Math.max(lastDot, lastComma)
83
+
84
+ let intPart: string
85
+ let fracPart: string
86
+
87
+ if (lastSep === -1) {
88
+ intPart = body
89
+ fracPart = ''
90
+ } else {
91
+ const tail = body.slice(lastSep + 1)
92
+ if (tail.length === 2) {
93
+ intPart = body.slice(0, lastSep).replace(/[.,]/g, '')
94
+ fracPart = tail
95
+ } else {
96
+ // Sem decimal — todos os separadores são milhar.
97
+ intPart = body.replace(/[.,]/g, '')
98
+ fracPart = ''
99
+ }
100
+ }
101
+
102
+ intPart = intPart.replace(/\D/g, '')
103
+ fracPart = fracPart.padEnd(2, '0').slice(0, 2)
104
+
105
+ if (intPart === '') intPart = '0'
106
+
107
+ const cents = BigInt(intPart) * 100n + BigInt(fracPart)
108
+ return negative ? -cents : cents
109
+ }
110
+
111
+ // =============================================================================
112
+ // Datetime / date / time — pt-BR aware
113
+ // =============================================================================
114
+
115
+ /**
116
+ * Formata ISO datetime ("2026-05-28T13:45:00Z") como string humana.
117
+ * pt-BR → "28/05/2026 13:45". Outros locales delegam pra Intl.
118
+ */
119
+ export function formatDatetime(iso: string, locale: string = DEFAULT_LOCALE): string {
120
+ const d = new Date(iso)
121
+ return new Intl.DateTimeFormat(locale, {
122
+ day: '2-digit',
123
+ month: '2-digit',
124
+ year: 'numeric',
125
+ hour: '2-digit',
126
+ minute: '2-digit',
127
+ hour12: false,
128
+ }).format(d)
129
+ }
130
+
131
+ /**
132
+ * Formata date "YYYY-MM-DD" como string humana. pt-BR → "28/05/2026".
133
+ * Constrói o Date em UTC pra evitar drift de timezone que mudaria o dia.
134
+ */
135
+ export function formatDate(date: string, locale: string = DEFAULT_LOCALE): string {
136
+ const [y, m, d] = date.split('-').map(Number)
137
+ const utc = new Date(Date.UTC(y!, m! - 1, d!))
138
+ return new Intl.DateTimeFormat(locale, {
139
+ day: '2-digit',
140
+ month: '2-digit',
141
+ year: 'numeric',
142
+ timeZone: 'UTC',
143
+ }).format(utc)
144
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @softize/opus/schema — driver-agnostic schema utilities.
3
+ *
4
+ * Aqui mora:
5
+ * - Metadata machinery (`attachLogicalType`, `getLogicalType`) — armazena
6
+ * logical type info anexada a qualquer Schema, independente de driver.
7
+ * - Tipo `LogicalTypeMeta` re-exportado de `@softize/opus/core`.
8
+ *
9
+ * Drivers (zod) implementam factories (`t.*`) que usam essa machinery.
10
+ * OpenAPI gen (`./openapi`) consome a metadata pra anotar specs.
11
+ */
12
+
13
+ import type { LogicalTypeMeta } from '../core/index.ts'
14
+
15
+ // =============================================================================
16
+ // Metadata mechanism (driver-agnostic)
17
+ // =============================================================================
18
+
19
+ // A implementação MUDOU pro core (a UI lê a meta do contrato e a fronteira SPA
20
+ // só deixa ui tocar ui/lib/core) — re-export mantém a API pública daqui.
21
+ export { attachLogicalType, getLogicalType } from '../core/logical-type.ts'
22
+
23
+ export type { LogicalTypeMeta }
24
+
25
+ // =============================================================================
26
+ // Entity layer (defineEntity + relations + inferência)
27
+ // =============================================================================
28
+
29
+ export {
30
+ defineEntity,
31
+ belongsTo,
32
+ hasOne,
33
+ hasMany,
34
+ manyToMany,
35
+ entityColumns,
36
+ entityTable,
37
+ entityPk,
38
+ entityRowSchema,
39
+ entityInsertSchema,
40
+ entityUpdateSchema,
41
+ desiredColumns,
42
+ diffColumns,
43
+ isEntityConfig,
44
+ snakeCase,
45
+ resolveNaming,
46
+ } from './entity.ts'
47
+ export type {
48
+ EntityConfig,
49
+ EntityFields,
50
+ EntityRow,
51
+ EntityInsert,
52
+ EntityUpdate,
53
+ Relation,
54
+ RelationKind,
55
+ IndexSpec,
56
+ ResolvedColumn,
57
+ ColumnShape,
58
+ DriftKind,
59
+ DriftFinding,
60
+ NamingStrategy,
61
+ } from './entity.ts'
62
+
63
+ // — Scaffold de migration ——————————————————————————————————————————————————————
64
+ export { scaffoldMigration, scaffoldFileContent, kyselyColumnType } from './scaffold.ts'
65
+ export type { ScaffoldResult } from './scaffold.ts'