@voltro/cli 0.1.0

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 (626) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +57 -0
  3. package/README.md +26 -0
  4. package/SECURITY.md +56 -0
  5. package/THIRD-PARTY-NOTICES.md +20236 -0
  6. package/bin/voltro.mjs +43 -0
  7. package/dist/bin.d.ts +1 -0
  8. package/dist/bin.js +9 -0
  9. package/dist/commands-CfPH2Wf4.js +18061 -0
  10. package/dist/frameworkInspectState-CX2250XB.js +86 -0
  11. package/dist/index.d.ts +48 -0
  12. package/dist/index.js +5 -0
  13. package/dist/inspectState.d.ts +116 -0
  14. package/dist/inspectState.js +2 -0
  15. package/dist/startup.d.ts +26 -0
  16. package/dist/startup.js +2 -0
  17. package/dist/startupRunner-CRhuUl91.js +71 -0
  18. package/package.json +88 -0
  19. package/templates/AGENTS.core.md +258 -0
  20. package/templates/AGENTS.md +351 -0
  21. package/templates/agent-docs/_index.md +93 -0
  22. package/templates/agent-docs/_manifest.json +655 -0
  23. package/templates/agent-docs/ai.md +1845 -0
  24. package/templates/agent-docs/authentication.md +1788 -0
  25. package/templates/agent-docs/caching.md +624 -0
  26. package/templates/agent-docs/cli.md +1650 -0
  27. package/templates/agent-docs/configuration.md +295 -0
  28. package/templates/agent-docs/data.md +2172 -0
  29. package/templates/agent-docs/database/advancedqueries.md +1583 -0
  30. package/templates/agent-docs/database/columntypes.md +1200 -0
  31. package/templates/agent-docs/database/hosting.md +881 -0
  32. package/templates/agent-docs/database/migrations.md +2938 -0
  33. package/templates/agent-docs/database/misc.md +270 -0
  34. package/templates/agent-docs/database/overview.md +108 -0
  35. package/templates/agent-docs/database/querying.md +1622 -0
  36. package/templates/agent-docs/database/scaling.md +331 -0
  37. package/templates/agent-docs/database/schema.md +1458 -0
  38. package/templates/agent-docs/database/seedsdialects.md +1235 -0
  39. package/templates/agent-docs/database/transactions.md +285 -0
  40. package/templates/agent-docs/deployment.md +999 -0
  41. package/templates/agent-docs/internationalization.md +359 -0
  42. package/templates/agent-docs/introduction.md +438 -0
  43. package/templates/agent-docs/multi-tenancy.md +610 -0
  44. package/templates/agent-docs/observability.md +350 -0
  45. package/templates/agent-docs/plugins.md +1273 -0
  46. package/templates/agent-docs/reference.md +978 -0
  47. package/templates/agent-docs/routing.md +1553 -0
  48. package/templates/agent-docs/scheduling.md +666 -0
  49. package/templates/agent-docs/schema-driven-ui.md +607 -0
  50. package/templates/agent-docs/security.md +42 -0
  51. package/templates/agent-docs/templates/apibackends.md +3214 -0
  52. package/templates/agent-docs/templates/appshells.md +2062 -0
  53. package/templates/agent-docs/templates/custom.md +128 -0
  54. package/templates/agent-docs/templates/overview.md +122 -0
  55. package/templates/agent-docs/templates/serverless.md +315 -0
  56. package/templates/agent-docs/testing.md +376 -0
  57. package/templates/agent-docs/workflows.md +1351 -0
  58. package/templates/apps/api-ai/README.md +105 -0
  59. package/templates/apps/api-ai/actions/summarize.action.server.tsx +32 -0
  60. package/templates/apps/api-ai/actions/summarize.action.ts +33 -0
  61. package/templates/apps/api-ai/agents/support.agent.server.tsx +31 -0
  62. package/templates/apps/api-ai/agents/support.agent.tsx +27 -0
  63. package/templates/apps/api-ai/app.config.ts +57 -0
  64. package/templates/apps/api-ai/database/schema.ts +67 -0
  65. package/templates/apps/api-ai/package.json +27 -0
  66. package/templates/apps/api-ai/seeds/docs.seed.ts +67 -0
  67. package/templates/apps/api-ai/template.json +6 -0
  68. package/templates/apps/api-ai/tests/summarize.test.ts +50 -0
  69. package/templates/apps/api-ai/tools/searchDocs.tool.tsx +60 -0
  70. package/templates/apps/api-ai/tsconfig.json +5 -0
  71. package/templates/apps/api-auth/.env +17 -0
  72. package/templates/apps/api-auth/README.md +109 -0
  73. package/templates/apps/api-auth/actions/me.action.server.ts +20 -0
  74. package/templates/apps/api-auth/actions/me.action.ts +19 -0
  75. package/templates/apps/api-auth/app.config.ts +59 -0
  76. package/templates/apps/api-auth/database/schema.ts +26 -0
  77. package/templates/apps/api-auth/package.json +28 -0
  78. package/templates/apps/api-auth/template.json +6 -0
  79. package/templates/apps/api-auth/tests/me.test.ts +37 -0
  80. package/templates/apps/api-auth/tsconfig.json +5 -0
  81. package/templates/apps/api-backend/README.md +25 -0
  82. package/templates/apps/api-backend/app.config.ts +39 -0
  83. package/templates/apps/api-backend/database/schema.ts +57 -0
  84. package/templates/apps/api-backend/mutations/notes.create.mutation.server.ts +19 -0
  85. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +37 -0
  86. package/templates/apps/api-backend/package.json +28 -0
  87. package/templates/apps/api-backend/queries/notes.query.server.ts +14 -0
  88. package/templates/apps/api-backend/queries/notes.query.ts +20 -0
  89. package/templates/apps/api-backend/template.json +6 -0
  90. package/templates/apps/api-backend/tests/notes.create.test.ts +50 -0
  91. package/templates/apps/api-backend/tsconfig.json +5 -0
  92. package/templates/apps/api-backend-deactivation/README.md +49 -0
  93. package/templates/apps/api-backend-deactivation/actions/users.get.action.server.ts +17 -0
  94. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +18 -0
  95. package/templates/apps/api-backend-deactivation/app.config.ts +20 -0
  96. package/templates/apps/api-backend-deactivation/database/schema.ts +41 -0
  97. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.server.ts +6 -0
  98. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +16 -0
  99. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.server.ts +10 -0
  100. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +19 -0
  101. package/templates/apps/api-backend-deactivation/package.json +28 -0
  102. package/templates/apps/api-backend-deactivation/template.json +6 -0
  103. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +47 -0
  104. package/templates/apps/api-backend-deactivation/tsconfig.json +5 -0
  105. package/templates/apps/api-backend-mail/README.md +39 -0
  106. package/templates/apps/api-backend-mail/actions/sendWelcome.action.server.ts +15 -0
  107. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +21 -0
  108. package/templates/apps/api-backend-mail/app.config.ts +34 -0
  109. package/templates/apps/api-backend-mail/database/schema.ts +57 -0
  110. package/templates/apps/api-backend-mail/emails/welcome.email.tsx +106 -0
  111. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.server.ts +19 -0
  112. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +37 -0
  113. package/templates/apps/api-backend-mail/package.json +30 -0
  114. package/templates/apps/api-backend-mail/queries/notes.query.server.ts +14 -0
  115. package/templates/apps/api-backend-mail/queries/notes.query.ts +20 -0
  116. package/templates/apps/api-backend-mail/template.json +6 -0
  117. package/templates/apps/api-backend-mail/tests/notes.create.test.ts +50 -0
  118. package/templates/apps/api-backend-mail/tsconfig.json +5 -0
  119. package/templates/apps/api-backend-mariadb/.env.example +35 -0
  120. package/templates/apps/api-backend-mariadb/README.md +31 -0
  121. package/templates/apps/api-backend-mariadb/app.config.ts +68 -0
  122. package/templates/apps/api-backend-mariadb/database/schema.ts +57 -0
  123. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.server.ts +19 -0
  124. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +37 -0
  125. package/templates/apps/api-backend-mariadb/package.json +30 -0
  126. package/templates/apps/api-backend-mariadb/queries/notes.query.server.ts +14 -0
  127. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +20 -0
  128. package/templates/apps/api-backend-mariadb/template.json +6 -0
  129. package/templates/apps/api-backend-mariadb/tests/notes.create.test.ts +50 -0
  130. package/templates/apps/api-backend-mariadb/tsconfig.json +5 -0
  131. package/templates/apps/api-backend-storage/README.md +86 -0
  132. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.server.ts +21 -0
  133. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +21 -0
  134. package/templates/apps/api-backend-storage/actions/uploadDocument.action.server.ts +21 -0
  135. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +20 -0
  136. package/templates/apps/api-backend-storage/app.config.ts +38 -0
  137. package/templates/apps/api-backend-storage/database/schema.ts +57 -0
  138. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.server.ts +19 -0
  139. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +37 -0
  140. package/templates/apps/api-backend-storage/package.json +27 -0
  141. package/templates/apps/api-backend-storage/queries/notes.query.server.ts +14 -0
  142. package/templates/apps/api-backend-storage/queries/notes.query.ts +20 -0
  143. package/templates/apps/api-backend-storage/template.json +6 -0
  144. package/templates/apps/api-backend-storage/tests/notes.create.test.ts +50 -0
  145. package/templates/apps/api-backend-storage/tsconfig.json +5 -0
  146. package/templates/apps/api-data-advanced/.env +17 -0
  147. package/templates/apps/api-data-advanced/README.md +88 -0
  148. package/templates/apps/api-data-advanced/app.config.ts +56 -0
  149. package/templates/apps/api-data-advanced/database/actors.entity.ts +14 -0
  150. package/templates/apps/api-data-advanced/database/authors.entity.ts +30 -0
  151. package/templates/apps/api-data-advanced/database/authors.relations.ts +15 -0
  152. package/templates/apps/api-data-advanced/database/books.entity.ts +59 -0
  153. package/templates/apps/api-data-advanced/database/books.relations.ts +10 -0
  154. package/templates/apps/api-data-advanced/database/index.ts +23 -0
  155. package/templates/apps/api-data-advanced/database/tenants.entity.ts +11 -0
  156. package/templates/apps/api-data-advanced/package.json +28 -0
  157. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.server.ts +18 -0
  158. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +40 -0
  159. package/templates/apps/api-data-advanced/queries/books.search.query.server.ts +17 -0
  160. package/templates/apps/api-data-advanced/queries/books.search.query.ts +39 -0
  161. package/templates/apps/api-data-advanced/seeds/catalog.seed.ts +101 -0
  162. package/templates/apps/api-data-advanced/template.json +6 -0
  163. package/templates/apps/api-data-advanced/tests/queries.test.ts +124 -0
  164. package/templates/apps/api-data-advanced/tsconfig.json +5 -0
  165. package/templates/apps/api-durable/README.md +81 -0
  166. package/templates/apps/api-durable/aggregates/orderStats.aggregate.ts +49 -0
  167. package/templates/apps/api-durable/app.config.ts +41 -0
  168. package/templates/apps/api-durable/database/schema.ts +65 -0
  169. package/templates/apps/api-durable/mutations/orders.approve.mutation.server.ts +43 -0
  170. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +23 -0
  171. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +45 -0
  172. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +40 -0
  173. package/templates/apps/api-durable/package.json +28 -0
  174. package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +37 -0
  175. package/templates/apps/api-durable/startup/warm.startup.tsx +33 -0
  176. package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +27 -0
  177. package/templates/apps/api-durable/template.json +6 -0
  178. package/templates/apps/api-durable/tests/orders.place.test.ts +91 -0
  179. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +21 -0
  180. package/templates/apps/api-durable/tsconfig.json +5 -0
  181. package/templates/apps/api-durable/workflows/order.fulfill.workflow.server.tsx +103 -0
  182. package/templates/apps/api-durable/workflows/order.fulfill.workflow.tsx +25 -0
  183. package/templates/apps/api-feature-flags/README.md +63 -0
  184. package/templates/apps/api-feature-flags/actions/notes.export.action.server.ts +11 -0
  185. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +18 -0
  186. package/templates/apps/api-feature-flags/app.config.ts +41 -0
  187. package/templates/apps/api-feature-flags/database/schema.ts +37 -0
  188. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.server.ts +29 -0
  189. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +28 -0
  190. package/templates/apps/api-feature-flags/package.json +29 -0
  191. package/templates/apps/api-feature-flags/template.json +6 -0
  192. package/templates/apps/api-feature-flags/tests/notes.create.test.ts +67 -0
  193. package/templates/apps/api-feature-flags/tsconfig.json +5 -0
  194. package/templates/apps/api-governance/.env +4 -0
  195. package/templates/apps/api-governance/README.md +61 -0
  196. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -0
  197. package/templates/apps/api-governance/actions/profiles.get.action.ts +17 -0
  198. package/templates/apps/api-governance/app.config.ts +35 -0
  199. package/templates/apps/api-governance/database/schema.ts +41 -0
  200. package/templates/apps/api-governance/mutations/profiles.create.mutation.server.ts +17 -0
  201. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +21 -0
  202. package/templates/apps/api-governance/package.json +29 -0
  203. package/templates/apps/api-governance/template.json +6 -0
  204. package/templates/apps/api-governance/tests/profiles.create.test.ts +59 -0
  205. package/templates/apps/api-governance/tsconfig.json +5 -0
  206. package/templates/apps/api-kv/README.md +100 -0
  207. package/templates/apps/api-kv/actions/sync.pull.action.server.ts +75 -0
  208. package/templates/apps/api-kv/actions/sync.pull.action.ts +25 -0
  209. package/templates/apps/api-kv/actions/sync.reset.action.server.ts +25 -0
  210. package/templates/apps/api-kv/actions/sync.reset.action.ts +22 -0
  211. package/templates/apps/api-kv/actions/sync.status.action.server.ts +28 -0
  212. package/templates/apps/api-kv/actions/sync.status.action.ts +17 -0
  213. package/templates/apps/api-kv/app.config.ts +48 -0
  214. package/templates/apps/api-kv/database/schema.ts +62 -0
  215. package/templates/apps/api-kv/package.json +28 -0
  216. package/templates/apps/api-kv/queries/events.list.query.server.ts +14 -0
  217. package/templates/apps/api-kv/queries/events.list.query.ts +23 -0
  218. package/templates/apps/api-kv/template.json +6 -0
  219. package/templates/apps/api-kv/tests/sync.test.ts +103 -0
  220. package/templates/apps/api-kv/tsconfig.json +5 -0
  221. package/templates/apps/api-moderation/README.md +46 -0
  222. package/templates/apps/api-moderation/app.config.ts +33 -0
  223. package/templates/apps/api-moderation/database/schema.ts +45 -0
  224. package/templates/apps/api-moderation/mutations/comments.create.mutation.server.ts +8 -0
  225. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +20 -0
  226. package/templates/apps/api-moderation/mutations/posts.create.mutation.server.ts +9 -0
  227. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +25 -0
  228. package/templates/apps/api-moderation/package.json +29 -0
  229. package/templates/apps/api-moderation/template.json +6 -0
  230. package/templates/apps/api-moderation/tests/posts.create.test.ts +79 -0
  231. package/templates/apps/api-moderation/tsconfig.json +5 -0
  232. package/templates/apps/api-observability/README.md +106 -0
  233. package/templates/apps/api-observability/app.config.ts +32 -0
  234. package/templates/apps/api-observability/database/schema.ts +38 -0
  235. package/templates/apps/api-observability/mutations/notes.create.mutation.server.ts +33 -0
  236. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +15 -0
  237. package/templates/apps/api-observability/package.json +29 -0
  238. package/templates/apps/api-observability/queries/notes.list.query.server.ts +7 -0
  239. package/templates/apps/api-observability/queries/notes.list.query.ts +14 -0
  240. package/templates/apps/api-observability/template.json +6 -0
  241. package/templates/apps/api-observability/tests/notes.create.test.ts +36 -0
  242. package/templates/apps/api-observability/tsconfig.json +5 -0
  243. package/templates/apps/api-ratelimit/README.md +49 -0
  244. package/templates/apps/api-ratelimit/app.config.ts +42 -0
  245. package/templates/apps/api-ratelimit/database/schema.ts +37 -0
  246. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.server.ts +16 -0
  247. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +34 -0
  248. package/templates/apps/api-ratelimit/package.json +29 -0
  249. package/templates/apps/api-ratelimit/template.json +6 -0
  250. package/templates/apps/api-ratelimit/tests/notes.create.test.ts +69 -0
  251. package/templates/apps/api-ratelimit/tsconfig.json +5 -0
  252. package/templates/apps/api-rbac/README.md +59 -0
  253. package/templates/apps/api-rbac/app.config.ts +48 -0
  254. package/templates/apps/api-rbac/database/schema.ts +37 -0
  255. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +22 -0
  256. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +26 -0
  257. package/templates/apps/api-rbac/package.json +29 -0
  258. package/templates/apps/api-rbac/template.json +6 -0
  259. package/templates/apps/api-rbac/tests/notes.create.test.ts +80 -0
  260. package/templates/apps/api-rbac/tsconfig.json +5 -0
  261. package/templates/apps/api-rest/README.md +85 -0
  262. package/templates/apps/api-rest/app.config.ts +70 -0
  263. package/templates/apps/api-rest/database/schema.ts +52 -0
  264. package/templates/apps/api-rest/lib/product.ts +30 -0
  265. package/templates/apps/api-rest/package.json +27 -0
  266. package/templates/apps/api-rest/routes/v1/products.create.route.tsx +45 -0
  267. package/templates/apps/api-rest/routes/v1/products.delete.route.tsx +25 -0
  268. package/templates/apps/api-rest/routes/v1/products.get.route.tsx +29 -0
  269. package/templates/apps/api-rest/routes/v1/products.list.route.tsx +43 -0
  270. package/templates/apps/api-rest/template.json +6 -0
  271. package/templates/apps/api-rest/tests/products.create.test.ts +53 -0
  272. package/templates/apps/api-rest/tsconfig.json +5 -0
  273. package/templates/apps/api-saas/README.md +106 -0
  274. package/templates/apps/api-saas/app.config.ts +51 -0
  275. package/templates/apps/api-saas/database/schema.ts +50 -0
  276. package/templates/apps/api-saas/mutations/projects.create.mutation.server.ts +52 -0
  277. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +18 -0
  278. package/templates/apps/api-saas/package.json +31 -0
  279. package/templates/apps/api-saas/queries/projects.list.query.server.ts +7 -0
  280. package/templates/apps/api-saas/queries/projects.list.query.ts +18 -0
  281. package/templates/apps/api-saas/template.json +6 -0
  282. package/templates/apps/api-saas/tests/projects.create.test.ts +47 -0
  283. package/templates/apps/api-saas/tsconfig.json +5 -0
  284. package/templates/apps/api-search/README.md +68 -0
  285. package/templates/apps/api-search/app.config.ts +31 -0
  286. package/templates/apps/api-search/database/schema.ts +47 -0
  287. package/templates/apps/api-search/lib/search.ts +27 -0
  288. package/templates/apps/api-search/mutations/articles.create.mutation.server.ts +19 -0
  289. package/templates/apps/api-search/mutations/articles.create.mutation.ts +35 -0
  290. package/templates/apps/api-search/package.json +29 -0
  291. package/templates/apps/api-search/queries/articles.list.query.server.ts +14 -0
  292. package/templates/apps/api-search/queries/articles.list.query.ts +19 -0
  293. package/templates/apps/api-search/seeds/articles.seed.ts +41 -0
  294. package/templates/apps/api-search/startup/searchBackfill.startup.tsx +24 -0
  295. package/templates/apps/api-search/template.json +6 -0
  296. package/templates/apps/api-search/tests/articles.create.test.ts +69 -0
  297. package/templates/apps/api-search/tsconfig.json +5 -0
  298. package/templates/apps/api-versioning/README.md +51 -0
  299. package/templates/apps/api-versioning/actions/documents.asOf.action.server.ts +13 -0
  300. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +14 -0
  301. package/templates/apps/api-versioning/actions/documents.history.action.server.ts +15 -0
  302. package/templates/apps/api-versioning/actions/documents.history.action.ts +17 -0
  303. package/templates/apps/api-versioning/app.config.ts +24 -0
  304. package/templates/apps/api-versioning/database/schema.ts +38 -0
  305. package/templates/apps/api-versioning/mutations/documents.create.mutation.server.ts +8 -0
  306. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +21 -0
  307. package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +9 -0
  308. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +21 -0
  309. package/templates/apps/api-versioning/package.json +29 -0
  310. package/templates/apps/api-versioning/template.json +6 -0
  311. package/templates/apps/api-versioning/tests/documents.create.test.ts +37 -0
  312. package/templates/apps/api-versioning/tsconfig.json +5 -0
  313. package/templates/apps/api-webhooks/.env +6 -0
  314. package/templates/apps/api-webhooks/README.md +106 -0
  315. package/templates/apps/api-webhooks/app.config.ts +16 -0
  316. package/templates/apps/api-webhooks/database/schema.ts +49 -0
  317. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +22 -0
  318. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.server.ts +37 -0
  319. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +15 -0
  320. package/templates/apps/api-webhooks/package.json +28 -0
  321. package/templates/apps/api-webhooks/queries/orders.list.query.server.ts +7 -0
  322. package/templates/apps/api-webhooks/queries/orders.list.query.ts +15 -0
  323. package/templates/apps/api-webhooks/template.json +6 -0
  324. package/templates/apps/api-webhooks/tests/orders.fulfill.test.ts +51 -0
  325. package/templates/apps/api-webhooks/tsconfig.json +5 -0
  326. package/templates/apps/api-webhooks/webhooks/orders.webhook.tsx +35 -0
  327. package/templates/apps/changelog/README.md +77 -0
  328. package/templates/apps/changelog/app.config.ts +27 -0
  329. package/templates/apps/changelog/content/releases/0.1.0.mdx +19 -0
  330. package/templates/apps/changelog/content/releases/0.2.0.mdx +28 -0
  331. package/templates/apps/changelog/package.json +30 -0
  332. package/templates/apps/changelog/scripts/generate-rss.mjs +38 -0
  333. package/templates/apps/changelog/src/globals.css +66 -0
  334. package/templates/apps/changelog/src/globals.d.ts +16 -0
  335. package/templates/apps/changelog/src/lib/locale.test.ts +72 -0
  336. package/templates/apps/changelog/src/lib/locale.ts +55 -0
  337. package/templates/apps/changelog/src/lib/releases.ts +21 -0
  338. package/templates/apps/changelog/src/locales/de.ts +22 -0
  339. package/templates/apps/changelog/src/locales/en.ts +30 -0
  340. package/templates/apps/changelog/src/pages/[locale]/[slug].tsx +24 -0
  341. package/templates/apps/changelog/src/pages/[locale]/index.tsx +13 -0
  342. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +95 -0
  343. package/templates/apps/changelog/src/pages/[slug].test.tsx +127 -0
  344. package/templates/apps/changelog/src/pages/[slug].tsx +52 -0
  345. package/templates/apps/changelog/src/pages/index.test.tsx +112 -0
  346. package/templates/apps/changelog/src/pages/index.tsx +55 -0
  347. package/templates/apps/changelog/src/pages/layout.tsx +84 -0
  348. package/templates/apps/changelog/template.json +6 -0
  349. package/templates/apps/changelog/tsconfig.json +11 -0
  350. package/templates/apps/edge-functions/README.md +55 -0
  351. package/templates/apps/edge-functions/functions/aiComplete.serverless.ts +58 -0
  352. package/templates/apps/edge-functions/functions/currencyConvert.serverless.ts +47 -0
  353. package/templates/apps/edge-functions/functions/geoGreeting.serverless.ts +44 -0
  354. package/templates/apps/edge-functions/functions/health.serverless.ts +29 -0
  355. package/templates/apps/edge-functions/functions/resolveLink.serverless.ts +32 -0
  356. package/templates/apps/edge-functions/functions/shareLink.serverless.ts +30 -0
  357. package/templates/apps/edge-functions/functions/slackNotify.serverless.ts +40 -0
  358. package/templates/apps/edge-functions/functions/verifySignature.serverless.ts +52 -0
  359. package/templates/apps/edge-functions/package.json +21 -0
  360. package/templates/apps/edge-functions/template.json +6 -0
  361. package/templates/apps/edge-functions/tsconfig.json +10 -0
  362. package/templates/apps/frontend-admin/README.md +40 -0
  363. package/templates/apps/frontend-admin/app.config.ts +37 -0
  364. package/templates/apps/frontend-admin/package.json +30 -0
  365. package/templates/apps/frontend-admin/src/config.ts +8 -0
  366. package/templates/apps/frontend-admin/src/globals.css +76 -0
  367. package/templates/apps/frontend-admin/src/globals.d.ts +6 -0
  368. package/templates/apps/frontend-admin/src/lib/admin.ts +16 -0
  369. package/templates/apps/frontend-admin/src/lib/auth.ts +24 -0
  370. package/templates/apps/frontend-admin/src/locales/de.ts +67 -0
  371. package/templates/apps/frontend-admin/src/locales/en.ts +79 -0
  372. package/templates/apps/frontend-admin/src/locales/index.ts +15 -0
  373. package/templates/apps/frontend-admin/src/pages/(marketing)/index.test.tsx +55 -0
  374. package/templates/apps/frontend-admin/src/pages/(marketing)/index.tsx +32 -0
  375. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +29 -0
  376. package/templates/apps/frontend-admin/src/pages/(marketing)/login.test.tsx +73 -0
  377. package/templates/apps/frontend-admin/src/pages/(marketing)/login.tsx +35 -0
  378. package/templates/apps/frontend-admin/src/pages/admin/[entity].tsx +121 -0
  379. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +119 -0
  380. package/templates/apps/frontend-admin/src/pages/admin/error.tsx +20 -0
  381. package/templates/apps/frontend-admin/src/pages/admin/fallbacks.test.tsx +68 -0
  382. package/templates/apps/frontend-admin/src/pages/admin/index.test.tsx +88 -0
  383. package/templates/apps/frontend-admin/src/pages/admin/index.tsx +65 -0
  384. package/templates/apps/frontend-admin/src/pages/admin/layout.test.tsx +114 -0
  385. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +97 -0
  386. package/templates/apps/frontend-admin/src/pages/admin/loading.tsx +15 -0
  387. package/templates/apps/frontend-admin/src/pages/admin/not-found.tsx +15 -0
  388. package/templates/apps/frontend-admin/src/pages/layout.tsx +12 -0
  389. package/templates/apps/frontend-admin/src/pages/layouts.test.tsx +54 -0
  390. package/templates/apps/frontend-admin/template.json +6 -0
  391. package/templates/apps/frontend-admin/tsconfig.json +11 -0
  392. package/templates/apps/frontend-app/README.md +98 -0
  393. package/templates/apps/frontend-app/app.config.ts +43 -0
  394. package/templates/apps/frontend-app/package.json +30 -0
  395. package/templates/apps/frontend-app/src/locales/de.ts +36 -0
  396. package/templates/apps/frontend-app/src/locales/en.ts +43 -0
  397. package/templates/apps/frontend-app/src/locales/index.ts +15 -0
  398. package/templates/apps/frontend-app/src/pages/index.test.tsx +167 -0
  399. package/templates/apps/frontend-app/src/pages/index.tsx +136 -0
  400. package/templates/apps/frontend-app/src/pages/layout.tsx +41 -0
  401. package/templates/apps/frontend-app/src/pages/schema-ui.test.tsx +99 -0
  402. package/templates/apps/frontend-app/src/pages/schema-ui.tsx +74 -0
  403. package/templates/apps/frontend-app/template.json +6 -0
  404. package/templates/apps/frontend-app/tsconfig.json +11 -0
  405. package/templates/apps/frontend-blank/README.md +18 -0
  406. package/templates/apps/frontend-blank/app.config.ts +29 -0
  407. package/templates/apps/frontend-blank/package.json +29 -0
  408. package/templates/apps/frontend-blank/src/locales/de.ts +15 -0
  409. package/templates/apps/frontend-blank/src/locales/en.ts +22 -0
  410. package/templates/apps/frontend-blank/src/locales/index.ts +15 -0
  411. package/templates/apps/frontend-blank/src/pages/index.test.tsx +55 -0
  412. package/templates/apps/frontend-blank/src/pages/index.tsx +27 -0
  413. package/templates/apps/frontend-blank/src/pages/layout.test.tsx +54 -0
  414. package/templates/apps/frontend-blank/src/pages/layout.tsx +35 -0
  415. package/templates/apps/frontend-blank/template.json +6 -0
  416. package/templates/apps/frontend-blank/tsconfig.json +11 -0
  417. package/templates/apps/frontend-contact/README.md +65 -0
  418. package/templates/apps/frontend-contact/app.config.ts +25 -0
  419. package/templates/apps/frontend-contact/functions/sendMessage.serverless.ts +69 -0
  420. package/templates/apps/frontend-contact/package.json +33 -0
  421. package/templates/apps/frontend-contact/src/components/ContactForm.island.test.tsx +142 -0
  422. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +104 -0
  423. package/templates/apps/frontend-contact/src/config.ts +12 -0
  424. package/templates/apps/frontend-contact/src/globals.css +84 -0
  425. package/templates/apps/frontend-contact/src/lib/locale.ts +55 -0
  426. package/templates/apps/frontend-contact/src/locales/de.ts +26 -0
  427. package/templates/apps/frontend-contact/src/locales/en.ts +29 -0
  428. package/templates/apps/frontend-contact/src/pages/[locale]/index.tsx +14 -0
  429. package/templates/apps/frontend-contact/src/pages/index.test.tsx +70 -0
  430. package/templates/apps/frontend-contact/src/pages/index.tsx +63 -0
  431. package/templates/apps/frontend-contact/src/pages/layout.tsx +65 -0
  432. package/templates/apps/frontend-contact/template.json +6 -0
  433. package/templates/apps/frontend-contact/tsconfig.json +11 -0
  434. package/templates/apps/frontend-dashboard/README.md +54 -0
  435. package/templates/apps/frontend-dashboard/app.config.ts +39 -0
  436. package/templates/apps/frontend-dashboard/package.json +29 -0
  437. package/templates/apps/frontend-dashboard/src/config.ts +8 -0
  438. package/templates/apps/frontend-dashboard/src/globals.css +72 -0
  439. package/templates/apps/frontend-dashboard/src/globals.d.ts +6 -0
  440. package/templates/apps/frontend-dashboard/src/lib/auth.ts +27 -0
  441. package/templates/apps/frontend-dashboard/src/locales/de.ts +49 -0
  442. package/templates/apps/frontend-dashboard/src/locales/en.ts +60 -0
  443. package/templates/apps/frontend-dashboard/src/locales/index.ts +15 -0
  444. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.test.tsx +55 -0
  445. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.tsx +34 -0
  446. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +32 -0
  447. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.test.tsx +74 -0
  448. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.tsx +40 -0
  449. package/templates/apps/frontend-dashboard/src/pages/dashboard/error.tsx +21 -0
  450. package/templates/apps/frontend-dashboard/src/pages/dashboard/fallbacks.test.tsx +68 -0
  451. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.test.tsx +55 -0
  452. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.tsx +38 -0
  453. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.test.tsx +88 -0
  454. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +69 -0
  455. package/templates/apps/frontend-dashboard/src/pages/dashboard/loading.tsx +21 -0
  456. package/templates/apps/frontend-dashboard/src/pages/dashboard/not-found.tsx +19 -0
  457. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.test.tsx +69 -0
  458. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.tsx +35 -0
  459. package/templates/apps/frontend-dashboard/src/pages/layout.tsx +12 -0
  460. package/templates/apps/frontend-dashboard/src/pages/layouts.test.tsx +54 -0
  461. package/templates/apps/frontend-dashboard/template.json +6 -0
  462. package/templates/apps/frontend-dashboard/tsconfig.json +11 -0
  463. package/templates/apps/frontend-docs/README.md +19 -0
  464. package/templates/apps/frontend-docs/app.config.ts +25 -0
  465. package/templates/apps/frontend-docs/package.json +29 -0
  466. package/templates/apps/frontend-docs/src/globals.css +32 -0
  467. package/templates/apps/frontend-docs/src/lib/locale.test.ts +72 -0
  468. package/templates/apps/frontend-docs/src/lib/locale.ts +56 -0
  469. package/templates/apps/frontend-docs/src/locales/de.ts +24 -0
  470. package/templates/apps/frontend-docs/src/locales/en.ts +28 -0
  471. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug].tsx +18 -0
  472. package/templates/apps/frontend-docs/src/pages/[locale]/index.tsx +13 -0
  473. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +67 -0
  474. package/templates/apps/frontend-docs/src/pages/docs/[...slug].test.tsx +115 -0
  475. package/templates/apps/frontend-docs/src/pages/docs/[...slug].tsx +82 -0
  476. package/templates/apps/frontend-docs/src/pages/index.test.tsx +90 -0
  477. package/templates/apps/frontend-docs/src/pages/index.tsx +45 -0
  478. package/templates/apps/frontend-docs/src/pages/layout.test.tsx +84 -0
  479. package/templates/apps/frontend-docs/src/pages/layout.tsx +66 -0
  480. package/templates/apps/frontend-docs/template.json +6 -0
  481. package/templates/apps/frontend-docs/tsconfig.json +11 -0
  482. package/templates/apps/frontend-i18n/README.md +61 -0
  483. package/templates/apps/frontend-i18n/app.config.ts +33 -0
  484. package/templates/apps/frontend-i18n/package.json +28 -0
  485. package/templates/apps/frontend-i18n/src/globals.css +46 -0
  486. package/templates/apps/frontend-i18n/src/globals.d.ts +6 -0
  487. package/templates/apps/frontend-i18n/src/lib/locale.test.ts +72 -0
  488. package/templates/apps/frontend-i18n/src/lib/locale.ts +55 -0
  489. package/templates/apps/frontend-i18n/src/locales/de.ts +24 -0
  490. package/templates/apps/frontend-i18n/src/locales/en.ts +25 -0
  491. package/templates/apps/frontend-i18n/src/pages/[locale]/about.tsx +11 -0
  492. package/templates/apps/frontend-i18n/src/pages/[locale]/index.tsx +13 -0
  493. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +50 -0
  494. package/templates/apps/frontend-i18n/src/pages/about.test.tsx +75 -0
  495. package/templates/apps/frontend-i18n/src/pages/about.tsx +31 -0
  496. package/templates/apps/frontend-i18n/src/pages/index.test.tsx +102 -0
  497. package/templates/apps/frontend-i18n/src/pages/index.tsx +43 -0
  498. package/templates/apps/frontend-i18n/src/pages/layout.test.tsx +86 -0
  499. package/templates/apps/frontend-i18n/src/pages/layout.tsx +70 -0
  500. package/templates/apps/frontend-i18n/template.json +6 -0
  501. package/templates/apps/frontend-i18n/tsconfig.json +11 -0
  502. package/templates/apps/frontend-landing/README.md +17 -0
  503. package/templates/apps/frontend-landing/app.config.ts +25 -0
  504. package/templates/apps/frontend-landing/package.json +29 -0
  505. package/templates/apps/frontend-landing/src/globals.css +23 -0
  506. package/templates/apps/frontend-landing/src/lib/locale.test.ts +72 -0
  507. package/templates/apps/frontend-landing/src/lib/locale.ts +55 -0
  508. package/templates/apps/frontend-landing/src/locales/de.ts +24 -0
  509. package/templates/apps/frontend-landing/src/locales/en.ts +26 -0
  510. package/templates/apps/frontend-landing/src/pages/[locale]/index.tsx +13 -0
  511. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +37 -0
  512. package/templates/apps/frontend-landing/src/pages/index.test.tsx +110 -0
  513. package/templates/apps/frontend-landing/src/pages/index.tsx +75 -0
  514. package/templates/apps/frontend-landing/src/pages/layout.test.tsx +84 -0
  515. package/templates/apps/frontend-landing/src/pages/layout.tsx +66 -0
  516. package/templates/apps/frontend-landing/template.json +6 -0
  517. package/templates/apps/frontend-landing/tsconfig.json +11 -0
  518. package/templates/apps/frontend-spa/README.md +45 -0
  519. package/templates/apps/frontend-spa/app.config.ts +27 -0
  520. package/templates/apps/frontend-spa/package.json +29 -0
  521. package/templates/apps/frontend-spa/src/globals.css +84 -0
  522. package/templates/apps/frontend-spa/src/locales/de.ts +22 -0
  523. package/templates/apps/frontend-spa/src/locales/en.ts +29 -0
  524. package/templates/apps/frontend-spa/src/locales/index.ts +15 -0
  525. package/templates/apps/frontend-spa/src/pages/index.test.tsx +137 -0
  526. package/templates/apps/frontend-spa/src/pages/index.tsx +123 -0
  527. package/templates/apps/frontend-spa/src/pages/layout.tsx +27 -0
  528. package/templates/apps/frontend-spa/template.json +6 -0
  529. package/templates/apps/frontend-spa/tsconfig.json +11 -0
  530. package/templates/apps/frontend-ssr/README.md +68 -0
  531. package/templates/apps/frontend-ssr/app.config.ts +32 -0
  532. package/templates/apps/frontend-ssr/package.json +29 -0
  533. package/templates/apps/frontend-ssr/src/globals.css +67 -0
  534. package/templates/apps/frontend-ssr/src/locales/de.ts +41 -0
  535. package/templates/apps/frontend-ssr/src/locales/en.ts +54 -0
  536. package/templates/apps/frontend-ssr/src/locales/index.ts +16 -0
  537. package/templates/apps/frontend-ssr/src/pages/feed-swr.test.tsx +69 -0
  538. package/templates/apps/frontend-ssr/src/pages/feed-swr.tsx +54 -0
  539. package/templates/apps/frontend-ssr/src/pages/feed.test.tsx +73 -0
  540. package/templates/apps/frontend-ssr/src/pages/feed.tsx +64 -0
  541. package/templates/apps/frontend-ssr/src/pages/index.test.tsx +89 -0
  542. package/templates/apps/frontend-ssr/src/pages/index.tsx +72 -0
  543. package/templates/apps/frontend-ssr/src/pages/layout.tsx +37 -0
  544. package/templates/apps/frontend-ssr/template.json +6 -0
  545. package/templates/apps/frontend-ssr/tsconfig.json +11 -0
  546. package/templates/apps/frontend-ssr-api/README.md +50 -0
  547. package/templates/apps/frontend-ssr-api/app.config.ts +43 -0
  548. package/templates/apps/frontend-ssr-api/package.json +30 -0
  549. package/templates/apps/frontend-ssr-api/src/globals.css +38 -0
  550. package/templates/apps/frontend-ssr-api/src/globals.d.ts +6 -0
  551. package/templates/apps/frontend-ssr-api/src/locales/de.ts +20 -0
  552. package/templates/apps/frontend-ssr-api/src/locales/en.ts +31 -0
  553. package/templates/apps/frontend-ssr-api/src/locales/index.ts +16 -0
  554. package/templates/apps/frontend-ssr-api/src/pages/index.test.tsx +105 -0
  555. package/templates/apps/frontend-ssr-api/src/pages/index.tsx +83 -0
  556. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +28 -0
  557. package/templates/apps/frontend-ssr-api/template.json +6 -0
  558. package/templates/apps/frontend-ssr-api/tsconfig.json +11 -0
  559. package/templates/apps/frontend-static-blog/README.md +49 -0
  560. package/templates/apps/frontend-static-blog/app.config.ts +34 -0
  561. package/templates/apps/frontend-static-blog/package.json +28 -0
  562. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.test.tsx +65 -0
  563. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +35 -0
  564. package/templates/apps/frontend-static-blog/src/content/posts.ts +64 -0
  565. package/templates/apps/frontend-static-blog/src/globals.css +75 -0
  566. package/templates/apps/frontend-static-blog/src/lib/locale.test.ts +72 -0
  567. package/templates/apps/frontend-static-blog/src/lib/locale.ts +55 -0
  568. package/templates/apps/frontend-static-blog/src/locales/de.ts +19 -0
  569. package/templates/apps/frontend-static-blog/src/locales/en.ts +26 -0
  570. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug].tsx +20 -0
  571. package/templates/apps/frontend-static-blog/src/pages/[locale]/index.tsx +13 -0
  572. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +62 -0
  573. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].test.tsx +116 -0
  574. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].tsx +69 -0
  575. package/templates/apps/frontend-static-blog/src/pages/index.test.tsx +100 -0
  576. package/templates/apps/frontend-static-blog/src/pages/index.tsx +58 -0
  577. package/templates/apps/frontend-static-blog/src/pages/layout.tsx +63 -0
  578. package/templates/apps/frontend-static-blog/template.json +6 -0
  579. package/templates/apps/frontend-static-blog/tsconfig.json +11 -0
  580. package/templates/baselines/bare/.env.example +37 -0
  581. package/templates/baselines/bare/README.md +47 -0
  582. package/templates/baselines/bare/baseline.json +31 -0
  583. package/templates/baselines/bare/deploy/README.md +43 -0
  584. package/templates/baselines/bare/deploy/voltro.service.example +36 -0
  585. package/templates/baselines/compose/.env.example +46 -0
  586. package/templates/baselines/compose/README.md +69 -0
  587. package/templates/baselines/compose/baseline.json +51 -0
  588. package/templates/baselines/compose/docker/.dockerignore +38 -0
  589. package/templates/baselines/compose/docker/api.Dockerfile +57 -0
  590. package/templates/baselines/compose/docker/dev.Dockerfile +35 -0
  591. package/templates/baselines/compose/docker/web.Dockerfile +59 -0
  592. package/templates/baselines/compose/docker-compose.dev.yml +89 -0
  593. package/templates/baselines/compose/docker-compose.prod.yml +87 -0
  594. package/templates/baselines/compose/docker-compose.yml +41 -0
  595. package/templates/baselines/compose-mariadb/.env.example +57 -0
  596. package/templates/baselines/compose-mariadb/README.md +78 -0
  597. package/templates/baselines/compose-mariadb/baseline.json +51 -0
  598. package/templates/baselines/compose-mariadb/docker/.dockerignore +38 -0
  599. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +57 -0
  600. package/templates/baselines/compose-mariadb/docker/dev.Dockerfile +35 -0
  601. package/templates/baselines/compose-mariadb/docker/mariadb-init.sql +6 -0
  602. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +59 -0
  603. package/templates/baselines/compose-mariadb/docker-compose.dev.yml +117 -0
  604. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +114 -0
  605. package/templates/baselines/compose-mariadb/docker-compose.yml +79 -0
  606. package/templates/baselines/helm/.env.example +39 -0
  607. package/templates/baselines/helm/README.md +98 -0
  608. package/templates/baselines/helm/baseline.json +53 -0
  609. package/templates/baselines/helm/charts/voltro-app/.helmignore +10 -0
  610. package/templates/baselines/helm/charts/voltro-app/Chart.yaml +10 -0
  611. package/templates/baselines/helm/charts/voltro-app/templates/_helpers.tpl +36 -0
  612. package/templates/baselines/helm/charts/voltro-app/templates/configmap.yaml +13 -0
  613. package/templates/baselines/helm/charts/voltro-app/templates/deployment-api.yaml +120 -0
  614. package/templates/baselines/helm/charts/voltro-app/templates/deployment-web.yaml +45 -0
  615. package/templates/baselines/helm/charts/voltro-app/templates/ingress.yaml +37 -0
  616. package/templates/baselines/helm/charts/voltro-app/templates/postgres-service.yaml +19 -0
  617. package/templates/baselines/helm/charts/voltro-app/templates/postgres-statefulset.yaml +73 -0
  618. package/templates/baselines/helm/charts/voltro-app/templates/secret.yaml +33 -0
  619. package/templates/baselines/helm/charts/voltro-app/templates/service-api.yaml +19 -0
  620. package/templates/baselines/helm/charts/voltro-app/templates/service-web.yaml +19 -0
  621. package/templates/baselines/helm/charts/voltro-app/values-dev.yaml +21 -0
  622. package/templates/baselines/helm/charts/voltro-app/values-prod.yaml +58 -0
  623. package/templates/baselines/helm/charts/voltro-app/values-staging.yaml +25 -0
  624. package/templates/baselines/helm/charts/voltro-app/values.yaml +109 -0
  625. package/templates/baselines/helm/deploy/README.md +94 -0
  626. package/templates/patches/@effect__cluster@0.59.0.patch +262 -0
