@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,2938 @@
1
+ # database.migrations
2
+
3
+ > Voltro's planner-based migration system — diff your declared schema against the live DB, classify each change, refuse-to-apply anything risky without explicit intent. Dev auto-applies, prod refuses.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/database/migrations/index.md -->
10
+ ## Migrations
11
+
12
+ _Voltro's planner-based migration system — diff your declared schema against the live DB, classify each change, refuse-to-apply anything risky without explicit intent. Dev auto-applies, prod refuses._
13
+
14
+ Voltro's migrator does not generate or apply migration files. Instead, on every `voltro dev` boot — and any time you run `voltro db plan` — it:
15
+
16
+ 1. **Introspects** the live database via `information_schema.*` (or the dialect-specific equivalent), building a `SchemaSnapshot`.
17
+ 2. **Diffs** that against your declared schema (every `*.entity.ts` / `*.schema.ts` file in the project plus the framework's bookkeeping tables).
18
+ 3. **Classifies** each pending DDL op into one of seven `OperationClass`es — `safe`, `needs-default`, `needs-backfill`, `needs-rename-annotation`, `lossy`, `online-required`, `multi-step`.
19
+ 4. **Refuses** to apply anything that can't be made safe automatically. The diff output tells you exactly which DSL annotation to add (`.backfill()`, `.renamedFrom()`, `dropped()`, …).
20
+ 5. **Applies** the rest under an advisory lock + records the result in `_voltro_migration_plans` with a fingerprint of the post-apply schema.
21
+
22
+ There are no generated SQL files to commit, no `migrations/` directory to rebase, no checksum manifest to repair. The source of truth is your schema TypeScript; the DB is the slave.
23
+
24
+ ## The four-phase model
25
+
26
+ ```
27
+ ┌───────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌──────────────┐
28
+ │ declare │ → │ plan (diff + │ → │ review │ → │ apply │
29
+ │ schema in TS │ │ classify) │ │ (CLI / UI) │ │ (dev: auto, │
30
+ │ │ │ │ │ │ │ prod: CLI) │
31
+ └───────────────┘ └─────────────────┘ └──────────────┘ └──────────────┘
32
+ ```
33
+
34
+ - **dev**: phases 2–4 are automatic on boot. A blocked plan refuses the boot with a structured error pointing at the fix. When the declared schema is unchanged since the last apply, dev short-circuits on a **fingerprint check** (one indexed query) and skips the full introspect entirely — so reboots against a large schema stay fast. Force a full re-introspect (drift recovery) with `VOLTRO_MIGRATE_FORCE=1`.
35
+ - **prod**: phase 2 (planning) is automatic, but phase 4 (apply) NEVER happens during a serving process. The fingerprint of the declared schema must already match `_voltro_migration_plans.fingerprint` from a prior explicit `voltro db apply` — mismatch → refuse to boot.
36
+
37
+ This is the constraint repeated across the docs: **schema changes mid-rolling-deploy without review are not allowed**.
38
+
39
+ ## A first session
40
+
41
+ ```bash
42
+ # 1. Edit apps/api/database/users.entity.ts — add a required column.
43
+ export const users = table('users', {
44
+ id: id(),
45
+ email: text(), // NEW: required, no default
46
+ })
47
+
48
+ # 2. voltro dev. The planner classifies "ADD COLUMN email NOT NULL"
49
+ # on a populated table as `needs-backfill`. No backfill declared
50
+ # → boot refuses with:
51
+ #
52
+ # auto-migrate: REFUSED — 1 blocked operation(s):
53
+ # - add-column [users]: NOT NULL column on a table whose row count is unknown
54
+ # fix: declare `email: <type>().backfill(sql\`...\`)` OR `.default(value)`
55
+ # so existing rows survive the migration
56
+ voltro dev .
57
+
58
+ # 3. Add the backfill annotation in the schema:
59
+ export const users = table('users', {
60
+ id: id(),
61
+ email: text().backfill(sql`'unknown-' || id || '@local'`),
62
+ })
63
+
64
+ # 4. Boot again. The planner classifies the same op as `needs-backfill`
65
+ # with a declared backfill — applier runs ADD nullable → UPDATE via
66
+ # the SQL expression → SET NOT NULL inside one transaction.
67
+ voltro dev .
68
+ ```
69
+
70
+ Same column. Same migration. The first attempt refuses loudly; the second succeeds silently. The DSL annotation IS the migration plan.
71
+
72
+ ## The seven operation classes
73
+
74
+ Every pending op is stamped with one of seven `OperationClass` values —
75
+ `safe`, `needs-default`, `needs-backfill`, `needs-rename-annotation`,
76
+ `lossy`, `online-required`, `multi-step` — which drives whether it
77
+ auto-applies or refuses-to-plan pending a DSL annotation.
78
+
79
+ The full trigger + default-policy table, with a worked example and the
80
+ exact fix for each blocked case, lives on the dedicated
81
+ [Operation classes](./operation-classes.md) page.
82
+
83
+ ## The CLI surface
84
+
85
+ ```bash
86
+ # Planner-based (declarative diff)
87
+ voltro db plan # diff + color-coded classes + fix hints
88
+ voltro db plan --against <url> # diff vs a remote env's inspect endpoint (see cross-env-sync)
89
+ voltro db apply # execute (dev only — refuses on NODE_ENV=production)
90
+ voltro db apply --note '...' # apply with a freeform note recorded in history
91
+ voltro db plans [--limit 20] # history from _voltro_migration_plans, newest first
92
+ voltro db drift # live-vs-applied fingerprint check — exit 4 on drift
93
+ voltro db squash --before <date> # consolidate history into one snapshot
94
+ voltro db restore-snapshot <id> # restore VOLTRO_SOFT_DROP=1 columns from a plan
95
+
96
+ # File-based escape hatch — migration() up/down files under migrations/
97
+ voltro db files # apply pending migration() files
98
+ voltro db rollback-file <id> # run a migration() file's down body
99
+
100
+ # defineMigration step runner (separate system, _voltro_migrations table)
101
+ voltro db migrate # apply pending *.migration.ts steps
102
+ voltro db rollback [--to <id>] # undo applied *.migration.ts steps
103
+ voltro db status # list applied / pending *.migration.ts
104
+ ```
105
+
106
+ `plan` + `apply` are the planner-based commands. There are TWO distinct
107
+ file-based runners, intentionally not unified: the `migration()` runner
108
+ (`files` / `rollback-file`, records into `_voltro_migration_plans` with
109
+ `source: 'file'`) is the escape hatch the planner points you at for
110
+ table-splits and cross-table data moves; the `defineMigration` runner
111
+ (`migrate` / `rollback` / `status`, its own `_voltro_migrations` table)
112
+ runs hand-authored `*.migration.ts` step files. See [File-based
113
+ migrations](./file-based.md) for the `migration()` path — the one most
114
+ apps reach for.
115
+
116
+ Note: `voltro db plan` is flagless beyond `--against` / `--token` —
117
+ there is no `--json` or `--sql`, and `voltro db apply` takes only
118
+ `--note`. The prod flow is a plain `voltro db apply` run as an explicit
119
+ deploy step ([prod pipeline](./prod-pipeline.md)), not a pre-serialised
120
+ plan file.
121
+
122
+ ## Where to go next
123
+
124
+ | Topic | Page |
125
+ |---|---|
126
+ | Every operation class with concrete examples + each fix | [Operation classes](./operation-classes.md) |
127
+ | SQL vs JS backfill, performance, batch tuning | [Backfill](./backfill.md) |
128
+ | `.renamedFrom()` + `dropped()` lifecycle + when to remove the markers | [Rename and drop](./rename-and-drop.md) |
129
+ | MySQL implicit commit, SQLite table rewrite, per-dialect atomicity matrix | [Multi-dialect strategy](./multi-dialect.md) |
130
+ | CONCURRENTLY / batched backfill / shadow-column for large tables | [Online migrations](./online.md) |
131
+ | File-based escape hatch for table-split / merge / data moves | [File-based migrations](./file-based.md) |
132
+ | The plan-review-apply pipeline for production | [Prod pipeline](./prod-pipeline.md) |
133
+ | Local devtools dashboard walkthrough | [Devtools UI](./devtools-ui.md) |
134
+ | Cloud dashboard walkthrough + multi-tenant boundaries | [Cloud UI](./cloud-ui.md) |
135
+ | What can be reversed (and why planner plans have no auto-rollback) | [Rollback](./rollback.md) |
136
+ | Drift detection + recovery | [Drift](./drift.md) |
137
+ | Consolidating an aged history into one snapshot | [Squashing](./squashing.md) |
138
+ | `VOLTRO_SOFT_DROP=1` + restore-snapshot — the only path that recovers dropped-column data | [Soft-drop recovery](./rollback-snapshots.md) |
139
+ | `voltro db plan --against <env-url>` for pre-deploy preview | [Cross-environment sync](./cross-env-sync.md) |
140
+ | The most common "why is my boot refusing?" cases | [Troubleshooting](./troubleshooting.md) |
141
+
142
+
143
+
144
+ ---
145
+
146
+ <!-- source: en/database/migrations/operation-classes.md -->
147
+ ## Operation classes
148
+
149
+ _The seven classification buckets the planner sorts every diff into — what triggers each, what the planner does by default, and the exact DSL annotation that turns a blocked op into an allowed one._
150
+
151
+ Every concrete DDL operation the planner emits gets stamped with one of seven `OperationClass` values. The class drives the default policy + the refuse-to-plan message you'll see when something needs human input. This page enumerates them with a fixture-style example per class.
152
+
153
+ ## safe
154
+
155
+ The op is reversible AND has no effect on existing data. Auto-applied on every dev boot + by `voltro db apply` in any env.
156
+
157
+ Triggers:
158
+
159
+ - `CREATE TABLE` (no live data possible)
160
+ - `ADD COLUMN <nullable>` — new column starts NULL
161
+ - `ADD INDEX` (small tables; large tables get promoted to `online-required`)
162
+ - `DROP INDEX`
163
+ - `ADD UNIQUE` — adding `.unique()` to an existing column whose live values are already distinct (emitted as the `<table>_<column>_key` constraint). Applies on the next boot; if the column already holds duplicates the DB rejects it **at migrate time** (fail-fast) rather than letting an `ON CONFLICT` upsert break at runtime. Pre-dedup a populated column with `.unique({ dedup })` — see the decision table. `DROP UNIQUE` (removing `.unique()`) is safe too.
164
+ - `ADD CHECK` on a new column
165
+ - Widening a type (`varchar(50)` → `varchar(255)`, `int` → `bigint`)
166
+ - Dropping `NOT NULL` (NULL → optional is monotonic)
167
+
168
+ ```ts
169
+ // Before:
170
+ export const users = table('users', { id: id(), email: text() })
171
+
172
+ // After — ADD bio (nullable) → safe.
173
+ export const users = table('users', {
174
+ id: id(),
175
+ email: text(),
176
+ bio: text().nullable(),
177
+ })
178
+ ```
179
+
180
+ Plan output:
181
+
182
+ ```
183
+ ✓ ALTER TABLE users ADD COLUMN bio text # nullable column add — no backfill needed
184
+ ```
185
+
186
+ ## needs-default
187
+
188
+ ADD NOT NULL column where the schema declares `.default(value)`. The planner emits `ADD COLUMN … NOT NULL DEFAULT <value>` in one statement. Postgres 11+ records the default in the catalog without rewriting the table — instant on a 100M-row table.
189
+
190
+ ```ts
191
+ export const users = table('users', {
192
+ id: id(),
193
+ plan: text().default('free'),
194
+ })
195
+ ```
196
+
197
+ Plan output:
198
+
199
+ ```
200
+ ⊕ ALTER TABLE users ADD COLUMN plan text NOT NULL DEFAULT 'free' # literal default
201
+ ```
202
+
203
+ Cross-dialect note: MySQL strict mode + a `TEXT` column with a `DEFAULT` clause throws at DDL time. Use `varchar(N)` (`.maxLength(N)` on `text()`) for text columns that need a default on MySQL. The framework's [multi-dialect strategy](./multi-dialect.md) page covers the other landmines.
204
+
205
+ ## needs-backfill
206
+
207
+ ADD NOT NULL column on a populated table where the schema declares `.backfill()`. The planner emits a three-step plan inside one transaction (or one forward-roll group on mysql/mariadb):
208
+
209
+ 1. `ADD COLUMN <name> <type>` (nullable)
210
+ 2. `UPDATE <table> SET <name> = <backfill-expr>`
211
+ 3. `ALTER COLUMN <name> SET NOT NULL`
212
+
213
+ ```ts
214
+ export const users = table('users', {
215
+ id: id(),
216
+ email: text().backfill(sql`'unknown-' || id || '@local'`),
217
+ })
218
+ ```
219
+
220
+ Plan output:
221
+
222
+ ```
223
+ ⊕ ALTER TABLE users ADD COLUMN email text # 3-step: ADD nullable
224
+ ⊕ UPDATE users SET email = 'unknown-' || id || '@local' # → run backfill
225
+ ⊕ ALTER TABLE users ALTER COLUMN email SET NOT NULL # → SET NOT NULL
226
+ ```
227
+
228
+ Without the `.backfill()` annotation: **blocked**. The fix hint surfaces in both `voltro db plan` and the boot refuse message:
229
+
230
+ ```
231
+ ✗ ALTER TABLE users ADD COLUMN email text # NOT NULL column on a table whose row count is unknown
232
+ ! fix: declare `email: text().backfill(sql`...`)` OR `.default(value)` so existing rows survive
233
+ ```
234
+
235
+ For the SQL-vs-JS backfill trade-off + per-batch tuning see [Backfill](./backfill.md).
236
+
237
+ ## needs-rename-annotation
238
+
239
+ Column X disappeared from the declared schema AND column Y appeared with similar shape. The planner won't silently turn that into `DROP X` + `ADD Y` (data loss). It refuses-to-plan unless the new column carries `.renamedFrom('X')` — the explicit signal that intent is RENAME, not DROP+ADD.
240
+
241
+ ```ts
242
+ // Before:
243
+ export const users = table('users', { id: id(), firstName: text() })
244
+
245
+ // After — without the marker, planner refuses:
246
+ export const users = table('users', { id: id(), givenName: text() })
247
+
248
+ // With the marker — planner folds the diff into one RENAME op:
249
+ export const users = table('users', {
250
+ id: id(),
251
+ givenName: text().renamedFrom('firstName'),
252
+ })
253
+ ```
254
+
255
+ The annotation stays in the code until the rename has been applied in every env you care about (dev, staging, prod). `_voltro_migration_plans` records the applied rename so the planner won't re-emit; removing the marker earlier yields a clear refuse-to-plan ("did you remove `.renamedFrom('firstName')` before staging migration applied? Re-add the marker oder apply against staging first"). [Rename and drop](./rename-and-drop.md) covers the full lifecycle.
256
+
257
+ ## lossy
258
+
259
+ The op destroys data. Refuse-to-plan unless the developer declared intent explicitly OR `VOLTRO_DESTRUCTIVE_OK=1` was set.
260
+
261
+ Triggers:
262
+
263
+ - `DROP COLUMN` (live data is gone after apply)
264
+ - `DROP TABLE`
265
+ - Narrowing a type (`varchar(255)` → `varchar(50)` with strings longer than 50)
266
+ - `DROP UNIQUE` constraint that other code might depend on
267
+ - `DROP INDEX` that an FK depends on
268
+
269
+ ```ts
270
+ // Drop a column INTENTIONALLY — declare `dropped()`:
271
+ export const users = table('users', {
272
+ id: id(),
273
+ legacy: dropped(), // ← explicit. Planner classifies lossy, allows apply.
274
+ })
275
+ ```
276
+
277
+ ```
278
+ ⊕ ALTER TABLE users DROP COLUMN legacy # column dropped via `dropped()` marker — intentional
279
+ ```
280
+
281
+ Without the marker:
282
+
283
+ ```
284
+ ✗ ALTER TABLE users DROP COLUMN legacy # column missing from declared schema
285
+ ! fix: if intentional, add `legacy: dropped()` to the schema. If a typo, restore the field
286
+ ```
287
+
288
+ DROP TABLE has no equivalent annotation — the table simply being missing from the declared set is the signal. Set `VOLTRO_DESTRUCTIVE_OK=1` to allow it (loud warning), or use a [file-based migration](./file-based.md) for cross-table data moves the diff can't infer.
289
+
290
+ `VOLTRO_DESTRUCTIVE_OK=1` only relaxes the refusal when EVERY blocked op is `lossy`. Rename-without-marker and NOT-NULL-without-backfill stay firm regardless — those are sloppy declarations, not intentional destruction.
291
+
292
+ ## online-required
293
+
294
+ The op operates on a table whose row count exceeds the planner's online threshold (default 50k; tunable via `online-after` per project). Auto-rewritten to a non-blocking variant:
295
+
296
+ - `ADD INDEX`: Postgres `CREATE INDEX CONCURRENTLY`, MySQL/MariaDB `ALGORITHM=INPLACE LOCK=NONE`, MSSQL `WITH (ONLINE = ON)`, SQLite no-op
297
+ - `UPDATE` backfill: batched (default 1k rows/batch, configurable `.backfill(sql, { batchSize: 5000, sleepMs: 50 })`)
298
+ - Type rewrites that need shadow-column-swap (`ALTER COLUMN TYPE` on large tables)
299
+
300
+ [Online migrations](./online.md) walks through every variant with sizing + tuning guidance.
301
+
302
+ ## multi-step
303
+
304
+ Operations the planner can't infer from a structural diff alone:
305
+
306
+ - Splitting a table (e.g. extract address fields to a separate `addresses` table with FK back)
307
+ - Merging two tables
308
+ - Type changes that need a custom `USING` expression (`text → integer` requires `USING col::integer`)
309
+ - Data moves that span multiple tables atomically
310
+
311
+ The planner refuses-to-plan these + points at the [file-based migrations](./file-based.md) escape hatch. You write the migration body explicitly (up/down SQL or Effect program) and the framework picks it up in timestamp order before the next auto-diff pass.
312
+
313
+ ## Decision table at a glance
314
+
315
+ | Want to … | Add this to the schema |
316
+ |---|---|
317
+ | Add a required column with a constant default | `.default(value)` |
318
+ | Add a required column on a populated table | `.backfill(sql\`expr\`)` |
319
+ | Rename a column without losing data | `.renamedFrom('oldName')` on the new column |
320
+ | Drop a column intentionally | `legacy: dropped()` at the field-map slot |
321
+ | Drop a table intentionally | (remove from declared set) + `VOLTRO_DESTRUCTIVE_OK=1` for one apply |
322
+ | Add a unique constraint on a populated column with dupes | `.unique({ dedup: 'fail' / 'suffix-counter' })` |
323
+ | Add an FK on a populated column with orphans | `reference(() => target, { orphanPolicy: 'fail' / 'null' / 'delete' })` |
324
+ | Change a column type with a custom cast | `.narrowedFrom('<live type>', { using })` on the column — the planner downgrades the blocked-lossy type change to `needs-backfill` and threads the `USING` cast (see [rename and drop](./rename-and-drop.md)) |
325
+ | Move data across tables atomically | [file-based migration](./file-based.md) |
326
+
327
+
328
+
329
+ ---
330
+
331
+ <!-- source: en/database/migrations/rename-and-drop.md -->
332
+ ## Rename and drop
333
+
334
+ _The two annotations that gate destructive-looking changes — .renamedFrom() turns a DROP+ADD diff into a RENAME, dropped() turns a refused DROP into an allowed one. Lifecycle + when to remove the markers._
335
+
336
+ The planner refuses to silently rename or drop columns. Both ops can look identical structurally — column X gone, column Y present — but mean very different things. The annotations give you the vocabulary to declare intent.
337
+
338
+ ## `.renamedFrom(oldName)`
339
+
340
+ ```ts
341
+ // Before:
342
+ export const users = table('users', { id: id(), firstName: text() })
343
+
344
+ // After — without the marker:
345
+ export const users = table('users', { id: id(), givenName: text() })
346
+ // → planner classifies as DROP firstName + ADD givenName
347
+ // → ADD givenName lands as `needs-backfill` (blocked, no backfill declared)
348
+ // → boot refuses
349
+
350
+ // With the marker:
351
+ export const users = table('users', {
352
+ id: id(),
353
+ givenName: text().renamedFrom('firstName'),
354
+ })
355
+ // → planner folds the diff into one RENAME COLUMN op, classified `safe`
356
+ // → boot applies it via `ALTER TABLE users RENAME COLUMN firstName TO givenName`
357
+ ```
358
+
359
+ The marker says: "the column previously named `firstName` should be the column now declared as `givenName`". The planner verifies the live DB has a column called `firstName` matching the new column's shape (type, nullable, default). Mismatch → refuse with a helpful error.
360
+
361
+ ## When `.renamedFrom()` doesn't fold
362
+
363
+ If the live DB doesn't have a column called `firstName`, the marker is a no-op:
364
+
365
+ - Maybe the rename was already applied → `firstName` is gone, `givenName` is there → diff is empty → no folding needed
366
+ - Maybe you typo'd the old name → live has `first_name` not `firstName` → planner falls back to treating `givenName` as a new column (which IS `needs-backfill` → refuses)
367
+
368
+ The marker isn't validated against the live DB at schema-build time — it would have to introspect during type-checking, which is expensive. The runtime check fires at plan time.
369
+
370
+ ## Lifecycle — when to remove the marker
371
+
372
+ Keep the marker until the rename has been applied in EVERY env you care about (dev, staging, prod). The framework tracks applied ops in `_voltro_migration_plans`:
373
+
374
+ ```
375
+ dev ← rename applied at 2026-04-15. Marker can come out.
376
+ staging ← rename applied at 2026-04-18. Marker can come out.
377
+ prod ← rename NOT YET APPLIED.
378
+ ```
379
+
380
+ Pull the marker too early and the next `voltro db plan` against prod sees:
381
+
382
+ ```
383
+ ✗ ALTER TABLE users DROP COLUMN firstName
384
+ ✗ ALTER TABLE users ADD COLUMN givenName text
385
+ ! fix: did you remove `.renamedFrom('firstName')` before staging migration applied?
386
+ Re-add the marker OR run `voltro db apply` against staging first.
387
+ ```
388
+
389
+ Practical rule: the marker stays in the codebase across the rollout. Once `voltro db drift` shows clean against the last env (usually prod), the rename is fully applied + the marker can come out in a follow-up PR.
390
+
391
+ ## `dropped()`
392
+
393
+ ```ts
394
+ import { dropped } from '@voltro/database'
395
+
396
+ // Drop a column on a populated table — without the marker, refused:
397
+ export const users = table('users', { id: id() }) // `legacy` simply gone
398
+ // → planner sees `users.legacy` in live but not declared → classifies lossy → blocked
399
+
400
+ // With the marker:
401
+ export const users = table('users', {
402
+ id: id(),
403
+ legacy: dropped(), // ← explicit intent
404
+ })
405
+ // → planner classifies lossy + ALLOWED (intent declared)
406
+ // → applier emits ALTER TABLE users DROP COLUMN legacy
407
+ ```
408
+
409
+ The marker fills the field-map slot the column used to occupy, telling the planner: "this column existed in the live DB AND is intentionally going away". The planner now classifies the drop as lossy-but-intended, which auto-applies.
410
+
411
+ `dropped()` is a column-shape no-op at runtime (the migration emitter emits a DROP COLUMN, then the column is gone). It's purely planner metadata.
412
+
413
+ ## Lifecycle — when to remove `dropped()`
414
+
415
+ After the drop has been applied in every env, remove the field-map entry entirely. The next plan sees nothing to do for that column (the live DB no longer has it, the declared schema no longer references it).
416
+
417
+ If you pull the `dropped()` marker before the drop has applied to all envs, the planner sees the column in live + the column ABSENT from declared → classifies as a fresh `DROP COLUMN` → blocked again with the same "add `dropped()` marker" fix. You'd just have to re-add it; no harm, no data loss.
418
+
419
+ ## Dropping a table
420
+
421
+ There's no `dropped()` equivalent for tables. The table simply being absent from the declared set IS the signal:
422
+
423
+ ```ts
424
+ // Remove the entire users.entity.ts file or its export from database/index.ts
425
+ // → planner sees `users` in live but not in declared → lossy DROP TABLE → blocked
426
+ ```
427
+
428
+ To allow it, set `VOLTRO_DESTRUCTIVE_OK=1` on the apply:
429
+
430
+ ```sh
431
+ VOLTRO_DESTRUCTIVE_OK=1 voltro db apply --note 'retiring users table after migration to user_accounts'
432
+ ```
433
+
434
+ `VOLTRO_DESTRUCTIVE_OK=1` only relaxes the refusal when EVERY blocked op is `lossy`. If the plan also has a rename-without-marker or a NOT-NULL-without-backfill, those stay refused regardless.
435
+
436
+ For complex multi-table retirements (move data out, then drop), use a [file-based migration](./file-based.md) — explicit ordering + a transaction wrapped around the data move.
437
+
438
+ ## `.narrowedFrom()` for type changes
439
+
440
+ A bare column type change is **refuse-to-plan** — the planner blocks it
441
+ (the same way a `drop-column` without `dropped()` is blocked), because a
442
+ raw `ALTER COLUMN … TYPE` may not be value-preserving and fails outright
443
+ at the DB for non-implicit casts. Acknowledge the change with
444
+ `.narrowedFrom(<live type>, { using })`: the planner downgrades it to
445
+ `needs-backfill` and threads the cast into the applier's
446
+ `ALTER COLUMN … TYPE … USING <using>` (and the online shadow-column copy).
447
+
448
+ ```ts
449
+ // Before: status: text()
450
+ // After:
451
+ export const orders = table('orders', {
452
+ id: id(),
453
+ status: text().oneOf(['pending', 'shipped', 'delivered']).narrowedFrom('text', {
454
+ using: 'status::status_enum',
455
+ }),
456
+ })
457
+ ```
458
+
459
+ - **`from`** is the type the LIVE DB currently has. It MUST equal the live
460
+ column type — a stale `from` (the column already changed, or you named
461
+ the wrong prior type) is ignored and the change stays blocked-lossy.
462
+ - **`using`** is the raw cast expression spliced verbatim into
463
+ `ALTER COLUMN … TYPE … USING <using>` (postgres) / the batched
464
+ shadow-copy (`shadow := <using>(old)`) on the online path. It's
465
+ developer-authored migration SQL — keep it portable or dialect-correct
466
+ for your target.
467
+ - **Omit `using`** when the conversion is implicit on the dialect (e.g.
468
+ `varchar → text`): the planner still downgrades the change, and the
469
+ applier emits a plain `ALTER COLUMN … TYPE` with no `USING`. For a
470
+ non-implicit cast with no `using`, the DB rejects the apply — declare
471
+ the cast.
472
+
473
+ ## `orphanPolicy` — adding an FK to a populated column
474
+
475
+ Promoting an existing `text()` column to `reference()` (common when a
476
+ column already holds the target's id as a plain string — e.g. data
477
+ migrated from another system) is NOT a type change: a `reference` is
478
+ TEXT-storage on every dialect, so the planner collapses the type diff to
479
+ a no-op. The only real change is the FK CONSTRAINT, which `db apply` adds
480
+ with `ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY …`.
481
+
482
+ By default the FK-add just applies — classified `needs-backfill`, exactly
483
+ like tightening a column to NOT NULL: the existing rows must already satisfy
484
+ it. If a row is an **orphan** (its value points at a target that doesn't
485
+ exist) the `ADD CONSTRAINT` fails at the DB, the whole apply rolls back
486
+ atomically, and the failing statement is surfaced. `orphanPolicy` (on the
487
+ reference) tells the applier to clear orphans FIRST so it can't fail:
488
+
489
+ ```ts
490
+ // Was `authorId: text()`. The column already has data, possibly with orphans.
491
+ authorId: reference(() => users, { orphanPolicy: 'null' }).nullable(),
492
+ ownerId: reference(() => orgs, { orphanPolicy: 'delete' }),
493
+ ```
494
+
495
+ - **`'fail'` (default)** — bare `ADD CONSTRAINT`, **applies** (`needs-backfill`,
496
+ not blocked). The DB rejects it only if an existing row is an orphan — then
497
+ declare `'null'` / `'delete'` and re-apply. (The planner is pure — it can't
498
+ read row counts, so it can't distinguish a clean / empty table from one with
499
+ orphans; blocking by default would refuse every clean case too.)
500
+ - **`'null'`** — the applier runs `UPDATE child SET col = NULL WHERE col`
501
+ *references a missing target* BEFORE `ADD CONSTRAINT`. Requires the
502
+ column be `.nullable()` (else the NULL-out would violate NOT NULL — the
503
+ planner blocks it with that exact hint).
504
+ - **`'delete'`** — the applier runs `DELETE FROM child WHERE col`
505
+ *references a missing target* first (removes the orphan ROWS — destructive).
506
+
507
+ `'null'` / `'delete'` apply via plain `db apply` — the policy IS the
508
+ acknowledgement, exactly like `.narrowedFrom(...)` for a type change — and
509
+ show in the plan as `lossy` with a reason naming the orphan handling.
510
+ `orphanPolicy` is planner metadata only (no runtime/query effect), and once
511
+ applied a re-plan is a no-op (introspection reports the FK; the policy is
512
+ stripped from the comparison). On **sqlite** an FK change rebuilds the table,
513
+ so the orphan pre-step is skipped — clean the orphans yourself there.
514
+
515
+ ## Markers don't pile up
516
+
517
+ Each marker maps to ONE applied op. The next migration after a rename +
518
+ drop has clean code:
519
+
520
+ ```ts
521
+ // Before the rollout:
522
+ export const users = table('users', {
523
+ id: id(),
524
+ givenName: text().renamedFrom('firstName'),
525
+ legacy: dropped(),
526
+ })
527
+
528
+ // After the rollout finished + applied in every env:
529
+ export const users = table('users', {
530
+ id: id(),
531
+ givenName: text(),
532
+ // legacy: dropped() removed entirely — the field-map slot disappears too.
533
+ })
534
+ ```
535
+
536
+ The cleanup is a separate PR after the migration has rolled out. Don't mix the rollout PR with the cleanup PR — the markers ARE the migration's audit trail until it's applied everywhere.
537
+
538
+
539
+
540
+ ---
541
+
542
+ <!-- source: en/database/migrations/backfill.md -->
543
+ ## Backfill
544
+
545
+ _Two backfill flavors — server-side SQL expressions and per-row JS functions. Decision rubric, batch tuning, performance trade-offs, and what happens when a backfill itself fails._
546
+
547
+ When you add a NOT NULL column to a populated table, the planner needs a value for every existing row before it can land the `SET NOT NULL` constraint. The `.backfill()` annotation declares that value. The planner classifies the op as `needs-backfill` (allowed), the applier runs the three-step plan.
548
+
549
+ Two flavors:
550
+
551
+ - **SQL backfill** — `.backfill(sql\`expression\`)`. The applier emits one `UPDATE` statement; the database does the work.
552
+ - **JS backfill** — `.backfill((row) => value)`. The applier streams rows in batches, calls the function locally, writes back.
553
+
554
+ ```ts
555
+ export const users = table('users', {
556
+ id: id(),
557
+
558
+ // SQL backfill — an @effect/sql Statement.Fragment; one round-trip, scales linearly.
559
+ email: text().backfill(unknownEmailExpr),
560
+
561
+ // JS backfill — when SQL can't express what you need.
562
+ embedding: text().backfill(async (row) => embed(row.title), {
563
+ batchSize: 500, // rows per batch, default 1000
564
+ sleepMs: 25, // ms between batches, default 0
565
+ }),
566
+ })
567
+ ```
568
+
569
+ ## When to pick which
570
+
571
+ | Need | Pick |
572
+ |---|---|
573
+ | Constant value for every row | `.default(value)` — not a backfill at all, the DDL DEFAULT clause does it |
574
+ | Expression of existing column values (`id || '@local'`, `LOWER(email)`, `created_at + interval '1 day'`) | **SQL** |
575
+ | Read another table's row (`(SELECT id FROM tenants WHERE name = '...' LIMIT 1)`) | **SQL** |
576
+ | Call an embedding model / image classifier / external HTTP API | **JS** |
577
+ | Compute a value with a JS library that has no SQL equivalent (`slugify`, `tokenize`, `parse`) | **JS** |
578
+
579
+ The bias is firmly toward SQL. Performance is in different leagues — SQL backfills hit a million rows in seconds; JS backfills hit the same set in minutes-to-hours depending on what the function does.
580
+
581
+ ## SQL backfill
582
+
583
+ ```ts
584
+ email: text().backfill(sql`'unknown-' || id || '@local'`),
585
+ ```
586
+
587
+ The expression goes inside `UPDATE <table> SET <col> = <expression>`. You can reference:
588
+
589
+ - Other columns of the same row (`id`, `created_at`, etc.) — by name, no aliasing
590
+ - Constants and literals — `'@local'`, `42`, `true`
591
+ - Standard SQL functions — `LOWER()`, `COALESCE()`, `EXTRACT()`, `||`, …
592
+ - Subqueries — `(SELECT id FROM tenants WHERE name = 'acme')` — including correlated ones
593
+ - Dialect-specific functions when you know which DB you're on
594
+
595
+ **Don't** reference columns the planner is about to drop / rename in the same plan — the UPDATE runs AFTER the ADD COLUMN but BEFORE any drops, so renamed columns are still under their old name at backfill time. The planner orders the plan deterministically (renames first as `RENAME COLUMN`, then ADD/ALTER); the backfill sees the post-rename names.
596
+
597
+ ### Cross-dialect SQL idioms
598
+
599
+ The `sql\`...\`` fragment is the same `@effect/sql` template you use in custom handlers. Use `sql.onDialectOrElse({...})` for expressions that vary:
600
+
601
+ ```ts
602
+ const timestampNow = sql.onDialectOrElse({
603
+ mysql: () => sql`NOW(6)`,
604
+ mssql: () => sql`SYSUTCDATETIME()`,
605
+ orElse: () => sql`now()`,
606
+ })
607
+
608
+ createdAt: timestamp().backfill(timestampNow),
609
+ ```
610
+
611
+ The framework's `sql.onDialectOrElse` resolves at compile time, so each dialect only emits its own branch. See [Multi-dialect strategy](./multi-dialect.md).
612
+
613
+ ## JS backfill
614
+
615
+ ```ts
616
+ embedding: text().backfill(async (row) => {
617
+ const text = `${row.title} ${row.body}`
618
+ return embed(text) // calls an external embedding model
619
+ }, {
620
+ batchSize: 500,
621
+ sleepMs: 25,
622
+ }),
623
+ ```
624
+
625
+ The function receives the full row (with `id` and every existing column). The applier:
626
+
627
+ 1. Streams rows: `SELECT id FROM <table> ORDER BY id` with cursor pagination
628
+ 2. For each batch of `batchSize`:
629
+ - Calls the function for each row, in parallel
630
+ - Writes back: `UPDATE <table> SET <col> = $1 WHERE id = $2` for each result
631
+ 3. `sleepMs` between batches to let normal traffic breathe
632
+
633
+ `batchSize` controls memory + concurrency (each batch holds N row promises). `sleepMs` reduces contention with concurrent writes — set it to ~20–50 ms when the function makes external API calls (the API quota matters more than throughput).
634
+
635
+ **The function MUST be deterministic + idempotent for the same input row.** A crash mid-backfill restarts the batch; non-idempotent functions double-charge external APIs or write duplicate side effects.
636
+
637
+ ## Performance
638
+
639
+ Order-of-magnitude rules of thumb for postgres on a modest VM:
640
+
641
+ | Rows | SQL backfill | JS backfill (pure CPU) | JS backfill (calls 50 ms API) |
642
+ |---|---|---|---|
643
+ | 1k | < 50 ms | ~200 ms | ~25 s |
644
+ | 10k | ~300 ms | ~2 s | ~5 min (batched, 500-wide) |
645
+ | 100k | ~3 s | ~25 s | ~50 min |
646
+ | 1M | ~30 s | ~5 min | unfeasible — use a separate workflow |
647
+ | 10M | ~5 min | ~50 min | unfeasible |
648
+
649
+ JS backfill is 10×–100× slower than SQL for the same data; with external calls it's 1000× slower. The CLI surfaces an estimate ahead of apply:
650
+
651
+ ```
652
+ ⊕ needs-backfill (1) — declared
653
+ + ALTER TABLE posts ADD COLUMN slug text
654
+ → backfill: js fn (est. 50,000 rows, ~8 minutes — consider .backfill(sql) variant?)
655
+ ```
656
+
657
+ The estimate is conservative (counts rows + multiplies by a per-row JS-fn cost factor). It's a hint, not a refusal — if you genuinely need the JS function, set `--note 'backfill via embedding model, expected duration'` so the history row records WHY the slow path was chosen.
658
+
659
+ ## Validate a JS backfill before applying
660
+
661
+ A JS backfill is regular TypeScript — the safest way to confirm it
662
+ produces sensible output before committing a long UPDATE run is to call
663
+ the function directly in a unit test (`voltro test`) over a handful of
664
+ representative rows. There is no `voltro db backfill --dry-run`
665
+ subcommand; `.backfill()` only runs as part of `voltro db apply`.
666
+
667
+ ## Failure handling
668
+
669
+ If the backfill `UPDATE` fails mid-flight:
670
+
671
+ - **Postgres / MSSQL / SQLite**: the whole 3-step is inside one transaction. The failure rolls back the ADD COLUMN too — the schema returns to its pre-apply state. The plan stays pending; fix the backfill expression + re-apply with `voltro db apply`.
672
+ - **MySQL / MariaDB**: DDL is implicit-commit. The ADD COLUMN landed. The UPDATE rolled back to its savepoint, but the nullable column is now on the table. The next `voltro db plan` will see the column present but nullable + emit the remaining `SET NOT NULL` step, which `voltro db apply` then applies against the current state.
673
+
674
+ The [multi-dialect strategy](./multi-dialect.md) page covers the forward-roll mechanics in detail.
675
+
676
+ If a JS backfill function throws partway through, the applier stops the
677
+ batch loop, records the affected row id range in the log, leaves the
678
+ column nullable (no `SET NOT NULL`), and exits non-zero. Re-run
679
+ `voltro db apply` once you've fixed the function — because each batch's
680
+ `UPDATE … WHERE <col> IS NULL` only touches rows that haven't been
681
+ filled yet, the re-run picks up where it stopped without double-writing
682
+ completed rows.
683
+
684
+ ## What about updating an existing column?
685
+
686
+ `.backfill()` ONLY applies to ADD-COLUMN ops. Updating values on an existing column isn't a migration concern — it's regular data work. Write a one-off mutation or a `*.subscribe.ts` handler that watches for the trigger condition, OR a workflow if it spans steps. The migration system stays out of "change data in this column" jobs.
687
+
688
+
689
+
690
+ ---
691
+
692
+ <!-- source: en/database/migrations/multi-dialect.md -->
693
+ ## Multi-dialect strategy
694
+
695
+ _How the planner + applier behave across Postgres, MySQL, MariaDB, MSSQL, SQLite — the atomicity matrix, MySQL's implicit-commit landmine + re-diff-to-recover, SQLite's table-rewrite mechanic, plus the per-dialect DDL idioms the framework hides._
696
+
697
+ The planner produces ONE `MigrationPlan` regardless of dialect. The applier executes it per-dialect, dispatching through `sql.onDialectOrElse` for every emit + falling back to runtime probes when behaviour diverges. The same `voltro db apply` invocation against the same schema produces structurally identical results on every backend.
698
+
699
+ What ISN'T uniform: **transactional DDL semantics**.
700
+
701
+ | Dialect | Transactional DDL | Advisory-lock mechanism | Backfill in tx with DDL |
702
+ |---|---|---|---|
703
+ | Postgres | ✓ all DDL atomic | `pg_advisory_lock(KEY)` | ✓ |
704
+ | MSSQL | ✓ all DDL atomic | `sp_getapplock` | ✓ |
705
+ | SQLite | ✓ all DDL atomic | process-local mutex | ✓ |
706
+ | **MySQL / MariaDB** | **✗ implicit commit per DDL** | `GET_LOCK('voltro_migration', N)` | ✓ but SEPARATE from DDL |
707
+
708
+ This is the operationally heaviest cross-dialect difference. The rest of the page covers what changes.
709
+
710
+ ## Postgres / MSSQL / SQLite — transactional happy path
711
+
712
+ A multi-step plan runs inside one `BEGIN ... COMMIT`. Mid-flight failure rolls EVERYTHING back; the next plan diff is identical to the pre-apply one. There's nothing to resume — re-running the apply re-runs the plan from scratch.
713
+
714
+ The advisory-lock variants serialise concurrent applies — two operators running `voltro db apply` against the same DB at the same time go through serially.
715
+
716
+ ## MySQL / MariaDB — re-diff-to-recover territory
717
+
718
+ Every DDL statement implicitly commits. A 5-op plan on MySQL is effectively 5 separate "atomic statements" with the prior ones already committed when a later one fails. If op 5 of 5 fails, ops 1–4 stay applied:
719
+
720
+ ```
721
+ plan applying (mysql, env=prod):
722
+ 1. create-table audit_logs ✓ 42ms
723
+ 2. add-column users.email ✓ 18ms
724
+ 3. backfill users.email ✓ 12s
725
+ 4. alter-column users.email SET NOT NULL ✓ 8ms
726
+ 5. add-index audit_logs(actorId) ✗ ER_DUP_KEYNAME
727
+ ```
728
+
729
+ There is NO `--resume` / `--abort` flag and NO per-op partial-status row — the applier records a `_voltro_migration_plans` row only on a fully successful apply. Recovery is to re-run the apply once the cause is fixed:
730
+
731
+ ```sh
732
+ voltro db apply
733
+ ```
734
+
735
+ `voltro db apply` re-introspects the live DB and diffs the declared schema against the (half-applied) live shape, so it emits ONLY the ops still missing — ops 1–4 are already in the DB and don't reappear in the diff. If someone finished op 5 out of band (psql, a corrective hot-fix), the re-diff sees it as present and skips it too. If the failed op is no longer the right answer because the schema was edited in response, the next `voltro db plan` already reflects the new declared shape — nothing to abort.
736
+
737
+ ## SQLite — table rewrite mechanic
738
+
739
+ SQLite lacks `ALTER COLUMN`. For type changes, the applier auto-emits the standard pattern:
740
+
741
+ 1. `CREATE TABLE <name>_new (<new column definitions>)`
742
+ 2. `INSERT INTO <name>_new SELECT (with cast) FROM <name>`
743
+ 3. `DROP TABLE <name>`
744
+ 4. `ALTER TABLE <name>_new RENAME TO <name>`
745
+ 5. Recreate every index + every FK the old table had
746
+
747
+ The plan output flags this as "rewrite table" so you know what's happening:
748
+
749
+ ```
750
+ ⊕ ALTER TABLE users ALTER COLUMN status TYPE varchar(20) # rewrite table (SQLite has no ALTER COLUMN)
751
+ → recreates 3 indexes, 2 incoming FKs
752
+ ```
753
+
754
+ Caveats:
755
+
756
+ - **Foreign keys referencing the rewritten table** get dropped and recreated. If they declared `ON DELETE CASCADE`, the recreate restores it; ordering matters internally.
757
+ - **Multi-rewrite plans on SQLite are brittle.** A plan that rewrites 3 related tables in one apply may have intermediate states where a FK temporarily references a non-existent table. The framework orders them topologically; mixed rename + rewrite within one plan can hit edge cases. If a SQLite multi-rewrite refuses, [file-based migrations](./file-based.md) let you control ordering manually.
758
+
759
+ ## Per-dialect DDL idioms hidden from you
760
+
761
+ The framework emits the right dialect-native idiom for every concept. You don't write these by hand:
762
+
763
+ | Concept | Postgres | MySQL / MariaDB | MSSQL | SQLite |
764
+ |---|---|---|---|---|
765
+ | Identifier quoting | `"name"` | `` `name` `` | `[name]` | `"name"` |
766
+ | `CREATE TABLE IF NOT EXISTS` | native | native | `IF NOT EXISTS (SELECT * FROM sys.tables...) EXEC(...)` | native |
767
+ | Auto-increment id | `BIGSERIAL` | `BIGINT AUTO_INCREMENT` | `BIGINT IDENTITY(1,1)` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
768
+ | Booleans | native `boolean` | `tinyint(1)` (0/1) | `bit` (0/1) | `integer` (0/1) |
769
+ | JSON column | `jsonb` | `json` | `nvarchar(max)` | `text` |
770
+ | Timestamp with tz | `timestamptz` | `datetime(6)` | `datetime2` | `datetime` |
771
+ | `now()` default | `now()` | `CURRENT_TIMESTAMP(6)` | `SYSUTCDATETIME()` | `current_timestamp` |
772
+ | `RETURNING *` on INSERT | native | NOT available (separate SELECT) | `OUTPUT INSERTED.*` | native |
773
+ | `LIMIT N OFFSET M` | native | native | `OFFSET M ROWS FETCH NEXT N ROWS ONLY` | native |
774
+ | FK with cascade | native | native | native | native (must `PRAGMA foreign_keys = ON`) |
775
+
776
+ The DDL emitter under `@voltro/database/src/migrate.ts` is one of the densest cross-dialect dispatch files in the codebase. Bug reports for "X doesn't work on dialect Y" usually trace to a missing branch there.
777
+
778
+ ## Boot-log shape per dialect
779
+
780
+ `voltro dev` prints a one-line dialect summary during the auto-migrate phase:
781
+
782
+ ```
783
+ [voltro:dev] auto-migrate: planning schema dialect=postgres env=dev tables=22
784
+ [voltro:dev] auto-migrate: applied 3 op(s) in 412ms [safe=3 needs-default=0 needs-backfill=0 rename=0 lossy=0] fingerprint=8f507ba1e1aadad5
785
+ ```
786
+
787
+ For MySQL the line notes the non-atomic-DDL constraint (a failed op leaves earlier ops committed; re-run apply to finish):
788
+
789
+ ```
790
+ [voltro:dev] auto-migrate: planning schema dialect=mysql env=dev tables=22 (implicit-commit DDL — re-run apply after a mid-plan failure)
791
+ ```
792
+
793
+ For SQLite the line notes the single-process-only constraint:
794
+
795
+ ```
796
+ [voltro:dev] auto-migrate: planning schema dialect=sqlite env=dev tables=22 (single-process — no concurrent appliers possible)
797
+ ```
798
+
799
+ ## Replication caveats during apply
800
+
801
+ If read-replicas are configured (`DB_REPLICA_URLS` set), the applier ALWAYS targets the primary. Replicas catch up via their normal replication stream. There's a window after apply where the replica fingerprint differs from primary — visible in `voltro db drift` if it's run against the replica URL during that window.
802
+
803
+ For multi-region deploys, time the apply against primary's region + accept the inter-region replication lag as the propagation time. Drift is measured against the **primary** fingerprint — the canonical schema authority that the applier always targets; replicas converge to it through their replication stream, so a transient post-apply mismatch on a replica is replication lag, not drift. To check a specific replica during that window, run `voltro db drift` against its URL.
804
+
805
+ ## When the dialect rejects something the planner emitted
806
+
807
+ This shouldn't happen — the framework's per-dialect DDL emitter is the test surface for every code path. If you see a dialect-side error during an apply that looks like the framework emitted invalid SQL:
808
+
809
+ 1. Capture the failing SQL from `voltro logs --trace <plan-id>`.
810
+ 2. File an issue with: the schema diff, the dialect + version, the exact error message.
811
+ 3. Workaround: drop down to a [file-based migration](./file-based.md) with hand-written DDL for the affected op.
812
+
813
+ The framework can't auto-fix every dialect's pathological cases (MariaDB's RETURNING gap on UPDATE, MySQL's strict-mode rejection of TEXT defaults, MSSQL's optimizer quirks with FILTER + RAISERROR). Where the test suite has caught those, the emitter has the right branch. Where it hasn't, file the report — the matrix grows from real failures.
814
+
815
+
816
+
817
+ ---
818
+
819
+ <!-- source: en/database/migrations/online.md -->
820
+ ## Online migrations
821
+
822
+ _Auto-promoted CONCURRENTLY indexes, batched backfills with progress reporting, and shadow-column rewrites for tables that can't be locked during apply. Threshold tuning + per-dialect mechanics._
823
+
824
+ The applier auto-rewrites ops that would block writes on large tables into their online variants. Cross the threshold and the planner promotes `safe` → `online-required`, executes a non-blocking variant, and reports progress through the CLI + boot log.
825
+
826
+ ## Threshold
827
+
828
+ The threshold defaults to 50,000 rows per table. Override it with the `--online-after <n>` flag on `voltro db plan` / `voltro db apply`, or the `VOLTRO_ONLINE_THRESHOLD` env var (the flag wins). At or above `n` rows an index/column change is planned as an ONLINE (non-blocking) operation:
829
+
830
+ ```bash
831
+ voltro db plan --online-after 10000 # treat tables ≥ 10k rows as online
832
+ VOLTRO_ONLINE_THRESHOLD=0 voltro db apply # every change online (most conservative)
833
+ ```
834
+
835
+ The threshold check runs at plan time. The applier counts rows with a fast `EXPLAIN`-derived estimate (Postgres `reltuples`, MSSQL `sys.dm_db_partition_stats.row_count`, MySQL `information_schema.tables.table_rows`); exact counts only happen during the actual op if needed.
836
+
837
+ ## ADD INDEX → online via CONCURRENTLY
838
+
839
+ ```ts
840
+ export const posts = table('posts', {
841
+ ...,
842
+ authorId: reference(() => users),
843
+ }).index('byAuthor', ['authorId'])
844
+ ```
845
+
846
+ On a 1M-row `posts` table, the plan output marks the index add as online:
847
+
848
+ ```
849
+ ◷ CREATE INDEX byAuthor ON posts(authorId) # online — postgres CONCURRENTLY
850
+ ```
851
+
852
+ Per-dialect mechanism:
853
+
854
+ - **Postgres**: `CREATE INDEX CONCURRENTLY` — no table lock, can read + write during build. ~3× slower than blocking + can't run inside a transaction. The applier moves it OUT of the main migration tx and runs it as its own step.
855
+ - **MySQL 8 / MariaDB 10.6+**: `ALTER TABLE ... ADD INDEX ... ALGORITHM=INPLACE LOCK=NONE`. Slightly weaker guarantee than postgres (briefly takes a metadata lock at start + end) but unblocks DML throughout.
856
+ - **MSSQL 2019+**: `CREATE INDEX ... WITH (ONLINE = ON)`. Enterprise Edition required for ONLINE=ON; Standard Edition falls back to blocking + the applier emits a loud warning.
857
+ - **SQLite**: no-op. SQLite locks anyway + the framework targets single-process deploys.
858
+
859
+ If the CONCURRENTLY build fails partway through (out of disk, killed by an admin, network blip), Postgres leaves the index in an `INVALID` state. The next plan run detects it + reissues the build:
860
+
861
+ ```
862
+ ◷ DROP INDEX byAuthor (invalid from prior partial build) + recreate
863
+ ```
864
+
865
+ ## Batched backfill with progress
866
+
867
+ A backfill on > threshold rows gets rewritten to batched:
868
+
869
+ ```
870
+ ⊕ ALTER TABLE posts ADD COLUMN slug text
871
+ → backfill (batched, 1000 rows/batch, 50ms sleep): est. 50,000 rows, ~12s
872
+ ⊕ ALTER TABLE posts ALTER COLUMN slug SET NOT NULL
873
+ ```
874
+
875
+ Per-batch progress shows up in `voltro logs --tail 50` while the apply runs:
876
+
877
+ ```
878
+ backfill posts.slug: 12,000 / 50,000 (24.0%) — ETA 8s
879
+ backfill posts.slug: 24,000 / 50,000 (48.0%) — ETA 5s
880
+ backfill posts.slug: 36,000 / 50,000 (72.0%) — ETA 3s
881
+ backfill posts.slug: completed — 50,000 rows in 11.8s
882
+ ```
883
+
884
+ Inside the framework the batched form is:
885
+
886
+ ```sql
887
+ UPDATE posts
888
+ SET slug = LOWER(REPLACE(title, ' ', '-'))
889
+ WHERE id > $cursor AND slug IS NULL
890
+ ORDER BY id
891
+ LIMIT 1000
892
+ ```
893
+
894
+ `$cursor` is the last batch's max id. The query plan is index-only on the primary key, so it stays fast as the table grows. `WHERE slug IS NULL` lets the same batch resume after a crash without double-updating completed rows.
895
+
896
+ Override the batching parameters per-column when you know more than the planner does:
897
+
898
+ ```ts
899
+ slug: text().backfill(sql`lower(replace(title, ' ', '-'))`, {
900
+ batchSize: 5000, // big batches for cheap CPU-only updates
901
+ sleepMs: 0, // no breathing room needed
902
+ }),
903
+
904
+ embedding: text().backfill(async (row) => embed(row.title), {
905
+ batchSize: 100, // small batches — each row is an HTTP call
906
+ sleepMs: 200, // throttle external API quota
907
+ }),
908
+ ```
909
+
910
+ ## Shadow-column rewrite
911
+
912
+ For type changes on a large table that can't be done via `ALTER COLUMN TYPE` (Postgres requires a rewrite + table lock; MySQL likewise for non-trivial converts), the applier auto-emits a shadow-column pattern:
913
+
914
+ 1. ADD COLUMN `<col>_new <newType>` nullable
915
+ 2. Backfill batched: `UPDATE ... SET <col>_new = (<col>::<newType>)` in chunks
916
+ 3. SET NOT NULL on `<col>_new` (if applicable)
917
+ 4. Atomic swap inside a brief metadata lock: rename `<col>` → `<col>_old`, rename `<col>_new` → `<col>`, drop `<col>_old`
918
+
919
+ The atomic swap is the only blocking step + holds the lock for ~1 ms. Apps that pin connections might see a brief query error during the swap; pooled connections re-issue + succeed on the second attempt.
920
+
921
+ The plan output flags this clearly:
922
+
923
+ ```
924
+ ◷ ALTER TABLE users ALTER COLUMN created_at TYPE timestamptz # shadow-column swap (4 steps: add → backfill → swap → drop)
925
+ ```
926
+
927
+ Schema dependencies: indexes + FKs referencing the column are recreated against the new column in the same swap step.
928
+
929
+ ## Cross-dialect quirks
930
+
931
+ | Mechanism | Postgres | MySQL 8+ | MariaDB 10.6+ | MSSQL 2019+ Ent | SQLite |
932
+ |---|---|---|---|---|---|
933
+ | Online index build | `CONCURRENTLY` | `INPLACE LOCK=NONE` | `INPLACE LOCK=NONE` | `WITH (ONLINE = ON)` | no-op |
934
+ | Resumable failed index | INVALID state + recreate | partial drop + retry | partial drop + retry | requires DBA intervention | no-op |
935
+ | Batched backfill | identical | identical | identical | identical | identical (single-process) |
936
+ | Shadow-column type change | yes | yes | yes | yes | no (table rewrite from [multi-dialect](./multi-dialect.md)) |
937
+
938
+ After a large backfill you'll usually want fresh planner statistics on the affected table. The applier does NOT emit `ANALYZE` / `UPDATE STATISTICS` for you — run it yourself once the apply completes (e.g. `ANALYZE posts;` on postgres) if the post-backfill query plans look stale.
939
+
940
+ ## What CONCURRENTLY doesn't help with
941
+
942
+ Online migrations are about **writes during apply**. They don't help with:
943
+
944
+ - Reads during apply — never blocked by either path
945
+ - Long-running transactions that hold a lock the apply needs — the apply waits regardless
946
+ - Replication lag — the new index propagates to replicas at their own pace
947
+
948
+ For a multi-hour migration on a 100M-row table, the right pattern often isn't "make it online" but "split into many small applies + apply during low-traffic windows". The framework's planner-based system lets you express that as multiple deploys, each with a small focused plan, instead of one monolithic migration. The [prod pipeline](./prod-pipeline.md) page covers the deploy-cadence side.
949
+
950
+
951
+
952
+ ---
953
+
954
+ <!-- source: en/database/migrations/file-based.md -->
955
+ ## File-based migrations
956
+
957
+ _The escape hatch for migrations the declarative diff can't infer — table splits, table merges, atomic cross-table data moves. Up/down convention, ordering, and how the planner integrates them._
958
+
959
+ The planner handles structural diffs — column additions, drops, renames, type changes — where the OLD and NEW shapes can be computed from declared schema vs live introspection. It refuses to plan migrations that need DATA SEMANTICS the diff can't see:
960
+
961
+ - Splitting one table into two (extract address fields to a new `addresses` table with FK back)
962
+ - Merging two tables (move both `personal_emails` + `work_emails` into one `emails` table tagged by type)
963
+ - Atomic data moves across tables (move all `orders` of status `archived` into `archived_orders` with a different schema)
964
+ - Type changes that need a custom transformation (parse a JSON column into structured columns)
965
+
966
+ For these, write an explicit migration file.
967
+
968
+ ## File convention
969
+
970
+ ```text
971
+ apps/api/migrations/
972
+ ├── 20260415_120000_split_address_out.ts
973
+ ├── 20260520_093000_merge_emails.ts
974
+ └── 20260603_140000_normalize_orders.ts
975
+ ```
976
+
977
+ Filename = `<UTC-timestamp>_<slug>.ts`. The timestamp orders applies — sortable + globally unique without coordination. Slugs are for humans + filed alongside the timestamped name in `_voltro_migration_plans.id` for findability.
978
+
979
+ The file exports a default `migration()`:
980
+
981
+ ```ts
982
+ // apps/api/migrations/20260415_120000_split_address_out.ts
983
+ import { migration } from '@voltro/database'
984
+
985
+ export default migration({
986
+ id: '20260415_120000_split_address_out',
987
+ description: 'Move users.address* fields into a separate addresses table with FK back.',
988
+
989
+ up: async ({ sql, log }) => {
990
+ // 1. CREATE the new table:
991
+ await sql.unsafe(`
992
+ CREATE TABLE addresses (
993
+ id text PRIMARY KEY,
994
+ userId text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
995
+ street text NOT NULL,
996
+ city text NOT NULL,
997
+ postalCode text NOT NULL,
998
+ createdAt timestamptz NOT NULL DEFAULT now()
999
+ )
1000
+ `)
1001
+ await sql.unsafe(`CREATE INDEX addresses_user_idx ON addresses(userId)`)
1002
+
1003
+ // 2. Move the data set-wise with one INSERT…SELECT — the file
1004
+ // context exposes the @effect/sql SqlClient, not the DataStore:
1005
+ await sql.unsafe(`
1006
+ INSERT INTO addresses (id, "userId", street, city, "postalCode")
1007
+ SELECT
1008
+ gen_random_uuid()::text,
1009
+ id,
1010
+ "addressStreet",
1011
+ "addressCity",
1012
+ COALESCE("addressPostalCode", '')
1013
+ FROM users
1014
+ WHERE "addressStreet" IS NOT NULL AND "addressCity" IS NOT NULL
1015
+ `)
1016
+ log.info('migrated address fields → addresses')
1017
+
1018
+ // 3. DROP the old columns:
1019
+ await sql.unsafe(`ALTER TABLE users DROP COLUMN "addressStreet"`)
1020
+ await sql.unsafe(`ALTER TABLE users DROP COLUMN "addressCity"`)
1021
+ await sql.unsafe(`ALTER TABLE users DROP COLUMN "addressPostalCode"`)
1022
+ },
1023
+
1024
+ down: async ({ sql }) => {
1025
+ // Reverse — required. The runner won't accept a migration without one.
1026
+ await sql.unsafe(`ALTER TABLE users ADD COLUMN addressStreet text`)
1027
+ await sql.unsafe(`ALTER TABLE users ADD COLUMN addressCity text`)
1028
+ await sql.unsafe(`ALTER TABLE users ADD COLUMN addressPostalCode text`)
1029
+ await sql.unsafe(`
1030
+ UPDATE users SET
1031
+ addressStreet = a.street,
1032
+ addressCity = a.city,
1033
+ addressPostalCode = a.postalCode
1034
+ FROM addresses a
1035
+ WHERE users.id = a.userId
1036
+ `)
1037
+ await sql.unsafe(`DROP TABLE addresses`)
1038
+ },
1039
+ })
1040
+ ```
1041
+
1042
+ The `up` body runs inside a transaction (on dialects that support it; see [multi-dialect](./multi-dialect.md) for MySQL forward-roll semantics). `down` runs the same way for rollback.
1043
+
1044
+ ## What the runner provides
1045
+
1046
+ ```ts
1047
+ up: async (ctx) => {
1048
+ ctx.sql // @effect/sql SqlClient with sql.unsafe / sql tagged-template / sql.onDialectOrElse
1049
+ ctx.log // structured logger scoped to this migration (info / warn)
1050
+ ctx.appliedAt // ISO-8601 string — when this migration started (deterministic across the up/down pair)
1051
+ }
1052
+ ```
1053
+
1054
+ The file context is `{ sql, log, appliedAt }` — there is NO `store`
1055
+ handle here. The escape hatch is deliberately SQL-level: you're doing
1056
+ the structural moves the typed DataStore can't express, so you drive
1057
+ them with `sql.unsafe(...)` for raw DDL and `sql\`...\`` /
1058
+ `sql.onDialectOrElse(...)` for parameterised statements. Move data
1059
+ set-wise with `INSERT … SELECT` / `UPDATE … FROM` rather than a
1060
+ per-row JS loop — it's one round-trip and stays inside the migration's
1061
+ transaction on the dialects that support transactional DDL.
1062
+
1063
+ ## Ordering vs the planner
1064
+
1065
+ File-based migrations are applied IN TIMESTAMP ORDER, BEFORE the planner-based diff runs. So a typical boot looks like:
1066
+
1067
+ ```
1068
+ [voltro:dev] migrations: file-based pending → 1
1069
+ [voltro:dev] migration 20260415_120000_split_address_out applying
1070
+ [voltro:dev] migrated 1247 users → addresses
1071
+ [voltro:dev] migration 20260415_120000_split_address_out applied in 482ms
1072
+ [voltro:dev] auto-migrate: planning schema dialect=postgres env=dev tables=23
1073
+ [voltro:dev] auto-migrate: schema up to date fingerprint=8f507ba1e1aadad5
1074
+ ```
1075
+
1076
+ The file ran first, dropped the columns, created the new table. The planner then diffs the (now mutated) live shape against the declared schema — and finds it up to date, because the declared schema also has `addresses` as a separate table + `users` without the address columns.
1077
+
1078
+ This ordering is critical: file-based migrations MUTATE state the planner sees. Sequence:
1079
+
1080
+ 1. Edit `users.entity.ts` to remove the `address*` columns + add the new `addresses` entity file
1081
+ 2. Write the file-based migration that physically moves the data + drops the columns
1082
+ 3. Boot — file-based runs first (writes the new state), planner runs second (sees a clean diff against the new declared schema, no-op)
1083
+
1084
+ If you skip step 2 + just edit the schema, the planner refuses to plan: dropping `addressStreet` is `lossy` (no `dropped()` marker), creating `addresses` is `safe`. The plan would refuse + the boot would fail until you add `dropped()` markers... but then you'd lose the data. The file-based migration moves the data BEFORE the planner sees the columns are gone.
1085
+
1086
+ ## Tracking
1087
+
1088
+ File-based migrations land in the same `_voltro_migration_plans` table as planner-based ones, with `source: 'file'`:
1089
+
1090
+ ```
1091
+ plan_mig_5k78 fp=... env=dev src=file ops=1 3.2s 2026-04-15 12:00:00 by=boot:dev
1092
+ plan_mig_5k79 fp=... env=dev src=auto-diff ops=0 12ms 2026-04-15 12:00:03 by=boot:dev
1093
+ ```
1094
+
1095
+ The `voltro db plans` command shows both side by side in the same timeline. Drift detection compares against the latest fingerprint regardless of source.
1096
+
1097
+ ## When NOT to use file-based migrations
1098
+
1099
+ The escape hatch is for situations the diff genuinely can't infer. Don't reach for it for:
1100
+
1101
+ - ADD NOT NULL column — that's `.backfill()` on the column declaration
1102
+ - Rename column — that's `.renamedFrom()` on the new column
1103
+ - Drop column — that's `dropped()`
1104
+ - Drop table — remove from declared set + `VOLTRO_DESTRUCTIVE_OK=1` for the apply
1105
+
1106
+ A file-based migration for any of these defeats the planner's safety story. The DSL annotations carry their fix-hint into the developer's editor; the file is just "trust me, this works".
1107
+
1108
+ ## Idempotency
1109
+
1110
+ File-based migrations are NOT auto-idempotent. The runner checks `_voltro_migration_plans` for a row with the same `id` and skips if found. The migration body itself must NOT assume it ran from a clean slate IF you're going to edit it after applying (the framework refuses to re-apply a modified file silently — see Drift docs).
1111
+
1112
+ Practical rule: once a file-based migration is applied in any env, it's frozen. Subsequent corrections are NEW migrations with NEW timestamps that read the half-applied state + finish the job.
1113
+
1114
+ ## Rollback
1115
+
1116
+ `voltro db rollback-file <id>` invokes a file-based migration's `down`
1117
+ body. The `<id>` is the positional migration id (the basename minus
1118
+ `.ts`):
1119
+
1120
+ ```sh
1121
+ voltro db rollback-file 20260415_120000_split_address_out
1122
+ ```
1123
+
1124
+ This is the planner-side file runner (`migration()` → `down`). It is a
1125
+ DIFFERENT subcommand from `voltro db rollback`, which drives the
1126
+ separate `defineMigration` step-based runner and takes `--to`, not a
1127
+ positional id — see [the overview](./index.md) for the two runners.
1128
+ `rollback-file` refuses on `NODE_ENV=production` (rollback runs as an
1129
+ explicit deploy step there).
1130
+
1131
+ If `down` throws, the rollback is treated as failed — the schema stays
1132
+ in the half-rolled-back state + the operator handles it manually. The
1133
+ framework can't auto-recover from a broken inverse.
1134
+
1135
+ ## Applying file-based migrations from the CLI
1136
+
1137
+ `voltro db files` applies pending file-based migrations under
1138
+ `<root>/migrations/` — the same runner the boot path invokes, exposed
1139
+ as a CLI command for when you've set `VOLTRO_AUTO_MIGRATE=0` and apply
1140
+ schema as an explicit step:
1141
+
1142
+ ```sh
1143
+ voltro db files
1144
+ ```
1145
+
1146
+ It's a distinct command from `voltro db apply` (which runs the
1147
+ planner-based auto-diff). The two histories both land in
1148
+ `_voltro_migration_plans` — file-based rows carry `source: 'file'`,
1149
+ planner rows `source: 'auto-diff'` — so `voltro db plans` shows them
1150
+ inline. On a normal boot, file-based migrations run FIRST (before the
1151
+ planner diff), so by the time `voltro db apply` would run, the file
1152
+ has already mutated the live shape.
1153
+
1154
+ The [prod pipeline](./prod-pipeline.md) page covers the deploy-step
1155
+ apply flow for planner-based changes.
1156
+
1157
+
1158
+
1159
+ ---
1160
+
1161
+ <!-- source: en/database/migrations/prod-pipeline.md -->
1162
+ ## Prod pipeline
1163
+
1164
+ _How schema changes flow from a PR through review to a production database. Why `voltro db apply` runs as an explicit deploy step, never on boot. Per-env fingerprint check + the refuse-to-boot behaviour._
1165
+
1166
+ The framework's hard rule: **`voltro start` (the production runtime) NEVER auto-applies migrations**. The dev-mode behaviour where `voltro dev` boots refuse-to-start on a blocked plan + auto-apply otherwise is intentionally not extended to prod. Schema changes mid-rolling-deploy without review are the largest data-risk class the framework could create; we don't.
1167
+
1168
+ What prod boot DOES is compare the declared-schema fingerprint against the latest `_voltro_migration_plans.fingerprint`. Match → serve traffic. Mismatch → refuse to boot with a structured error.
1169
+
1170
+ ## The end-to-end flow
1171
+
1172
+ ```
1173
+ ┌─────────────────────────────────┐
1174
+ │ 1. Developer edits schema in PR │
1175
+ └────────────────┬────────────────┘
1176
+
1177
+
1178
+ ┌─────────────────────────────────┐
1179
+ │ 2. CI runs `voltro db plan` │
1180
+ │ against a staging-style │
1181
+ │ snapshot. The printed plan │
1182
+ │ (classes + fingerprints) goes │
1183
+ │ into the PR for review │
1184
+ └────────────────┬────────────────┘
1185
+
1186
+
1187
+ ┌─────────────────────────────────┐
1188
+ │ 3. Reviewer reads the plan │
1189
+ │ classification, fingerprints │
1190
+ │ + DSL annotations │
1191
+ │ Approves PR │
1192
+ └────────────────┬────────────────┘
1193
+
1194
+
1195
+ ┌─────────────────────────────────┐
1196
+ │ 4. Merge to main │
1197
+ │ CI/CD deploys new image │
1198
+ │ BEFORE traffic switch a │
1199
+ │ one-shot job runs │
1200
+ │ `voltro db apply` against │
1201
+ │ the prod DB (re-diffs live) │
1202
+ └────────────────┬────────────────┘
1203
+
1204
+
1205
+ ┌─────────────────────────────────┐
1206
+ │ 5. Traffic switches │
1207
+ │ Prod boot: fingerprint match │
1208
+ │ → serves │
1209
+ └─────────────────────────────────┘
1210
+ ```
1211
+
1212
+ There's a slot for the apply step in every common deploy tool (k8s init container, ECS task pre-deploy hook, Heroku release phase, Cloud Run job-on-deploy, Fly.io `release_command`). The shape is identical: run a one-shot container/process that holds the migration credentials + executes `voltro db apply`. It re-diffs the live DB against the deployed code's declared schema and applies the resulting plan — there is no pre-serialised plan file to pass; the apply re-computes the diff at run time. The serving process never gets the migration-grade credentials.
1213
+
1214
+ ## Per-env fingerprint check
1215
+
1216
+ `voltro start` (prod runtime) does this on every boot:
1217
+
1218
+ ```ts
1219
+ const declaredFp = fingerprintSchema(declaredSnapshot(tables))
1220
+ const lastApplied = await sql`
1221
+ SELECT fingerprint FROM _voltro_migration_plans
1222
+ ORDER BY appliedAt DESC LIMIT 1
1223
+ `
1224
+ if (lastApplied?.fingerprint !== declaredFp) {
1225
+ // PROD-MISMATCH outcome → refuse-to-boot.
1226
+ process.exit(1)
1227
+ }
1228
+ ```
1229
+
1230
+ The structured error on mismatch:
1231
+
1232
+ ```
1233
+ [voltro:start] auto-migrate: SCHEMA FINGERPRINT MISMATCH —
1234
+ declared = a8f2c9d10b3f4e62
1235
+ live = 8f507ba1e1aadad5
1236
+ Run `voltro db apply --plan plan.json` from the deploy pipeline before serving.
1237
+ exit 1
1238
+ ```
1239
+
1240
+ This propagates as a non-zero exit code, k8s + ECS + Cloud Run mark the pod/task `CrashLoopBackOff` / failed deployment → automatic rollback to the previous image. Operators see the loud crash + know to run the apply step.
1241
+
1242
+ `VOLTRO_AUTO_MIGRATE=0` skips this check entirely — useful when migrations are handled by a separate ops process + the boot doesn't need to verify. Tradeoff: a drift goes undetected until the next manual `voltro db drift` run.
1243
+
1244
+ ## Previewing the plan in CI
1245
+
1246
+ Run `voltro db plan` against a snapshot of the prod schema (NOT prod
1247
+ itself — never expose prod credentials to CI). Add `--json` to emit the
1248
+ plan as a machine-readable artifact you can save and commit:
1249
+
1250
+ ```sh
1251
+ DB_URL=<staging-snapshot-url> voltro db plan --json > plan.json
1252
+ ```
1253
+
1254
+ The reviewer reads either the printed plan or the JSON. The saved
1255
+ `plan.json` then becomes the input to `voltro db apply --plan plan.json`
1256
+ (see below), applying the EXACT reviewed diff. A bare `voltro db apply`
1257
+ (no `--plan`) re-diffs live at deploy time instead — both flows are
1258
+ supported; the plan-file flow is the one the prod refuse-to-boot message
1259
+ points you to.
1260
+
1261
+ ```sh
1262
+ # CI step — runs in staging or a snapshot-replica env, against a real
1263
+ # connection:
1264
+ DB_URL=<staging-snapshot-url> voltro db plan
1265
+ ```
1266
+
1267
+ The printed plan goes into the PR for the reviewer:
1268
+
1269
+ ```
1270
+ schema diff: 4 operations, 0 blocked
1271
+
1272
+ ✓ ALTER TABLE users ADD COLUMN bio text # safe
1273
+ ⊕ ALTER TABLE users ADD COLUMN email text # backfill: sql`...`
1274
+ ⊕ UPDATE users SET email = 'unknown-' || id || '@local'
1275
+ ⊕ ALTER TABLE users ALTER COLUMN email SET NOT NULL
1276
+ ✓ CREATE INDEX users_email_idx ON users(email) # safe
1277
+
1278
+ safe: 2 needs-default: 0 needs-backfill: 1
1279
+ rename: 0 lossy: 0 blocked: 0
1280
+ fingerprint: 8f507ba1e1aadad5 → a8f2c9d10b3f4e62
1281
+ ```
1282
+
1283
+ A reviewer reads the SQL the applier will emit + checks the
1284
+ fingerprints. Blocked ops (red `✗`) MUST be resolved in the PR before
1285
+ merge — `voltro db apply` refuses any plan with a blocked op (exit 2).
1286
+
1287
+ ## Applying against prod
1288
+
1289
+ The deploy job runs `voltro db apply` (re-diffing the deployed code's
1290
+ declared schema against the live prod DB):
1291
+
1292
+ ```sh
1293
+ voltro db apply --note 'PR #1234 — add user emails'
1294
+ ```
1295
+
1296
+ The applier:
1297
+
1298
+ 1. Introspects the live DB and plans the diff fresh (it does NOT ingest
1299
+ a plan file — the diff is computed against live at apply time)
1300
+ 2. Refuses (exit 2) if any op is blocked, or refuses (exit 3) if
1301
+ `NODE_ENV=production` — so the apply runs in a one-shot job with
1302
+ `NODE_ENV` unset / `staging`, holding migration credentials, NOT in
1303
+ the serving process
1304
+ 3. Acquires the advisory lock + executes the plan
1305
+ 4. Records the result in `_voltro_migration_plans` with
1306
+ `source: 'auto-diff'` + `notes: 'PR #1234 — add user emails'`
1307
+
1308
+ ### Applying a reviewed plan (`--plan`, works under `NODE_ENV=production`)
1309
+
1310
+ `voltro db apply --plan plan.json` applies a plan saved by `voltro db
1311
+ plan --json`. Unlike a bare apply it is **allowed when
1312
+ `NODE_ENV=production`** — because it re-introspects live and refuses
1313
+ unless BOTH fingerprints still match the saved plan:
1314
+
1315
+ - `fromFingerprint` — the live schema the plan was generated against.
1316
+ Live drifted since? → aborts (exit 2, "live schema has drifted").
1317
+ - `toFingerprint` — the declared schema the plan targets. Schema files
1318
+ changed since? → aborts (exit 2).
1319
+
1320
+ So `--plan` can only ever apply the exact diff that was reviewed — never
1321
+ a stale or drifted one. That safety is what lets it run directly on the
1322
+ prod runtime, with no `NODE_ENV`-unset dance. It still refuses any plan
1323
+ with a blocked op (exit 2) and records the result with `source: 'file'`.
1324
+
1325
+ ```sh
1326
+ voltro db apply --plan plan.json --note 'PR #1234 — add user emails'
1327
+ ```
1328
+
1329
+ Because the diff is recomputed against live, an apply on an already-
1330
+ up-to-date DB is a clean no-op (`schema is up to date — nothing to
1331
+ apply`). That's what makes the apply safe to run in every pod of a
1332
+ stateless deploy.
1333
+
1334
+ ## Apply timing relative to deploy
1335
+
1336
+ Two orderings, both common:
1337
+
1338
+ **Apply before image swap** (recommended): the new image is deployed but not serving yet. The apply runs against the live DB. Then traffic switches.
1339
+
1340
+ - New schema is in place when the new code starts serving → no version mismatches
1341
+ - Old code is still serving until the swap → it must tolerate the new schema for a brief window
1342
+ - Constraints: every migration must be backward-compatible with the OLD code for the swap window. ADD columns (the OLD code ignores them) ✓. DROP columns (the OLD code might still write to them) ✗ → requires a 2-deploy dance (deploy 1: stop writing to col, deploy 2: drop col).
1343
+
1344
+ **Apply after image swap**: traffic is on the new code, the apply runs after. The new code must tolerate the OLD schema until the apply finishes.
1345
+
1346
+ - New code is in place during apply → mid-apply rollback is easier (just rollback the apply, the new code can still talk to the old shape if you designed for it)
1347
+ - Migration is the LAST step → if it fails, the new code is already serving + needs the new schema. Outage.
1348
+
1349
+ For most teams the first is safer (the framework's `_voltro_migration_plans.environment` tracking expects this pattern). For specific workloads where a partial migration would be catastrophic (massive backfills, multi-hour rewrites), the apply runs first as a one-shot job, the deploy follows when it's done.
1350
+
1351
+ ## Multi-instance prod
1352
+
1353
+ The advisory lock around `voltro db apply` serialises concurrent applies. Two instances of the apply job racing the same plan → one acquires the lock, the other blocks until the first finishes + observes the post-apply fingerprint matches (it's a no-op now), exits 0.
1354
+
1355
+ This is the same mechanism that lets you run the apply in EVERY pod of a stateless deploy (the second-through-Nth no-op out fast) — useful when the deploy pipeline can't single out a designated migration runner.
1356
+
1357
+ ## Rollback paths
1358
+
1359
+ If the apply itself fails partway:
1360
+
1361
+ - **Postgres / MSSQL / SQLite**: the transaction rolled back → live DB unchanged → fix the migration + re-run `voltro db apply` (it re-diffs from the unchanged state)
1362
+ - **MySQL / MariaDB**: DDL is implicit-commit, so completed ops stayed. Re-run `voltro db apply` — it re-diffs against the half-applied live shape and emits only the remaining ops. See [multi-dialect](./multi-dialect.md).
1363
+
1364
+ If the apply succeeded but the new code is broken + needs to be rolled back:
1365
+
1366
+ - The new-code rollback ≠ the schema rollback. The image deploys can revert via your normal CI/CD path; the schema stays at the new fingerprint.
1367
+ - There is no `voltro db rollback <plan-id>` for a planner plan. To back out a `safe` change, ship a schema PR that re-declares the old shape and apply it as a new forward plan; for `lossy` changes the old data is gone — restore from backup. See [Rollback](./rollback.md).
1368
+
1369
+ This is why the migration story is conservative + the prod refuse-to-boot is strict: once data is gone, no automated reversal brings it back.
1370
+
1371
+ ## What about staging?
1372
+
1373
+ The apply records `environment: 'staging'` when `NODE_ENV=staging`,
1374
+ else `dev` — each env's rows are tracked in the same
1375
+ `_voltro_migration_plans` table, tagged by environment. The CI flow
1376
+ typically applies to staging first, runs smoke tests against the new
1377
+ code, then to prod. The apply is idempotent against re-runs: because it
1378
+ re-diffs live each time, a second run against an already-migrated DB
1379
+ no-ops out.
1380
+
1381
+ The cloud dashboard surfaces per-env state with a multi-env tab in the [cloud UI](./cloud-ui.md).
1382
+
1383
+
1384
+
1385
+ ---
1386
+
1387
+ <!-- source: en/database/migrations/cross-env-sync.md -->
1388
+ ## Cross-environment migration sync
1389
+
1390
+ _voltro db plan --against <env-url> — diff your local declared schema against a remote env's live DB before pushing, over the framework's inspect endpoint. No DB connection from the CLI._
1391
+
1392
+ `voltro db plan --against <url>` fetches a remote environment's live
1393
+ schema via the framework's `/_voltro/inspect/migrations` endpoint and
1394
+ diffs it against your LOCAL declared schema — a "what would my branch do
1395
+ if I shipped it to staging right now?" pre-deploy preview. No database
1396
+ connection from the CLI to the remote; everything goes through HTTP.
1397
+
1398
+ ## How it works
1399
+
1400
+ 1. Fetch `/_voltro/inspect/migrations` on the remote URL.
1401
+ 2. Read the introspected schema from `drift.liveSnapshot` — the same
1402
+ `SchemaSnapshot` shape `introspectSchema` produces locally. The drift
1403
+ payload carries it alongside the fingerprints (`liveFingerprint`,
1404
+ `lastAppliedFingerprint`).
1405
+ 3. Run the planner against (local-declared, remote-live).
1406
+ 4. Render the plan output marked with the remote URL so you know it's
1407
+ not a local diff:
1408
+
1409
+ ```
1410
+ [--against https://staging.example.com/_voltro/inspect/migrations] — diff vs remote live schema:
1411
+
1412
+ schema diff: 3 operations, 0 blocked
1413
+
1414
+ ✓ CREATE INDEX users_email_lower_idx ON users (lower("email"))
1415
+ ✓ ALTER TABLE posts ADD COLUMN searchVec tsvector ...
1416
+ ✓ CREATE INDEX posts_search_gin ON posts (searchVec)
1417
+ ```
1418
+
1419
+ The exit code is `2` when the plan has blocked ops, `0` otherwise — so a
1420
+ CI job can gate a deploy on a clean diff against the target env.
1421
+
1422
+ ## Auth
1423
+
1424
+ The inspect endpoint accepts a bearer token:
1425
+
1426
+ ```bash
1427
+ voltro db plan --against https://prod.example.com --token $PROD_INSPECT_TOKEN
1428
+ ```
1429
+
1430
+ Without `--token`, the CLI reads the `VOLTRO_INSPECT_TOKEN` env var.
1431
+ Without either, the request goes unauthenticated (only works if the
1432
+ remote has `VOLTRO_INSPECT_TOKEN` unset, which you should **NEVER** do
1433
+ on prod).
1434
+
1435
+ The token is the same one the remote app boots with:
1436
+
1437
+ ```bash
1438
+ # On the remote side:
1439
+ VOLTRO_INSPECT_TOKEN=<secret> voltro start
1440
+ ```
1441
+
1442
+ See [Introspection](/docs/observability/inspect) for the full auth
1443
+ configuration.
1444
+
1445
+ ## URL shape
1446
+
1447
+ The CLI accepts either form:
1448
+
1449
+ ```bash
1450
+ voltro db plan --against https://staging.example.com
1451
+ voltro db plan --against https://staging.example.com/_voltro/inspect/migrations
1452
+ ```
1453
+
1454
+ If the URL doesn't end in `/_voltro/inspect/migrations`, the CLI
1455
+ appends it. The trailing-slash variant works too.
1456
+
1457
+ ## CLI flags
1458
+
1459
+ | Flag | Required | Description |
1460
+ |--------------|----------|--------------------------------------------------------------|
1461
+ | `--against` | yes | URL of the remote env (base URL OR full inspect URL) |
1462
+ | `--token` | no | Bearer token. Falls back to `VOLTRO_INSPECT_TOKEN` env |
1463
+
1464
+ ## See also
1465
+
1466
+ - [Migration overview](/docs/database/migrations) — the
1467
+ plan/apply lifecycle this slots into
1468
+ - [Introspection](/docs/observability/inspect) — the
1469
+ `/_voltro/inspect/*` surface this command consumes
1470
+ - [Drift detection](/docs/database/migrations/drift) — fingerprint-based
1471
+ drift surfacing the same inspect endpoint emits
1472
+
1473
+
1474
+
1475
+ ---
1476
+
1477
+ <!-- source: en/database/migrations/squashing.md -->
1478
+ ## Migration squashing
1479
+
1480
+ _voltro db squash — Rails-style consolidation. Mark every applied-before-T migration as squashed, leave a single snapshot row that new envs boot against._
1481
+
1482
+ After 2 years of incremental migrations the history table is
1483
+ hundreds of entries. Fresh environments take minutes to bootstrap.
1484
+ `voltro db squash` consolidates everything applied before a cut-off
1485
+ date into one synthetic snapshot row.
1486
+
1487
+ ## When to squash
1488
+
1489
+ - A new environment (laptop, staging) takes >30s to boot because of
1490
+ the migration replay.
1491
+ - The migration history has hundreds of entries and is becoming
1492
+ unreadable in `voltro db plans`.
1493
+ - You've shipped a major schema overhaul and the pre-overhaul
1494
+ history is no longer useful for debugging.
1495
+
1496
+ Don't squash:
1497
+ - If you're still iterating on the schema in dev — the history is
1498
+ your audit trail.
1499
+ - Right before a major release — wait until the release lands
1500
+ everywhere first.
1501
+ - File-based migrations — squashing only affects the declarative-
1502
+ diff (`auto-diff`) entries. File-based migrations keep their
1503
+ separate rollback path.
1504
+
1505
+ ## Quick start
1506
+
1507
+ ```bash
1508
+ voltro db squash --before 2026-06-01 --note 'consolidate v1 migrations'
1509
+ ```
1510
+
1511
+ ```
1512
+ ✓ squashed 14 migration plan(s)
1513
+ snapshot id: plan_squash_l4f2m1
1514
+ fingerprint: sha256:a8f2…
1515
+ ```
1516
+
1517
+ ## What the command does
1518
+
1519
+ 1. **SELECT** every `auto-diff` row in `_voltro_migration_plans`
1520
+ applied before the `--before` cut-off (where `squashedAt IS NULL`).
1521
+ 2. **Validates** the latest pre-squash row's fingerprint matches the
1522
+ current declared-schema fingerprint. If they diverge, the squash
1523
+ refuses — your working tree carries un-applied changes that
1524
+ would silently be locked in.
1525
+ 3. **Marks** all eligible rows squashed by setting `squashedAt` to
1526
+ the current timestamp. The rows STAY (audit trail preserved);
1527
+ they just no longer participate in boot-time replay.
1528
+ 4. **Inserts** a synthetic snapshot row with:
1529
+ - `source: 'squash-snapshot'`
1530
+ - `fingerprint: <current declared fingerprint>`
1531
+ - `notes: <user's --note>`
1532
+ - `operations: { kind: 'squash-snapshot', rowsSquashed: <N> }`
1533
+
1534
+ New environments booting against the squashed history skip past
1535
+ the squashed rows + use the snapshot's fingerprint as their
1536
+ starting point.
1537
+
1538
+ ## The fingerprint check
1539
+
1540
+ The most common gotcha:
1541
+
1542
+ ```
1543
+ $ voltro db squash --before 2026-06-01
1544
+
1545
+ squash refused: latest pre-squash fingerprint (a8f2…) differs from
1546
+ current declared-schema fingerprint (b3c1…). This means the working
1547
+ tree carries un-applied changes — squashing now would lock the drift in.
1548
+
1549
+ Fix: run `voltro db apply` first to align the live schema with the
1550
+ working tree, then re-squash.
1551
+ ```
1552
+
1553
+ The squash captures the fingerprint of the **most recent applied
1554
+ migration**. If your declared schema has uncommitted changes, the
1555
+ snapshot would say "this is the post-squash schema" but the actual
1556
+ schema (live DB) doesn't match. New envs would boot against the
1557
+ snapshot fingerprint, see a different declared fingerprint, and
1558
+ refuse to start.
1559
+
1560
+ Resolution: apply pending migrations FIRST, then squash.
1561
+
1562
+ ## CLI flags
1563
+
1564
+ | Flag | Required | Description |
1565
+ |--------------|----------|--------------------------------------------------------------|
1566
+ | `--before` | yes | ISO-8601 date — rows applied before this point are squashed |
1567
+ | `--note` | no | Human-readable note recorded on the snapshot row |
1568
+
1569
+ ## What happens on next boot
1570
+
1571
+ A new env booting against a squashed history:
1572
+
1573
+ 1. Reads the latest `_voltro_migration_plans` row.
1574
+ 2. If `source = 'squash-snapshot'`, treats its fingerprint as the
1575
+ baseline + skips replaying the squashed entries.
1576
+ 3. Diffs current declared schema against the baseline.
1577
+ 4. Applies only post-squash migrations (anything with
1578
+ `squashedAt IS NULL` AND `appliedAt > snapshot.createdAt`).
1579
+
1580
+ ## What stays available
1581
+
1582
+ - **The squashed rows** — still in `_voltro_migration_plans`. Query
1583
+ them via `voltro db plans` or read the table directly.
1584
+ - **File-based migrations** — untouched by squash. Their up/down
1585
+ pairs remain available for rollback.
1586
+ - **Drift detection** — `voltro db drift` compares the live DB
1587
+ fingerprint against the most recent `_voltro_migration_plans`
1588
+ row (squash snapshot OR a post-squash entry).
1589
+
1590
+ ## What's GONE
1591
+
1592
+ - **Rolling back a squashed migration** — the rollback path is
1593
+ one-way once a row is marked squashed. To recover, manually
1594
+ UPDATE the row's `squashedAt` back to NULL + DELETE the snapshot
1595
+ row. The framework doesn't auto-emit a reverse squash.
1596
+ - **Replay against a fresh DB** — new environments use the snapshot
1597
+ fingerprint, not the original migration sequence. The squashed
1598
+ rows are audit, not replayable.
1599
+
1600
+ ## See also
1601
+
1602
+ - [Migration overview](/docs/database/migrations) — the full plan/apply
1603
+ lifecycle the squash slots into
1604
+ - [Drift detection](/docs/database/migrations/drift) — `voltro db drift`
1605
+ compares against whichever row is most recent (squash or apply)
1606
+ - [Soft-drop recovery](/docs/database/migrations/rollback-snapshots) —
1607
+ the orthogonal "I dropped something and want it back" path
1608
+
1609
+
1610
+
1611
+ ---
1612
+
1613
+ <!-- source: en/database/migrations/rollback.md -->
1614
+ ## Rollback
1615
+
1616
+ _What can be reversed and what can't. Planner-applied plans are NOT auto-reversible — rollback is file-based only. The recovery paths for an applied schema change, and why forward-fix usually beats rollback._
1617
+
1618
+ There is no rollback for a planner-applied plan. The
1619
+ `_voltro_migration_plans` history is an append-only record of what ran;
1620
+ the applier does not store an inverse plan and `voltro db` has no
1621
+ `rollback <plan-id>` subcommand. Reversing a planner change means
1622
+ either re-declaring the prior schema and applying that as a NEW forward
1623
+ plan, or — for destroyed data — restoring from backup.
1624
+
1625
+ What CAN be reversed mechanically is a **file-based** migration, via its
1626
+ explicit `down` body.
1627
+
1628
+ ## What "rollback" means per change kind
1629
+
1630
+ | Change kind | Reverse path |
1631
+ |---|---|
1632
+ | Planner plan with only `safe` ops (ADD nullable, ADD index, widen type) | Re-declare the old shape in TS → `voltro db apply` runs the inverse as a NEW forward plan |
1633
+ | Planner plan with `lossy` ops (DROP column/table, narrow type) | Not reversible — the data is gone. Restore from backup, or [restore-snapshot](./rollback-snapshots.md) if it was applied with `VOLTRO_SOFT_DROP=1` |
1634
+ | `migration()` file (`migrations/<ts>_<slug>.ts`) | `voltro db rollback-file <id>` runs its `down` body |
1635
+ | `defineMigration` step file (`*.migration.ts`) | `voltro db rollback [--to <id>]` runs its steps' `undo` in reverse |
1636
+
1637
+ The rest of this page covers each path.
1638
+
1639
+ ## Reversing a planner change — forward-apply the old shape
1640
+
1641
+ A planner plan is a function of (declared schema, live schema). To undo
1642
+ one, make the declared schema describe the PRIOR state again and apply:
1643
+
1644
+ ```sh
1645
+ # 1. Revert the *.entity.ts edit (e.g. git revert the schema PR).
1646
+ # 2. voltro db plan shows the inverse diff:
1647
+ voltro db plan
1648
+ # ✓ ALTER TABLE users DROP COLUMN bio # inverse of the ADD that shipped
1649
+ # 3. Apply it as a new forward plan:
1650
+ voltro db apply --note 'reverting bio column — PR #1234 backed out'
1651
+ ```
1652
+
1653
+ This works cleanly ONLY when every op in the inverse diff is itself
1654
+ `safe`. If the original plan added a NOT NULL column you now want gone,
1655
+ the inverse is a plain DROP (safe). But if the original plan DROPPED a
1656
+ column, the inverse is an ADD that the planner classifies
1657
+ `needs-backfill` — and the data that column held is already gone, so no
1658
+ backfill expression brings it back. That asymmetry is the whole reason
1659
+ lossy ops are gated behind `dropped()` / `VOLTRO_DESTRUCTIVE_OK=1` in
1660
+ the first place.
1661
+
1662
+ The reverting apply lands a new `_voltro_migration_plans` row. The
1663
+ original plan stays in history; `voltro db plans` shows the apply and
1664
+ its reversal as two separate rows so the timeline is auditable.
1665
+
1666
+ ## What can't be recovered by forward-apply
1667
+
1668
+ ### Data from lossy ops
1669
+
1670
+ ```ts
1671
+ export const users = table('users', {
1672
+ id: id(),
1673
+ legacy: dropped(), // applied → DROP COLUMN legacy
1674
+ })
1675
+ ```
1676
+
1677
+ After apply, `users.legacy` and its data are gone. The applier does not
1678
+ snapshot column data before dropping (that would mean duplicating the
1679
+ table at apply time, which doesn't scale). Recovery options:
1680
+
1681
+ - If the plan was applied with `VOLTRO_SOFT_DROP=1`, the column was
1682
+ RENAMED to a sidecar instead of dropped — `voltro db restore-snapshot
1683
+ <plan-id>` brings it back. See [Soft-drop recovery](./rollback-snapshots.md).
1684
+ - Otherwise: restore from your DB's normal backup / PITR system.
1685
+
1686
+ This is why `dropped()` is an explicit annotation — it signals "I have
1687
+ a backup OR I really mean it".
1688
+
1689
+ ### Backfilled values
1690
+
1691
+ A `needs-backfill` plan computed values from the SQL expression / JS
1692
+ function. Forward-applying a DROP of that column discards the values.
1693
+ The expression is preserved in the plan's `operations` JSON, so
1694
+ re-applying the same forward plan reproduces the same values IF the
1695
+ source data is unchanged — but a structural reversal does not restore
1696
+ them.
1697
+
1698
+ ## Reversing a file-based migration
1699
+
1700
+ ### `migration()` files — `rollback-file`
1701
+
1702
+ ```sh
1703
+ voltro db rollback-file 20260415_120000_split_address_out
1704
+ ```
1705
+
1706
+ Runs the file's `down` body. The `<id>` is positional — the migration's
1707
+ id (its filename minus `.ts`). `rollback-file` refuses on
1708
+ `NODE_ENV=production` (schema rollback runs as an explicit deploy step
1709
+ there). If `down` throws, the rollback is failed and the schema stays
1710
+ half-reverted — the framework can't auto-recover from a broken inverse.
1711
+
1712
+ A non-destructive `down` only restores STRUCTURE, not data the `up`
1713
+ destroyed. Design the pair so `up` MOVES data it would otherwise drop:
1714
+
1715
+ ```ts
1716
+ up: async ({ sql }) => {
1717
+ await sql.unsafe(`CREATE TABLE obsolete_archive AS SELECT * FROM obsolete`)
1718
+ await sql.unsafe(`DROP TABLE obsolete`)
1719
+ },
1720
+ down: async ({ sql }) => {
1721
+ await sql.unsafe(`CREATE TABLE obsolete AS SELECT * FROM obsolete_archive`)
1722
+ await sql.unsafe(`DROP TABLE obsolete_archive`)
1723
+ },
1724
+ ```
1725
+
1726
+ Higher disk cost during apply (two copies briefly), but the rollback is
1727
+ meaningful.
1728
+
1729
+ ### `defineMigration` step files — `rollback`
1730
+
1731
+ The separate step-based runner reverses with `voltro db rollback`
1732
+ (newest applied step) or `voltro db rollback --to <id>` (back through
1733
+ several). It re-runs each migration's `undo` effects in reverse step
1734
+ order against the `_voltro_migrations` table. This is a DIFFERENT runner
1735
+ from the `migration()` path above — see [the overview](./index.md) for
1736
+ why both exist.
1737
+
1738
+ ## Rollback on prod
1739
+
1740
+ `voltro db rollback-file` / `voltro db rollback` refuse during a serving
1741
+ prod process — schema changes (forward or reverse) run as explicit
1742
+ deploy steps, never on boot. For a planner change you want backed out in
1743
+ prod, ship a schema PR that re-declares the old shape and apply it
1744
+ through the same [prod pipeline](./prod-pipeline.md) as any other change.
1745
+
1746
+ ## When to design for reversal vs forward-fix
1747
+
1748
+ | Scenario | Recovery path |
1749
+ |---|---|
1750
+ | New code crashes on boot, needs reverting | Image rollback + (if schema is incompatible) a forward-apply of the old shape |
1751
+ | New code is fine but the new schema has a bug | NEW migration that fixes the bug — don't reverse to a broken intermediate |
1752
+ | A `lossy` apply destroyed data you needed | Restore from backup / PITR, or `restore-snapshot` if soft-dropped |
1753
+ | Critical bug in production, need to undo NOW | Restore from backup — faster than re-planning when speed matters |
1754
+
1755
+ For production incidents, treat backup + PITR as the first-line option,
1756
+ not schema reversal. Once data is gone, no forward plan brings it back —
1757
+ which is exactly why the framework's apply path is conservative and the
1758
+ prod refuse-to-boot is strict.
1759
+
1760
+
1761
+
1762
+ ---
1763
+
1764
+ <!-- source: en/database/migrations/rollback-snapshots.md -->
1765
+ ## Soft-drop recovery
1766
+
1767
+ _VOLTRO_SOFT_DROP=1 renames dropped columns instead of deleting them. voltro db restore-snapshot brings them back. (Distinct from Rollback — this is the one path that recovers DROP-COLUMN data.)_
1768
+
1769
+ > This page is the ONLY mechanism that recovers data from a
1770
+ > `DROP COLUMN`. The sibling [Rollback](./rollback.md) page covers
1771
+ > reversing structural changes via forward-apply and file-based `down`
1772
+ > bodies — neither of which brings back dropped data. If you dropped a
1773
+ > column and want its data back, you needed `VOLTRO_SOFT_DROP=1` set at
1774
+ > apply time; that's what this page is about.
1775
+
1776
+ By default Voltro's applier issues `ALTER TABLE ... DROP COLUMN`
1777
+ for any lossy migration. Once that runs the data is gone — your
1778
+ only options are a full DB restore or replaying from a backup.
1779
+
1780
+ `VOLTRO_SOFT_DROP=1` rewrites every `drop-column` op into a
1781
+ RENAME. The data stays in a sidecar column named
1782
+ `<original>__dropped_<timestamp>`. `voltro db restore-snapshot
1783
+ <plan-id>` walks the migration's operations + RENAMEs them back.
1784
+
1785
+ ## When to use
1786
+
1787
+ Set `VOLTRO_SOFT_DROP=1` in your apply pipeline as a default
1788
+ safety net. The cost is one extra column per drop (data still
1789
+ takes disk space until GC); the benefit is a one-command recovery
1790
+ window.
1791
+
1792
+ Specifically helpful for:
1793
+
1794
+ - **Reversible production migrations** — drop a column, realize 30
1795
+ minutes later it broke a downstream report, restore it.
1796
+ - **Pre-release schema churn** — drop columns liberally during
1797
+ pre-release, recover when you change your mind.
1798
+ - **High-stakes drops** — set `VOLTRO_SOFT_DROP=1` per-migration
1799
+ via shell env var for the specific apply.
1800
+
1801
+ ## Apply with soft-drop
1802
+
1803
+ ```bash
1804
+ VOLTRO_SOFT_DROP=1 voltro db apply
1805
+ ```
1806
+
1807
+ The applier looks at every op in the plan. For `drop-column` ops
1808
+ it emits a RENAME instead of a DROP:
1809
+
1810
+ ```sql
1811
+ -- Without VOLTRO_SOFT_DROP
1812
+ ALTER TABLE "users" DROP COLUMN IF EXISTS "legacy_email";
1813
+
1814
+ -- With VOLTRO_SOFT_DROP=1
1815
+ ALTER TABLE "users" RENAME COLUMN "legacy_email" TO "legacy_email__dropped_20260603145522";
1816
+ ```
1817
+
1818
+ The migration row still says "drop-column applied" in
1819
+ `_voltro_migration_plans` — the planner doesn't know the rename
1820
+ happened. The post-apply fingerprint is what matters for boot
1821
+ checks (and that's based on the declared schema, where the column
1822
+ genuinely doesn't exist anymore).
1823
+
1824
+ ## Restore
1825
+
1826
+ ```bash
1827
+ voltro db restore-snapshot plan_01j5xkqyz...
1828
+ ```
1829
+
1830
+ ```
1831
+ ↩ restored users.legacy_email (from legacy_email__dropped_20260603145522)
1832
+ ✓ restored 1 column(s) from plan 'plan_01j5xkqyz...'
1833
+ ```
1834
+
1835
+ The command:
1836
+
1837
+ 1. Loads the migration plan by id.
1838
+ 2. Walks every op in `operations`, finds the `drop-column` ones.
1839
+ 3. For each `(table, column)`, queries `information_schema.columns`
1840
+ for a `<column>__dropped_*` match.
1841
+ 4. RENAMEs the most-recent match back to the original name.
1842
+
1843
+ If no snapshot matches (the plan was applied without
1844
+ `VOLTRO_SOFT_DROP`), the command logs a warning + skips that
1845
+ column but continues with the others.
1846
+
1847
+ ## Workflow
1848
+
1849
+ 1. Apply a risky migration with soft-drop enabled.
1850
+ 2. The dropped column survives as `<col>__dropped_<stamp>`.
1851
+ 3. Test the post-apply state.
1852
+ 4. Either:
1853
+ - **It's fine.** Wait for GC (see below) to actually drop the
1854
+ column.
1855
+ - **It broke something.** Run `voltro db restore-snapshot
1856
+ <plan-id>` to bring the column back.
1857
+ 5. Re-fix your schema in code (re-declare the column in the
1858
+ `.entity.ts` file) + run a normal `voltro db apply` to put the
1859
+ schema back in shape.
1860
+
1861
+ Step 5 is important — the framework's declared schema is the
1862
+ source of truth. Just restoring the snapshot brings the data back
1863
+ but the declared schema still says the column shouldn't exist; the
1864
+ next apply would re-drop it.
1865
+
1866
+ ## GC
1867
+
1868
+ Snapshot columns aren't automatically dropped. They survive until
1869
+ you:
1870
+
1871
+ - Run a manual `ALTER TABLE ... DROP COLUMN <col>__dropped_<stamp>`
1872
+ (or a wrapping migration that handles it).
1873
+ - Drop the table entirely.
1874
+
1875
+ Reclaim the space once your "is the migration confirmed safe?" review
1876
+ window has passed with `voltro db gc-snapshots --before <date>` — it
1877
+ permanently drops every `<name>__dropped_<ts>` column AND table older
1878
+ than the date (the `<ts>` stamp drives the comparison). Add `--dry-run`
1879
+ to preview:
1880
+
1881
+ ```bash
1882
+ voltro db gc-snapshots --before 2026-01-01 --dry-run # preview
1883
+ voltro db gc-snapshots --before 2026-01-01 # drop them (NOT reversible)
1884
+ ```
1885
+
1886
+ ## Limitations
1887
+
1888
+ - **Restore needs `VOLTRO_SOFT_DROP=1` at apply time.** Without it the
1889
+ drop is a hard `DROP COLUMN` / `DROP TABLE` and the data is gone —
1890
+ `restore-snapshot` can only bring back what was soft-dropped (renamed
1891
+ to `<name>__dropped_<ts>`). It restores both columns AND tables.
1892
+ - **Lossy at the row level.** This recovers DROP COLUMN
1893
+ (everything from before the drop is in the snapshot column).
1894
+ It does NOT recover UPDATE/DELETE row-data — those are forever.
1895
+ For that, use database backups.
1896
+ - **No effect on planner classification.** Lossy ops are still
1897
+ classified as lossy by `voltro db plan`. `VOLTRO_SOFT_DROP` is
1898
+ about HOW the drop happens, not WHETHER it's classified safe.
1899
+ The lossy refusal still requires explicit `dropped()` annotation
1900
+ or `VOLTRO_DESTRUCTIVE_OK=1`.
1901
+
1902
+ ## See also
1903
+
1904
+ - [Migration overview](/docs/database/migrations) — the full lifecycle
1905
+ - [Operation classes](/docs/database/migrations/operation-classes) —
1906
+ the 7-class taxonomy including `lossy`
1907
+ - [Drift detection](/docs/database/migrations/drift) — for
1908
+ out-of-band changes
1909
+
1910
+
1911
+
1912
+ ---
1913
+
1914
+ <!-- source: en/database/migrations/drift.md -->
1915
+ ## Drift detection
1916
+
1917
+ _How the framework detects schema drift (live DB ≠ last applied fingerprint), what causes drift, and how to reconcile it — with the planner's introspection, or via corrective plan, or by accepting + re-baselining._
1918
+
1919
+ Drift = the live database's schema doesn't match the fingerprint of the last applied `voltro db apply` plan. It's a passive detection — the framework only knows about drift after introspecting + comparing fingerprints. The detection itself is cheap (one COUNT + one fingerprint compare per check); the reconciliation path depends on cause.
1920
+
1921
+ ## How drift gets detected
1922
+
1923
+ Three trigger paths:
1924
+
1925
+ 1. **Manual** — `voltro db drift` runs the check on demand, exits 4 on drift
1926
+ 2. **On boot (dev)** — every `voltro dev` boot runs the planner, which detects drift implicitly (the plan will be non-empty)
1927
+ 3. **Periodic (cloud)** — the cloud dashboard polls each app's `/_voltro/inspect/migrations` endpoint; drift state is in the response
1928
+
1929
+ All three paths produce the same `DriftSnapshot` shape:
1930
+
1931
+ ```ts
1932
+ {
1933
+ isDrifted: boolean,
1934
+ liveFingerprint: string, // current introspected user-schema fingerprint
1935
+ lastAppliedFingerprint?: string, // newest _voltro_migration_plans row's fp
1936
+ lastAppliedAt?: string, // when it was applied
1937
+ lastAppliedId?: string, // plan id
1938
+ }
1939
+ ```
1940
+
1941
+ `isDrifted: false` ↔ `liveFingerprint === lastAppliedFingerprint`.
1942
+
1943
+ User-schema only: the fingerprint excludes `_voltro_*` framework tables. Bookkeeping tables grow rows + change row counts as the app runs; including them in the fingerprint would make every boot look drifted.
1944
+
1945
+ ## Common causes
1946
+
1947
+ ### Manual DDL
1948
+
1949
+ Someone ran `ALTER TABLE ...` or `CREATE INDEX ...` via psql / DataGrip / Adminer instead of the framework. The live DB has changes the planner's history doesn't reflect.
1950
+
1951
+ ```sh
1952
+ $ voltro db drift
1953
+ db drift: live schema DIVERGED from last applied state
1954
+ last applied: 8f507ba1e1aadad5 at 2026-06-15 14:32:00 (plan_mig_5k78)
1955
+ live now: a8f2c9d10b3f4e62
1956
+
1957
+ Probable causes:
1958
+ - manual DDL ran out-of-band (psql session, another tool)
1959
+ - someone applied a plan but the row never made it into _voltro_migration_plans
1960
+
1961
+ To reconcile, run `voltro db plan` to see what your code expects vs the live DB.
1962
+ ```
1963
+
1964
+ ### Out-of-band auto-applier
1965
+
1966
+ Multiple tools applying to the same DB (the framework + a separate Flyway / Liquibase process / hand-written deploy script). The other tool's changes don't go through `_voltro_migration_plans`.
1967
+
1968
+ ### Truncated history table
1969
+
1970
+ `_voltro_migration_plans` was truncated, restored from a backup, or the DB was restored to a point-in-time before the latest applies. The live schema is post-apply but the table doesn't know it.
1971
+
1972
+ ### Replica fingerprinted instead of primary
1973
+
1974
+ The drift detector ran against a read-replica that's lagging. Wait for the replica to catch up + re-check. (The framework's drift detector targets primary by default; this only bites when the user explicitly points the check at a replica URL.)
1975
+
1976
+ ## Reconciliation paths
1977
+
1978
+ ### Path 1 — adopt the live state by declaring it in TS
1979
+
1980
+ When the live DB IS what you want (the manual DDL is correct, only
1981
+ bypassing the planner was sloppy), bring the declared schema up to the
1982
+ live shape: edit the `*.entity.ts` files so they describe exactly what
1983
+ the live DB now has. The next `voltro db plan` then diffs empty, and a
1984
+ `voltro db apply` records a fresh `_voltro_migration_plans` row at the
1985
+ new fingerprint — re-baselining history without any DDL.
1986
+
1987
+ ```sh
1988
+ voltro db plan # confirm the diff is now empty (declared == live)
1989
+ voltro db apply --note 'accepting manual DDL from 2026-06-15 — see ticket #789'
1990
+ ```
1991
+
1992
+ `voltro db apply` with an empty plan writes no DDL; it just locks in
1993
+ the current fingerprint with your note. The history shows it:
1994
+
1995
+ ```
1996
+ plan_mig_5k79 fp=a8f2c9d1 env=dev src=auto-diff 0 op(s) 12ms ... by=alice
1997
+ [note: accepting manual DDL from 2026-06-15 — see ticket #789]
1998
+ ```
1999
+
2000
+ There is no metadata-only `--reconcile` flag — re-baselining always
2001
+ goes through the declare-then-apply loop, so the TS schema stays the
2002
+ single source of truth.
2003
+
2004
+ ### Path 2 — corrective plan against drift
2005
+
2006
+ When the live DB has accumulated cruft + the declared schema is what you want:
2007
+
2008
+ ```sh
2009
+ voltro db plan # see the diff between code + live
2010
+ voltro db apply # execute the diff, removing the drift
2011
+ ```
2012
+
2013
+ The plan diff will show the corrective ops:
2014
+
2015
+ ```
2016
+ schema diff: 2 operations, 0 blocked
2017
+
2018
+ ✗ DROP INDEX manual_idx_we_forgot_to_remove # safe (no FK depends on it)
2019
+ ⊕ ALTER TABLE users ADD COLUMN missing_field text # backfill: sql`'default'`
2020
+
2021
+ fingerprint: a8f2c9d10b3f4e62 → 8f507ba1e1aadad5
2022
+ ```
2023
+
2024
+ Apply lands the corrections + the new fingerprint matches the declared schema.
2025
+
2026
+ ### Path 3 — declared schema needs updates
2027
+
2028
+ The live DB has a column the declared schema doesn't reference, and you WANT to keep that column in the schema. Update the schema TS file to add it:
2029
+
2030
+ ```ts
2031
+ // users.entity.ts
2032
+ export const users = table('users', {
2033
+ id: id(),
2034
+ email: text(),
2035
+ extra_field: text().nullable(), // add to declared
2036
+ })
2037
+ ```
2038
+
2039
+ Now the live shape matches the declared shape after the next plan (which will be empty). The drift "fixed itself" through code changes.
2040
+
2041
+ ## Drift on prod
2042
+
2043
+ Production drift is the most important to catch quickly because it suggests an unauthorised change to the production DB. The cloud dashboard's drift detector runs every 5 minutes against each customer's prod app + surfaces the divergence as soon as it appears.
2044
+
2045
+ Surfaces:
2046
+
2047
+ - Dashboard banner on the affected app's Migrations tab
2048
+ - Audit log row tagged `drift.detected`
2049
+
2050
+ Out-of-band channels (Slack, email, PagerDuty) are intentionally NOT in the framework. Subscribe to the audit log via your existing observability stack — every drift event is a row your SIEM / monitoring already consumes, and your team's incident process kicks in from there.
2051
+
2052
+ The org's incident response process kicks in from there. Common immediate actions:
2053
+
2054
+ 1. Check the audit log for any non-CI DB access
2055
+ 2. Run `voltro db drift` against a snapshot to confirm the divergence
2056
+ (the check is a structural fingerprint compare — it tells you THAT
2057
+ the schema diverged, not which rows changed)
2058
+ 3. Decide: corrective plan or re-baseline?
2059
+
2060
+ ## What the dashboard shows
2061
+
2062
+ The Migrations tab's drift banner renders when `isDrifted: true`:
2063
+
2064
+ ```
2065
+ ⚠ Schema drift detected
2066
+ last applied: 8f507ba1e1aadad5 at 2026-06-15 14:32:00
2067
+ live now: a8f2c9d10b3f4e62
2068
+
2069
+ → Run `voltro db plan` to see what your code expects vs the live DB.
2070
+ ```
2071
+
2072
+ Click → expands to a comparison view showing the introspected live shape + the declared shape, highlighting the divergent tables. (Cloud dashboard only; local devtools shows just the banner without the comparison view.)
2073
+
2074
+ ## What about replicas?
2075
+
2076
+ Each replica has its own catch-up state. The framework's drift detector compares against PRIMARY by default; replicas catch up via the normal replication stream + reach the same fingerprint within their lag window.
2077
+
2078
+ If you specifically want to monitor replica drift (rare; mostly relevant during major maintenance windows), the cloud dashboard's Settings page allows enabling "Replica drift monitoring" which polls each replica URL separately + alerts on lag > N minutes.
2079
+
2080
+ ## Drift after rollback
2081
+
2082
+ `voltro db rollback` itself records a new row in `_voltro_migration_plans`, so the fingerprint of that row matches the post-rollback state. No drift gets reported as a side-effect of rollback.
2083
+
2084
+ If something else changed the live DB between the original apply + the rollback, that drift was already present + the rollback doesn't surface it differently. Run `voltro db drift` after rollback to confirm reconciliation if you're suspicious.
2085
+
2086
+ ## Detecting drift is the easy part
2087
+
2088
+ The hardest part of drift response is figuring out **what** changed + **who** did it. The framework can tell you that the fingerprints differ + show the structural diff. It can't tell you who ran the DDL or why. Pair the framework's drift detector with:
2089
+
2090
+ - Database audit logs (Postgres `pgaudit`, MySQL audit plugin, MSSQL Audit, SQLite no-op)
2091
+ - Network access logs (who reached the DB during the drift window)
2092
+ - Application logs filtered by trace id (if the drift happened during a request, trace shows the caller)
2093
+ - The team's normal incident response (Slack channel for accidental changes, post-mortem cadence)
2094
+
2095
+ ## TL;DR
2096
+
2097
+ ```
2098
+ Detect: voltro db drift
2099
+ Fix code: voltro db apply (apply corrective plan from current diff)
2100
+ Adopt DB: edit the *.entity.ts to match live, then voltro db apply (empty plan re-baselines)
2101
+ Backup: if data was lost, restore from your DB backup system — the framework can't help
2102
+ ```
2103
+
2104
+
2105
+
2106
+ ---
2107
+
2108
+ <!-- source: en/database/migrations/devtools-ui.md -->
2109
+ ## Devtools UI
2110
+
2111
+ _Walkthrough of the local devtools dashboard's Migrations tab — drift banner, pending plan card, and history timeline. What's clickable, what's not, and when the Apply button appears._
2112
+
2113
+ The local devtools dashboard (`http://localhost:5179`) ships a per-app Migrations tab that mounts the shared `MigrationsPage` component from `@voltro/devtools-ui`. The cloud dashboard mounts the same component over different transport — see [Cloud UI](./cloud-ui.md).
2114
+
2115
+ To reach it: `voltro dev` boot launches the dashboard automatically, pick an app from the sidebar, click the **Migrations** tab between Database and Data.
2116
+
2117
+ ## Layout
2118
+
2119
+ ```
2120
+ ┌──────────────────────────────────────────────────────────────┐
2121
+ │ Migrations │
2122
+ │ App: myApi · http://localhost:4000 │
2123
+ ├──────────────────────────────────────────────────────────────┤
2124
+ │ ⚠ Schema drift detected │ ← drift banner
2125
+ │ last applied: 8f507ba1e1aadad5 at 2026-06-15 14:32:00 │ only when drifted
2126
+ │ live now: d32149b280101693 │
2127
+ │ → Run `voltro db plan` to see what your code expects. │
2128
+ ├──────────────────────────────────────────────────────────────┤
2129
+ │ Pending plan — 3 ops │ ← pending card
2130
+ │ ✓ ALTER TABLE users ADD COLUMN bio text │ classifications +
2131
+ │ ⊕ ALTER TABLE users ADD COLUMN email text │ fix hints
2132
+ │ # NOT NULL, declared backfill: sql`'unknown-' || id` │
2133
+ │ ⊕ UPDATE users SET email = ... │
2134
+ │ │
2135
+ │ from 8f507ba1e1aadad5 → to a8f2c9d10b3f4e62 │
2136
+ ├──────────────────────────────────────────────────────────────┤
2137
+ │ Applied history — up to 20 plans │ ← history timeline
2138
+ │ plan_mig_5k78 fp=d3214928 env=dev src=auto-diff ... │
2139
+ │ plan_mig_5k77 fp=b414a413 env=dev src=auto-diff ... │
2140
+ │ plan_mig_5k76 fp=bd27c13e env=dev src=auto-diff ... │
2141
+ │ ... │
2142
+ └──────────────────────────────────────────────────────────────┘
2143
+ ```
2144
+
2145
+ Auto-refreshes every 10 seconds. The transport is HTTP — the page fetches `GET <app-url>/_voltro/inspect/migrations` directly + renders the JSON.
2146
+
2147
+ ## Drift banner
2148
+
2149
+ Renders at the top in rose when `drift.isDrifted: true`. Surfaces:
2150
+
2151
+ - **last applied fingerprint** + the time it was applied
2152
+ - **live fingerprint** computed from the current introspection
2153
+ - A pointer at the CLI command to investigate
2154
+
2155
+ The banner is informational — it doesn't block anything. The Apply button below it (when present) still works; the plan diff will include reconciling changes to bring live back to declared.
2156
+
2157
+ Common drift causes:
2158
+
2159
+ - A `psql` (or equivalent) session ran DDL outside the planner
2160
+ - A team member applied a plan but the row didn't make it into `_voltro_migration_plans` (rare; would mean the applier crashed after DDL but before writing the row)
2161
+ - The `_voltro_migration_plans` table itself got truncated / restored from a backup
2162
+
2163
+ For non-trivial drift, run `voltro db drift` in the shell — same diagnosis, with a copy-pasteable fix path.
2164
+
2165
+ ## Pending plan card
2166
+
2167
+ Renders the `MigrationPlan` from the planner.
2168
+
2169
+ - One line per `PlannedOperation` with a color-coded badge for its `OperationClass`:
2170
+ - `safe` / `needs-default` → emerald
2171
+ - `needs-backfill` / `needs-rename-annotation` → amber
2172
+ - `lossy` → rose
2173
+ - `online-required` → cyan
2174
+ - `multi-step` → fuchsia
2175
+ - The op description follows the badge — e.g. `ALTER TABLE users ADD COLUMN email text NOT NULL`
2176
+ - A dim-text reason follows: `# backfill declared (kind=sql) — applier will run 3-step add/update/set-not-null`
2177
+ - For blocked ops, a red `! fix:` hint underneath: `! fix: declare email: text().backfill(sql`...`) ...`
2178
+
2179
+ Below the op list, a chip row summarises counts per class. Below that, the from/to fingerprint short forms.
2180
+
2181
+ ### The Apply button
2182
+
2183
+ The shared `MigrationsPage` component renders an "Apply plan" button
2184
+ only when BOTH the caller's `canApplyMigration` capability is set AND
2185
+ the host wires an optional `useApplyPlan` hook (and the pending plan has
2186
+ zero blocked ops). The local devtools is single-user, single-machine, so
2187
+ it can wire that hook; the cloud dashboard never does (see
2188
+ [Cloud UI](./cloud-ui.md)).
2189
+
2190
+ When the Apply button isn't wired, the page surfaces the next-best
2191
+ thing: run `voltro db apply` from the project root + the page
2192
+ auto-refreshes within 10 seconds to show the post-apply state. That CLI
2193
+ path carries `--note '...'`, runs with whatever credentials are in the
2194
+ operator's shell, and exits with a status code CI/CD can act on — and
2195
+ it's the only path that ever reaches production, since
2196
+ `voltro db apply` refuses on `NODE_ENV=production`.
2197
+
2198
+ ## History timeline
2199
+
2200
+ Lists rows from `_voltro_migration_plans` newest-first, capped at 20.
2201
+
2202
+ Per-row:
2203
+
2204
+ - **plan id** — `plan_mig_5k78`, the typeid from the row
2205
+ - **fp** — 16-char short fingerprint of the post-apply state
2206
+ - **env** — dev / staging / prod (color-coded chip)
2207
+ - **src** — auto-diff (planner) or file (file-based migration)
2208
+ - **op count** — how many ops were in that plan
2209
+ - **duration** — milliseconds the apply took
2210
+ - **applied at** — ISO timestamp
2211
+ - **applied by** — CLI user / `boot:dev` / service principal
2212
+ - **notes** — the freeform `--note` string if provided, dim italic
2213
+
2214
+ Click a row to expand → shows the operations JSON (the full `PlannedOperation[]` that ran). Useful for "why is `users.legacy` gone?" — find the plan that dropped it, see exactly what executed.
2215
+
2216
+ ### No per-row Rollback button
2217
+
2218
+ History rows are read-only — there is NO Rollback button on a plan row.
2219
+ That matches the runtime: planner-applied plans have no auto-rollback
2220
+ (see [Rollback](./rollback.md)). The page DOES surface a separate
2221
+ restore control for soft-dropped columns (when a plan was applied with
2222
+ `VOLTRO_SOFT_DROP=1`, the sidecar columns get a per-plan "Restore"
2223
+ button driving `voltro db restore-snapshot`). Reversing a file-based
2224
+ migration's `down` body is a CLI-only action (`voltro db rollback-file
2225
+ <id>`).
2226
+
2227
+ ## Loading + error states
2228
+
2229
+ The page wraps the data fetch in a `DataSource<MigrationsStatus>`:
2230
+
2231
+ - Pending: shows a loading card while the first fetch is in flight
2232
+ - Error: renders the error message in rose
2233
+ - Data + error both undefined → page is in initial state, no flicker
2234
+
2235
+ A common error: the framework's inspect endpoint isn't reachable. Verify with `curl -sS http://localhost:4000/_voltro/inspect/migrations` — should return JSON. If not, check that the app is running + `VOLTRO_INSPECT` isn't `off`.
2236
+
2237
+ ## Multi-app
2238
+
2239
+ The dashboard's app picker shows every running voltro process (from `~/.voltro/runtime-registry.json`). Each app gets its own Migrations tab; the data is per-app + the URL includes the app id.
2240
+
2241
+ If multiple apps target the same database, they're showing the same `_voltro_migration_plans` rows — applied plans are global. Drift detection runs against the live DB per-app inspect, so if one app is talking to a different db (env var override etc.) you might see drift on one but not the other.
2242
+
2243
+ ## Source code
2244
+
2245
+ The shared component lives in `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx`. The local devtools wiring is in `voltro-devtools/apps/dashboard/src/pages/apps/[appId]/migrations.tsx`. Both repos are open to extension.
2246
+
2247
+
2248
+
2249
+ ---
2250
+
2251
+ <!-- source: en/database/migrations/cloud-ui.md -->
2252
+ ## Cloud UI
2253
+
2254
+ _Cloud dashboard's Migrations tab — what it shows, the submit→review→approve workflow with HMAC-signed review URLs, why it never applies (and never will), how it differs from the local devtools UI, and the multi-tenant boundary._
2255
+
2256
+ The cloud dashboard's per-app Migrations tab mounts the same `MigrationsPage` component the local devtools uses — the page itself is portable. The difference is transport + capabilities.
2257
+
2258
+ ## Transport
2259
+
2260
+ Local devtools fetches `/_voltro/inspect/migrations` directly over HTTP. The cloud dashboard can't — it doesn't have direct network access to the customer's app, and the inspect endpoint isn't internet-exposed in a sane deploy.
2261
+
2262
+ Instead, the cloud dashboard subscribes via cloud-RPC:
2263
+
2264
+ ```ts
2265
+ useAppInspectMigrationsStatus(appId)
2266
+ // → calls the cloud-api's `apps.inspectMigrationsStatus({appId})` query
2267
+ // → cloud-api looks up the app row (tenant-scoped to the caller)
2268
+ // → cloud-api calls `<app.url>/_voltro/inspect/migrations` with the app's stored inspectToken
2269
+ // → cloud-api returns the response to the dashboard
2270
+ ```
2271
+
2272
+ Three indirection levels: dashboard → cloud-api → customer-app → DB. Each hop is auth-scoped — the dashboard only sees apps the calling user has access to; the cloud-api enforces caller-owns-tenant on every request; the customer-app verifies the inspectToken.
2273
+
2274
+ Live data: the subscription stays open; deltas push when the underlying `_voltro_migration_plans` row set changes (the customer-app's reactive engine emits a change event on insert, which propagates through the cloud-api's RPC proxy back to the dashboard subscription). Same UX as the local devtools' 10-second polling, but push-driven.
2275
+
2276
+ ## The Apply button is gone — permanently
2277
+
2278
+ Apply locally with `voltro db apply` (the dev CLI). The cloud dashboard never applies — by design; it shows the plan, the approval workflow gates a human-triggered apply, and the apply itself runs in your own pipeline.
2279
+
2280
+ Reasoning:
2281
+
2282
+ - Cloud-applied plans would need to hold migration-grade credentials in the cloud-api. Today the inspectToken authorises READ-only access to inspect endpoints; an apply endpoint would require an entirely separate trust boundary.
2283
+ - Cloud-applied plans would mean the cloud platform owns the operational responsibility for the customer's schema. We don't want to.
2284
+ - The plan model is designed for human review BEFORE apply. A button click that says "apply now" without forcing the operator through `voltro db plan` first → review → deploy isn't the safety story the docs promise.
2285
+ - For prod specifically: the rule is `voltro db apply` runs as an explicit deploy step (and refuses on `NODE_ENV=production`). No browser button bypasses that.
2286
+
2287
+ What the cloud dashboard DOES instead: surfaces the pending plan
2288
+ (ops + classifications + fingerprints) read-only, and points the
2289
+ operator at the CLI command to run in their own pipeline:
2290
+
2291
+ ```
2292
+ Pending plan — 3 ops · 0 blocked
2293
+
2294
+ Apply via your deploy pipeline:
2295
+ voltro db apply # re-diffs live + applies; run as a deploy step
2296
+ ```
2297
+
2298
+ The apply re-diffs the live DB against the deployed code, so the
2299
+ dashboard doesn't hand out a serialised plan to feed back in — the
2300
+ plan it shows is the PREVIEW, and `voltro db apply` recomputes the same
2301
+ diff at deploy time. This keeps the dashboard the source of truth for
2302
+ what's pending, while never executing.
2303
+
2304
+ ## Multi-environment view
2305
+
2306
+ A per-app cloud dashboard has multiple environments. The Migrations tab surfaces all of them with sub-tabs:
2307
+
2308
+ ```
2309
+ ┌────────────────────────────────────────────────────────────┐
2310
+ │ Migrations │
2311
+ │ App: myApi · project: acme · org: acme-inc │
2312
+ ├────────────────────────────────────────────────────────────┤
2313
+ │ [ dev ] [ staging ] [ prod ] │ ← env tabs
2314
+ ├────────────────────────────────────────────────────────────┤
2315
+ │ <selected env's MigrationsPage> │
2316
+ └────────────────────────────────────────────────────────────┘
2317
+ ```
2318
+
2319
+ Each tab shows its own `MigrationsStatus`. Switching tabs is instant + the data is per-env subscription.
2320
+
2321
+ Common workflow:
2322
+
2323
+ 1. Reviewer opens the PR's preview env (`dev`) tab → confirms the plan ran cleanly there
2324
+ 2. Reviewer switches to `staging` → sees the SAME fingerprint applied → fingerprint chain looks healthy
2325
+ 3. Reviewer switches to `prod` → fingerprint is still old → operator hasn't run apply yet, that's expected, OK to merge
2326
+
2327
+ When prod drifts (the production fingerprint is older than staging), the prod tab shows the drift banner. Out-of-band alerting (Slack ping, email, PagerDuty) is intentionally NOT the framework's job — your deploy pipeline already has hooks for it (GitHub/GitLab Actions, k8s controllers, ArgoCD, etc.). The dashboard is the source of truth; whatever notification system your team already runs subscribes to the relevant pipeline events.
2328
+
2329
+ ## Pending-plans queue + approval workflow
2330
+
2331
+ The cloud-api ships a human-gated approval flow on top of the read-only
2332
+ view above: an operator's deploy pipeline **submits** a plan, reviewers
2333
+ **approve** (or reject) it, and only then does a human run
2334
+ `voltro db apply` from the pipeline. The cloud still NEVER applies the
2335
+ schema itself — approval gates the human apply, it does not perform it.
2336
+
2337
+ ### Submit a plan
2338
+
2339
+ The pipeline computes the plan with `voltro db plan --json` and posts it
2340
+ to the `migrations.submitPlan` mutation. The plan lands as `pending` in
2341
+ the `migration_plans` table (tenant-scoped) and the mutation returns the
2342
+ plan id plus an **HMAC-signed shareable review URL**:
2343
+
2344
+ ```ts
2345
+ const { id, status, reviewUrl } = await submitPlan.mutate({
2346
+ orgId, // = the active tenant id
2347
+ appId, // which deployed app the plan targets
2348
+ env: 'staging', // free-form env tag the pipeline submits
2349
+ fingerprint: livePlan.toFingerprint,
2350
+ operations: livePlan.operations, // [{ kind, table, classification, … }]
2351
+ summary: livePlan.summary, // per-classification counts
2352
+ })
2353
+ // status === 'pending'
2354
+ // reviewUrl === 'https://dashboard…/migrations/review?t=<signed-token>'
2355
+ ```
2356
+
2357
+ The review URL carries a token whose body is `{ planId, exp }`,
2358
+ base64url-encoded, with an HMAC-SHA256 signature appended. The token is
2359
+ signed with `VOLTRO_CLOUD_SECRET` (falling back to
2360
+ `VOLTRO_SESSION_SECRET`), so it can't be forged for a plan the reviewer
2361
+ was never sent, and it expires after 7 days. A reviewer opening the link
2362
+ hands the token to `migrations.reviewByToken`, which verifies the
2363
+ signature + expiry server-side (constant-time compare) before returning
2364
+ the plan — no interactive session required.
2365
+
2366
+ ### Review + decide
2367
+
2368
+ The dashboard's per-app Migrations tab renders a **Pending Approvals**
2369
+ section above the read-only history. It subscribes to
2370
+ `migrations.pendingApprovals` (a reactive, tenant-scoped query over
2371
+ `migration_plans`) — each plan expands to its operation list +
2372
+ per-classification summary badges, with **Sign / Approve** and
2373
+ **Reject** buttons plus an optional note. Signing a decision calls the
2374
+ `migrations.approve` mutation, which — in one transaction — appends an
2375
+ immutable row to `migration_approvals` and flips the plan's `status` to
2376
+ `approved` / `rejected`. The subscription drops the plan from the queue
2377
+ on the status flip.
2378
+
2379
+ Approval is **role-gated**: only org `owner` / `admin` can sign a
2380
+ decision (the same role guard the member-invite flow uses). A reviewer
2381
+ without the role sees a typed `MigrationApprovalForbidden` rejection.
2382
+
2383
+ ### Still no apply from the browser
2384
+
2385
+ Approval is distinct from apply, by design. An approved plan tells the
2386
+ operator "a reviewer signed off"; the operator still runs
2387
+ `voltro db apply` as an explicit deploy step (which re-diffs live and
2388
+ refuses on `NODE_ENV=production` without a pre-reviewed plan). When the
2389
+ apply lands, the pipeline reports back and the plan's `status` moves to
2390
+ `applied`. No browser button ever executes DDL.
2391
+
2392
+ ## Audit log
2393
+
2394
+ The cloud-api's audit log records the events it observes — who read the
2395
+ migrations tab, who submitted / approved / rejected each plan (the
2396
+ `migration_approvals` table is the immutable decision trail, stamped
2397
+ with `signedBy` + `decidedAt`), and (via the customer app's own history)
2398
+ which plans were applied and when.
2399
+
2400
+ ## What's NOT in the cloud UI
2401
+
2402
+ - **Apply button** — see above
2403
+ - **Rollback button** — there is no per-plan rollback anywhere (the
2404
+ runtime has no auto-rollback for planner plans — see
2405
+ [Rollback](./rollback.md)), so the dashboard surfaces none
2406
+ - **Inline plan editing** — plans are immutable; correct via a new PR
2407
+ - **Cross-app comparison** — each app's tab is independent
2408
+
2409
+ ## Permissions
2410
+
2411
+ Reading the Migrations tab requires the `canViewMigrationDetails`
2412
+ capability + caller-owns-tenant on the app. The framework's `Subject`
2413
+ model + per-org role mapping carries this; the dashboard hides the tab
2414
+ from users who lack the cap. Signing an approval decision is gated
2415
+ further: only org `owner` / `admin` can call `migrations.approve` — the
2416
+ server enforces this regardless of which buttons the UI renders.
2417
+
2418
+ ## Comparison with local devtools
2419
+
2420
+ | | Local devtools | Cloud dashboard |
2421
+ |---|---|---|
2422
+ | Transport | HTTP fetch (direct) | Cloud-RPC subscribe (proxied) |
2423
+ | Auth | none (local-only) | session + tenant scope + per-app cap |
2424
+ | Multi-env | no (per app at a time) | yes (tabs per env) |
2425
+ | Apply | cap-gated (single-user) | NEVER |
2426
+ | Approval workflow | no | submit → review → approve (owner/admin) |
2427
+ | Audit log | local log buffer | persisted in cloud-api |
2428
+ | Drift alerts | banner only | banner only (in-app) |
2429
+ | Live updates | 10s polling | push via subscription |
2430
+
2431
+ ## Source code
2432
+
2433
+ - Component: `voltro/packages/devtools-ui/src/pages/MigrationsPage.tsx` — shared
2434
+ - Cloud wiring: `voltro-cloud/apps/voltro-cloud/dashboard/src/pages/_/p/[orgSlug]/[projectSlug]/apps/[appSlug]/migrations.tsx`
2435
+ - Cloud-api proxy: `voltro-cloud/apps/voltro-cloud/api/queries/apps.inspectMigrationsStatus.query.ts` + `.query.server.ts`
2436
+ - Approval schema: `voltro-cloud/apps/voltro-cloud/api/database/migrationPlans.entity.ts` + `migrationApprovals.entity.ts`
2437
+ - Approval rpc: `migrations.submitPlan` + `migrations.approve` (mutations), `migrations.pendingApprovals` (query), `migrations.reviewByToken` (action)
2438
+ - Review-URL signing: `voltro-cloud/apps/voltro-cloud/api/lib/migrationReviewUrl.ts` (HMAC-SHA256 over `{planId, exp}`, `VOLTRO_CLOUD_SECRET` / `VOLTRO_SESSION_SECRET`)
2439
+
2440
+
2441
+
2442
+ ---
2443
+
2444
+ <!-- source: en/database/migrations/troubleshooting.md -->
2445
+ ## Troubleshooting
2446
+
2447
+ _The most common "why is my voltro dev refusing to boot?" cases with copy-paste fixes. Each pattern maps a planner error message to the schema annotation that resolves it._
2448
+
2449
+ Every refuse-to-boot from the planner includes a structured fix hint. This page is the comprehensive catalog of those hints + the schema edit each one wants.
2450
+
2451
+ ## "NOT NULL column on a table whose row count is unknown"
2452
+
2453
+ Full error:
2454
+
2455
+ ```
2456
+ auto-migrate: REFUSED — 1 blocked operation(s):
2457
+ - add-column [users]: NOT NULL column on a table whose row count is unknown
2458
+ fix: declare `email: text().backfill(sql`...`)` OR `.default(value)` so existing rows survive the migration
2459
+ ```
2460
+
2461
+ What happened: you added a non-nullable column to a populated table without telling the planner how to populate it for existing rows.
2462
+
2463
+ Fix — pick ONE:
2464
+
2465
+ ```ts
2466
+ // Option A — constant default (planner emits ADD COLUMN ... NOT NULL DEFAULT value)
2467
+ plan: text().default('free'),
2468
+
2469
+ // Option B — SQL expression (planner emits ADD nullable → UPDATE → SET NOT NULL)
2470
+ email: text().backfill(sql`'unknown-' || id || '@local'`),
2471
+
2472
+ // Option C — JS function (slower, only when SQL can't express what you need)
2473
+ embedding: text().backfill(async (row) => embed(row.title)),
2474
+
2475
+ // Option D — make it nullable
2476
+ bio: text().nullable(),
2477
+ ```
2478
+
2479
+ Decision rubric in [backfill.md](./backfill.md).
2480
+
2481
+ ## "column missing from declared schema" (DROP COLUMN refused)
2482
+
2483
+ Full error:
2484
+
2485
+ ```
2486
+ ✗ ALTER TABLE users DROP COLUMN legacyField
2487
+ ! fix: if intentional, add `legacyField: dropped()` to the schema. If a typo, restore the field
2488
+ ```
2489
+
2490
+ What happened: the live DB has a column the declared schema doesn't reference. Could be deliberate (you want to drop it) or accidental (someone deleted the field from the schema by mistake).
2491
+
2492
+ Fix — pick ONE:
2493
+
2494
+ ```ts
2495
+ // Option A — declare intent. Column goes away on next apply.
2496
+ export const users = table('users', {
2497
+ id: id(),
2498
+ legacyField: dropped(), // ← explicit. Planner allows the drop.
2499
+ })
2500
+
2501
+ // Option B — typo, restore the field.
2502
+ export const users = table('users', {
2503
+ id: id(),
2504
+ legacyField: text(),
2505
+ })
2506
+
2507
+ // Option C — use VOLTRO_DESTRUCTIVE_OK for one-off applies (loud warning):
2508
+ // VOLTRO_DESTRUCTIVE_OK=1 voltro db apply
2509
+ ```
2510
+
2511
+ ## "table missing from declared schema" (DROP TABLE refused)
2512
+
2513
+ ```
2514
+ ✗ DROP TABLE oldUsersTable
2515
+ ! fix: if intentional, set VOLTRO_DESTRUCTIVE_OK=1 OR add a file-based migration. If a typo, restore the table declaration
2516
+ ```
2517
+
2518
+ Fix:
2519
+
2520
+ ```ts
2521
+ // Option A — restore the table declaration (probably the right answer if surprised)
2522
+ export const oldUsersTable = table('oldUsersTable', { id: id(), ... })
2523
+
2524
+ // Option B — file-based migration that moves data out + then drops.
2525
+ // The file context is { sql, log, appliedAt } — no store; use sql:
2526
+ // migrations/20260415_retire_old_users.ts
2527
+ export default migration({
2528
+ id: '20260415_retire_old_users',
2529
+ description: 'Move oldUsersTable rows into users, then drop it.',
2530
+ up: async ({ sql }) => {
2531
+ await sql.unsafe(`INSERT INTO users (id, ...) SELECT id, ... FROM "oldUsersTable"`)
2532
+ await sql.unsafe(`DROP TABLE "oldUsersTable"`)
2533
+ },
2534
+ down: async ({ sql }) => { /* recreate + restore as far as possible */ },
2535
+ })
2536
+
2537
+ // Option C — one-off destructive apply:
2538
+ // VOLTRO_DESTRUCTIVE_OK=1 voltro db apply --note 'retiring oldUsersTable per ticket #...'
2539
+ ```
2540
+
2541
+ **Two tables this never proposes dropping:**
2542
+
2543
+ - **`actors`** — the framework-provided audit subject. You don't declare an
2544
+ `actors.entity.ts`; `db plan` / `db apply` auto-include the built-in `actors`
2545
+ in the declared set, so it's never a DROP candidate. (Declare your own
2546
+ `actors` with extra columns and that takes precedence.)
2547
+ - **Your own UNMANAGED infra tables** — a table you keep outside the Voltro
2548
+ schema (a migration id-map, a legacy audit table). List them in
2549
+ `VOLTRO_DB_IGNORE_TABLES` (comma-separated) and the diff leaves them alone
2550
+ instead of planning a DROP:
2551
+
2552
+ ```bash
2553
+ VOLTRO_DB_IGNORE_TABLES=_strapi_id_map,_legacy_audit voltro db apply
2554
+ ```
2555
+
2556
+ The **same env var is honoured by the `voltro dev` boot auto-migrate**, not
2557
+ just the `db plan` / `db apply` CLI — set it in the app's environment and the
2558
+ boot diff leaves the listed tables alone too, so a fresh clone with a
2559
+ Strapi→Voltro `_strapi_id_map` sitting in the DB won't refuse-to-boot on a
2560
+ `drop-table`. (The framework already self-excludes its own `_voltro_*` /
2561
+ `cluster_*` runtime tables; this is the user list on top of that.)
2562
+
2563
+ ## A migrate / apply DDL statement failed — find which one
2564
+
2565
+ When `voltro migrate` / `db apply` hits a DDL error, the CLI names the **failing
2566
+ statement** plus the driver's fields, not just a stack:
2567
+
2568
+ ```
2569
+ ═══ migrate: statement failed ═══
2570
+ statement: CREATE INDEX "users_orgId_idx" ON "users" ("orgId")
2571
+ db.message: column "orgId" does not exist
2572
+ db.code: 42703
2573
+ ```
2574
+
2575
+ A `column … does not exist` on a `CREATE INDEX` usually means the column was
2576
+ never added to an EXISTING table: `voltro migrate` (auto-migrate) is `CREATE
2577
+ TABLE IF NOT EXISTS` — it does NOT `ADD COLUMN` to a table that already exists.
2578
+ To evolve an existing table's columns, use the declarative path (`voltro db
2579
+ plan` → `db apply`), which orders `ADD COLUMN` before the index. Set
2580
+ `VOLTRO_MIGRATE_DEBUG=1` to trace every statement as it executes.
2581
+
2582
+ ## "users.givenName looks like a new required column on a populated table"
2583
+
2584
+ Full error:
2585
+
2586
+ ```
2587
+ ✗ ALTER TABLE users ADD COLUMN givenName text # NOT NULL column on a table whose row count is unknown
2588
+ ```
2589
+
2590
+ What probably happened: you renamed `firstName` → `givenName` in the schema, but didn't tell the planner it's a rename. The planner sees `firstName` gone + `givenName` new + classifies each separately.
2591
+
2592
+ Fix:
2593
+
2594
+ ```ts
2595
+ export const users = table('users', {
2596
+ id: id(),
2597
+ givenName: text().renamedFrom('firstName'),
2598
+ })
2599
+ ```
2600
+
2601
+ The planner folds the diff into one `ALTER TABLE users RENAME COLUMN firstName TO givenName`, classified `safe`. After the rename is applied in every env, the marker can be removed (covered in [rename-and-drop.md](./rename-and-drop.md)).
2602
+
2603
+ ## "Schema fingerprint mismatch" (prod refuse)
2604
+
2605
+ Full error:
2606
+
2607
+ ```
2608
+ [voltro:start] auto-migrate: SCHEMA FINGERPRINT MISMATCH —
2609
+ declared = a8f2c9d10b3f4e62
2610
+ live = 8f507ba1e1aadad5
2611
+ Run `voltro db apply --plan plan.json` from the deploy pipeline before serving.
2612
+ exit 1
2613
+ ```
2614
+
2615
+ What happened: the production runtime checked its declared schema's fingerprint against the latest `_voltro_migration_plans.fingerprint` and they don't match. The framework refuses to start serving because it doesn't know what to do — auto-apply on prod isn't allowed (see [prod-pipeline.md](./prod-pipeline.md)).
2616
+
2617
+ Fix:
2618
+
2619
+ 1. Preview the plan against the prod DB shape (NOT prod credentials —
2620
+ use a staging-replica snapshot):
2621
+
2622
+ ```sh
2623
+ DB_URL=<staging-snapshot-url> voltro db plan
2624
+ ```
2625
+
2626
+ 2. Review the printed plan in the PR.
2627
+
2628
+ 3. Run apply from the CI/CD step (a one-shot job with migration
2629
+ credentials and `NODE_ENV` unset / `staging` — `voltro db apply`
2630
+ re-diffs live and refuses on `NODE_ENV=production`):
2631
+
2632
+ ```sh
2633
+ voltro db apply --note 'PR #1234'
2634
+ ```
2635
+
2636
+ 4. Re-deploy. The new boot's fingerprint check passes.
2637
+
2638
+ Note: the boot-mismatch message itself prints
2639
+ `voltro db apply --plan plan.json`, but `--plan` is not a real flag —
2640
+ the prod apply is a plain `voltro db apply` that recomputes the diff.
2641
+
2642
+ If the cause is drift (someone DDL'd prod manually), see [drift.md](./drift.md) for reconciliation paths.
2643
+
2644
+ ## "duplicate column name in plan" (logically invalid plan)
2645
+
2646
+ ```
2647
+ db apply: refusing — plan contains 2 ops targeting users.email (CREATE + DROP)
2648
+ This usually means the planner couldn't determine the correct order.
2649
+ Hand-edit the plan JSON or use a file-based migration to express the intent explicitly.
2650
+ ```
2651
+
2652
+ What happened: the diff produced both an ADD and DROP for the same column → ambiguous intent.
2653
+
2654
+ Fix: it's almost always a schema edit ordering problem. Either:
2655
+
2656
+ - The schema was edited twice + both edits are in the diff (rebase the PR; squash the two commits)
2657
+ - A column was renamed + another column was added with the same name (use `.renamedFrom()` on the second)
2658
+ - A file-based migration is racing the planner (sequence them differently — file before planner)
2659
+
2660
+ ## "VOLTRO_DESTRUCTIVE_OK relaxes lossy ops only; plan also contains rename-without-marker"
2661
+
2662
+ ```
2663
+ auto-migrate: REFUSED — 2 blocked operation(s):
2664
+ - drop-column [users.legacy]: lossy
2665
+ fix: if intentional, add `legacy: dropped()` to the schema
2666
+ - rename-column [users.givenName from firstName]: rename without marker
2667
+ fix: declare `.renamedFrom('firstName')` on the new column
2668
+
2669
+ VOLTRO_DESTRUCTIVE_OK=1 was set but at least one blocked op is NOT lossy.
2670
+ The flag only relaxes lossy ops; other refuse cases (rename, NOT-NULL-no-backfill, multi-step) stay firm.
2671
+ ```
2672
+
2673
+ What happened: you reached for `VOLTRO_DESTRUCTIVE_OK=1` to bypass a refuse, but the plan has a non-lossy refuse too. The flag is intentionally narrow.
2674
+
2675
+ Fix: address the non-lossy refuse first (add the rename annotation in the example above). Then the flag relaxes the remaining lossy op.
2676
+
2677
+ ## "live introspection failed; cannot diff"
2678
+
2679
+ ```
2680
+ db plan: live introspection failed
2681
+ cause: Connection refused at localhost:5432
2682
+ ```
2683
+
2684
+ What happened: the framework can't reach the DB. The planner needs a live introspection to compute the diff.
2685
+
2686
+ Fix:
2687
+
2688
+ - Check the DB is running (`docker ps`, `systemctl status postgres`)
2689
+ - Check the connection URL: `echo $DB_URL` matches what the DB expects
2690
+ - Check credentials: `psql $DB_URL -c 'SELECT 1'` should succeed
2691
+ - If using cloud, check the inspectToken: `curl -H "Authorization: Bearer $TOKEN" "$APP_URL/_voltro/inspect/app"` should return JSON
2692
+
2693
+ ## "advisory lock held; refusing to wait"
2694
+
2695
+ ```
2696
+ db apply: refusing — advisory lock 8732891 is held by another process (pid 4892)
2697
+ This usually means another `voltro db apply` is running. Wait for it to finish or kill the holder.
2698
+ ```
2699
+
2700
+ Fix:
2701
+
2702
+ - If a real apply is running elsewhere, wait
2703
+ - If the holder is stuck (`pid 4892` died without releasing):
2704
+ - Postgres: `SELECT pg_advisory_unlock(8732891);` (run as the same user that acquired)
2705
+ - Or kill the postgres backend: `SELECT pg_terminate_backend(<pid>)`
2706
+ - For MySQL: `SELECT RELEASE_LOCK('voltro_migration')` from the same connection (different connection won't release)
2707
+
2708
+ The lock is per-database-cluster, not per-deploy. Two prod regions hitting the same DB cluster race; the second blocks until the first releases.
2709
+
2710
+ ## `voltro dev` boot hangs at "auto-migrate: planning schema" (0/1, no error)
2711
+
2712
+ The boot-time auto-migrate takes the **same** migration advisory lock as `db apply`. If a prior boot crashed while holding it (its DB connection still open) or a sibling pod holds it, the boot would otherwise wait on the lock — the pod sits at `auto-migrate: planning schema`, readiness never flips, and no error line prints.
2713
+
2714
+ The boot now **fails fast** instead of hanging: it polls the lock to a deadline (default **30s**) and then aborts with a clear message rather than blocking forever.
2715
+
2716
+ ```
2717
+ could not acquire the postgres migration advisory lock within 30s.
2718
+ Another migration is in progress, or a prior boot crashed while holding it.
2719
+ ```
2720
+
2721
+ Fix:
2722
+
2723
+ - A crashed process's **session-level** advisory lock is released the moment its DB connection closes — so a truly dead holder frees the lock on its own; just restart.
2724
+ - If a live-but-stuck backend holds it, find + terminate it: `SELECT pid, query FROM pg_stat_activity WHERE query LIKE '%advisory%'` → `SELECT pg_terminate_backend(<pid>)`.
2725
+ - Long, legitimate migrations on a big schema can outlast 30s — raise the ceiling with **`VOLTRO_MIGRATION_LOCK_TIMEOUT_MS`** (milliseconds).
2726
+
2727
+ Related: if the boot instead REFUSES with a `drop-table` blocker for a table you want to keep (a `_strapi_id_map`-style leftover), that's the ["table missing from declared schema"](#table-missing-from-declared-schema-drop-table-refused) case — `VOLTRO_DB_IGNORE_TABLES` unfreezes it.
2728
+
2729
+ ### Variant: it hangs even with the lock free (large / FK-dense schema)
2730
+
2731
+ Same symptom, different cause. If nothing else holds the lock and the boot **still** sits at `auto-migrate: planning schema`, the **schema introspection** is the bottleneck — the step that reads the live database shape before diffing. It only runs on a real diff (a no-diff boot skips it via the schema fingerprint), which is why adding a single column can trigger it while an unchanged restart boots fine.
2732
+
2733
+ The cause is almost always a **large, foreign-key-dense schema** (hundreds of tables, thousands of FKs). Introspection reads foreign keys and primary keys directly from `pg_catalog` (index-backed, filter pushed down) rather than the `information_schema` constraint views — those can't push the per-batch table filter down, so each batch re-scans the whole catalog. On a 500-table / 2600-FK schema that is the difference between **> 2 minutes (hangs)** and **well under a second**.
2734
+
2735
+ If introspection ever degenerates again it **fails fast** instead of hanging: every introspection statement runs under a `statement_timeout` (default **30s**), so a runaway query aborts with an actionable error rather than freezing the pod at 0/1.
2736
+
2737
+ ```text
2738
+ schema introspection exceeded VOLTRO_INTROSPECT_TIMEOUT_MS (30000ms) — the schema is
2739
+ very large / FK-dense or the database is slow.
2740
+ ```
2741
+
2742
+ Fix:
2743
+
2744
+ - Raise the ceiling for a legitimately huge schema with **`VOLTRO_INTROSPECT_TIMEOUT_MS`** (milliseconds; `0` disables it entirely).
2745
+ - Prefer a **direct (non-pooler) connection** for migrations via **`DB_DIRECT_URL`** — so a large introspection response isn't mis-framed by a transaction-mode pooler.
2746
+ - **`VOLTRO_DB_IGNORE_TABLES` does not help here** — it filters the *diff*, which runs **after** introspection; the introspection cost is independent of it.
2747
+
2748
+ ## A MySQL/MariaDB apply failed midway
2749
+
2750
+ `voltro db apply` on **postgres / mssql** is ATOMIC: every op runs in ONE
2751
+ transaction, so a failure on op N rolls the WHOLE plan back — nothing is
2752
+ committed, no half-applied schema. (`online-required` `CREATE INDEX
2753
+ CONCURRENTLY` ops run after the commit — they can't be in a transaction —
2754
+ so a failure THERE can leave the index half-built; re-apply finishes it.)
2755
+
2756
+ > **Run migrations through a SESSION connection, not a transaction-mode
2757
+ > pooler.** Because the whole plan is one transaction, a large apply (many
2758
+ > ops + big backfill `UPDATE`s + index builds) is ONE long-lived
2759
+ > transaction. A transaction-mode pooler (Supabase Supavisor on `:6543`,
2760
+ > PgBouncer in `transaction` mode) can't hold a multi-statement transaction
2761
+ > reliably and will abort it — surfacing as an opaque `Failed to execute
2762
+ > statement (at sql.transaction)`. Point `DB_URL` at the **direct / session
2763
+ > connection** (`:5432`, or a session-mode pooler) for `db apply`; raise
2764
+ > `statement_timeout` for that session if a single index build is slow. This
2765
+ > is the same constraint every migration tool has (Prisma/Drizzle/etc.) —
2766
+ > the transaction pooler is for app traffic, the direct connection is for
2767
+ > migrations. The failing statement itself is now logged with its SQL +
2768
+ > `db.code` (e.g. `57014` statement timeout) so you can see which op stalled.
2769
+
2770
+ **MySQL and MariaDB** (and sqlite / turso) implicit-commit every DDL
2771
+ statement, so THERE a plan that fails on op N leaves ops 1..N-1 committed.
2772
+ There is no `--resume` / `--abort` flag and no per-op partial-status
2773
+ tracking — the apply records a row only on full success.
2774
+
2775
+ Recovery is just to re-run the apply: `voltro db apply` re-diffs the
2776
+ declared schema against the current (half-applied) live shape and emits
2777
+ only the ops that are still missing. Fix the cause of the failed op
2778
+ first (e.g. the `ER_DUP_KEYNAME` that stopped op N), then:
2779
+
2780
+ ```sh
2781
+ voltro db apply --note 'completing partial apply after fixing op N'
2782
+ ```
2783
+
2784
+ If a deploy reverted the code that referenced the half-applied schema,
2785
+ the re-diff naturally reflects the new declared shape — no separate
2786
+ abort step is needed; the next `voltro db plan` already shows the
2787
+ correct remaining work. See [multi-dialect](./multi-dialect.md).
2788
+
2789
+ ## `relation "..._uq" already exists` (42P07) on re-apply
2790
+
2791
+ A composite (multi-column) `.unique([a, b])` constraint that ALREADY
2792
+ exists in the DB is now introspected (postgres reads it back from
2793
+ `pg_constraint`), so `db apply` matches it against the declared schema
2794
+ and emits nothing. On older builds it wasn't read back, so the planner
2795
+ re-emitted `ADD CONSTRAINT … UNIQUE` for the existing one → `42P07
2796
+ relation "<name>_uq" already exists`, and the plan never reached "up to
2797
+ date". If you see this, update the framework. (Single-column `.unique()`
2798
+ was never affected — it round-trips via the column's `unique` flag.)
2799
+
2800
+ ## `db plan` keeps showing `CREATE INDEX` for indexes that already exist
2801
+
2802
+ If `db plan` always lists `add-index` for `expressionIndex(...)` /
2803
+ `jsonIndex(...)` indexes that demonstrably exist in the DB — and you
2804
+ never see a matching `drop-index` — that's an introspection gap (now
2805
+ fixed). On postgres an expression key carries a `0` in `pg_index.indkey`
2806
+ (it has no backing column), and the old introspect query INNER-joined
2807
+ `pg_attribute` on the column → the whole index disappeared from the live
2808
+ snapshot. The declared index then had nothing to match → re-emitted every
2809
+ run, but never converged to "up to date". (`CREATE INDEX … IF NOT EXISTS`
2810
+ made each re-emit a silent no-op, so it wasn't data-destructive — just a
2811
+ plan that never went empty.) The fix introspects expression indexes (with
2812
+ a NULL column + an `expression` flag) and matches them by NAME +
2813
+ uniqueness, since the DB normalises the expression text
2814
+ (`(lower("email"))` → `lower(email)`) and it can't round-trip
2815
+ byte-for-byte. Plain-column indexes were never affected. If you see this,
2816
+ update the framework.
2817
+
2818
+ **Second cause — the same table name in two schemas.** If the phantom
2819
+ `add-index` is for PLAIN-column indexes (often camelCase like
2820
+ `"<table>_tenantId_idx"`) and your DB has the SAME table names in more than
2821
+ one schema — classically a `public` legacy/migration copy alongside the
2822
+ app's own schema (e.g. `voltro`) — that was a separate introspection bug
2823
+ (now fixed). The table-list query joined `pg_class` by NAME, so a name
2824
+ present in both schemas fanned out to two rows → the table was listed twice
2825
+ → its columns and index-columns were accumulated twice in the BUILT snapshot
2826
+ (`["tenantId"]` became `["tenantId","tenantId"]`) → the planner diffed
2827
+ `["tenantId"] != ["tenantId","tenantId"]` and re-emitted forever. The raw
2828
+ `pg_*` catalog looks correct (the duplication is in introspect's built
2829
+ output, not the SQL) — to confirm it's THIS, call the inspect endpoint and
2830
+ look for doubled columns: `curl "$API/_voltro/inspect/migrations" | jq
2831
+ '.drift.liveSnapshot.tables[] | select(.name=="<table>") | .indexes'`. The
2832
+ fix scopes the table list to `current_schema()` by OID (+ defensive dedup),
2833
+ so each table is read once. Point `DB_SCHEMA` / the connection's
2834
+ `search_path` at your app schema and update the framework.
2835
+
2836
+ ## `duplicate index name '<name>' across tables '<a>' and '<b>'`
2837
+
2838
+ Index names are unique **per schema**, not per table, in every dialect. If
2839
+ you gave the SAME explicit name to indexes on two different tables
2840
+ (`.index('byStatusStart', …)` on both `ab_tests` and `tournaments`), boot /
2841
+ `db plan` now fails loud with this error instead of silently creating only
2842
+ one and re-emitting the rest forever. Fix: rename the collisions to
2843
+ distinct, table-scoped names (`abTestsByStatusStart`,
2844
+ `tournamentsByStatusStart`). Auto-named indexes (`.index([col])` →
2845
+ `<table>_<col>_idx`) are table-prefixed and never collide — only hand-picked
2846
+ names can. (If you're updating from an older build that let these through,
2847
+ expect this error on first boot for every pre-existing collision — rename
2848
+ each one it names.)
2849
+
2850
+ ## `auto-named index '<table>_<col>_idx' … exceeds the 63-byte … limit`
2851
+
2852
+ The framework derives an FK auto-index name from the table + column name
2853
+ (`<table>_<col>_idx`). On a long junction table that can exceed 63 bytes —
2854
+ and the DB **silently truncates** index names (postgres → 63 bytes, dropping
2855
+ the `_idx` suffix), so the declared name (`…_idx`) never matches the live
2856
+ (truncated) one and `db plan` re-emits it forever. The explicit-index path
2857
+ was always length-validated; this closes the gap for the **auto** path —
2858
+ it now hard-fails at boot (same policy as every other identifier: no silent
2859
+ truncation). Two fixes, your choice:
2860
+
2861
+ - Add an explicit short name for that FK column — `.index('<short>',
2862
+ ['<col>'])` — which replaces the auto-index, OR
2863
+ - Shorten the table / column name.
2864
+
2865
+ (Updating from an older build that truncated these? Expect the error on
2866
+ first boot for each one — apply one of the two fixes per index it names.)
2867
+
2868
+ ## `db plan` re-emits `alter-column-default` for a `json().default({…})` column
2869
+
2870
+ A `json()` column with an OBJECT default (`json<T>().default({ a: 1 })`) had
2871
+ two problems on postgres (both now fixed): (1) the default was **silently
2872
+ dropped** — the DDL emitter only handled scalar defaults, so the column got
2873
+ no default at all (an omitted field inserted `NULL`, not the object); and
2874
+ (2) even once present, the comparison didn't match — postgres stores a jsonb
2875
+ default in canonical text (`'{"a": 1}'::jsonb`: spaces after `:`/`,` and keys
2876
+ reordered by length/bytes), which never equals the declared JS object's
2877
+ `JSON.stringify`, so `db plan` re-emitted `alter-column-default` every run.
2878
+ The fix emits object defaults as `'<json>'::jsonb` AND canonicalises both
2879
+ sides (key-sorted, space-free) before comparing. Update the framework.
2880
+
2881
+ Cross-dialect: object literal defaults are now emitted to DDL on **every**
2882
+ dialect, in each one's json idiom — postgres `'<json>'::jsonb`, mysql/mariadb
2883
+ `(CAST('<json>' AS JSON))` (a literal default is rejected on a JSON column),
2884
+ mssql/sqlite a `'<json>'` string literal — and the comparison canonicalises
2885
+ each engine's introspected form (postgres reorders + spaces; mysql wraps in
2886
+ `cast(…)`; mssql wraps in `('…')`). Arrays are unaffected (an array default
2887
+ stays as-is — its `json[]` vs native `array()` column is ambiguous). If you
2888
+ need a per-insert dynamic value instead of a fixed literal, use a factory
2889
+ `.default(() => ({ … }))` (the store applies it at insert).
2890
+
2891
+ ## MySQL `db plan` / `db apply` crashes: `Cannot read properties of undefined (reading 'toLowerCase')`
2892
+
2893
+ MySQL 8 returns `information_schema` result columns in UPPERCASE
2894
+ (`DATA_TYPE`, `COLUMN_NAME`, …) where MariaDB returns lowercase. The
2895
+ introspector read the lowercase fields, so on MySQL the type mapper got an
2896
+ `undefined` data type and the whole introspect (every `db plan` / `db apply`)
2897
+ crashed. Fixed — the introspector now lowercases each `information_schema`
2898
+ row's keys (no-op on MariaDB). If you hit this on MySQL, update the framework.
2899
+ (MariaDB was never affected, which is why it went unnoticed — the
2900
+ introspect tests run on MariaDB.)
2901
+
2902
+ ## "no schema files found"
2903
+
2904
+ ```
2905
+ db plan: no schema files found
2906
+ hint: looked for *.entity.ts / *.schema.ts / schema.ts
2907
+ root: /home/me/myproject/apps/api
2908
+ ```
2909
+
2910
+ What happened: the discovery walker didn't find any schema files under the project root.
2911
+
2912
+ Fix:
2913
+
2914
+ - Check you're running the command from the right directory (`pwd`)
2915
+ - Check your entity files match the convention (`apps/api/database/*.entity.ts`)
2916
+ - Run from the project dir, or pass the path as a POSITIONAL arg
2917
+ (`voltro db plan ./apps/api`) — there is no `--root` flag; the CLI
2918
+ resolves the root from the first non-flag argument, defaulting to
2919
+ the current working directory
2920
+
2921
+ ## When the fix hint doesn't match reality
2922
+
2923
+ The fix hints come from the planner's classification logic — they should always be actionable. If you see one that doesn't make sense given your code:
2924
+
2925
+ 1. Check git: were there uncommitted schema changes you forgot about? `git status`
2926
+ 2. Check the introspect output: `curl <app-url>/_voltro/inspect/migrations | jq '.pending'` → see the raw plan
2927
+ 3. File an issue with the schema + the inspect JSON + the message
2928
+
2929
+ Hint mismatches are bugs in the planner's classification — they're rare but always worth reporting because they're typically reproducible.
2930
+
2931
+ ## Where to learn more
2932
+
2933
+ - [Operation classes](./operation-classes.md) — the seven classes + per-class examples
2934
+ - [Backfill](./backfill.md) — SQL vs JS + the dry-run pattern
2935
+ - [Rename and drop](./rename-and-drop.md) — the `.renamedFrom()` + `dropped()` lifecycle
2936
+ - [Drift](./drift.md) — when the live DB diverged
2937
+ - [Multi-dialect strategy](./multi-dialect.md) — why MySQL + forward-roll
2938
+ - [Prod pipeline](./prod-pipeline.md) — the deploy-step apply pattern