@voltro/cli 0.33.0 → 0.35.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 (343) hide show
  1. package/CHANGELOG.md +1968 -0
  2. package/bin/nodeEnvironment.d.mts +30 -0
  3. package/bin/nodeEnvironment.mjs +158 -0
  4. package/bin/voltro.mjs +69 -5
  5. package/dist/addCommand-BNeoeSxe.js +124 -0
  6. package/dist/addCommand-aXSQveak.js +2 -0
  7. package/dist/agentsMd-BTchIZku.js +2 -0
  8. package/dist/agentsMd-mhQMF1bx.js +254 -0
  9. package/dist/apiBuild-B8aoJvuw.js +2 -0
  10. package/dist/{apiBuild-h9VHtnlw.js → apiBuild-DYD_ONLD.js} +46 -46
  11. package/dist/{appGraph-gQ_6GkQQ.js → appGraph-KGDPTuTy.js} +1 -1
  12. package/dist/appGraph-zuMGKVYX.js +2 -0
  13. package/dist/appPort-B_HpJ_ck.js +48 -0
  14. package/dist/baselineCommand-C2ClWZN3.js +2 -0
  15. package/dist/baselineCommand-DIttzO8A.js +227 -0
  16. package/dist/bin.js +71 -28
  17. package/dist/build-CD8K4XOr.js +711 -0
  18. package/dist/cacheCommand-DA4OH9xt.js +42 -0
  19. package/dist/capabilitiesCommand-nq_pz5xd.js +123 -0
  20. package/dist/checkCommand-Ct9xkTrS.js +232 -0
  21. package/dist/checkCommand-DKpDLlqu.js +2 -0
  22. package/dist/{cliArgs-qdZSElM3.js → cliArgs-D4p8n7EE.js} +12 -1
  23. package/dist/cliError-BmdYnghb.js +10 -0
  24. package/dist/cliOutput-D1tSBoRM.js +15 -0
  25. package/dist/{cliRuntime-Oh517vCV.js → cliRuntime-Dh7UDinH.js} +20 -20
  26. package/dist/cloudClient-DWL-Hw_T.js +67 -0
  27. package/dist/cloudCmd-Cvv5HGaZ.js +364 -0
  28. package/dist/clusterCmd-D5wsCmA_.js +54 -0
  29. package/dist/codegen-CYM3Zqrf.js +605 -0
  30. package/dist/codegen-ChBi_hVa.js +2 -0
  31. package/dist/codegenCommand-C4YoQIc2.js +30 -0
  32. package/dist/codemodRunner-BnFq3Fgu.js +5384 -0
  33. package/dist/commandRunner-BLAEFLjp.js +47 -0
  34. package/dist/commands-BE8E7zF3.js +816 -0
  35. package/dist/connectionConfig-UFlIEiys.js +66 -0
  36. package/dist/dashboardCommand-D7SgZGaN.js +25 -0
  37. package/dist/dataCommand-BhYwDgg-.js +537 -0
  38. package/dist/dataProfile-dW-PsfLB.js +15 -0
  39. package/dist/dbCommand-DS4b97Is.js +2 -0
  40. package/dist/{dbCommand-DTLKAfbA.js → dbCommand-O8HA63s2.js} +552 -402
  41. package/dist/{dev-C_P8FLSx.js → dev-C7sFZq3m.js} +3670 -3169
  42. package/dist/dev-D2BikO7a.js +3 -0
  43. package/dist/devActivity-Dx_3nnGv.js +100 -0
  44. package/dist/devActivity.js +1 -1
  45. package/dist/dialectDriver-CgXnDfec.js +39 -0
  46. package/dist/discover-C9XKJDco.js +25 -0
  47. package/dist/doctorCommand-CM4Ch9C7.js +2 -0
  48. package/dist/{checkCommand-xGhRFFg2.js → doctorCommand-DnimF5IM.js} +523 -1278
  49. package/dist/dormancyCommand-QewYug_s.js +69 -0
  50. package/dist/e2eCmd-BRabZww-.js +147 -0
  51. package/dist/embeddingsCommand-BfiLS_QI.js +73 -0
  52. package/dist/envCommand-CCGPRQY1.js +60 -0
  53. package/dist/evalCommand-6RUfPen4.js +118 -0
  54. package/dist/evolveCommand-CHsLCtDf.js +281 -0
  55. package/dist/fileTaxonomy-CJfgOllU.js +457 -0
  56. package/dist/frameworkTableAssembly-YyVe32Cb.js +2 -0
  57. package/dist/frameworkTableAssembly-oMPBKqlE.js +511 -0
  58. package/dist/generateCommand-DbgcUpGw.js +147 -0
  59. package/dist/index.d.ts +45 -0
  60. package/dist/index.js +4 -3
  61. package/dist/infoCommand-DwOgK1t6.js +60 -0
  62. package/dist/{inspect-BUUjt773.js → inspect-CjTYzAs_.js} +113 -41
  63. package/dist/inspect-P4pxoMaV.js +2 -0
  64. package/dist/inspectCmd-EHFZ9yYu.js +224 -0
  65. package/dist/inspectFetch-EMuhTG_9.js +151 -0
  66. package/dist/inspectMetrics-CfdKLh6t.js +72 -0
  67. package/dist/loadEnv-D9nEOClM.js +44 -0
  68. package/dist/logFileSink-C_D2wRN1.js +105 -0
  69. package/dist/logsCmd-D36xK7Zu.js +260 -0
  70. package/dist/manifestBuild-COkJoyAr.js +2 -0
  71. package/dist/{manifestBuild-BLrVuSlM.js → manifestBuild-hpPLaGxV.js} +1 -1
  72. package/dist/metaCommands-7MJfZ5cf.js +196 -0
  73. package/dist/migrate-BV7I-ZHZ.js +83 -0
  74. package/dist/mssqlClusterPatch-_4cE_nun.js +44 -0
  75. package/dist/newCommand-COWOJ1_E.js +156 -0
  76. package/dist/nodeEnvironment-cGFAj1J8.js +28 -0
  77. package/dist/packageCommand-Cug_3Ogl.js +271 -0
  78. package/dist/pageConvention-cEiRxdab.js +5 -0
  79. package/dist/privacyCommand-C-Df56U_.js +146 -0
  80. package/dist/probeCommand-CZfaaUOZ.js +122 -0
  81. package/dist/projectScaffold-DmzEKHib.js +2 -0
  82. package/dist/projectScaffold-LMMtaavR.js +814 -0
  83. package/dist/renderModeScan-D7J1B7Kw.js +105 -0
  84. package/dist/renderProfile-1OWWAAtx.js +81 -0
  85. package/dist/runtimeRegistry-DMeKfTHP.js +81 -0
  86. package/dist/runtimeTrace-CH3eUiMw.js +91 -0
  87. package/dist/scheduleCmd-DQRu6BZC.js +149 -0
  88. package/dist/scheduleManifestCmd-D2x0CTTY.js +249 -0
  89. package/dist/schemaIr-UJybUUZW.js +103 -0
  90. package/dist/{sdkgen-C81QIkiL.js → sdkgen-BLkvGRfX.js} +111 -209
  91. package/dist/seedRunner-ZmLSqNe2.js +333 -0
  92. package/dist/serveCommand-CbDHU6l-.js +2 -0
  93. package/dist/serveCommand-iwlUBNS1.js +1766 -0
  94. package/dist/serveEntry.js +5 -5
  95. package/dist/serverlessCommand-CfJZy6dS.js +482 -0
  96. package/dist/start-BgN62boB.js +3 -0
  97. package/dist/start-T4VesWiM.js +1087 -0
  98. package/dist/startEntry.js +2 -2
  99. package/dist/staticCommand-Dr2M6tpU.js +304 -0
  100. package/dist/storageCommand-Co6NfLqN.js +42 -0
  101. package/dist/templates-De8IR5-c.js +102 -0
  102. package/dist/test-CI6iDsYc.js +115 -0
  103. package/dist/tracesCmd-DStmCJPi.js +232 -0
  104. package/dist/tsconfigPaths-BWXBWgcl.js +107 -0
  105. package/dist/tsxLoader-EuXmSJ1K.js +51 -0
  106. package/dist/typecheckCommand-BlsWiCNq.js +61 -0
  107. package/dist/updateCommand-BlMZhWgO.js +2 -0
  108. package/dist/updateCommand-x0pI_x-B.js +585 -0
  109. package/dist/webDev-BcykISYQ2.js +2 -0
  110. package/dist/{inspectMetrics-1xzTKAFx.js → webDev-Dybxew86.js} +988 -1560
  111. package/dist/webhookDiscovery-CrGAfhIG.js +2 -0
  112. package/dist/webhookDiscovery-D7VaeMlz.js +51 -0
  113. package/dist/webhooksCommand-DlAgS2Iw.js +267 -0
  114. package/dist/workflowsCmd-BGF-mRZ5.js +608 -0
  115. package/package.json +209 -17
  116. package/templates/AGENTS.core.md +58 -3
  117. package/templates/AGENTS.md +64 -7
  118. package/templates/agent-docs/_index.md +6 -4
  119. package/templates/agent-docs/_manifest.json +23 -6
  120. package/templates/agent-docs/ai.md +370 -0
  121. package/templates/agent-docs/authentication.md +313 -31
  122. package/templates/agent-docs/caching.md +6 -0
  123. package/templates/agent-docs/cli.md +853 -50
  124. package/templates/agent-docs/data.md +608 -12
  125. package/templates/agent-docs/database/migrations.md +223 -25
  126. package/templates/agent-docs/database/misc.md +156 -40
  127. package/templates/agent-docs/database/querying.md +19 -1
  128. package/templates/agent-docs/database/scaling.md +60 -0
  129. package/templates/agent-docs/database/schema.md +1 -0
  130. package/templates/agent-docs/database/seedsdialects.md +208 -19
  131. package/templates/agent-docs/database/transactions.md +68 -0
  132. package/templates/agent-docs/deployment.md +284 -25
  133. package/templates/agent-docs/introduction.md +88 -17
  134. package/templates/agent-docs/local-first-mobile.md +79 -4
  135. package/templates/agent-docs/multi-tenancy.md +188 -42
  136. package/templates/agent-docs/observability.md +58 -3
  137. package/templates/agent-docs/plugins/ai-flows.md +247 -2
  138. package/templates/agent-docs/plugins/analytics-postgres.md +1 -1
  139. package/templates/agent-docs/plugins/audit.md +37 -1
  140. package/templates/agent-docs/plugins/auth-social.md +143 -0
  141. package/templates/agent-docs/plugins/auth-workos.md +4 -2
  142. package/templates/agent-docs/plugins/auth.md +131 -6
  143. package/templates/agent-docs/plugins/billing.md +132 -15
  144. package/templates/agent-docs/plugins/cdc-out.md +46 -7
  145. package/templates/agent-docs/plugins/clickhouse.md +32 -1
  146. package/templates/agent-docs/plugins/duckdb.md +1 -1
  147. package/templates/agent-docs/plugins/flags.md +132 -0
  148. package/templates/agent-docs/plugins/governance.md +105 -7
  149. package/templates/agent-docs/plugins/multitenancy.md +9 -4
  150. package/templates/agent-docs/plugins/presence.md +13 -2
  151. package/templates/agent-docs/plugins/ratelimit.md +9 -0
  152. package/templates/agent-docs/plugins/search.md +162 -8
  153. package/templates/agent-docs/plugins/sso-saml.md +47 -8
  154. package/templates/agent-docs/plugins/storage.md +11 -0
  155. package/templates/agent-docs/plugins/webhooks.md +105 -0
  156. package/templates/agent-docs/plugins.md +152 -18
  157. package/templates/agent-docs/reference.md +60 -3
  158. package/templates/agent-docs/releases.md +1117 -0
  159. package/templates/agent-docs/routing.md +43 -25
  160. package/templates/agent-docs/scheduling.md +27 -0
  161. package/templates/agent-docs/schema-driven-ui.md +92 -12
  162. package/templates/agent-docs/security.md +426 -0
  163. package/templates/agent-docs/templates/apibackends.md +87 -18
  164. package/templates/agent-docs/templates/appshells.md +32 -14
  165. package/templates/agent-docs/templates/overview.md +13 -8
  166. package/templates/agent-docs/testing.md +211 -14
  167. package/templates/agent-docs/whats-new.md +98 -136
  168. package/templates/agent-docs/workflows.md +231 -14
  169. package/templates/apps/api-ai/actions/summarize.action.ts +11 -0
  170. package/templates/apps/api-ai/package.json +8 -7
  171. package/templates/apps/api-auth/actions/me.action.ts +13 -0
  172. package/templates/apps/api-auth/package.json +9 -8
  173. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +9 -0
  174. package/templates/apps/api-backend/package.json +12 -8
  175. package/templates/apps/api-backend/queries/notes.query.ts +30 -8
  176. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +14 -0
  177. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +8 -0
  178. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +10 -0
  179. package/templates/apps/api-backend-deactivation/package.json +8 -7
  180. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +17 -0
  181. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +9 -0
  182. package/templates/apps/api-backend-mail/package.json +9 -8
  183. package/templates/apps/api-backend-mail/queries/notes.query.ts +30 -8
  184. package/templates/apps/api-backend-mariadb/.env.example +14 -0
  185. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +9 -0
  186. package/templates/apps/api-backend-mariadb/package.json +10 -9
  187. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +30 -8
  188. package/templates/apps/api-backend-sqlite/.env.example +14 -0
  189. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +9 -0
  190. package/templates/apps/api-backend-sqlite/package.json +9 -8
  191. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +30 -8
  192. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +13 -0
  193. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +12 -0
  194. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +9 -0
  195. package/templates/apps/api-backend-storage/package.json +9 -8
  196. package/templates/apps/api-backend-storage/queries/notes.query.ts +30 -8
  197. package/templates/apps/api-cms/actions/content.get.action.ts +7 -0
  198. package/templates/apps/api-cms/actions/content.types.action.ts +6 -0
  199. package/templates/apps/api-cms/actions/me.action.ts +13 -0
  200. package/templates/apps/api-cms/app.config.ts +19 -0
  201. package/templates/apps/api-cms/authz.ts +63 -0
  202. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +10 -0
  203. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +10 -0
  204. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +5 -0
  205. package/templates/apps/api-cms/package.json +11 -10
  206. package/templates/apps/api-cms/queries/content.list.query.ts +23 -7
  207. package/templates/apps/api-cms/tests/accessDecisions.test.ts +121 -0
  208. package/templates/apps/api-cms/tests/content.descriptors.test.ts +8 -4
  209. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +8 -0
  210. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +13 -0
  211. package/templates/apps/api-collab/package.json +9 -8
  212. package/templates/apps/api-collab/queries/documents.query.ts +19 -7
  213. package/templates/apps/api-data-advanced/package.json +9 -8
  214. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +12 -0
  215. package/templates/apps/api-data-advanced/queries/books.search.query.ts +9 -0
  216. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +17 -0
  217. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +9 -0
  218. package/templates/apps/api-durable/package.json +9 -8
  219. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +16 -0
  220. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +12 -0
  221. package/templates/apps/api-feature-flags/package.json +10 -9
  222. package/templates/apps/api-governance/README.md +30 -8
  223. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -1
  224. package/templates/apps/api-governance/actions/profiles.get.action.ts +29 -4
  225. package/templates/apps/api-governance/database/schema.ts +16 -4
  226. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +12 -0
  227. package/templates/apps/api-governance/package.json +9 -8
  228. package/templates/apps/api-kv/actions/sync.pull.action.ts +15 -0
  229. package/templates/apps/api-kv/actions/sync.reset.action.ts +13 -0
  230. package/templates/apps/api-kv/actions/sync.status.action.ts +7 -0
  231. package/templates/apps/api-kv/package.json +9 -8
  232. package/templates/apps/api-kv/queries/events.list.query.ts +19 -8
  233. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +11 -0
  234. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +13 -0
  235. package/templates/apps/api-moderation/package.json +9 -8
  236. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +8 -0
  237. package/templates/apps/api-observability/package.json +9 -8
  238. package/templates/apps/api-observability/queries/notes.list.query.ts +13 -0
  239. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +14 -0
  240. package/templates/apps/api-ratelimit/package.json +9 -8
  241. package/templates/apps/api-rbac/package.json +9 -8
  242. package/templates/apps/api-rest/package.json +8 -7
  243. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +13 -0
  244. package/templates/apps/api-saas/package.json +12 -11
  245. package/templates/apps/api-saas/queries/projects.list.query.ts +11 -0
  246. package/templates/apps/api-saas-starter/actions/me.action.ts +13 -0
  247. package/templates/apps/api-saas-starter/app.config.ts +19 -0
  248. package/templates/apps/api-saas-starter/authz.ts +75 -0
  249. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +10 -0
  250. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +9 -0
  251. package/templates/apps/api-saas-starter/package.json +15 -11
  252. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +19 -8
  253. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -7
  254. package/templates/apps/api-saas-starter/tests/accessDecisions.test.ts +135 -0
  255. package/templates/apps/api-search/mutations/articles.create.mutation.ts +14 -0
  256. package/templates/apps/api-search/package.json +9 -8
  257. package/templates/apps/api-search/queries/articles.list.query.ts +21 -8
  258. package/templates/apps/api-status/README.md +10 -3
  259. package/templates/apps/api-status/app.config.ts +8 -3
  260. package/templates/apps/api-status/authz.ts +5 -3
  261. package/templates/apps/api-status/package.json +9 -8
  262. package/templates/apps/api-status/queries/components.list.query.ts +14 -6
  263. package/templates/apps/api-status/queries/incidents.live.query.ts +23 -10
  264. package/templates/apps/api-status/queries/updates.list.query.ts +16 -9
  265. package/templates/apps/api-status/tests/status.test.ts +9 -1
  266. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +12 -0
  267. package/templates/apps/api-versioning/actions/documents.history.action.ts +13 -0
  268. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +9 -0
  269. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +12 -0
  270. package/templates/apps/api-versioning/package.json +9 -8
  271. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +16 -0
  272. package/templates/apps/api-webhooks/package.json +10 -9
  273. package/templates/apps/api-webhooks/queries/orders.list.query.ts +10 -0
  274. package/templates/apps/changelog/package.json +8 -6
  275. package/templates/apps/edge-functions/package.json +2 -2
  276. package/templates/apps/frontend-admin/package.json +10 -8
  277. package/templates/apps/frontend-admin/src/lib/admin.ts +20 -9
  278. package/templates/apps/frontend-admin/src/locales/de.ts +11 -1
  279. package/templates/apps/frontend-admin/src/locales/en.ts +13 -1
  280. package/templates/apps/frontend-admin/src/pages/admin/[entity]/page.tsx +65 -22
  281. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +130 -23
  282. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +3 -2
  283. package/templates/apps/frontend-admin/src/pages/admin/page.test.tsx +19 -2
  284. package/templates/apps/frontend-admin/src/pages/admin/page.tsx +9 -4
  285. package/templates/apps/frontend-app/app.config.ts +4 -3
  286. package/templates/apps/frontend-app/package.json +11 -8
  287. package/templates/apps/frontend-app/src/lib/api.ts +25 -0
  288. package/templates/apps/frontend-app/src/pages/page.test.tsx +130 -82
  289. package/templates/apps/frontend-app/src/pages/page.tsx +14 -18
  290. package/templates/apps/frontend-auth/package.json +10 -8
  291. package/templates/apps/frontend-blank/package.json +9 -7
  292. package/templates/apps/frontend-cms/package.json +11 -9
  293. package/templates/apps/frontend-collab/package.json +12 -9
  294. package/templates/apps/frontend-collab/src/pages/page.test.tsx +122 -78
  295. package/templates/apps/frontend-contact/package.json +9 -7
  296. package/templates/apps/frontend-dashboard/package.json +9 -7
  297. package/templates/apps/frontend-docs/package.json +9 -7
  298. package/templates/apps/frontend-i18n/package.json +8 -6
  299. package/templates/apps/frontend-landing/package.json +9 -7
  300. package/templates/apps/frontend-portal/package.json +10 -8
  301. package/templates/apps/frontend-saas/app.config.ts +10 -6
  302. package/templates/apps/frontend-saas/package.json +10 -8
  303. package/templates/apps/frontend-saas/src/lib/api.ts +27 -32
  304. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +7 -8
  305. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +27 -3
  306. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +4 -4
  307. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +3 -4
  308. package/templates/apps/frontend-spa/package.json +9 -7
  309. package/templates/apps/frontend-ssr/package.json +9 -7
  310. package/templates/apps/frontend-ssr-api/package.json +10 -8
  311. package/templates/apps/frontend-static-blog/package.json +8 -6
  312. package/templates/apps/frontend-status/package.json +10 -8
  313. package/templates/apps/mobile-app/README.md +1 -0
  314. package/templates/apps/mobile-app/package.json +4 -2
  315. package/templates/apps/mobile-app/src/app/index.tsx +22 -12
  316. package/templates/apps/mobile-app/src/app/orders/[id].tsx +1 -1
  317. package/templates/apps/mobile-app/src/lib/api.ts +34 -0
  318. package/templates/apps/mobile-app/voltro.mobile.ts +4 -2
  319. package/templates/baselines/bare/.env.example +14 -0
  320. package/templates/baselines/bare/baseline.json +4 -4
  321. package/templates/baselines/compose/.env.example +14 -0
  322. package/templates/baselines/compose/README.md +1 -1
  323. package/templates/baselines/compose/baseline.json +5 -5
  324. package/templates/baselines/compose-mariadb/.env.example +14 -0
  325. package/templates/baselines/compose-mariadb/README.md +1 -1
  326. package/templates/baselines/compose-mariadb/baseline.json +5 -5
  327. package/templates/baselines/helm/.env.example +14 -0
  328. package/templates/baselines/helm/baseline.json +4 -4
  329. package/dist/apiBuild-C-x9YacA.js +0 -2
  330. package/dist/appGraph-CvQCte0z.js +0 -2
  331. package/dist/checkCommand-DRovTKza.js +0 -2
  332. package/dist/commands-CJfepbm4.js +0 -11541
  333. package/dist/dbCommand-b1gum4td.js +0 -2
  334. package/dist/dev-iiMtlkfs.js +0 -3
  335. package/dist/devActivity-BhIu6ncs.js +0 -159
  336. package/dist/frameworkTableAssembly-BwIrO5nv.js +0 -638
  337. package/dist/frameworkTableAssembly-D-EebUQX.js +0 -2
  338. package/dist/inspect-mmBuRXmy.js +0 -2
  339. package/dist/manifestBuild-Dj8Jjoto.js +0 -2
  340. package/dist/seedRunner-Bqxgp7HZ.js +0 -230
  341. package/dist/serveCommand-DdaM4Hup.js +0 -1608
  342. package/dist/start-C0koT0UO.js +0 -1084
  343. /package/templates/apps/api-ai/actions/{summarize.action.server.tsx → summarize.action.server.ts} +0 -0