@@ -0,0 +1,1622 @@
1
+ # database.querying
2
+
3
+ > Three cardinalities, one DSL. Declaration via relations(), eager loading via .with(), reactive invalidation via the two-stage dependency-graph + per-field pre-filter.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/database/query-builder.md -->
10
+ ## Query builder
11
+
12
+ _The full ctx.store API — select, where, orderBy, limit, aggregates, raw SQL escape hatches._
13
+
14
+ `ctx.store` is the framework's typed data layer. It's available on every executor's `AppContext`. Reads are dependency-tracked for subscriptions; writes fire CDC events that invalidate subscribers.
15
+
16
+ This page covers selects + filters + aggregates. For relations see [Joins](/docs/database/joins); for writes see [Transactions](/docs/database/transactions).
17
+
18
+ ## Select
19
+
20
+ ```ts
21
+ ctx.store.select('notes') // SELECT * FROM notes
22
+ .where('tenantId', tenantId) // WHERE tenantId = $1
23
+ .orderBy('createdAt', 'desc')
24
+ .limit(20)
25
+ .all() // → ReadonlyArray<Note>
26
+ ```
27
+
28
+ Terminal operations:
29
+
30
+ | Method | Returns | When to use |
31
+ |---|---|---|
32
+ | `.all()` | `ReadonlyArray<T>` | Multi-row result. |
33
+ | `.one()` | `T` (throws if missing) | "I know this exists" — primary-key lookups. |
34
+ | `.maybeOne()` | `T \| null` | Lookup that may fail (login by email). |
35
+ | `.first()` | `T \| null` | First row; equivalent to `.limit(1).maybeOne()`. |
36
+ | `.count()` | `number` | Counts. |
37
+ | `.exists()` | `boolean` | EXISTS check; cheap. |
38
+
39
+ Column projection — pick only the fields you need:
40
+
41
+ ```ts
42
+ ctx.store.select('notes').select('id', 'title') // SELECT id, title
43
+ ```
44
+
45
+ The return type narrows automatically — `{ id: string; title: string }[]`.
46
+
47
+ ## `where`
48
+
49
+ Filters chain (AND-merged):
50
+
51
+ ```ts
52
+ ctx.store.select('notes')
53
+ .where('authorId', myId)
54
+ .where('createdAt', '>', cutoff)
55
+ .where('archived', false)
56
+ ```
57
+
58
+ ### Operators
59
+
60
+ ```ts
61
+ .where('col', value) // = (default)
62
+ .where('col', '=', value)
63
+ .where('col', '!=', value) // also '<>'
64
+ .where('col', '<', value)
65
+ .where('col', '<=', value)
66
+ .where('col', '>', value)
67
+ .where('col', '>=', value)
68
+ .where('col', 'in', [a, b, c])
69
+ .where('col', 'like', 'abc%')
70
+ .where('col', 'contains', 'needle') // case-insensitive substring (ILIKE '%…%')
71
+ .where('col', 'fts', 'query string') // full-text fallback (LIKE-based here)
72
+ ```
73
+
74
+ These are the only operators the ergonomic `.where(col, op, value)` form accepts. For `IS NULL` / `NOT IN` / `IS NOT NULL`, pass a predicate built with the `@voltro/database` helpers:
75
+
76
+ ```ts
77
+ import { isNull, isNotNull, notInSet } from '@voltro/database'
78
+
79
+ ctx.store.select('notes').where(isNull('deletedAt'))
80
+ ctx.store.select('notes').where(isNotNull('publishedAt'))
81
+ ctx.store.select('notes').where(notInSet('status', ['archived', 'spam']))
82
+ ```
83
+
84
+ For index-backed full-text search use the `.matching('indexName', 'query')` builder (see [Full-text search](/docs/database/full-text-search)); the `'fts'` operator above is a plain substring fallback.
85
+
86
+ ### OR / NOT
87
+
88
+ The predicate helpers compose into the single-argument `.where(predicate)` form:
89
+
90
+ ```ts
91
+ import { or, not, and, eq, contains } from '@voltro/database'
92
+
93
+ ctx.store.select('notes').where(or(
94
+ eq('authorId', myId),
95
+ contains('sharedWith', myId),
96
+ ))
97
+
98
+ ctx.store.select('notes').where(not(eq('archived', true)))
99
+ ```
100
+
101
+ `and(...)` is rarely needed because chained `.where()` calls are already AND'd; useful inside `or(...)` to nest.
102
+
103
+ ### JSON path filters
104
+
105
+ For `json<T>()` columns:
106
+
107
+ ```ts
108
+ ctx.store.select('notes')
109
+ .where('prefs.fontSize', 'md') // prefs->>'fontSize' = 'md'
110
+ .where('prefs.collapsed', 'contains', 'inbox') // prefs->'collapsed' @> '["inbox"]'
111
+ ```
112
+
113
+ See [JSON columns](/docs/database/json) for indexing + path semantics.
114
+
115
+ ## `orderBy`
116
+
117
+ Each call adds ONE column + direction; chain for multi-column ordering:
118
+
119
+ ```ts
120
+ .orderBy('createdAt', 'desc')
121
+ .orderBy('priority', 'desc').orderBy('createdAt', 'asc')
122
+ ```
123
+
124
+ ## `limit` / `offset`
125
+
126
+ ```ts
127
+ .limit(20)
128
+ .limit(20).offset(40)
129
+ ```
130
+
131
+ For cursor pagination that avoids OFFSET's O(n) scan, use the standalone `paginateById` helper over `ctx.store.query(...)`:
132
+
133
+ ```ts
134
+ import { paginateById } from '@voltro/database'
135
+
136
+ const rows = await ctx.store.query(
137
+ paginateById(database.notes.orderBy('createdAt', 'desc').descriptor, req.cursor, 20),
138
+ )
139
+ const nextCursor = rows.at(-1)?.id ?? null
140
+ return { rows, nextCursor }
141
+ ```
142
+
143
+ `paginateById(descriptor, cursor, limit)` adds a `WHERE id > cursor ORDER BY id LIMIT n` clause and preserves any existing predicate. Works for any sortable id scheme (TypeID, ULID, Snowflake, Numeric).
144
+
145
+ ## Aggregates
146
+
147
+ The aggregate terminals live on the `database.<table>` builder, run via `ctx.store.query(...)`:
148
+
149
+ ```ts
150
+ import { count, sum, max, eq } from '@voltro/database'
151
+
152
+ // COUNT(*) — one row, { count: number }
153
+ const rows = await ctx.store.query(
154
+ database.notes.where(eq('archived', false)).count().descriptor,
155
+ )
156
+ const open = rows[0]!.count
157
+
158
+ // Bundle multiple aggregates into one row
159
+ await ctx.store.query(
160
+ database.orders.where(eq('orgId', oid)).aggregate({
161
+ total: sum('amount'),
162
+ peak: max('createdAt'),
163
+ cnt: count(),
164
+ }).descriptor,
165
+ )
166
+ ```
167
+
168
+ Helpers: `count()`, `countDistinct(col)`, `sum(col)`, `avg(col)`, `min(col)`, `max(col)`. See [Aggregations](/docs/database/aggregations) for `.groupBy()` / `.having()` + window functions.
169
+
170
+ ## Distinct
171
+
172
+ `distinct` lives on the `database.<table>` builder, run via `ctx.store.query(...)`:
173
+
174
+ ```ts
175
+ await ctx.store.query(database.notes.select('authorId').distinct().descriptor)
176
+ ```
177
+
178
+ For `DISTINCT ON` and one-row-per-group, see [DISTINCT + DISTINCT ON](/docs/database/distinct).
179
+
180
+ ## Raw SQL escape hatch
181
+
182
+ When the DSL doesn't model what you need:
183
+
184
+ ```ts
185
+ import { sql } from '@voltro/database/sql'
186
+
187
+ const rows = await ctx.store.raw!<{ id: string; n: number }>(sql`
188
+ SELECT id, count(*) AS n
189
+ FROM events
190
+ WHERE occurred_at > ${cutoff}
191
+ GROUP BY id
192
+ ORDER BY n DESC
193
+ LIMIT 10
194
+ `)
195
+ ```
196
+
197
+ The `sql` tag is imported from the server-only `@voltro/database/sql` subpath — it never reaches the browser bundle. It parameterises values automatically: every `${value}` interpolation is bound as a parameter by the store's dialect-specific driver, never spliced into the SQL text. A value containing `'; DROP TABLE` round-trips as data, not SQL. The generic parameter (`<{ id: string; n: number }>`) names the row shape.
198
+
199
+ `store.raw` is an **optional** method on the store — it exists on every SQL store (postgres / mysql / mariadb / mssql / sqlite / turso) but NOT on the in-memory store (there's no SQL engine to run raw text against). The `!` non-null assertion above is appropriate on SQL-backed apps; for code that must run on the memory store too, guard with `if (ctx.store.raw)`.
200
+
201
+ **You own dialect-portability of the static text.** Only the interpolated values are auto-parameterised — the rest of the fragment is emitted verbatim. Postgres-only syntax (`->>`, `plainto_tsquery`) breaks on mysql. Keep the static SQL portable, or branch on the dialect.
202
+
203
+ **Raw queries aren't tracked by the reactive engine** — the planner can't infer which tables an arbitrary SQL string touches. If you want a subscription to invalidate on a raw read's tables, declare them explicitly:
204
+
205
+ ```ts
206
+ ctx.store.raw!<{ … }>(sql`…`, { dependsOn: ['events'] })
207
+ ```
208
+
209
+ ## Tenant scoping (implicit)
210
+
211
+ If the table has the `tenant()` mixin, every `select` auto-merges `WHERE tenantId = ctx.subject.tenantId`. You don't write it; the runtime injects it. To opt out (admin queries crossing tenants), use `.unscoped()`:
212
+
213
+ ```ts
214
+ ctx.store.select('notes').unscoped().all()
215
+ ```
216
+
217
+ `.unscoped()` drops the automatic tenant filter for cross-tenant staff reads — gate it yourself in the handler (e.g. `requireScope(ctx.subject, 'admin:full')`) before exposing it. Soft-delete reads have the parallel `.withDeleted()` opt-out.
218
+
219
+ ## Where to find more
220
+
221
+ The query builder has dedicated pages for the deeper topics:
222
+
223
+ - **[Aggregations](/docs/database/aggregations)** — `.count()` /
224
+ `.aggregate({...})` / `.groupBy()` / `.having()` + window functions
225
+ (`rowNumber` / `rank` / `lag` / `lead` / `sumOver`).
226
+ - **[Sub-queries](/docs/database/sub-queries)** — `inSubquery` /
227
+ `notInSubquery` / `exists` / `notExists` predicates that reference
228
+ other queries.
229
+ - **[Set operations](/docs/database/set-operations)** — `union` /
230
+ `unionAll` / `intersect` / `except` combine multiple queries with
231
+ the same column shape.
232
+ - **[DISTINCT + DISTINCT ON](/docs/database/distinct)** — dedup row
233
+ sets, pick one row per group on postgres.
234
+ - **[Self-joins](/docs/database/self-joins)** — `.as(alias)` +
235
+ `.innerJoin(table, alias, on)` + `.selectJoined({...})` for parent/
236
+ child trees and CTE references.
237
+ - **[CTEs](/docs/database/query-builder#ctes)** — `.withCte(name, sub)`
238
+ for named sub-queries reusable inside the outer SELECT.
239
+ - **[Recursive CTEs](/docs/database/recursive-cte)** — `.recursiveCte`
240
+ for tree walks (org hierarchy, comment threads, file folders).
241
+ - **[Bulk writes](/docs/database/bulk-operations)** — `.updateMany` /
242
+ `.upsert` / `.insertIgnore` for one-statement bulk operations.
243
+
244
+ ## CTEs (Common Table Expressions)
245
+
246
+ For complex queries with reusable sub-queries, declare named CTEs:
247
+
248
+ ```ts
249
+ import { eq, notInSubquery, queryFor } from '@voltro/database'
250
+
251
+ const blocked = queryFor(database.blockedUsers).select('userId').descriptor
252
+
253
+ const visibleUsers = await ctx.store.query(
254
+ queryFor(database.users)
255
+ .withCte('blocked', blocked)
256
+ .where(notInSubquery('id', { table: 'blocked', projection: ['userId'], /* ... */ }))
257
+ .descriptor,
258
+ )
259
+ ```
260
+
261
+ Emits `WITH blocked AS (SELECT "userId" FROM "blockedUsers") SELECT ...`.
262
+
263
+ For recursive CTEs (`WITH RECURSIVE`) see the
264
+ [Recursive CTE page](/docs/database/recursive-cte).
265
+
266
+
267
+
268
+ ---
269
+
270
+ <!-- source: en/database/joins.md -->
271
+ ## Joins & relations
272
+
273
+ _Eager-loading related rows via relations + .with(), and explicit joins via queryFor().innerJoin(). How the reactive engine tracks them._
274
+
275
+ Voltro has two ways to read across tables:
276
+
277
+ 1. **Relations + `.with(spec)`** — the ergonomic path for "fetch X with
278
+ its Y". Declare relations once in a `*.relations.ts` file; eager-load
279
+ them with `.with({ ... })`. Result comes back as nested objects.
280
+ 2. **Explicit joins** — `queryFor(table).as(alias).innerJoin(Table, alias, on)`
281
+ for self-joins, CTE references, and flat aliased projections. Covered
282
+ in depth on the [Self-joins](/docs/database/self-joins) page.
283
+
284
+ Foreign keys are declared with `reference(() => table)` — singular,
285
+ thunk-arg.
286
+
287
+ ## Declaring relations
288
+
289
+ Relations live OUTSIDE the table descriptor, in a `*.relations.ts` file
290
+ (convention). The framework registers them at boot and `.with()` uses
291
+ them to eager-load.
292
+
293
+ ```ts
294
+ // database/users.relations.ts
295
+ import { relations } from '@voltro/database'
296
+ import { users, profiles, orgs, orgMemberships } from './index'
297
+
298
+ export const usersRelations = relations(users, ({ one, many, manyToMany }) => ({
299
+ profile: one(profiles), // 1:1
300
+ ownedOrgs: many(orgs, { foreignKey: 'ownerId' }), // 1:N
301
+ organizations: manyToMany(orgs, { // N:M
302
+ through: orgMemberships,
303
+ sourceKey: 'userId',
304
+ targetKey: 'orgId',
305
+ }),
306
+ }))
307
+ ```
308
+
309
+ `foreignKey` auto-derives when exactly one `reference()` column on the
310
+ target points back at the source — set it explicitly only for tables
311
+ with multiple FKs into the same parent (`createdBy` + `updatedBy` →
312
+ `actors`). Many-to-many always uses an explicit through-table you write
313
+ yourself.
314
+
315
+ ## Eager-loading via `.with(spec)`
316
+
317
+ ```ts
318
+ const rows = await ctx.store.query(
319
+ database.users
320
+ .where(eq('tenantId', tenantId))
321
+ .with({
322
+ profile: true, // 1:1 → object | null
323
+ ownedOrgs: { limit: 5, orderBy: [{ column: 'createdAt', direction: 'desc' }] },
324
+ organizations: true, // N:M via the through-table
325
+ })
326
+ .descriptor,
327
+ )
328
+ // rows[0].profile → Profile | null
329
+ // rows[0].ownedOrgs → Org[]
330
+ // rows[0].organizations → Org[]
331
+ ```
332
+
333
+ Each branch takes its own `where` / `orderBy` / `limit` / `offset` /
334
+ nested `with`. `limit: 5` applies **per parent** (up to 5 orgs FOR EACH
335
+ user) — same semantics as Drizzle / Prisma / Hibernate. The framework
336
+ renders the whole tree as ONE SQL roundtrip via the dialect's
337
+ JSON-aggregation idiom (no N+1).
338
+
339
+ Nested `.with()` threads arbitrary depth:
340
+
341
+ ```ts
342
+ database.users.with({ posts: { with: { author: true } } })
343
+ ```
344
+
345
+ ## Explicit joins — `queryFor().innerJoin()`
346
+
347
+ When eager-load doesn't fit — self-joins, joining a CTE, or a flat
348
+ aliased result shape — use the explicit join builder. The full reference
349
+ is on the [Self-joins](/docs/database/self-joins) page; the shape:
350
+
351
+ ```ts
352
+ import { eq, queryFor } from '@voltro/database'
353
+
354
+ const rows = await ctx.store.query(
355
+ queryFor(database.messages).as('m')
356
+ .innerJoin(database.users, 'u', eq('u.id', 'm.authorId'))
357
+ .selectJoined({
358
+ messageId: 'm.id',
359
+ body: 'm.body',
360
+ authorName: 'u.name',
361
+ })
362
+ .descriptor,
363
+ )
364
+ // rows: Array<{ messageId: string; body: string; authorName: string }>
365
+ ```
366
+
367
+ `.leftJoin(Table, alias, on)` has the same shape but keeps FROM-side rows
368
+ that have no JOIN-side match (joined columns are `null` for those).
369
+ Passing a `Table` descriptor threads its row type into `.selectJoined`
370
+ for full inference; passing a string name (CTE / dynamic) falls through
371
+ to `unknown`.
372
+
373
+ ## Reactive tracking
374
+
375
+ Eager-load (`.with()`) subscriptions track changes to the root table and
376
+ every relation the spec touches — the dispatcher consults a per-table
377
+ dependency map plus a per-field relevance pre-filter, so a write that
378
+ doesn't touch a depended-on column skips the re-query entirely.
379
+
380
+ For high-traffic joins where you only want to re-fire on the primary
381
+ table, project away the joined columns or denormalise into a generated
382
+ column.
383
+
384
+ ## Anti-patterns
385
+
386
+ - **Joining inside a loop.** Always express the read in the builder —
387
+ `.with({ ... })` does the JSON aggregate; the explicit join builder
388
+ does the SQL JOIN.
389
+ - **`reference('table')`.** The FK constructor is `reference(() => table)`
390
+ — singular, thunk-arg.
391
+ - **Using `.with()` for write paths.** Reads only; for writes use
392
+ [Transactions](/docs/database/transactions).
393
+
394
+
395
+
396
+ ---
397
+
398
+ <!-- source: en/database/relations/index.md -->
399
+ ## Relations
400
+
401
+ _Three cardinalities, one DSL. Declaration via relations(), eager loading via .with(), reactive invalidation via the two-stage dependency-graph + per-field pre-filter._
402
+
403
+ Voltro models table relationships explicitly via a `relations()` declaration that lives outside the table descriptor. The query builder's `.with(...)` chain reads this declaration to eager-load related rows in a single SQL round-trip, regardless of how many relations you traverse.
404
+
405
+ This index covers the cross-cutting bits. Each cardinality + the cross-cutting concerns has its own page below:
406
+
407
+ - [one()](./one-to-one) — 1:1 or N:1. Single related row per parent.
408
+ - [many()](./one-to-many) — 1:N. Array of related rows per parent.
409
+ - [manyToMany()](./many-to-many) — N:M through an explicit junction table.
410
+ - [Eager loading with `.with()`](./eager-loading) — single-roundtrip nested JSON.
411
+ - [Cascade + FK semantics](./cascade) — `onDelete: 'restrict'` defaults + FK-auto-index.
412
+ - [Reactive invalidation](./reactive) — two-stage gate: a per-table dependency-graph plus a per-field pre-filter.
413
+
414
+ ## Declaration
415
+
416
+ Relations live in `*.relations.ts` files alongside the schema. They're separate from the table descriptor so a table can be referenced from multiple sides without a circular import.
417
+
418
+ ```typescript
419
+ // database/users.relations.ts
420
+ import { relations } from '@voltro/database'
421
+ import { users, profiles, posts, orgs, orgMemberships } from './index'
422
+
423
+ export const usersRelations = relations(users, ({ one, many, manyToMany }) => ({
424
+ profile: one(profiles), // 1:1
425
+ posts: many(posts, { foreignKey: 'authorId' }), // 1:N
426
+ organizations: manyToMany(orgs, { // N:M
427
+ through: orgMemberships,
428
+ sourceKey: 'userId',
429
+ targetKey: 'orgId',
430
+ }),
431
+ }))
432
+ ```
433
+
434
+ The framework auto-registers every `relations(...)` call at boot — you don't write a manual barrel.
435
+
436
+ ## Quick start — all three cardinalities
437
+
438
+ ```typescript
439
+ // Schema
440
+ export const users = table('users', { id: id(), email: text() })
441
+ export const profiles = table('profiles', {
442
+ id: id(), userId: reference(() => users), bio: text(),
443
+ })
444
+ export const posts = table('posts', {
445
+ id: id(), authorId: reference(() => users), title: text(),
446
+ })
447
+ export const orgs = table('orgs', { id: id(), name: text() })
448
+ export const orgMemberships = table('org_memberships', {
449
+ id: id(),
450
+ userId: reference(() => users),
451
+ orgId: reference(() => orgs),
452
+ role: text(),
453
+ })
454
+
455
+ // Relations
456
+ relations(users, ({ one, many, manyToMany }) => ({
457
+ profile: one(profiles),
458
+ posts: many(posts),
459
+ organizations: manyToMany(orgs, {
460
+ through: orgMemberships,
461
+ sourceKey: 'userId',
462
+ targetKey: 'orgId',
463
+ }),
464
+ }))
465
+
466
+ // Eager-load query
467
+ const rows = await store.query(
468
+ database.users.with({
469
+ profile: true,
470
+ posts: { limit: 5, orderBy: [{ column: 'createdAt', direction: 'desc' }] },
471
+ organizations: true,
472
+ }).descriptor,
473
+ )
474
+
475
+ // TypeScript infers:
476
+ // rows: Array<User & {
477
+ // profile: Profile | null,
478
+ // posts: Post[],
479
+ // organizations: Org[],
480
+ // }>
481
+ ```
482
+
483
+ One SQL round-trip. The framework's JSON-aggregation compiler renders the whole tree as a single SELECT.
484
+
485
+ ## When to declare a relation
486
+
487
+ Declare a relation when:
488
+ - Application code wants to **eager-load** the related rows in a single query.
489
+ - A reactive subscription needs to **wake when the related table changes**.
490
+ - The TypeScript types should **flow through** the cardinality automatically.
491
+
492
+ Don't declare a relation when:
493
+ - The FK exists only for DB-level integrity and code never reads through it.
494
+ - The "relation" is computed across many tables and doesn't have a clean 1:1 / 1:N / N:M shape.
495
+
496
+ A `reference()` column gives you the FK constraint + B-tree index regardless of whether a relation is declared on top. Relations are an application-layer concern about how to TRAVERSE the FK, not whether the FK exists.
497
+
498
+ ## Decision tree — which cardinality?
499
+
500
+ ```text
501
+ Does the related table point AT this table?
502
+ ├─ One row only? → one(target) — 1:1 from this side, N:1 if FK is on this side
503
+ ├─ Many rows? → many(target) — 1:N
504
+ └─ Connected through a junction table that holds extra columns?
505
+ → manyToMany(target, { through: junction, ... }) — N:M
506
+ ```
507
+
508
+ If you're not sure, ask: "for one row in THIS table, how many rows in THE OTHER table do I get?" One → `one`. Many → `many`. Many with a third table in between → `manyToMany`.
509
+
510
+ ## Where it lives
511
+
512
+ - `voltro/packages/database/src/relations.ts` — `relations()` builder + `one`/`many`/`manyToMany` helpers
513
+ - `voltro/packages/database/src/relationsRegistry.ts` — process-global registry
514
+ - `voltro/packages/database/src/queryBuilder.ts` — `.with()` chain
515
+ - `voltro/packages/database/src/joinCompiler.ts` — walker fallback for compile-null cases
516
+ - `voltro/packages/database/src/jsonEagerCompiler.ts` — per-dialect nested JSON-aggregation
517
+ - `voltro/packages/runtime/src/dependencyGraph.ts` — multi-table subscription registration
518
+ - `voltro/packages/runtime/src/relevantFields.ts` — per-field pre-filter
519
+ - `voltro/packages/runtime/src/dispatcher.ts` — multi-table fan-out + delta diffing
520
+
521
+
522
+
523
+ ---
524
+
525
+ <!-- source: en/database/relations/one-to-one.md -->
526
+ ## one() — 1:1 and N:1
527
+
528
+ _Single related row per parent. Owning side vs optional side, FK location auto-derivation, common patterns._
529
+
530
+ `one(target)` declares that for one row in the source table, there's at most ONE related row in the target. The resolved value is `Target | null` — null when no related row exists.
531
+
532
+ ```typescript
533
+ relations(users, ({ one }) => ({
534
+ profile: one(profiles), // 1:1, FK on profiles
535
+ defaultOrg: one(orgs, { sourceKey: 'defaultOrgId' }), // N:1, FK on users
536
+ }))
537
+ ```
538
+
539
+ The framework derives the join direction from the existence of FK columns; if it can't disambiguate, you pass `sourceKey` (FK is on the SOURCE table pointing at the TARGET) or `foreignKey` (FK is on the TARGET table pointing at the SOURCE).
540
+
541
+ ## Two shapes
542
+
543
+ `one()` collapses two different relationship cardinalities into one DSL — they have the same shape from the application's perspective (single related row) but the SQL emitted is different.
544
+
545
+ ### Shape A — FK on the target (1:1 / 0..1:1)
546
+
547
+ Profile owns its FK to user. One user has zero or one profile.
548
+
549
+ ```typescript
550
+ export const profiles = table('profiles', {
551
+ id: id({ prefix: 'profile' }),
552
+ userId: reference(() => users, { onDelete: 'cascade' }), // ← FK
553
+ bio: text(),
554
+ })
555
+
556
+ relations(users, ({ one }) => ({
557
+ profile: one(profiles), // framework finds userId on profiles → join target.userId = source.id
558
+ }))
559
+ ```
560
+
561
+ SQL emitted:
562
+ ```sql
563
+ (SELECT row_to_json(p) FROM profiles p WHERE p.user_id = users.id LIMIT 1)
564
+ ```
565
+
566
+ The framework auto-derives this when the target table has exactly ONE `reference()` column pointing at the source. If there are multiple FKs from target → source, pass `foreignKey:`:
567
+
568
+ ```typescript
569
+ // posts has both authorId AND editorId → user
570
+ relations(users, ({ one }) => ({
571
+ authoredFirstPost: one(posts, { foreignKey: 'authorId' }),
572
+ editedFirstPost: one(posts, { foreignKey: 'editorId' }),
573
+ }))
574
+ ```
575
+
576
+ ### Shape B — FK on the source (N:1)
577
+
578
+ User owns the FK to a default org. Many users can point at the same org.
579
+
580
+ ```typescript
581
+ export const users = table('users', {
582
+ id: id(),
583
+ defaultOrgId: reference(() => orgs).nullable(), // ← FK
584
+ email: text(),
585
+ })
586
+
587
+ relations(users, ({ one }) => ({
588
+ defaultOrg: one(orgs, { sourceKey: 'defaultOrgId' }),
589
+ }))
590
+ ```
591
+
592
+ SQL emitted:
593
+ ```sql
594
+ (SELECT row_to_json(o) FROM orgs o WHERE o.id = users.default_org_id LIMIT 1)
595
+ ```
596
+
597
+ The framework needs `sourceKey:` because the FK is on the source side, pointing at the target's `id`. Without the hint, the framework would look for FKs on the target table back at the source.
598
+
599
+ ## Result type
600
+
601
+ ```typescript
602
+ const result = await store.query(
603
+ database.users.with({ profile: true, defaultOrg: true }).descriptor,
604
+ )
605
+ // result: Array<User & {
606
+ // profile: Profile | null,
607
+ // defaultOrg: Org | null,
608
+ // }>
609
+ ```
610
+
611
+ Always `Target | null` — even on tables where you "expect" the relation to exist. Application code MUST handle the null case; the framework doesn't model "required 1:1" as a different cardinality.
612
+
613
+ ## Owning side vs optional side
614
+
615
+ For 1:1 relationships, which side owns the FK is a schema design decision the framework doesn't force:
616
+
617
+ - **Owning side**: holds the FK column. Inserts on this side reference the target.
618
+ - **Optional side**: holds no FK. The relation flows through the owning side's FK on read.
619
+
620
+ Practical rule of thumb: put the FK on the **smaller-cardinality** side. If every user has at most one profile, the FK goes on profiles (which is cardinality-bound by users). If you flipped it — `users.profileId` — you'd need to manage the order of creation (insert profile first, then user with profileId) instead of the typical sequence (insert user, then optional profile).
621
+
622
+ ## Mutating through the relation
623
+
624
+ The framework's mutation surface doesn't have a "set profile" shortcut. You write the underlying INSERT / UPDATE explicitly:
625
+
626
+ ```typescript
627
+ // Create user + profile. Pre-compute the id so `profiles.userId` can
628
+ // reference it in the same flow.
629
+ import { typeid } from 'typeid-js'
630
+ const userId = typeid('user').toString()
631
+ await ctx.store.insert('users', { id: userId, email })
632
+ await ctx.store.insert('profiles', { userId, bio })
633
+
634
+ // Change a user's default org.
635
+ await ctx.store.update('users', userId, { defaultOrgId: newOrgId })
636
+ ```
637
+
638
+ This is on purpose — the relation declaration describes the SHAPE of the data, not the WAY mutations propagate. Cascade behaviour is controlled by `onDelete:` on the `reference()` column (see [cascade](./cascade)), not by the relation.
639
+
640
+ ## Eager loading with `.with({ profile: true })`
641
+
642
+ ```typescript
643
+ const users = await store.query(
644
+ database.users.where(eq('tenantId', tenantId)).with({ profile: true }).descriptor,
645
+ )
646
+ // users: Array<User & { profile: Profile | null }>
647
+ ```
648
+
649
+ The framework's JSON-agg compiler emits a single SELECT with the profile as a nested subquery. No N+1 round-trips.
650
+
651
+ For per-branch filtering / ordering on the related row, pass an object instead of `true`:
652
+
653
+ ```typescript
654
+ database.users.with({
655
+ profile: { where: eq('verified', true) },
656
+ })
657
+ ```
658
+
659
+ The framework applies the where clause inside the per-row subquery. If the profile doesn't match, the resolved value is null — same as if no profile existed.
660
+
661
+ ## Nested `.with()`
662
+
663
+ The related row can carry its own `.with()` for transitive relations:
664
+
665
+ ```typescript
666
+ database.users.with({
667
+ profile: {
668
+ with: { avatar: true }, // profile → avatar
669
+ },
670
+ })
671
+ // → Array<User & {
672
+ // profile: (Profile & { avatar: Avatar | null }) | null,
673
+ // }>
674
+ ```
675
+
676
+ Recursion depth is arbitrary. Each nested branch becomes another nested subquery in the same SELECT — the framework's compiler walks the spec to any depth.
677
+
678
+ ## Reactive subscriptions
679
+
680
+ A subscription opened with `.with({ profile: true })` registers against BOTH `users` and `profiles`. Changes to either table re-run the query (narrowed by the per-field pre-filter — see [reactive](./reactive)).
681
+
682
+ ## Caveats
683
+
684
+ - **Two `one()` calls on the same target table need distinct relation names**. The framework keys eager loads by relation name, not by target table — `database.users.with({ profile: true })` reads the relation named `'profile'`. If you declare both `defaultOrg` and `billingOrg` both pointing at `orgs`, they're distinct relations and `with({ defaultOrg: true, billingOrg: true })` reads them independently.
685
+ - **Self-referential 1:1 needs the thunk form**. `one(() => users)` for a "parent user" lookup. Without the thunk, the table reference would try to resolve before the table is registered and fail.
686
+ - **`one()` on a target that has a many-to-the-same-source relation is unusual but legal**. The framework reads the relation declaration verbatim — if you say "one profile per user" and the actual data has two profiles for one user, the query returns one (LIMIT 1 in the subquery) and the other is silently invisible. Use `many()` if multiplicity is genuinely possible.
687
+
688
+ ## Where it lives
689
+
690
+ - `voltro/packages/database/src/relations.ts` — `oneBuilder` (line 100)
691
+ - `voltro/packages/database/src/jsonEagerCompiler.ts` — per-dialect `*OneSubquery` (postgres, mysql, mssql, sqlite)
692
+ - `voltro/packages/database/src/joinCompiler.ts` — walker fallback for `one()` when the JSON-agg compiler can't dispatch
693
+
694
+
695
+
696
+ ---
697
+
698
+ <!-- source: en/database/relations/one-to-many.md -->
699
+ ## many() — 1:N
700
+
701
+ _Array of related rows per parent. FK auto-derivation, per-parent where/orderBy/limit semantics, performance caveats for unbounded children._
702
+
703
+ `many(target)` declares that for one row in the source table, there are zero or more related rows in the target. The resolved value is `Target[]`.
704
+
705
+ ```typescript
706
+ relations(users, ({ many }) => ({
707
+ posts: many(posts), // FK auto-derived as authorId
708
+ comments: many(comments, { foreignKey: 'userId' }), // explicit FK column
709
+ }))
710
+ ```
711
+
712
+ The FK lives on the TARGET table — it points back at the source. The framework derives it from the only `reference()` column pointing at the source, or you pass `foreignKey:` explicitly when the target has multiple FKs back.
713
+
714
+ ## Result type
715
+
716
+ ```typescript
717
+ const result = await store.query(
718
+ database.users.with({ posts: true }).descriptor,
719
+ )
720
+ // result: Array<User & { posts: Post[] }>
721
+ ```
722
+
723
+ Always `Target[]` — empty array when no related rows exist. Not `Target[] | null`.
724
+
725
+ ## Per-branch modifiers
726
+
727
+ The branch spec accepts `where`, `orderBy`, `limit`, `offset`. They apply **per parent** — `limit: 10` means up to 10 posts FOR EACH user, not 10 posts total.
728
+
729
+ ```typescript
730
+ database.users.with({
731
+ posts: {
732
+ where: eq('published', true),
733
+ orderBy: [{ column: 'createdAt', direction: 'desc' }],
734
+ limit: 10,
735
+ offset: 0,
736
+ },
737
+ })
738
+ ```
739
+
740
+ The SQL emitted depends on the dialect:
741
+
742
+ ### Postgres / SQLite / MSSQL
743
+
744
+ A FROM-derived-table wrapper around the per-parent set:
745
+
746
+ ```sql
747
+ -- Postgres
748
+ (SELECT jsonb_agg(t ORDER BY t.created_at DESC)
749
+ FROM (SELECT * FROM posts WHERE author_id = users.id AND published = true
750
+ ORDER BY created_at DESC LIMIT 10) t)
751
+ ```
752
+
753
+ ### MySQL
754
+
755
+ Similar shape with `JSON_OBJECT` + `JSON_ARRAYAGG`:
756
+
757
+ ```sql
758
+ COALESCE((SELECT JSON_ARRAYAGG(JSON_OBJECT(...))
759
+ FROM (SELECT * FROM posts WHERE author_id = users.id AND published = true
760
+ ORDER BY created_at DESC LIMIT 10) t), JSON_ARRAY())
761
+ ```
762
+
763
+ ### MariaDB
764
+
765
+ MariaDB rejects correlated references inside non-LATERAL derived tables. The framework switches to a ROW_NUMBER window-function pattern:
766
+
767
+ ```sql
768
+ COALESCE((SELECT JSON_ARRAYAGG(JSON_OBJECT(...) ORDER BY ranked.rn)
769
+ FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY author_id
770
+ ORDER BY created_at DESC) AS rn
771
+ FROM posts WHERE published = true) ranked
772
+ WHERE ranked.author_id = users.id AND ranked.rn <= 10), JSON_ARRAY())
773
+ ```
774
+
775
+ See [mariadb dialect details](../dialects/mariadb) for why.
776
+
777
+ ## Reverse direction
778
+
779
+ `many()` is unidirectional in the relation declaration — you declare from the parent side. The child side (target) can declare a reverse `one()` independently if application code needs it:
780
+
781
+ ```typescript
782
+ relations(users, ({ many }) => ({
783
+ posts: many(posts),
784
+ }))
785
+
786
+ relations(posts, ({ one }) => ({
787
+ author: one(users, { sourceKey: 'authorId' }), // posts.authorId → users.id
788
+ }))
789
+
790
+ // Both directions work in eager loads.
791
+ database.users.with({ posts: true })
792
+ database.posts.with({ author: true })
793
+ ```
794
+
795
+ The framework doesn't auto-generate reverse relations — declare them explicitly when you need them.
796
+
797
+ ## Cardinality assertion
798
+
799
+ `many()` carries no assertion about how MANY children each parent has. Zero is valid (empty array). A million is valid (potentially RAM-explosive — see "When NOT to eager-load" below). The relation describes "the set of children" without a multiplicity constraint.
800
+
801
+ If you genuinely have a 1:1 relationship modeled as `many()` because of legacy data, use `one()` instead — the framework will LIMIT 1 the subquery automatically and your TypeScript types will be `T | null` instead of `T[]`.
802
+
803
+ ## When NOT to eager-load
804
+
805
+ `.with({ posts: true })` builds the WHOLE child set into one JSON document per parent. For tables with extremely wide child sets — `users.with({ events: true })` on a user with 1M events — the result JSON is multi-MB per row. RAM-hostile.
806
+
807
+ Mitigations, in order of preference:
808
+
809
+ ### 1. Always pass `limit:` on unbounded children
810
+
811
+ ```typescript
812
+ database.users.with({
813
+ events: { limit: 100, orderBy: [{ column: 'createdAt', direction: 'desc' }] },
814
+ })
815
+ ```
816
+
817
+ The framework's per-parent slicing kicks in before aggregation, so memory is bounded by `parents × limit × row size`.
818
+
819
+ ### 2. Cursor-paginate the children separately
820
+
821
+ When the user might want pagination UI:
822
+
823
+ ```typescript
824
+ // Don't eager-load.
825
+ const [user] = await store.query(database.users.where(eq('id', uid)).descriptor)
826
+ const events = await ctx.store.query(
827
+ paginateById(database.events.where(eq('userId', uid)).descriptor, input.cursor, 100),
828
+ )
829
+ const nextCursor = events.at(-1)?.id
830
+ return { user, events, nextCursor }
831
+ ```
832
+
833
+ ### 3. Drop down to manual joins when shape matters
834
+
835
+ ```typescript
836
+ const rows = await ctx.store.transactional(async (txn) => {
837
+ const users = await txn.query(database.users.where(...))
838
+ const eventCounts = await txn.unsafe(
839
+ `SELECT user_id, COUNT(*) FROM events WHERE user_id = ANY($1) GROUP BY user_id`,
840
+ [users.map(u => u.id)],
841
+ )
842
+ return users.map(u => ({ ...u, eventCount: eventCounts[u.id] ?? 0 }))
843
+ })
844
+ ```
845
+
846
+ For aggregation-shape outputs (counts, sums, top-K subsets), the eager-load shape isn't what you want. Hand-write the query.
847
+
848
+ ## Walker fallback
849
+
850
+ When the JSON-agg compiler can't express a particular spec (unknown relation kind, dialect-specific edge case the per-dialect emitter doesn't cover), the framework transparently falls back to a portable per-relation N+1 walker. Same result, slower path.
851
+
852
+ Look for the boot log line if you want to know which path fired:
853
+
854
+ ```sh
855
+ voltro logs --tail 50 | grep "JSON-agg eager-load failed; falling back"
856
+ ```
857
+
858
+ If you see the warning regularly, the spec is hitting a code path the compiler hasn't covered yet — file an issue with the descriptor + dialect.
859
+
860
+ ## Reactive subscriptions
861
+
862
+ A subscription opened with `.with({ posts: true })` registers against both `users` and `posts`. Changes to either re-run the query (narrowed by the per-field pre-filter — see [reactive](./reactive)).
863
+
864
+ For tables with high write rates (`comments`, `events`, audit logs), reactive `.with({ ...heavyChild: true })` can cost a lot of re-queries. The per-field filter helps but doesn't eliminate the cost. Mitigations:
865
+
866
+ - Project narrowly. `.with({ posts: { limit: 5 } })` reads only 5 posts per user, so a write to a post the subscription doesn't include never wakes the sub.
867
+ - Split the subscription. Subscribe to the parent (`users`) and the children (`posts`) separately; each only fires on its own table's changes.
868
+
869
+ ## Caveats
870
+
871
+ - **FK auto-derivation requires exactly ONE matching `reference()`**. If the target has multiple FKs back at the source (`author_id`, `editor_id`, `reviewer_id` all → `users.id`), pass `foreignKey:` explicitly. The auto-derivation throws at schema-registration time with a clear error if it can't pick unambiguously.
872
+ - **Self-referential `many()` needs the thunk form**: `many(() => posts)` for a "replies" relation on posts.
873
+ - **Empty child set ≠ null parent**. `user.posts === []` for a user with no posts. `user.posts === undefined` only when the spec didn't request the relation. Distinguish in handler code.
874
+
875
+ ## Where it lives
876
+
877
+ - `voltro/packages/database/src/relations.ts` — `manyBuilder` (line 110)
878
+ - `voltro/packages/database/src/jsonEagerCompiler.ts` — `postgresManySubquery`, `mysqlManySubquery`, `mariadbManySubquery`, `mssqlManySubquery`, `sqliteManySubquery`
879
+ - `voltro/packages/database/src/joinCompiler.ts` — walker fallback (`attachMany`)
880
+
881
+
882
+
883
+ ---
884
+
885
+ <!-- source: en/database/relations/many-to-many.md -->
886
+ ## manyToMany() — N:M
887
+
888
+ _Explicit through-table pattern. Junction table with extra columns. Doubly-declared relations for both directions._
889
+
890
+ `manyToMany(target, { through, sourceKey, targetKey })` declares an N:M relationship through an explicit junction table. The framework never auto-generates the junction; you write it yourself with whatever extra columns it needs.
891
+
892
+ ```typescript
893
+ // The junction table — write it explicitly.
894
+ export const orgMemberships = table('org_memberships', {
895
+ id: id({ prefix: 'membership' }),
896
+ userId: reference(() => users, { onDelete: 'cascade' }),
897
+ orgId: reference(() => orgs, { onDelete: 'cascade' }),
898
+ role: text().oneOf(['owner', 'admin', 'member']),
899
+ joinedAt: timestamp().default('now'),
900
+ })
901
+
902
+ // The relation, declared on BOTH sides.
903
+ relations(users, ({ manyToMany }) => ({
904
+ organizations: manyToMany(orgs, {
905
+ through: orgMemberships,
906
+ sourceKey: 'userId',
907
+ targetKey: 'orgId',
908
+ }),
909
+ }))
910
+
911
+ relations(orgs, ({ manyToMany }) => ({
912
+ members: manyToMany(users, {
913
+ through: orgMemberships,
914
+ sourceKey: 'orgId', // junction column pointing at this side
915
+ targetKey: 'userId', // junction column pointing at the other side
916
+ }),
917
+ }))
918
+ ```
919
+
920
+ ## Why explicit through-table
921
+
922
+ The framework deliberately avoids the Prisma / TypeORM "auto-generated junction" pattern. Three reasons:
923
+
924
+ 1. **Extra columns are the rule, not the exception**. Memberships have roles. Tags have ordering. Subscriptions have permission tiers. The auto-junction always-needs-migrating-to-add-columns is a recurring tax. Writing the table yourself from the start avoids it.
925
+
926
+ 2. **Junction discoverability**. The junction is a real table you can query directly: `database.orgMemberships.where(eq('userId', uid))` is sometimes what you want, not "users with their organizations." Treating it as a first-class table makes both paths natural.
927
+
928
+ 3. **Schema is explicit**. Looking at your `database/` directory tells you exactly which tables exist. No hidden auto-generated tables to chase down at migration time.
929
+
930
+ ## SQL shape
931
+
932
+ The framework emits an INNER JOIN through the junction:
933
+
934
+ ```sql
935
+ -- For database.users.with({ organizations: true }):
936
+ SELECT users.*,
937
+ (SELECT json_agg(o) FROM (
938
+ SELECT orgs.* FROM org_memberships m
939
+ INNER JOIN orgs ON orgs.id = m.org_id
940
+ WHERE m.user_id = users.id
941
+ ) o
942
+ ) AS organizations
943
+ FROM users;
944
+ ```
945
+
946
+ The junction is in the FROM clause; the wrapping subquery aggregates the target rows. Per-dialect details (JSON_ARRAYAGG / FOR JSON PATH / json_group_array) match the [eager loading](./eager-loading) page.
947
+
948
+ For MariaDB the framework uses a ROW_NUMBER window-function pattern when per-parent limit/offset is set, same trade-off as `many()` — see [mariadb dialect details](../dialects/mariadb).
949
+
950
+ ## Per-branch modifiers
951
+
952
+ `where` / `orderBy` / `limit` / `offset` apply to the TARGET table — not the junction:
953
+
954
+ ```typescript
955
+ database.users.with({
956
+ organizations: {
957
+ where: eq('plan', 'enterprise'), // filters orgs, not memberships
958
+ orderBy: [{ column: 'name', direction: 'asc' }],
959
+ limit: 10,
960
+ },
961
+ })
962
+ ```
963
+
964
+ To filter on JUNCTION columns (e.g. "orgs where this user is an admin"), pass `onJunction` on the eager branch. The predicate is evaluated against the THROUGH-table row, not the target row:
965
+
966
+ ```typescript
967
+ database.users.with({
968
+ organizations: {
969
+ onJunction: eq('role', 'admin'), // filters the membership junction
970
+ orderBy: [{ column: 'name', direction: 'asc' }],
971
+ },
972
+ })
973
+ // → each user's `organizations` are exactly the orgs they're an admin of
974
+ ```
975
+
976
+ `onJunction` composes with the target-side `where` / `orderBy` / `limit` / `offset` — `where` still filters the target (orgs), `onJunction` filters the junction (memberships). It compiles into the correlated subquery's WHERE alongside the source-key correlation, qualified to the junction-table alias, on every dialect (postgres / mysql / mariadb / mssql / sqlite / turso). Ignored on `one` / `many` branches (no junction table exists).
977
+
978
+ ## Doubly-declared
979
+
980
+ `many` and `one` are unidirectional — you declare from the source side and the reverse is a separate `relations(...)` call. `manyToMany` is the same: declare BOTH directions independently if you need both:
981
+
982
+ ```typescript
983
+ relations(users, ({ manyToMany }) => ({
984
+ organizations: manyToMany(orgs, {
985
+ through: orgMemberships, sourceKey: 'userId', targetKey: 'orgId',
986
+ }),
987
+ }))
988
+
989
+ relations(orgs, ({ manyToMany }) => ({
990
+ members: manyToMany(users, {
991
+ through: orgMemberships, sourceKey: 'orgId', targetKey: 'userId',
992
+ }),
993
+ }))
994
+
995
+ // Both directions work in eager loads.
996
+ database.users.with({ organizations: true })
997
+ database.orgs.with({ members: true })
998
+ ```
999
+
1000
+ ## Inserting a membership
1001
+
1002
+ The framework has no "addMembership" shortcut. Insert the junction row directly:
1003
+
1004
+ ```typescript
1005
+ await ctx.store.insert('org_memberships', {
1006
+ userId, orgId,
1007
+ role: 'admin',
1008
+ })
1009
+ ```
1010
+
1011
+ If the junction has a unique constraint on `(userId, orgId)` — which it should, to prevent duplicate memberships — the framework's `PrimaryKeyConflictError` fires on duplicate inserts. Catch + handle:
1012
+
1013
+ ```typescript
1014
+ try {
1015
+ await ctx.store.insert('org_memberships', { userId, orgId, role })
1016
+ } catch (e) {
1017
+ if (e instanceof PrimaryKeyConflictError) {
1018
+ // Already a member.
1019
+ return { alreadyMember: true }
1020
+ }
1021
+ throw e
1022
+ }
1023
+ ```
1024
+
1025
+ ## Removing a membership
1026
+
1027
+ ```typescript
1028
+ const [m] = await store.query(
1029
+ database.orgMemberships.where(and(
1030
+ eq('userId', uid),
1031
+ eq('orgId', oid),
1032
+ )).descriptor,
1033
+ )
1034
+ if (m) {
1035
+ await ctx.store.delete('org_memberships', m.id)
1036
+ }
1037
+ ```
1038
+
1039
+ If you set `onDelete: 'cascade'` on the junction's FK to either side, deleting a user or org auto-removes all their memberships. This is the typical setup — memberships have no meaning without both parents.
1040
+
1041
+ ## Reactive subscriptions
1042
+
1043
+ A subscription opened with `.with({ organizations: true })` registers against THREE tables: `users`, `org_memberships`, `orgs`. Changes to any re-run the query.
1044
+
1045
+ This can be a lot of fan-out on tables with high junction write rates (a hot membership table getting writes every second). The per-field pre-filter (see [reactive](./reactive)) reduces but doesn't eliminate the cost. For very-hot-junction patterns, split the subscription.
1046
+
1047
+ ## TypeScript inference
1048
+
1049
+ ```typescript
1050
+ const rows = await store.query(
1051
+ database.users.with({ organizations: true }).descriptor,
1052
+ )
1053
+ // rows: Array<User & { organizations: Org[] }>
1054
+ ```
1055
+
1056
+ The junction columns (role, joinedAt) are NOT in the resolved Org type. The framework eager-loads the TARGET side only. If you need junction columns, query the junction table directly.
1057
+
1058
+ ## Caveats
1059
+
1060
+ - **Same target appearing twice on the source needs distinct relation names**. If users have `organizations` (membership-based) AND `ownedOrgs` (1:N via `orgs.ownerId`), declare them as separate relations with separate names. The framework keys by relation name.
1061
+ - **`sourceKey` is the junction column pointing at THIS side**. Easy to confuse with the schema's `reference()` column name on the source table itself. The framework's error message at registration time helps: "no column 'userId' on org_memberships" if you mistype.
1062
+ - **Cascade behaviour on the junction is separate from cascade on the parents**. `onDelete: 'cascade'` on `org_memberships.userId` removes memberships when the user is deleted, but doesn't touch `orgs`. Set it explicitly on each FK depending on lifecycle semantics.
1063
+ - **Junction extra columns aren't reactive through the m2m relation**. A write to `org_memberships.role` doesn't wake a subscription on `users.with({ organizations: true })` UNLESS the subscription's relevant-fields set includes `role` (which it doesn't by default — only the join keys are relevant for the m2m walk). If you want subscriptions to react to role changes, observe `org_memberships` directly.
1064
+
1065
+ ## Where it lives
1066
+
1067
+ - `voltro/packages/database/src/relations.ts` — `manyToManyBuilder` (line 120)
1068
+ - `voltro/packages/database/src/jsonEagerCompiler.ts` — `postgresManyToManySubquery`, `mysqlManyToManySubquery`, `mariadbManyToManySubquery`, `mssqlManyToManySubquery`, `sqliteManyToManySubquery`
1069
+ - `voltro/packages/database/src/joinCompiler.ts` — walker fallback (`attachManyToMany`)
1070
+
1071
+
1072
+
1073
+ ---
1074
+
1075
+ <!-- source: en/database/relations/eager-loading.md -->
1076
+ ## Eager loading with .with()
1077
+
1078
+ _Single-roundtrip nested JSON queries. Per-dialect SQL shapes. Walker fallback. When NOT to eager-load + stream-cursor pagination alternatives._
1079
+
1080
+ `.with(spec)` is the framework's eager-load chain. The query builder reads the relations registry to produce a single SQL query that returns the parent rows + every related table the spec touches as nested JSON.
1081
+
1082
+ ```typescript
1083
+ const result = await store.query(
1084
+ database.users.where(eq('tenantId', tenantId)).with({
1085
+ profile: true,
1086
+ posts: { limit: 5, orderBy: [{ column: 'createdAt', direction: 'desc' }] },
1087
+ organizations: true,
1088
+ }).descriptor,
1089
+ )
1090
+
1091
+ // TypeScript infers:
1092
+ // result: Array<User & {
1093
+ // profile: Profile | null,
1094
+ // posts: Post[],
1095
+ // organizations: Org[],
1096
+ // }>
1097
+ ```
1098
+
1099
+ One SQL round-trip. The framework's JSON-aggregation compiler renders the whole tree as a single SELECT.
1100
+
1101
+ ## Nesting
1102
+
1103
+ `.with()` nests arbitrarily deep:
1104
+
1105
+ ```typescript
1106
+ database.users.with({
1107
+ posts: {
1108
+ with: {
1109
+ author: true,
1110
+ comments: {
1111
+ with: { user: true },
1112
+ limit: 10,
1113
+ },
1114
+ },
1115
+ },
1116
+ })
1117
+ ```
1118
+
1119
+ That's users → posts → (author + comments → user). Still one SQL roundtrip. The JSON shape comes back nested; the framework decodes it to typed JS objects.
1120
+
1121
+ ## Per-dialect SQL shapes
1122
+
1123
+ Each dialect emits its native JSON-aggregation idiom. You write the same code; the framework dispatches.
1124
+
1125
+ ### Postgres
1126
+
1127
+ ```sql
1128
+ SELECT jsonb_build_object(
1129
+ 'id', users.id,
1130
+ 'email', users.email,
1131
+ 'profile', (SELECT to_jsonb(p) FROM profiles p WHERE p.user_id = users.id LIMIT 1),
1132
+ 'posts', COALESCE((SELECT jsonb_agg(t) FROM (
1133
+ SELECT * FROM posts WHERE author_id = users.id LIMIT 5
1134
+ ) t), '[]'::jsonb)
1135
+ ) AS __row
1136
+ FROM users;
1137
+ ```
1138
+
1139
+ `jsonb_build_object` + `jsonb_agg` are postgres-native; the eager compiler is best-in-class here.
1140
+
1141
+ ### MySQL 8+
1142
+
1143
+ ```sql
1144
+ SELECT JSON_OBJECT(
1145
+ 'id', users.id,
1146
+ 'email', users.email,
1147
+ 'profile', (SELECT JSON_OBJECT(...) FROM profiles p WHERE p.user_id = users.id LIMIT 1),
1148
+ 'posts', COALESCE((SELECT JSON_ARRAYAGG(JSON_OBJECT(...))
1149
+ FROM (SELECT * FROM posts WHERE author_id = users.id LIMIT 5) t),
1150
+ JSON_ARRAY())
1151
+ ) AS __row
1152
+ FROM users;
1153
+ ```
1154
+
1155
+ Functionally equivalent to postgres; the FROM-derived-table wrapper handles per-parent LIMIT.
1156
+
1157
+ ### MariaDB 10.6+
1158
+
1159
+ MariaDB rejects correlated references inside non-LATERAL derived tables. The framework switches to a ROW_NUMBER window-function pattern:
1160
+
1161
+ ```sql
1162
+ SELECT JSON_OBJECT(
1163
+ 'id', users.id,
1164
+ 'posts', COALESCE((SELECT JSON_ARRAYAGG(JSON_OBJECT(...) ORDER BY ranked.rn)
1165
+ FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY author_id
1166
+ ORDER BY created_at DESC) AS rn
1167
+ FROM posts) ranked
1168
+ WHERE ranked.author_id = users.id AND ranked.rn <= 5),
1169
+ JSON_ARRAY())
1170
+ ) AS __row
1171
+ FROM users;
1172
+ ```
1173
+
1174
+ For the no-pagination case, MariaDB uses `JSON_ARRAYAGG(... ORDER BY ...)` — a MariaDB-only extension that MySQL rejects — for cleaner SQL.
1175
+
1176
+ ### MSSQL 2019+
1177
+
1178
+ ```sql
1179
+ SELECT (SELECT TOP 1 * FROM users WHERE id = u.id
1180
+ FOR JSON PATH, WITHOUT_ARRAY_WRAPPER) AS __row
1181
+ FROM users u;
1182
+ ```
1183
+
1184
+ MSSQL's `FOR JSON PATH` builds nested objects from the SELECT's column list; sub-aggregations become correlated `(SELECT ... FOR JSON PATH)` blocks inside CASE expressions.
1185
+
1186
+ ### SQLite 3.38+
1187
+
1188
+ ```sql
1189
+ SELECT json_object(
1190
+ 'id', users.id,
1191
+ 'profile', (SELECT json_object(...) FROM profiles WHERE user_id = users.id LIMIT 1),
1192
+ 'posts', COALESCE((SELECT json_group_array(json_object(...))
1193
+ FROM (SELECT * FROM posts WHERE author_id = users.id LIMIT 5)
1194
+ ), json('[]'))
1195
+ ) AS __row
1196
+ FROM users;
1197
+ ```
1198
+
1199
+ `json_object` + `json_group_array` are SQLite's JSON1 extension; available since 3.38.
1200
+
1201
+ ## Walker fallback
1202
+
1203
+ When the JSON-agg compiler can't express a particular spec — for example a relation that hasn't been registered, or a per-dialect edge case the emitter doesn't cover — the framework transparently falls back to a portable per-relation N+1 walker.
1204
+
1205
+ The walker:
1206
+
1207
+ 1. Runs the parent query without any eager loading.
1208
+ 2. For each declared relation in the spec, issues a follow-up query against the related table.
1209
+ 3. Attaches the related rows by FK in JavaScript.
1210
+ 4. Recurses through nested `with`.
1211
+
1212
+ Result is identical to the JSON-agg path. Only difference is the round-trip count: N+M+K+… vs 1.
1213
+
1214
+ The framework emits a warning when this fires:
1215
+
1216
+ ```
1217
+ mariadb JSON-agg eager-load failed; falling back to walker
1218
+ ```
1219
+
1220
+ If you see this regularly, the spec is hitting a code path the compiler doesn't cover. File an issue with the descriptor + dialect.
1221
+
1222
+ ## When NOT to eager-load
1223
+
1224
+ Three patterns where `.with()` is the wrong tool:
1225
+
1226
+ ### 1. Children sets are large + unbounded
1227
+
1228
+ ```typescript
1229
+ // User with 1M events — RAM explosion.
1230
+ database.users.with({ events: true })
1231
+ ```
1232
+
1233
+ The JSON document per parent grows linearly with child count. At 1M events per user × 200 bytes per event = 200 MB per parent row. Use `limit:` always when the child set could grow.
1234
+
1235
+ ### 2. You need pagination UI for the children
1236
+
1237
+ ```typescript
1238
+ // User has 10k posts; UI shows 20 at a time with pagination.
1239
+ // Don't eager-load — query the children separately with paginateById.
1240
+ const [user] = await store.query(database.users.where(eq('id', uid)).descriptor)
1241
+ const rows = await ctx.store.query(
1242
+ paginateById(database.posts.where(eq('userId', uid)).descriptor, input.cursor, 20),
1243
+ )
1244
+ const nextCursor = rows.at(-1)?.id
1245
+ ```
1246
+
1247
+ Eager-loading 10k posts to render 20 is wasteful. Cursor pagination handles it cleanly.
1248
+
1249
+ ### 3. You want aggregations, not rows
1250
+
1251
+ ```typescript
1252
+ // Want: { user, postCount, lastPostAt } — NOT { user, posts: [...] }
1253
+ const rows = await ctx.store.transactional(async (txn) => {
1254
+ const users = await txn.query(database.users.where(...))
1255
+ const stats = await txn.unsafe(`
1256
+ SELECT user_id, COUNT(*) as post_count, MAX(created_at) as last_post_at
1257
+ FROM posts WHERE user_id = ANY($1) GROUP BY user_id
1258
+ `, [users.map(u => u.id)])
1259
+ return users.map(u => ({
1260
+ ...u,
1261
+ postCount: stats[u.id]?.post_count ?? 0,
1262
+ lastPostAt: stats[u.id]?.last_post_at ?? null,
1263
+ }))
1264
+ })
1265
+ ```
1266
+
1267
+ Aggregations are a different query shape; `.with()` returns rows, not summaries.
1268
+
1269
+ ## Type inference
1270
+
1271
+ Schema-registered relations propagate through `.with()` at the type level:
1272
+
1273
+ ```typescript
1274
+ const rows = await store.query(
1275
+ database.users.with({
1276
+ profile: true,
1277
+ ownedOrgs: { with: { projects: true } },
1278
+ }).descriptor,
1279
+ )
1280
+ // rows: Array<User & {
1281
+ // profile: Profile | null,
1282
+ // ownedOrgs: Array<Org & { projects: Project[] }>,
1283
+ // }>
1284
+ ```
1285
+
1286
+ Branded TypeID columns flow through — `rows[0].ownedOrgs[0].id` is `OrgId`, not just `string`.
1287
+
1288
+ ## Performance
1289
+
1290
+ The JSON-agg path is fast for typical app workloads (parents × ~100 KB JSON per parent). At larger scales the per-row JSON serialization becomes the dominant cost, and at that point the walker (which streams rows individually) may actually outperform JSON-agg.
1291
+
1292
+ Benchmarks (postgres on Apple M2, app-tier instance type):
1293
+
1294
+ | Pattern | JSON-agg | Walker (N+1) | Note |
1295
+ |------------------------------------------|----------|--------------|------|
1296
+ | 100 users × 5 posts each | 4ms | 35ms | JSON-agg wins decisively |
1297
+ | 1000 users × 50 posts each | 90ms | 280ms | JSON-agg still wins |
1298
+ | 1000 users × 5000 posts each (no limit) | 4.5s | 6s | Both pay; project narrowly |
1299
+ | 100 users × 100k posts each (no limit) | OOM | 12s | JSON-agg blows memory; walker survives |
1300
+
1301
+ The framework defaults to JSON-agg because the common case wins. If you hit the OOM cliff, add `limit:` to the branch.
1302
+
1303
+ ## Reactive subscriptions
1304
+
1305
+ Reactive subscriptions on `.with()` queries register against EVERY table the spec touches. See [reactive](./reactive) for the dependency-graph + per-field pre-filter details.
1306
+
1307
+ ## Where it lives
1308
+
1309
+ - `voltro/packages/database/src/queryBuilder.ts` — `.with(spec)` chain
1310
+ - `voltro/packages/database/src/jsonEagerCompiler.ts` — per-dialect JSON-aggregation compilers
1311
+ - `voltro/packages/database/src/joinCompiler.ts` — walker fallback (`attachEagerLoads`)
1312
+ - `voltro/packages/sql-postgres/src/store.ts`, etc. — each store's `runWithEager()` decides JSON-agg vs walker
1313
+
1314
+
1315
+
1316
+ ---
1317
+
1318
+ <!-- source: en/database/relations/cascade.md -->
1319
+ ## Cascade + FK auto-index
1320
+
1321
+ _onDelete defaults to 'restrict' for safety. FK auto-index defaults to true. Real migration scenarios + when to override._
1322
+
1323
+ `reference()` columns are foreign-key declarations. The framework picks two opinionated defaults that bite users from other frameworks:
1324
+
1325
+ ```typescript
1326
+ export const posts = table('posts', {
1327
+ id: id({ prefix: 'post' }),
1328
+ authorId: reference(() => users, {
1329
+ onDelete: 'cascade', // delete author → delete posts. Default 'restrict'.
1330
+ onUpdate: 'noAction', // FK PK never changes in practice. Default 'noAction'.
1331
+ index: true, // B-tree on authorId. Default true.
1332
+ }),
1333
+ title: text(),
1334
+ })
1335
+ ```
1336
+
1337
+ ## `onDelete` defaults to `'restrict'`
1338
+
1339
+ The framework is opinionated: deleting a user shouldn't silently nuke 100k posts. `'restrict'` is the default to surface FK violations LOUDLY at delete time.
1340
+
1341
+ | Value | Meaning |
1342
+ |---------------|----------------------------------------------------------------------|
1343
+ | `'restrict'` (default) | Refuse the delete if child rows exist. Parent delete throws. |
1344
+ | `'cascade'` | Delete child rows along with the parent. Use for owned lifecycles. |
1345
+ | `'setNull'` | Set the child's FK to NULL (column must be `.nullable()`). |
1346
+ | `'noAction'` | DB-level NO ACTION (postgres / mysql) — effectively same as restrict but defers the constraint check to commit. |
1347
+
1348
+ Pick based on the lifecycle relationship between parent and child:
1349
+
1350
+ - **`'cascade'`** for genuinely-owned children. Junction tables (memberships when a user OR org is deleted), child entities that have no meaning without the parent (a `comment_reactions` row when the comment is deleted).
1351
+ - **`'setNull'`** when the child outlives the parent in a degraded form. A `posts.editorId` when the editor user leaves the team — keep the post, drop the editor pointer.
1352
+ - **`'restrict'`** (default) when you want the framework to FORCE you to clean up explicitly. Most parent-child relationships in a typical app.
1353
+
1354
+ ## `index: true` default
1355
+
1356
+ Every `reference()` column gets a B-tree index automatically. The framework's [index audit](../indexes) confirms it at boot.
1357
+
1358
+ ```typescript
1359
+ reference(() => users) // → CREATE INDEX posts_author_id_idx ON posts (author_id);
1360
+ reference(() => users, { index: false }) // → no index
1361
+ ```
1362
+
1363
+ The lookup cost of a missing FK index dwarfs the write cost of an unnecessary one for nearly every workload. Opt out only for tiny lookup tables where a full-scan beats index maintenance — `lookup_codes` (a 20-row enum-like table), `singleton_config` (one row total). Anywhere a `JOIN` would happen, keep the default.
1364
+
1365
+ ## Real migration scenarios
1366
+
1367
+ ### Scenario 1 — cascade through a junction
1368
+
1369
+ ```typescript
1370
+ export const orgMemberships = table('org_memberships', {
1371
+ userId: reference(() => users, { onDelete: 'cascade' }),
1372
+ orgId: reference(() => orgs, { onDelete: 'cascade' }),
1373
+ role: text(),
1374
+ })
1375
+ ```
1376
+
1377
+ Delete a user → all their memberships cascade away. Delete an org → all its memberships cascade away. Standard pattern; the junction has no meaning without both parents.
1378
+
1379
+ ### Scenario 2 — setNull on optional ownership
1380
+
1381
+ ```typescript
1382
+ export const posts = table('posts', {
1383
+ authorId: reference(() => users, { onDelete: 'cascade' }), // owner — cascade
1384
+ editorId: reference(() => users, { onDelete: 'setNull' }).nullable(), // optional → setNull
1385
+ })
1386
+ ```
1387
+
1388
+ If the editor leaves the team, posts stay but lose the editor pointer. The author's deletion still removes the post.
1389
+
1390
+ ### Scenario 3 — restrict forcing explicit cleanup
1391
+
1392
+ ```typescript
1393
+ export const invoices = table('invoices', {
1394
+ customerId: reference(() => customers, { onDelete: 'restrict' }), // default
1395
+ })
1396
+
1397
+ // Application code:
1398
+ async function deleteCustomer(customerId: string) {
1399
+ const openInvoices = await store.query(
1400
+ database.invoices.where(eq('customerId', customerId)).descriptor,
1401
+ )
1402
+ if (openInvoices.length > 0) {
1403
+ throw new CustomerHasOpenInvoicesError({ count: openInvoices.length })
1404
+ }
1405
+ await ctx.store.delete('customers', customerId)
1406
+ }
1407
+ ```
1408
+
1409
+ The `'restrict'` default forces the application to think about what "delete customer" means. Auto-cascading invoices would corrupt accounting; auto-setNull would orphan them. The explicit check + typed error is the right contract.
1410
+
1411
+ ## Per-dialect emission
1412
+
1413
+ | Dialect | FK clause shape |
1414
+ |-----------|-----------------|
1415
+ | postgres | `REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION` |
1416
+ | mysql | ``REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION`` |
1417
+ | mariadb | ``REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION`` |
1418
+ | mssql | `REFERENCES [users]([id]) ON DELETE CASCADE` (mssql has no NO ACTION distinct from default) |
1419
+ | sqlite | `REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION` |
1420
+
1421
+ SQLite requires `PRAGMA foreign_keys = ON` to enforce — the framework sets this on every connect. Without it, sqlite is permissive (treats FK constraints as documentation only).
1422
+
1423
+ ## `onUpdate` — usually `'noAction'`
1424
+
1425
+ FK referenced columns are IDs. IDs don't change in practice — that's the whole point of having an ID. So `onUpdate` defaults to `'noAction'` and you usually don't touch it.
1426
+
1427
+ If you DO need to change an ID across a hierarchy (e.g. consolidating two organizations into one — `orgId: 'a' → orgId: 'b'`), the right answer is usually to:
1428
+
1429
+ 1. Find every row that references the old ID.
1430
+ 2. Update them all in a single transaction.
1431
+ 3. Delete the now-orphaned old parent row.
1432
+
1433
+ Not to set `onUpdate: 'cascade'` and trigger a single huge update through the DB engine. The cascade approach locks every affected table for the duration and breaks any subscriptions watching those tables.
1434
+
1435
+ ## Composite FKs — not supported
1436
+
1437
+ The framework's `reference()` is single-column only. If your schema has a composite FK (multi-column referential integrity), you'll have to:
1438
+
1439
+ 1. Declare each column individually with the right type.
1440
+ 2. Add the FK constraint via `unsafe()` SQL in a migration.
1441
+ 3. Skip the framework's relation declaration for that link.
1442
+
1443
+ This is rare. Composite FKs usually indicate a schema design that could collapse into a surrogate-key approach.
1444
+
1445
+ ## Index audit warnings
1446
+
1447
+ The framework's boot-time [index audit](../indexes) warns about redundant indexes — a FK auto-index that's a leading prefix of an explicit composite index is redundant:
1448
+
1449
+ ```
1450
+ [voltro:dev] WARN index audit · redundant-prefix
1451
+ table: memberships
1452
+ redundant: memberships_user_id_idx (from reference auto-index)
1453
+ coveredBy: memberships_user_org_idx (explicit composite on (user_id, org_id))
1454
+ hint: drop one
1455
+ ```
1456
+
1457
+ Decide based on workload. If you frequently query by `user_id` alone, keep both. If the composite is the only access pattern, drop the auto-index via `{ index: false }`.
1458
+
1459
+ ## Caveats
1460
+
1461
+ - **Cascade can chain deeply**. A delete on `users` cascades to `memberships`, then to anything that references `memberships` with cascade, and so on. Trace the depth before you set `'cascade'` on a high-cardinality table.
1462
+ - **Postgres `DEFERRABLE INITIALLY DEFERRED` FKs**. The framework doesn't emit DEFERRED constraints. If you need them (mass-loading scenarios), emit them in a custom migration.
1463
+ - **MariaDB enforces FK constraints in storage engines that support them**. InnoDB (the default) enforces; MyISAM doesn't. Use InnoDB. The framework's DDL emitter assumes it.
1464
+ - **SQLite enforcement requires `PRAGMA foreign_keys = ON`**. The framework sets this at connect. Other clients that open the same database without setting the pragma bypass FK enforcement silently.
1465
+
1466
+ ## Where it lives
1467
+
1468
+ - `voltro/packages/database/src/columns.ts` — `reference()` accepts `onDelete` / `onUpdate` / `index`
1469
+ - `voltro/packages/database/src/migrate.ts` — `referentialAction(action, dialect)`; the `REFERENCES <table> (id)` clause is emitted inline in the column-DDL builder alongside it
1470
+ - `voltro/packages/database/src/indexAudit.ts` — boot-time redundant-prefix warnings
1471
+
1472
+
1473
+
1474
+ ---
1475
+
1476
+ <!-- source: en/database/relations/reactive.md -->
1477
+ ## Reactive invalidation
1478
+
1479
+ _How `.with()` subscriptions wake on dependent-table changes. two-stage gate: a per-table dependency-graph plus a per-field pre-filter, soundness argument, trade-offs._
1480
+
1481
+ Reactive subscriptions on `.with()` queries don't just track the root table — they walk the eager spec at subscribe time, register against every dependent table, and pre-filter change events at the column level so updates that don't matter never reach the SQL planner.
1482
+
1483
+ ## How it works
1484
+
1485
+ When a subscription opens with `database.users.with({ posts: { with: { author: true } } })`, three things happen at subscribe time:
1486
+
1487
+ 1. **Snapshot fetch**: the framework runs the eager-load query once, delivers the result as `_tag: 'snapshot'` to the subscriber.
1488
+ 2. **Dependency graph registration**: `resolveDependentTables(descriptor)` walks the spec + relations registry to produce `{users, posts}` (author resolves back to users so it deduplicates). The dispatcher registers the subscription against EACH table.
1489
+ 3. **Relevance map computation**: `resolveRelevantFields(descriptor)` collects, per dependent table, the column SET whose change could affect the snapshot:
1490
+ - Projected columns (from `descriptor.projection`; wildcard `*` when unset).
1491
+ - Predicate columns (every column reachable from the WHERE clause AST).
1492
+ - Order columns (`descriptor.order[*].column`).
1493
+ - Eager-load join keys (source + target FK on every relation).
1494
+
1495
+ At runtime, every `ChangeEvent` consults this map BEFORE triggering a re-query.
1496
+
1497
+ ## v1 — Dependency graph fan-out
1498
+
1499
+ Stage 1 — the dependency graph tracks per-table dependency. When `posts` changes, the dispatcher finds every subscription whose dependent-table set includes `posts` and triggers a re-query. After the re-query, a shallow row-set compare (JSON.stringify per row) suppresses the delta if the result is shape-identical to the last delivered snapshot.
1500
+
1501
+ ```text
1502
+ ┌──────────────────────────────────────────────────────────────────┐
1503
+ │ Subscription: users.with({ posts: { with: { author: true } } }) │
1504
+ │ │
1505
+ │ change(users) → re-query → diff vs lastDelivered → maybe delta │
1506
+ │ change(posts) → re-query → diff vs lastDelivered → maybe delta │
1507
+ │ │
1508
+ │ change(orgs) → no-op (not in dependency graph) │
1509
+ └──────────────────────────────────────────────────────────────────┘
1510
+ ```
1511
+
1512
+ This is CORRECT but expensive: every write to a dependent table costs a SQL round-trip, even when the write touched a column the subscription doesn't care about.
1513
+
1514
+ ## Stage 2 — the per-field pre-filter
1515
+
1516
+ The per-field pre-filter adds a column-grain gate AHEAD of the re-query. On a write:
1517
+
1518
+ 1. `mutatedColumns(event)` returns the columns the change actually touched.
1519
+ - Insert / delete → wildcard `*` (membership change always relevant).
1520
+ - Update → diff `old` vs `new` for differing values.
1521
+ - Incomplete event (old=null on update) → wildcard (soundness).
1522
+ 2. `isEventRelevant(event, relevantMap)` intersects mutated × relevant.
1523
+ 3. Empty intersection → **skip the re-query entirely**. No SQL round-trip. No deep-equal compare. No delta.
1524
+
1525
+ ```text
1526
+ relevantMap = {
1527
+ users: { id, email, *projected, …predicate, …order },
1528
+ posts: { id, author_id, *projected, …predicate, …order },
1529
+ }
1530
+
1531
+ mutatedColumns(event{table: 'users', op: 'update', old: {x:1}, new: {x:2}}) = { 'x' }
1532
+ isEventRelevant ⇒ 'x' ∉ relevantMap.users ⇒ skip
1533
+ ```
1534
+
1535
+ The dispatcher logs the skip count per change event so operators can verify the filter is paying off:
1536
+
1537
+ ```sh
1538
+ voltro logs --tail 50 | grep handleChange/prefiltered
1539
+ ```
1540
+
1541
+ ## Soundness
1542
+
1543
+ The relevance set is a SUPER-set of fields whose change can flip the snapshot. The collection rules (`resolveRelevantFields`) overshoot deliberately:
1544
+
1545
+ - **Predicate columns**: a write to a predicate column may change set membership, even if the value the predicate compares against doesn't change.
1546
+ - **Order columns**: a write to an order column may shift the row's position, changing the slice the subscription sees.
1547
+ - **Eager-load join keys (FK + PK on each side)**: a write to a FK may shift which children belong to which parent.
1548
+ - **Projected columns**: if the snapshot emits the column, every change to it is by definition a delta.
1549
+ - **`*` wildcard when projection is unset**: the snapshot emits the full row, so every column is potentially load-bearing.
1550
+
1551
+ Skipping a write that lies OUTSIDE this set cannot produce a missed delta: the resulting snapshot was already correct. The pre-filter has zero false negatives.
1552
+
1553
+ The only failure mode is a false positive (slower v1 path runs unnecessarily) — no correctness consequence, just wasted work.
1554
+
1555
+ ## Insert / delete always pass through
1556
+
1557
+ Membership changes are load-bearing regardless of which columns the row carries. The pre-filter treats insert and delete as wildcard-relevant, so they always reach the re-query path. v1's deep-equal compare still suppresses the DELTA if the snapshot happens to be shape-identical (rare on insert/delete but possible if the new/deleted row was outside the subscription's predicate set).
1558
+
1559
+ ## Walking the eager spec
1560
+
1561
+ For each relation in the `.with()` spec, the relevance walker adds:
1562
+
1563
+ - **`one()`**: source's `sourceKey` (or `id` if not specified) + target's `id` + target's projected/predicate/order columns + target's own eager-load contributions.
1564
+ - **`many()`**: target's `foreignKey` (the FK on the target side) + target's `id` + target's per-branch where/order/limit columns + target's full schema columns (since `with: true` doesn't constrain projection).
1565
+ - **`manyToMany()`**: junction's `sourceKey` + junction's `targetKey` + target's `id` + target's per-branch contributions.
1566
+
1567
+ For very deep nested specs (depth > 3), the relevance set can include MANY tables. The dispatcher's per-table lookup is `O(1)` so the cost is dominated by the actual column intersection check, not by tree depth.
1568
+
1569
+ ## When the pre-filter helps the most
1570
+
1571
+ The pre-filter is most effective when:
1572
+
1573
+ - Subscriptions read NARROW projections (`projection: ['id', 'email']`) — every column-narrow write to other fields is skipped.
1574
+ - Tables have schema-wide hot columns (`updatedAt`, `lastSeenAt`, view counters) that lots of writes touch but no subscription reads.
1575
+ - Eager-loaded child tables have high write rates (chat messages, log entries) where most writes touch fields the parent doesn't project.
1576
+
1577
+ ## When the pre-filter helps less
1578
+
1579
+ - Subscriptions without projection (the default — `with: true` on every branch reads every column). The relevance set is wildcard everywhere; v1 fan-out kicks in for every dependent change.
1580
+ - Hot tables where every write touches at least one relevant column (every row carries `updated_at = NOW()` on every update, and `updated_at` is in the order clause).
1581
+
1582
+ For these cases, mitigations:
1583
+
1584
+ 1. **Project narrowly.** `projection: ['id', 'email']` on the subscription's descriptor narrows the relevance set.
1585
+ 2. **Split the subscription.** `database.users.where(eq('id', uid))` (no `.with()`) for the root, `database.orgs.where(...)` for the children. Each fires only on its own table.
1586
+ 3. **`unsafe()` for hand-tuned queries** when the framework's portable shape doesn't match what you need.
1587
+
1588
+ ## Observability
1589
+
1590
+ Two ways to verify the filter is working:
1591
+
1592
+ ### Trace logs
1593
+
1594
+ ```sh
1595
+ voltro logs --tail 100 | grep handleChange
1596
+ ```
1597
+
1598
+ Look for the per-change-event summary:
1599
+
1600
+ ```
1601
+ { "table": "posts", "op": "update", "matcher": 3, "dependent": 12, "triggered": 15 }
1602
+ { "table": "posts", "op": "update", "skipped": 11 }
1603
+ ```
1604
+
1605
+ `triggered` is how many subscriptions had `posts` in their dependent-table set. `skipped` is how many of those the pre-filter filtered out before the re-query. Ideal ratio: high `skipped` / `triggered` on hot tables.
1606
+
1607
+ ### Voltro Cloud dashboard
1608
+
1609
+ The reactive subscriptions panel (under the per-app Inspect tab) shows per-subscription details:
1610
+
1611
+ - Dependent table count.
1612
+ - Relevant columns per table.
1613
+ - Skip rate (recent window).
1614
+
1615
+ Use these to find subscriptions that aren't benefiting from the pre-filter and need narrowing.
1616
+
1617
+ ## Where it lives
1618
+
1619
+ - `voltro/packages/runtime/src/relevantFields.ts` — relevance walker + `isEventRelevant` + `mutatedColumns`
1620
+ - `voltro/packages/runtime/src/dependencyGraph.ts` — `resolveDependentTables` v1 walker
1621
+ - `voltro/packages/runtime/src/dispatcher.ts` — `handleChange` pre-filter call + skip-count log
1622
+ - `voltro/packages/runtime/src/dispatcherReactive.test.ts` — dependency-graph + pre-filter tests