@@ -5,7 +5,13 @@
5
5
  // renders one of three observable states: a loading skeleton, an "unknown
6
6
  // entity" banner, or the bound CRUD surface (heading + AutoForm + DataTable).
7
7
  // We mock @voltro/web (useParams + the AutoForm/DataTable components) and
8
- // @voltro/client (the discovery + gating hooks).
8
+ // @voltro/client (the discovery + access hooks).
9
+ //
10
+ // The access cases below are the point of this file. The page must gate on the
11
+ // DECLARED access the manifest carries, never on a scope string it invented,
12
+ // and it must SHOW an `unknown` decision instead of hiding it — a per-row guard
13
+ // is the server's to answer, and hiding it empties the admin for every
14
+ // multi-tenant app whose subjects hold no global scopes.
9
15
 
10
16
  import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
11
17
  import { act, createElement, type ReactNode } from 'react'
@@ -19,20 +25,25 @@ vi.mock('@voltro/web', () => ({
19
25
  useParams: () => params(),
20
26
  AutoForm: ({ mutation }: { mutation: string }) =>
21
27
  createElement('div', { 'data-testid': 'auto-form' }, mutation),
22
- DataTable: ({ query }: { query: string }) =>
23
- createElement('div', { 'data-testid': 'data-table' }, query),
28
+ DataTable: ({ query, rowActions }: { query: string; rowActions?: (r: Record<string, unknown>) => ReactNode }) =>
29
+ createElement('div', { 'data-testid': 'data-table' }, query, rowActions?.({ id: 'r1', entryNo: 'e1' })),
24
30
  }))
25
31
 
26
32
  interface ManifestState { manifest: unknown; loading: boolean }
27
33
  const capabilityManifest = vi.fn<() => ManifestState>()
28
34
  const entityAdmins = vi.fn<() => Array<Record<string, unknown>>>()
29
- const can = vi.fn<() => boolean>()
35
+ const accessDecision = vi.fn<() => 'allowed' | 'denied' | 'unknown'>()
36
+ const mutate = vi.fn()
30
37
 
31
38
  vi.mock('@voltro/client', () => ({
32
39
  useCapabilityManifest: () => capabilityManifest(),
33
40
  deriveEntityAdmins: () => entityAdmins(),
34
- useCan: () => can(),
35
- useMutation: () => ({ mutate: vi.fn() }),
41
+ useAccessDecision: () => accessDecision(),
42
+ requiredScopes: (guards?: ReadonlyArray<{ kind: string; scope?: ReadonlyArray<string> }>) =>
43
+ (guards ?? []).flatMap((g) => (g.kind === 'scope' ? [...(g.scope ?? [])] : [])),
44
+ openAccessReason: (guards?: ReadonlyArray<{ kind: string; reason?: string }>) =>
45
+ (guards ?? []).find((g) => g.kind === 'open')?.reason,
46
+ useMutation: () => ({ mutate }),
36
47
  useProvenance: () => ({ data: undefined, loading: false, error: undefined }),
37
48
  }))
38
49
 
@@ -58,12 +69,36 @@ const render = (node: ReactNode): void => {
58
69
  const cell = (id: string): string | null =>
59
70
  container.querySelector(`[data-testid="${id}"]`)?.textContent ?? null
60
71
 
72
+ /** A derived spec in its current shape. */
73
+ const spec = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
74
+ table: 'todos',
75
+ columns: [{ name: 'id' }, { name: 'title' }],
76
+ serverOnlyColumns: [],
77
+ sensitiveColumns: [],
78
+ reactive: true,
79
+ pkColumn: 'id',
80
+ editable: true,
81
+ list: { tag: 'todos.list' },
82
+ create: { tag: 'todos.create', guards: [{ kind: 'scope', scope: ['todos:manage'], mode: 'all', resourceScoped: false }] },
83
+ update: {},
84
+ delete: { tag: 'todos.delete' },
85
+ ...over,
86
+ })
87
+
88
+ const showEntity = (over: Record<string, unknown> = {}): void => {
89
+ params.mockReturnValue({ entity: (over['table'] as string) ?? 'todos' })
90
+ capabilityManifest.mockReturnValue({ manifest: {}, loading: false })
91
+ entityAdmins.mockReturnValue([spec(over)])
92
+ render(createElement(EntityPage))
93
+ }
94
+
61
95
  beforeEach(() => {
62
96
  params.mockReset()
63
97
  capabilityManifest.mockReset()
64
98
  entityAdmins.mockReset()
65
- can.mockReset()
66
- can.mockReturnValue(true)
99
+ accessDecision.mockReset()
100
+ mutate.mockReset()
101
+ accessDecision.mockReturnValue('allowed')
67
102
  })
68
103
 
69
104
  afterEach(() => {
@@ -98,22 +133,94 @@ describe('entity page — render states', () => {
98
133
  })
99
134
 
100
135
  test('renders the bound CRUD surface (heading + AutoForm + DataTable) for a known entity', () => {
101
- params.mockReturnValue({ entity: 'todos' })
102
- capabilityManifest.mockReturnValue({ manifest: {}, loading: false })
103
- entityAdmins.mockReturnValue([
104
- {
105
- table: 'todos',
106
- columns: ['id', 'title'],
107
- listTag: 'todos.list',
108
- createTag: 'todos.create',
109
- createScope: 'todos:create',
110
- deleteScope: 'todos:delete',
111
- deleteTag: 'todos.delete',
112
- },
113
- ])
114
- render(createElement(EntityPage))
136
+ showEntity()
115
137
  expect(container.querySelector('h1')!.textContent).toBe('todos')
116
138
  expect(cell('auto-form')).toBe('todos.create')
117
- expect(cell('data-table')).toBe('todos.list')
139
+ expect(cell('data-table')).toContain('todos.list')
140
+ })
141
+ })
142
+
143
+ describe('entity page — gates on the DECLARED access', () => {
144
+ test('shows the create form when the api says the caller may create', () => {
145
+ accessDecision.mockReturnValue('allowed')
146
+ showEntity()
147
+ expect(cell('auto-form')).toBe('todos.create')
148
+ })
149
+
150
+ test('hides it on a DEFINITE denial', () => {
151
+ accessDecision.mockReturnValue('denied')
152
+ showEntity()
153
+ expect(container.querySelector('[data-testid="auto-form"]')).toBeNull()
154
+ // …and the list is still there. A denied write is not a denied read.
155
+ expect(cell('data-table')).toContain('todos.list')
156
+ })
157
+
158
+ test('SHOWS it on an indeterminate decision, and says why', () => {
159
+ // The false-deny trap: a per-row guard cannot be pre-computed in a browser.
160
+ // Hiding it would empty the admin for exactly the multi-tenant apps whose
161
+ // subjects carry no global scopes.
162
+ accessDecision.mockReturnValue('unknown')
163
+ showEntity()
164
+ expect(cell('auto-form')).toBe('todos.create')
165
+ expect(container.textContent).toContain('checks permission per row')
166
+ })
167
+
168
+ test('names the scope the api ACTUALLY requires, so a refusal is readable', () => {
169
+ showEntity()
170
+ expect(container.textContent).toContain('requires todos:manage')
171
+ })
172
+
173
+ test('reports an openAccess decision with its reason', () => {
174
+ showEntity({ create: { tag: 'todos.create', guards: [{ kind: 'open', reason: 'demo seeding' }] } })
175
+ expect(container.textContent).toContain('open access — demo seeding')
176
+ })
177
+
178
+ test('says so when a procedure declared no access at all', () => {
179
+ showEntity({ create: { tag: 'todos.create' } })
180
+ expect(container.textContent).toContain('no access decision declared')
181
+ })
182
+
183
+ test('renders nothing to create when the api exposes no create mutation', () => {
184
+ showEntity({ create: {} })
185
+ expect(container.querySelector('[data-testid="auto-form"]')).toBeNull()
186
+ })
187
+ })
188
+
189
+ describe('entity page — the three exposure axes', () => {
190
+ test('names the .serverOnly() columns it withheld, and why', () => {
191
+ showEntity({ serverOnlyColumns: ['pwHash', 'internalNote'] })
192
+ expect(container.textContent).toContain('2 server-only columns')
193
+ expect(container.textContent).toContain('pwHash, internalNote')
194
+ expect(container.textContent).toContain('cannot be submitted')
195
+ })
196
+
197
+ test('flags .sensitive() columns as export-masked, NOT as hidden', () => {
198
+ // The category error to avoid: `.sensitive()` is the export axis. The value
199
+ // is real wire data the operator is meant to see.
200
+ showEntity({ sensitiveColumns: ['email'] })
201
+ expect(container.textContent).toContain('masked in exports: email')
202
+ })
203
+
204
+ test('says nothing when no column is marked', () => {
205
+ showEntity()
206
+ expect(container.textContent).not.toContain('server-only')
207
+ expect(container.textContent).not.toContain('masked in exports')
208
+ })
209
+ })
210
+
211
+ describe('entity page — row identity', () => {
212
+ test('keys the delete on the pk the api declares, not a hard-coded id', () => {
213
+ showEntity({ pkColumn: 'entryNo' })
214
+ const del = container.querySelector('button.danger')!
215
+ act(() => { del.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
216
+ // A hard-coded `id` would have sent `{ id: 'r1' }` — or `{ id: '' }` for a
217
+ // table that has no `id` column at all.
218
+ expect(mutate).toHaveBeenCalledWith({ entryNo: 'e1' })
219
+ })
220
+
221
+ test('offers no delete for a table with no addressable row', () => {
222
+ showEntity({ editable: false, pkColumn: undefined })
223
+ expect(container.querySelector('button.danger')).toBeNull()
224
+ expect(container.textContent).toContain('No single primary key')
118
225
  })
119
226
  })
@@ -69,8 +69,9 @@ export default function AdminLayout({ children }: { readonly children: ReactNode
69
69
  )
70
70
 
71
71
  return (
72
- // PermissionProvider feeds the current subject's scopes to useCan across the
73
- // admin. DEMO scopes here swap for your session's scopes (see lib/admin.ts).
72
+ // PermissionProvider feeds the current subject's scopes to useAccessDecision
73
+ // across the admin, which judges them against each procedure's OWN declared
74
+ // guards. DEMO scopes here — swap for your session's (see lib/admin.ts).
74
75
  <PermissionProvider scopes={DEMO_SCOPES}>
75
76
  <div className="admin">
76
77
  <aside>
@@ -57,11 +57,16 @@ describe('admin overview — config', () => {
57
57
  })
58
58
 
59
59
  describe('admin overview — render', () => {
60
+ const spec = (over: Record<string, unknown>): Record<string, unknown> => ({
61
+ columns: [], serverOnlyColumns: [], sensitiveColumns: [], reactive: false, editable: true,
62
+ list: {}, create: {}, update: {}, delete: {}, ...over,
63
+ })
64
+
60
65
  test('renders a linked card per entity with its column count and ops', () => {
61
66
  capabilityManifest.mockReturnValue({ manifest: {}, loading: false })
62
67
  entityAdmins.mockReturnValue([
63
- { table: 'todos', columns: ['id', 'title'], reactive: true, listTag: 'todos.list', createTag: 'todos.create' },
64
- { table: 'notes', columns: ['id'], reactive: false, listTag: 'notes.list' },
68
+ spec({ table: 'todos', columns: ['id', 'title'], reactive: true, list: { tag: 'todos.list' }, create: { tag: 'todos.create' } }),
69
+ spec({ table: 'notes', columns: ['id'], list: { tag: 'notes.list' } }),
65
70
  ])
66
71
 
67
72
  render(createElement(AdminOverview))
@@ -72,6 +77,18 @@ describe('admin overview — render', () => {
72
77
  expect(container.textContent).toContain('list · create')
73
78
  })
74
79
 
80
+ test('says how many columns are withheld rather than just showing fewer', () => {
81
+ // `columns` already excludes `.serverOnly()`, so a card that only printed the
82
+ // count would silently under-report the table. "2 columns" and "2 columns +
83
+ // 1 hidden" are different facts about the same entity.
84
+ capabilityManifest.mockReturnValue({ manifest: {}, loading: false })
85
+ entityAdmins.mockReturnValue([
86
+ spec({ table: 'users', columns: ['id', 'email'], serverOnlyColumns: ['pwHash'], list: { tag: 'users.list' } }),
87
+ ])
88
+ render(createElement(AdminOverview))
89
+ expect(container.textContent).toContain('1 server-only column, hidden')
90
+ })
91
+
75
92
  test('shows the error banner when the capability map is unreachable', () => {
76
93
  capabilityManifest.mockReturnValue({ manifest: undefined, loading: false, error: 'boom' })
77
94
  entityAdmins.mockReturnValue([])
@@ -39,19 +39,24 @@ export default function AdminOverview(): ReactNode {
39
39
  <div className="cards" style={{ marginTop: '1.25rem' }}>
40
40
  {entities.map((e) => {
41
41
  const ops = [
42
- e.listTag && 'list',
43
- e.createTag && 'create',
44
- e.updateTag && 'edit',
45
- e.deleteTag && 'delete',
42
+ e.list.tag && 'list',
43
+ e.create.tag && 'create',
44
+ e.update.tag && 'edit',
45
+ e.delete.tag && 'delete',
46
46
  ].filter(Boolean)
47
47
  return (
48
48
  <a className="card" key={e.table} href={`/admin/${e.table}`}>
49
49
  <div className="value">{e.table}</div>
50
50
  <div className="label">
51
+ {/* `columns` already excludes `.serverOnly()` — the count is
52
+ what an admin can actually show, not what the table has. */}
51
53
  <T id="overview.columns" values={{ count: e.columns.length }} />
52
54
  {e.reactive ? <T id="overview.reactiveSuffix" /> : null}
53
55
  </div>
54
56
  <div className="meta">{ops.length > 0 ? ops.join(' · ') : <T id="overview.readonly" />}</div>
57
+ {e.serverOnlyColumns.length > 0 ? (
58
+ <div className="meta"><T id="overview.serverOnly" values={{ count: e.serverOnlyColumns.length }} /></div>
59
+ ) : null}
55
60
  </a>
56
61
  )
57
62
  })}
@@ -31,9 +31,10 @@ export default {
31
31
  // more languages by adding a code here + a `src/locales/<code>.ts`.
32
32
  locales: ['en', 'de'] as const,
33
33
  defaultLocale: 'en' as const,
34
- // The api this frontend consumes. The NAME ('app') is the lookup key the
35
- // hooks use `useSubscription('app', 'notes.list')`, `useMutation('app',
36
- // 'notes.create')`. The `package` resolves the sibling api in this project
34
+ // The api this frontend consumes. The NAME ('app') is the lookup key — spelled
35
+ // ONCE, in `src/lib/api.ts`'s `createHooks<AppProcedures>('app')`, after which
36
+ // pages just call `useSubscription('notes.list')` and
37
+ // `useMutation('notes.create')`. The `package` resolves the sibling api
37
38
  // (scaffold it with `--api=api-backend`); its port is auto-discovered from
38
39
  // its own app.config.ts, and codegen pulls its typed rpc surface so the
39
40
  // hooks are end-to-end typed. Add more entries to consume more apis.
@@ -12,21 +12,24 @@
12
12
  "typecheck": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@{{projectName}}/api": "0.33.0",
16
- "@voltro/cli": "0.33.0",
17
- "@voltro/client": "0.33.0",
18
- "@voltro/env": "0.33.0",
19
- "@voltro/i18n": "0.33.0",
20
- "@voltro/ui-shadcn": "0.33.0",
21
- "@voltro/web": "0.33.0",
15
+ "@{{projectName}}/api": "0.35.0",
16
+ "@voltro/cli": "0.35.0",
17
+ "@voltro/client": "0.35.0",
18
+ "@voltro/env": "0.35.0",
19
+ "@voltro/i18n": "0.35.0",
20
+ "@voltro/ui-shadcn": "0.35.0",
21
+ "@voltro/web": "0.35.0",
22
22
  "react": "^19.0.0",
23
23
  "react-dom": "^19.0.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/devtools": "0.33.0",
26
+ "@voltro/devtools": "0.35.0",
27
+ "@voltro/testing": "0.35.0",
27
28
  "@types/react": "^19.0.0",
28
29
  "@types/react-dom": "^19.0.0",
30
+ "jsdom": "^30.0.0",
29
31
  "typescript": "^6.0.3",
32
+ "@vitest/coverage-v8": "^4.1.10",
30
33
  "vitest": "^4.1.10"
31
34
  }
32
35
  }
@@ -0,0 +1,25 @@
1
+ // The typed hook surface for the `app` api — bound ONCE, here, and imported by
2
+ // every page instead of `@voltro/client`.
3
+ //
4
+ // `AppProcedures` is emitted by codegen into the api's `rpcGroup.generated.ts`:
5
+ // a type-level map of every rpc tag to the descriptor behind it. Feeding it to
6
+ // `createHooks` makes the tag a literal union and the input/output types INFER:
7
+ //
8
+ // const { data } = useSubscription('notes.list') // rows, typed
9
+ // const create = useMutation('notes.create') // input + output typed
10
+ //
11
+ // So no page declares a row `interface` mirroring the api any more. The old
12
+ // `useSubscription<ReadonlyArray<Note>>('app', 'notes.list')` spelled the api
13
+ // name at every call site, left the tag an unchecked string (a typo was a
14
+ // runtime error), and the `<T>` was an ASSERTION nothing compared against the
15
+ // server. All three are compile-checked now.
16
+ //
17
+ // `'app'` is the key this web app gave the api in `app.config.ts` -> `apis`.
18
+ // `import type` is erased, so the binding costs the browser bundle nothing.
19
+ import { createHooks } from '@voltro/client'
20
+ import type { AppProcedures } from '@{{projectName}}/api/rpcGroup'
21
+
22
+ // Destructured on purpose - `react-hooks/rules-of-hooks` only treats a member
23
+ // call as a hook when the object is PascalCase, so top-level `useX` identifiers
24
+ // are what keep the React lint rules working at every call site.
25
+ export const { useSubscription, useMutation, useAction } = createHooks<AppProcedures>('app')
@@ -1,50 +1,70 @@
1
1
  // @vitest-environment jsdom
2
2
  //
3
- // `/` — the reactive notes page. It binds `useSubscription('app','notes.list')`
4
- // + `useMutation('app','notes.create')` from @voltro/client. We mock that module
5
- // so the render is deterministic and assert the observable output: loading /
6
- // empty / list states, optimistic-row styling, subscription-error banner, and
7
- // the submit path calling create.mutate with the trimmed title + body + tenant.
3
+ // `/` — the reactive notes page, driven through `makeVoltroTestClient`
4
+ // (`@voltro/testing/client`) rather than a `vi.mock` of anything.
5
+ //
6
+ // WHY THE HARNESS HERE. The old suite faked `src/lib/api` — the app's own typed
7
+ // binding with the argument that mocking `@voltro/client` underneath would be
8
+ // the wrong seam. That argument was right about the seam and reached the wrong
9
+ // conclusion, because the harness does not mock `@voltro/client` at all: it
10
+ // PROVIDES the runtime context the real hooks read. So `createHooks` runs, the
11
+ // real `useSubscription` / `useMutation` run, and the fake stops at the
12
+ // transport — one layer below the binding rather than on top of it.
13
+ //
14
+ // What that buys, concretely: the page is the framework's flagship example of
15
+ // the reactive loop, and every "different data" case here used to be a fresh
16
+ // mount with a different mock return. Nothing pushed a delta into a MOUNTED
17
+ // tree, which is the one behaviour the page exists to demonstrate. The last two
18
+ // cases do, via `setSubscription`.
8
19
 
9
- import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
20
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
10
21
  import { act, createElement, type ReactNode } from 'react'
11
22
  import { createRoot, type Root } from 'react-dom/client'
12
23
  import { I18nProvider } from '@voltro/i18n'
24
+ import { makeVoltroTestClient, type VoltroTestClient } from '@voltro/testing/client'
13
25
  import enCatalog from '../locales/en'
14
-
15
- interface SubState {
16
- data: unknown
17
- revision?: number
18
- emittedAt?: number
19
- error?: unknown
20
- pendingPatches?: number
21
- }
22
-
23
- const useSubscription = vi.fn<() => SubState>()
24
- const mutate = vi.fn<(input: unknown) => Promise<unknown>>()
25
- const useMutation = vi.fn(() => ({ mutate, pending: false, error: undefined, data: undefined }))
26
-
27
- vi.mock('@voltro/client', () => ({
28
- useSubscription: (...a: unknown[]) => useSubscription(...(a as [])),
29
- useMutation: (...a: unknown[]) => useMutation(...(a as [])),
30
- }))
31
-
32
- const { default: IndexPage } = await import('./page')
26
+ import IndexPage from './page'
33
27
 
34
28
  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
35
29
 
30
+ const LIST = 'notes.list'
31
+
36
32
  let container: HTMLDivElement
37
33
  let root: Root
34
+ let client: VoltroTestClient
35
+
36
+ const note = (over: Partial<{ id: string; title: string; body: string; optimistic: boolean }> = {}) => ({
37
+ id: over.id ?? 'n_1',
38
+ title: over.title ?? 'first',
39
+ body: over.body ?? '',
40
+ done: false,
41
+ tenantId: 'acme',
42
+ createdAt: new Date(0),
43
+ ...(over.optimistic === true ? { optimistic: true } : {}),
44
+ })
45
+
46
+ /** `notes.create` resolves by default; a case that needs the failure path
47
+ * overrides it. The harness records every call on `client.calls`. */
48
+ const harness = (
49
+ subscriptions: Readonly<Record<string, unknown>> = {},
50
+ create: (input: unknown) => unknown = () => undefined,
51
+ ): VoltroTestClient =>
52
+ makeVoltroTestClient({ subscriptions, mutations: { 'notes.create': create } })
38
53
 
39
54
  // The page uses <T>/useTFn, so it needs an <I18nProvider>. Wrap with the real
40
55
  // English catalog (the framework auto-wires this provider in the running app).
41
- const render = (node: ReactNode): void => {
56
+ const render = (): void => {
42
57
  container = document.createElement('div')
43
58
  document.body.appendChild(container)
44
59
  act(() => {
45
60
  root = createRoot(container)
46
61
  root.render(
47
- createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
62
+ createElement(I18nProvider, {
63
+ locale: 'en',
64
+ messages: enCatalog,
65
+ defaultLocale: 'en',
66
+ children: createElement(client.Provider, null, createElement(IndexPage) as ReactNode),
67
+ }),
48
68
  )
49
69
  })
50
70
  }
@@ -61,13 +81,14 @@ const setInput = (el: HTMLInputElement | HTMLTextAreaElement, value: string): vo
61
81
  })
62
82
  }
63
83
 
64
- beforeEach(() => {
65
- useSubscription.mockReset()
66
- mutate.mockReset()
67
- mutate.mockResolvedValue(undefined)
68
- useMutation.mockClear()
69
- useMutation.mockReturnValue({ mutate, pending: false, error: undefined, data: undefined })
70
- })
84
+ const submit = async (): Promise<void> => {
85
+ const form = container.querySelector('form') as HTMLFormElement
86
+ await act(async () => {
87
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
88
+ })
89
+ }
90
+
91
+ beforeEach(() => { client = harness() })
71
92
 
72
93
  afterEach(() => {
73
94
  if (root) act(() => root.unmount())
@@ -76,53 +97,43 @@ afterEach(() => {
76
97
  })
77
98
 
78
99
  describe('index — subscription states', () => {
79
- test('shows the loading hint before the first snapshot (data undefined)', () => {
80
- useSubscription.mockReturnValue({ data: undefined, revision: -1 })
81
- render(createElement(IndexPage))
100
+ test('shows the loading hint before the first snapshot', () => {
101
+ // A tag with no fixture IS the cold state — no stub had to invent one.
102
+ render()
82
103
  expect(container.textContent).toContain('loading…')
83
104
  expect(container.querySelectorAll('li')).toHaveLength(0)
84
105
  })
85
106
 
86
107
  test('shows the empty state once a snapshot arrives with no rows', () => {
87
- useSubscription.mockReturnValue({ data: [], revision: 0 })
88
- render(createElement(IndexPage))
108
+ client = harness({ [LIST]: [] })
109
+ render()
89
110
  expect(container.textContent).toContain('No notes yet')
90
111
  })
91
112
 
92
113
  test('renders each note with its title and body', () => {
93
- useSubscription.mockReturnValue({
94
- data: [
95
- { id: 'n_1', title: 'first', body: 'body one', done: false, tenantId: 'acme', createdAt: new Date(0) },
96
- { id: 'n_2', title: 'second', body: '', done: false, tenantId: 'acme', createdAt: new Date(0) },
97
- ],
98
- revision: 3,
99
- })
100
- render(createElement(IndexPage))
101
- const items = Array.from(container.querySelectorAll('li'))
102
- expect(items).toHaveLength(2)
114
+ client = harness({ [LIST]: [note({ id: 'n_1', title: 'first', body: 'body one' }), note({ id: 'n_2', title: 'second' })] })
115
+ render()
116
+ expect(container.querySelectorAll('li')).toHaveLength(2)
103
117
  expect(container.textContent).toContain('first')
104
118
  expect(container.textContent).toContain('body one')
105
119
  expect(container.textContent).toContain('second')
106
120
  })
107
121
 
108
- test('renders optimistic rows faintly + italicised', () => {
109
- useSubscription.mockReturnValue({
110
- data: [
111
- { id: 'n_opt', title: 'pending', body: '', done: false, tenantId: 'acme', createdAt: new Date(0), optimistic: true },
112
- ],
113
- revision: 1,
114
- pendingPatches: 1,
122
+ test('renders optimistic rows faintly + italicised, with the pending count', () => {
123
+ client = harness({ [LIST]: [] })
124
+ render()
125
+ act(() => {
126
+ client.setSubscription(LIST, [note({ id: 'n_opt', title: 'pending', optimistic: true })], { pendingPatches: 1 })
115
127
  })
116
- render(createElement(IndexPage))
117
128
  const li = container.querySelector('li')!
118
129
  expect(li.style.opacity).toBe('0.5')
119
130
  expect(li.style.fontStyle).toBe('italic')
120
131
  expect(container.textContent).toContain('1 pending')
121
132
  })
122
133
 
123
- test('renders the subscription error banner when error is set', () => {
124
- useSubscription.mockReturnValue({ data: undefined, error: 'boom', revision: -1 })
125
- render(createElement(IndexPage))
134
+ test('renders the subscription error banner when the stream fails', () => {
135
+ render()
136
+ act(() => { client.failSubscription(LIST, 'boom') })
126
137
  expect(container.textContent).toContain('subscription error')
127
138
  expect(container.textContent).toContain('boom')
128
139
  })
@@ -130,38 +141,75 @@ describe('index — subscription states', () => {
130
141
 
131
142
  describe('index — create form', () => {
132
143
  test('the Add button is disabled until a non-empty title is typed', () => {
133
- useSubscription.mockReturnValue({ data: [], revision: 0 })
134
- render(createElement(IndexPage))
144
+ client = harness({ [LIST]: [] })
145
+ render()
135
146
  const button = container.querySelector('button[type="submit"]') as HTMLButtonElement
136
147
  expect(button.disabled).toBe(true)
137
148
  setInput(container.querySelector('input') as HTMLInputElement, 'a note')
138
149
  expect(button.disabled).toBe(false)
139
150
  })
140
151
 
141
- test('submitting calls create.mutate with the trimmed title + body + tenant, then clears', async () => {
142
- useSubscription.mockReturnValue({ data: [], revision: 0 })
143
- render(createElement(IndexPage))
144
- const input = container.querySelector('input') as HTMLInputElement
145
- const textarea = container.querySelector('textarea') as HTMLTextAreaElement
146
- setInput(input, ' my title ')
147
- setInput(textarea, ' my body ')
148
- const form = container.querySelector('form') as HTMLFormElement
149
- await act(async () => {
150
- form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
151
- })
152
- expect(mutate).toHaveBeenCalledWith({ tenantId: 'acme', title: 'my title', body: 'my body' })
153
- expect(input.value).toBe('')
154
- expect(textarea.value).toBe('')
152
+ test('submitting calls notes.create with the trimmed title + body + tenant, then clears', async () => {
153
+ client = harness({ [LIST]: [] })
154
+ render()
155
+ setInput(container.querySelector('input') as HTMLInputElement, ' my title ')
156
+ setInput(container.querySelector('textarea') as HTMLTextAreaElement, ' my body ')
157
+ await submit()
158
+
159
+ expect(client.calls).toEqual([
160
+ { kind: 'mutation', tag: 'notes.create', input: { tenantId: 'acme', title: 'my title', body: 'my body' } },
161
+ ])
162
+ expect((container.querySelector('input') as HTMLInputElement).value).toBe('')
163
+ expect((container.querySelector('textarea') as HTMLTextAreaElement).value).toBe('')
155
164
  })
156
165
 
157
166
  test('a whitespace-only title does not fire a mutation', async () => {
158
- useSubscription.mockReturnValue({ data: [], revision: 0 })
159
- render(createElement(IndexPage))
167
+ client = harness({ [LIST]: [] })
168
+ render()
160
169
  setInput(container.querySelector('input') as HTMLInputElement, ' ')
161
- const form = container.querySelector('form') as HTMLFormElement
162
- await act(async () => {
163
- form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
170
+ await submit()
171
+ expect(client.calls).toEqual([])
172
+ })
173
+
174
+ test('a REJECTED create leaves the form cleared and the list untouched', async () => {
175
+ client = harness({ [LIST]: [] }, () => { throw new Error('TenantMismatch') })
176
+ render()
177
+ setInput(container.querySelector('input') as HTMLInputElement, 'nope')
178
+ await submit()
179
+ expect(client.calls).toHaveLength(1)
180
+ expect(container.textContent).toContain('No notes yet')
181
+ })
182
+ })
183
+
184
+ // The reactive loop itself — a server delta into a MOUNTED tree. This is what
185
+ // the page is a demonstration of, and what a per-mount mock cannot express.
186
+ describe('index — the reactive loop', () => {
187
+ test('a delta pushed by the server re-renders the list in place', () => {
188
+ client = harness({ [LIST]: [note({ id: 'n_1', title: 'first' })] })
189
+ render()
190
+ expect(container.querySelectorAll('li')).toHaveLength(1)
191
+
192
+ act(() => {
193
+ client.setSubscription(LIST, [note({ id: 'n_1', title: 'first' }), note({ id: 'n_2', title: 'second' })])
164
194
  })
165
- expect(mutate).not.toHaveBeenCalled()
195
+
196
+ expect(container.querySelectorAll('li')).toHaveLength(2)
197
+ expect(container.textContent).toContain('second')
198
+ })
199
+
200
+ test('the confirming delta clears the optimistic styling AND the pending count', () => {
201
+ client = harness({ [LIST]: [] })
202
+ render()
203
+ act(() => {
204
+ client.setSubscription(LIST, [note({ id: 'n_tmp', title: 'mine', optimistic: true })], { pendingPatches: 1 })
205
+ })
206
+ expect(container.querySelector('li')!.style.opacity).toBe('0.5')
207
+ expect(container.textContent).toContain('1 pending')
208
+
209
+ // The server confirms: the real row replaces the optimistic one.
210
+ act(() => { client.setSubscription(LIST, [note({ id: 'n_1', title: 'mine' })]) })
211
+
212
+ expect(container.querySelector('li')!.style.opacity).toBe('1')
213
+ expect(container.textContent).not.toContain('1 pending')
166
214
  })
167
215
  })