@voltro/cli 0.28.0 → 0.29.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 (280) hide show
  1. package/CHANGELOG.md +278 -0
  2. package/THIRD-PARTY-NOTICES.md +88 -2
  3. package/dist/{apiBuild-eUM32r1u.js → apiBuild-BESZbTjA.js} +3 -3
  4. package/dist/apiBuild-C8d74T6g.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BPAtyWs8.js +2420 -0
  7. package/dist/checkCommand-DBvZvCh1.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-CSkrUI1h.js → commands-82BDEktg.js} +6803 -8191
  10. package/dist/dbCommand-CaIVTp-a.js +2 -0
  11. package/dist/{dbCommand-CpYgmSw4.js → dbCommand-bCBbk6tz.js} +2 -2
  12. package/dist/{dev-CEZwJhmb.js → dev-Cg4BKLmi.js} +1801 -1612
  13. package/dist/{dev-DlBWWnJQ.js → dev-DeEJj5T4.js} +1 -1
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
  16. package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
  17. package/dist/index.js +1 -1
  18. package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
  19. package/dist/inspect-DHeaMZak.js +2 -0
  20. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
  21. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
  22. package/dist/manifestBuild-Ck_90gZy.js +2 -0
  23. package/dist/sdkgen-CW5NbYF0.js +582 -0
  24. package/dist/serveCommand-BXl8mhZD.js +1489 -0
  25. package/dist/serveEntry.js +2 -2
  26. package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
  27. package/dist/startEntry.js +2 -2
  28. package/package.json +17 -17
  29. package/templates/AGENTS.md +2 -1
  30. package/templates/agent-docs/_index.md +2 -1
  31. package/templates/agent-docs/_manifest.json +10 -1
  32. package/templates/agent-docs/ai.md +135 -0
  33. package/templates/agent-docs/cli.md +124 -6
  34. package/templates/agent-docs/configuration.md +32 -0
  35. package/templates/agent-docs/data.md +282 -0
  36. package/templates/agent-docs/database/migrations.md +47 -0
  37. package/templates/agent-docs/database/misc.md +57 -0
  38. package/templates/agent-docs/database/schema.md +3 -1
  39. package/templates/agent-docs/internationalization.md +161 -1
  40. package/templates/agent-docs/local-first-mobile.md +414 -0
  41. package/templates/agent-docs/routing.md +93 -2
  42. package/templates/agent-docs/schema-driven-ui.md +12 -0
  43. package/templates/agent-docs/templates/apibackends.md +1 -1
  44. package/templates/agent-docs/whats-new.md +162 -181
  45. package/templates/agent-docs/workflows.md +11 -0
  46. package/templates/apps/api-ai/package.json +7 -7
  47. package/templates/apps/api-auth/package.json +8 -8
  48. package/templates/apps/api-backend/package.json +7 -7
  49. package/templates/apps/api-backend-deactivation/package.json +7 -7
  50. package/templates/apps/api-backend-mail/package.json +8 -8
  51. package/templates/apps/api-backend-mariadb/package.json +9 -9
  52. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  53. package/templates/apps/api-backend-sqlite/README.md +38 -0
  54. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  55. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  56. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  57. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  58. package/templates/apps/api-backend-sqlite/package.json +30 -0
  59. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  60. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  61. package/templates/apps/api-backend-sqlite/template.json +6 -0
  62. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  63. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  64. package/templates/apps/api-backend-storage/package.json +8 -8
  65. package/templates/apps/api-cms/README.md +104 -0
  66. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  67. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  68. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  69. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  70. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  71. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  72. package/templates/apps/api-cms/app.config.ts +61 -0
  73. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  74. package/templates/apps/api-cms/content/index.ts +18 -0
  75. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  76. package/templates/apps/api-cms/database/schema.ts +64 -0
  77. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  78. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  79. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  80. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  81. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  82. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  83. package/templates/apps/api-cms/package.json +32 -0
  84. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  85. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  86. package/templates/apps/api-cms/template.json +6 -0
  87. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  88. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  89. package/templates/apps/api-cms/tsconfig.json +5 -0
  90. package/templates/apps/api-data-advanced/package.json +8 -8
  91. package/templates/apps/api-durable/package.json +8 -8
  92. package/templates/apps/api-feature-flags/package.json +9 -9
  93. package/templates/apps/api-governance/package.json +8 -8
  94. package/templates/apps/api-kv/package.json +8 -8
  95. package/templates/apps/api-moderation/package.json +8 -8
  96. package/templates/apps/api-observability/package.json +8 -8
  97. package/templates/apps/api-ratelimit/package.json +8 -8
  98. package/templates/apps/api-rbac/package.json +8 -8
  99. package/templates/apps/api-rest/package.json +7 -7
  100. package/templates/apps/api-saas/package.json +11 -11
  101. package/templates/apps/api-saas-starter/README.md +103 -0
  102. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  103. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  104. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  105. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  106. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  107. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  108. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  109. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  110. package/templates/apps/api-saas-starter/package.json +32 -0
  111. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  112. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  113. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  114. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  115. package/templates/apps/api-saas-starter/template.json +6 -0
  116. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  117. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  118. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  119. package/templates/apps/api-search/package.json +8 -8
  120. package/templates/apps/api-status/README.md +88 -0
  121. package/templates/apps/api-status/app.config.ts +36 -0
  122. package/templates/apps/api-status/authz.ts +33 -0
  123. package/templates/apps/api-status/database/schema.ts +70 -0
  124. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  125. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  126. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  127. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  128. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  129. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  130. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  131. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  132. package/templates/apps/api-status/package.json +30 -0
  133. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  134. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  135. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  136. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  137. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  138. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  139. package/templates/apps/api-status/template.json +6 -0
  140. package/templates/apps/api-status/tests/status.test.ts +70 -0
  141. package/templates/apps/api-status/tsconfig.json +5 -0
  142. package/templates/apps/api-versioning/package.json +8 -8
  143. package/templates/apps/api-webhooks/package.json +9 -9
  144. package/templates/apps/changelog/package.json +6 -6
  145. package/templates/apps/edge-functions/package.json +2 -2
  146. package/templates/apps/frontend-admin/package.json +8 -8
  147. package/templates/apps/frontend-app/package.json +8 -8
  148. package/templates/apps/frontend-auth/README.md +78 -0
  149. package/templates/apps/frontend-auth/app.config.ts +34 -0
  150. package/templates/apps/frontend-auth/package.json +32 -0
  151. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  152. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  153. package/templates/apps/frontend-auth/src/config.ts +11 -0
  154. package/templates/apps/frontend-auth/src/globals.css +105 -0
  155. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  156. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  157. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  158. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  159. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  160. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  161. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  162. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  163. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  164. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  165. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  166. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  167. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  168. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  169. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  170. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  171. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  172. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  173. package/templates/apps/frontend-auth/template.json +6 -0
  174. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  175. package/templates/apps/frontend-blank/package.json +7 -7
  176. package/templates/apps/frontend-cms/README.md +47 -0
  177. package/templates/apps/frontend-cms/app.config.ts +37 -0
  178. package/templates/apps/frontend-cms/package.json +33 -0
  179. package/templates/apps/frontend-cms/src/config.ts +8 -0
  180. package/templates/apps/frontend-cms/src/globals.css +105 -0
  181. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  182. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  183. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  184. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  185. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  186. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  187. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  188. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  189. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  190. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  191. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  192. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  193. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  194. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  195. package/templates/apps/frontend-cms/template.json +6 -0
  196. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  197. package/templates/apps/frontend-contact/package.json +7 -7
  198. package/templates/apps/frontend-dashboard/package.json +7 -7
  199. package/templates/apps/frontend-docs/package.json +7 -7
  200. package/templates/apps/frontend-i18n/package.json +6 -6
  201. package/templates/apps/frontend-landing/package.json +7 -7
  202. package/templates/apps/frontend-portal/README.md +71 -0
  203. package/templates/apps/frontend-portal/app.config.ts +37 -0
  204. package/templates/apps/frontend-portal/package.json +32 -0
  205. package/templates/apps/frontend-portal/src/config.ts +8 -0
  206. package/templates/apps/frontend-portal/src/globals.css +93 -0
  207. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  208. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  209. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  210. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  211. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  212. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  213. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  214. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  215. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  216. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  217. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  218. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  219. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  220. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  221. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  222. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  223. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  224. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  225. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  226. package/templates/apps/frontend-portal/template.json +6 -0
  227. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  228. package/templates/apps/frontend-saas/README.md +73 -0
  229. package/templates/apps/frontend-saas/app.config.ts +46 -0
  230. package/templates/apps/frontend-saas/package.json +32 -0
  231. package/templates/apps/frontend-saas/src/config.ts +8 -0
  232. package/templates/apps/frontend-saas/src/globals.css +85 -0
  233. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  234. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  235. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  236. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  237. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  238. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  239. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  240. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  241. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  242. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  243. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  244. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  245. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  246. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  247. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  248. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  249. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  250. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  251. package/templates/apps/frontend-saas/template.json +6 -0
  252. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  253. package/templates/apps/frontend-spa/package.json +7 -7
  254. package/templates/apps/frontend-ssr/package.json +7 -7
  255. package/templates/apps/frontend-ssr-api/package.json +8 -8
  256. package/templates/apps/frontend-static-blog/package.json +6 -6
  257. package/templates/apps/frontend-status/README.md +51 -0
  258. package/templates/apps/frontend-status/app.config.ts +39 -0
  259. package/templates/apps/frontend-status/package.json +32 -0
  260. package/templates/apps/frontend-status/src/config.ts +7 -0
  261. package/templates/apps/frontend-status/src/globals.css +70 -0
  262. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  263. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  264. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  265. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  266. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  267. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  268. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  269. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  270. package/templates/apps/frontend-status/template.json +6 -0
  271. package/templates/apps/frontend-status/tsconfig.json +11 -0
  272. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  273. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  274. package/dist/apiBuild-DgBS9ayv.js +0 -2
  275. package/dist/dbCommand-DvguqlzF.js +0 -2
  276. package/dist/fileConventions-3bffWssN.js +0 -30
  277. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  278. package/dist/inspect-gt8bq-Tz.js +0 -2
  279. package/dist/manifestBuild-ifczArzr.js +0 -2
  280. package/dist/serveCommand-ZTn-dPFa.js +0 -1425
@@ -0,0 +1,104 @@
1
+ # {{capProjectName}} {{capAppName}} — headless CMS backend
2
+
3
+ A headless CMS composed from [`@voltro/cms`](https://voltro.cloud/docs): content
4
+ types declared as **code**, compiled to draft/published tables, driven by the
5
+ write pipeline, and exposed as a typed rpc surface an editor consumes. Content is
6
+ tenant-scoped and gated by real editor auth. Boots **zero-infra** (`store:
7
+ 'memory'`, in-process users).
8
+
9
+ Pair it with the **frontend-cms** editor web template:
10
+
11
+ ```bash
12
+ voltro init my-cms --api=api-cms --web=frontend-cms
13
+ ```
14
+
15
+ ## Content types as code
16
+
17
+ Each `content/*.contentType.ts` file declares a type with the CMS `Schema` field
18
+ DSL. `@voltro/cms` is a **library, not a plugin** — there is no discovery of
19
+ `*.contentType.ts`; `database/schema.ts` imports the types and registers their
20
+ derived tables, and THAT is what makes them migrate.
21
+
22
+ ```ts
23
+ // content/blogPost.contentType.ts
24
+ export const blogPost = defineContentType({
25
+ name: 'blogPost', displayName: 'Blog post', pluralName: 'Blog posts',
26
+ fields: {
27
+ title: Schema.String.pipe(Schema.maxLength(200)),
28
+ slug: Schema.String.pipe(Schema.pattern(/^[a-z0-9-]+$/), Schema.unique(), derivedFrom('title', slugify)),
29
+ body: Schema.RichText({ allowImages: true }),
30
+ },
31
+ })
32
+ ```
33
+
34
+ `contentTypeToEntities(blogPost)` compiles this to a `blogPost_drafts` +
35
+ `blogPost_published` table pair — both tenant-scoped (`tenant()`), both carrying
36
+ the lifecycle `status` column, both reactive so a publish wakes subscriptions.
37
+
38
+ ## The rpc surface
39
+
40
+ | Procedure | Kind | Does |
41
+ |---|---|---|
42
+ | `content.types` | action | the registered types, wire-safe — the editor renders forms from this |
43
+ | `content.list` | query (live) | one type's rows, tenant-scoped; `status` picks drafts vs published |
44
+ | `content.get` | action | one row by id, for the edit form |
45
+ | `content.saveDraft` | mutation | derive → validate → upsert `<type>_drafts`; invalid input → typed `{ ok: false, violations }` |
46
+ | `content.publish` | mutation | copy draft → published (atomic); wakes consumer subscriptions |
47
+ | `content.unpublish` | mutation | remove the published copy, keep the draft |
48
+ | `session.me` | action | the resolved Subject (the editor's SSR gate reads it) |
49
+
50
+ Validation and not-found are RESULT UNIONS, not thrown errors — `saveDraft`
51
+ returns `{ ok: false, violations }` so the editor annotates the form, and
52
+ `publish` returns `{ ok: false }` for a missing/foreign draft. The descriptors
53
+ stay pure effect/Schema (browser-safe with nothing extra pulled in).
54
+
55
+ ## Run it
56
+
57
+ ```bash
58
+ voltro dev .
59
+ # → http://localhost:4000 (rpc + /auth/* HTTP routes)
60
+ ```
61
+
62
+ `voltro dev` mints a unique `VOLTRO_SESSION_SECRET` into a gitignored
63
+ `.env.local` on first boot — no secret ships with the template. It signs both
64
+ the session cookie and CMS preview tokens.
65
+
66
+ ## Files
67
+
68
+ ```
69
+ app.config.ts authRoutesPlugin + voltroPasswordStrategy (editor auth)
70
+ content/
71
+ blogPost.contentType.ts a content type as code (derived slug, rich text)
72
+ page.contentType.ts a second type (Literal layout → <select>)
73
+ index.ts the type registry + name lookup
74
+ database/schema.ts core actors/tenants + contentTypeToEntities(...) tables
75
+ actions/
76
+ content.types.* the wire-safe type descriptors the editor renders
77
+ content.get.* one row by id
78
+ me.action.* session.me
79
+ queries/content.list.* live, tenant-scoped, drafts-or-published
80
+ mutations/
81
+ content.saveDraft.* derive + validate + upsert draft
82
+ content.publish.* draft → published
83
+ content.unpublish.* take a published row down
84
+ tests/ write pipeline vs a real store + descriptor pins
85
+ ```
86
+
87
+ ## Going to production
88
+
89
+ | Want… | Do |
90
+ |---|---|
91
+ | Durable content + editors | `store: 'postgres'` + `postgresUserStore({ sql })` |
92
+ | A REST read API for consumers | mount `handleCmsRest(...)` (from `@voltro/cms`) — `GET /v1/cms/<type>`, API-key scoped |
93
+ | Draft preview from a consumer app | `previewToken(type, id)` / `verifyPreviewToken` (HMAC over `VOLTRO_SESSION_SECRET`) |
94
+ | Media fields | `Schema.Media()` + `@voltro/plugin-storage`; resolve keys with `resolveMedia(...)` |
95
+ | A real rich-text editor | swap the `richText` widget via `<ContentForm widgets={{ richText: … }} />` in the editor |
96
+
97
+ ## Anti-patterns
98
+
99
+ - **Expecting `*.contentType.ts` to be auto-discovered.** It isn't — register the
100
+ derived entities in `database/schema.ts` or the tables never migrate.
101
+ - **Importing a content-type file into the browser.** The editor fetches
102
+ `content.types` instead; content-type files import the server `@voltro/cms`.
103
+ - **Reusing a reserved field name** (`status`, `tenantId`, `publishAt`, the audit
104
+ columns). `defineContentType` throws — rename the field.
@@ -0,0 +1,27 @@
1
+ // Executor for `content.get`. Reads one row by id from the requested lifecycle
2
+ // table. `ctx.store.query` runs through the tenant-scoped store spine, so the
3
+ // read is automatically confined to the caller's tenant — a foreign id returns
4
+ // nothing.
5
+ import { eq } from '@voltro/database'
6
+ import type { AppContext } from '@voltro/runtime'
7
+ import { contentTypeByName } from '../content'
8
+
9
+ const execute = async (
10
+ input: { type: string; id: string; status?: 'draft' | 'published' },
11
+ ctx: AppContext,
12
+ ) => {
13
+ const ct = contentTypeByName.get(input.type)
14
+ if (ct === undefined) return { row: null }
15
+ const table = `${ct.name}_${input.status === 'published' ? 'published' : 'drafts'}`
16
+ const rows = await ctx.store.query({
17
+ table,
18
+ predicate: eq('id', input.id),
19
+ order: [],
20
+ take: 1,
21
+ skip: undefined,
22
+ projection: undefined,
23
+ })
24
+ return { row: rows[0] ?? null }
25
+ }
26
+
27
+ export default execute
@@ -0,0 +1,19 @@
1
+ // `content.get` — one content row by id, for the edit form. Returns the full
2
+ // row (arbitrary per-type fields), so the output is a loose record rather than a
3
+ // closed struct. `status` selects the table: 'draft' (the editor's in-progress
4
+ // copy, the default) or 'published' (the live copy). A row outside the caller's
5
+ // tenant reads as absent (null) — the store scope never returns it.
6
+ import { defineAction } from '@voltro/protocol'
7
+ import { Schema } from 'effect'
8
+
9
+ export const getContent = defineAction({
10
+ name: 'content.get',
11
+ input: Schema.Struct({
12
+ type: Schema.NonEmptyString,
13
+ id: Schema.NonEmptyString,
14
+ status: Schema.optional(Schema.Literal('draft', 'published')),
15
+ }),
16
+ output: Schema.Struct({
17
+ row: Schema.NullOr(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
18
+ }),
19
+ })
@@ -0,0 +1,26 @@
1
+ // Executor for `content.types`. Projects the registered content types down to
2
+ // the wire-safe descriptor the editor renders forms from (dropping the
3
+ // server-only `derived.transform` function + nested element/struct depth — the
4
+ // two shipped types are flat, and deep widgets fall back to a placeholder).
5
+ import type { AppContext } from '@voltro/runtime'
6
+ import { contentTypes as registered } from '../content'
7
+
8
+ const execute = async (_input: Record<string, never>, _ctx: AppContext) => ({
9
+ types: registered.map((ct) => ({
10
+ name: ct.name,
11
+ displayName: ct.displayName,
12
+ pluralName: ct.pluralName,
13
+ columns: ct.list?.columns ?? Object.keys(ct.fields),
14
+ fields: Object.entries(ct.fields).map(([name, field]) => ({
15
+ name,
16
+ kind: field.kind,
17
+ editorHint: field.editorHint,
18
+ isOptional: field.isOptional,
19
+ ...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),
20
+ ...(field.literals !== undefined ? { literals: field.literals } : {}),
21
+ ...(field.referenceType !== undefined ? { referenceType: field.referenceType } : {}),
22
+ })),
23
+ })),
24
+ })
25
+
26
+ export default execute
@@ -0,0 +1,40 @@
1
+ // `content.types` — the registered content-type descriptors, in a WIRE-SAFE
2
+ // shape. The editor calls this once and renders a `<ContentForm>` per field
3
+ // purely from `editorHint` + the field metadata — no content-type file is
4
+ // imported into the browser bundle, so adding a type on the server surfaces in
5
+ // the editor with zero client changes.
6
+ //
7
+ // A field's `derived.transform` is a FUNCTION (not serialisable) and the editor
8
+ // doesn't need it — the transform runs server-side on save — so the executor
9
+ // projects each field down to the data the form widgets read.
10
+ import { defineAction } from '@voltro/protocol'
11
+ import { Schema } from 'effect'
12
+
13
+ // The browser-facing view of one field: enough to pick + configure a widget.
14
+ const FieldWire = Schema.Struct({
15
+ name: Schema.String,
16
+ kind: Schema.String,
17
+ editorHint: Schema.String,
18
+ isOptional: Schema.Boolean,
19
+ maxLength: Schema.optional(Schema.Number),
20
+ literals: Schema.optional(Schema.Array(Schema.String)),
21
+ referenceType: Schema.optional(Schema.String),
22
+ })
23
+
24
+ export const contentTypes = defineAction({
25
+ name: 'content.types',
26
+ input: Schema.Struct({}),
27
+ output: Schema.Struct({
28
+ types: Schema.Array(
29
+ Schema.Struct({
30
+ name: Schema.String,
31
+ displayName: Schema.String,
32
+ pluralName: Schema.String,
33
+ // An ORDERED list (not a Record) so the editor renders fields in
34
+ // declaration order.
35
+ fields: Schema.Array(FieldWire),
36
+ columns: Schema.Array(Schema.String),
37
+ }),
38
+ ),
39
+ }),
40
+ })
@@ -0,0 +1,18 @@
1
+ // Executor for `session.me`. Reads the Subject the AuthMiddleware resolved (via
2
+ // voltroPasswordStrategy reading the session cookie) and returns it.
3
+ import type { AppContext } from '@voltro/runtime'
4
+
5
+ const execute = async (_input: Record<string, never>, ctx: AppContext) => {
6
+ const subject = ctx.request.subject as {
7
+ readonly type: string
8
+ readonly id: string | null
9
+ readonly tenantId: string | null
10
+ }
11
+ return {
12
+ type: subject.type,
13
+ id: subject.id ?? null,
14
+ tenantId: subject.tenantId ?? null,
15
+ }
16
+ }
17
+
18
+ export default execute
@@ -0,0 +1,16 @@
1
+ // `session.me` — the caller's resolved identity. The editor's SSR gate reads it
2
+ // to decide render-vs-redirect-to-login, and it proves the auth loop end to end:
3
+ // no cookie → anonymous Subject; sign in via POST /auth/sign-in → a `user`
4
+ // Subject. Browser-safe descriptor — no server imports here.
5
+ import { defineAction } from '@voltro/protocol'
6
+ import { Schema } from 'effect'
7
+
8
+ export const me = defineAction({
9
+ name: 'session.me',
10
+ input: Schema.Struct({}),
11
+ output: Schema.Struct({
12
+ type: Schema.String,
13
+ id: Schema.NullOr(Schema.String),
14
+ tenantId: Schema.NullOr(Schema.String),
15
+ }),
16
+ })
@@ -0,0 +1,61 @@
1
+ // The headless CMS backend for the {{projectName}} project. Read by `voltro dev`.
2
+ //
3
+ // It composes @voltro/cms — a LIBRARY, not a plugin — with real editor auth:
4
+ //
5
+ // • @voltro/cms — content types declared as code (`content/*.contentType.ts`
6
+ // via `defineContentType` + the `Schema` field DSL). `contentTypeToEntities`
7
+ // compiles each to a `<type>_drafts` + `<type>_published` table pair (see
8
+ // database/schema.ts), and the write pipeline (`saveDraft` → derive+validate,
9
+ // `publish`/`unpublish`) drives the lifecycle. There is NO framework
10
+ // discovery of `*.contentType.ts` — the schema file imports the types and
11
+ // registers their tables, which is what makes them migrate.
12
+ // • @voltro/plugin-auth — password sign-up/in/out over an HttpOnly session
13
+ // cookie so only signed-in editors reach the write surface, and
14
+ // `voltroPasswordStrategy` resolving that cookie into a typed `Subject`.
15
+ //
16
+ // Content is tenant-scoped: every derived content table carries `tenant()`, so
17
+ // the runtime AND-merges `tenantId = subject.tenantId` into reads and stamps it
18
+ // on writes — one tenant's drafts never leak to another. Boots ZERO-infra
19
+ // (`store: 'memory'`, in-process users); the README shows the production swaps.
20
+ import { defineEnv, envVar } from '@voltro/env'
21
+ import { authRoutesPlugin, memoryUserStore, voltroPasswordStrategy } from '@voltro/plugin-auth'
22
+
23
+ export const env = defineEnv({
24
+ LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
25
+ // HMAC key that signs + verifies the session cookie AND the CMS preview
26
+ // tokens (`previewToken` reads VOLTRO_SESSION_SECRET). `generate` means this
27
+ // project mints its OWN key into a gitignored `.env.local` on first boot — no
28
+ // value ships with the template (a shipped placeholder would be a signing key
29
+ // published to everyone who downloads it). Production mints its own the same
30
+ // way; a missing secret is a hard boot failure in serve/build/start.
31
+ VOLTRO_SESSION_SECRET: envVar.secret({
32
+ generate: 'base64url',
33
+ description: 'HMAC key that signs session cookies and CMS preview tokens.',
34
+ }),
35
+ })
36
+
37
+ // In-process user store — zero infra. `postgresUserStore({ sql })` for durable
38
+ // editor accounts (it manages its own `_voltro_auth_*` tables, auto-migrated).
39
+ const userStore = memoryUserStore()
40
+
41
+ export default {
42
+ type: 'api' as const,
43
+ name: '{{capProjectName}}{{capAppName}}',
44
+ store: 'memory' as const,
45
+ env,
46
+ plugins: [
47
+ authRoutesPlugin({
48
+ store: userStore,
49
+ // New editor sign-ups land in this tenant; all content they author is
50
+ // scoped to it.
51
+ defaultTenantId: 'acme',
52
+ successRedirect: '/',
53
+ cookieSecure: process.env.NODE_ENV === 'production',
54
+ }),
55
+ ],
56
+ auth: {
57
+ // Resolves the session cookie into the request Subject the handlers read
58
+ // (`ctx.request.subject`) and the store spine scopes writes by.
59
+ strategies: [voltroPasswordStrategy()],
60
+ },
61
+ }
@@ -0,0 +1,39 @@
1
+ // A content type declared as CODE — typesafe, version-controlled, PR-reviewed.
2
+ //
3
+ // Each field uses the CMS `Schema` field DSL (distinct from the @voltro/database
4
+ // column DSL). `defineContentType` resolves this to a descriptor the editor
5
+ // introspects to generate its form, and `contentTypeToEntities` (in
6
+ // database/schema.ts) compiles the fields DOWN to real `blogPost_drafts` +
7
+ // `blogPost_published` columns.
8
+ //
9
+ // Imported from the MAIN `@voltro/cms` entry because it runs server-side at
10
+ // discovery time (schema.ts imports it). The browser-safe field DSL is the same
11
+ // object either way — the editor page never imports this file; it fetches the
12
+ // resolved descriptor over rpc (content.types).
13
+ import { Schema, defineContentType, derivedFrom, slugify } from '@voltro/cms'
14
+
15
+ export const blogPost = defineContentType({
16
+ name: 'blogPost',
17
+ displayName: 'Blog post',
18
+ pluralName: 'Blog posts',
19
+ fields: {
20
+ // Single-line text, capped at 200 chars (validated at save time).
21
+ title: Schema.String.pipe(Schema.maxLength(200)),
22
+ // Computed-on-save from `title` (a caller value is overwritten), constrained
23
+ // to url-safe chars, and UNIQUE per tenant — the derived tables get a real
24
+ // composite (tenantId, slug) DB UNIQUE.
25
+ slug: Schema.String.pipe(
26
+ Schema.pattern(/^[a-z0-9-]+$/),
27
+ Schema.unique(),
28
+ derivedFrom('title', slugify),
29
+ ),
30
+ // Rich text stored as a JSON document (e.g. a TipTap doc). Images allowed.
31
+ body: Schema.RichText({ allowImages: true }),
32
+ // Optional short summary → nullable column + non-required editor input.
33
+ excerpt: Schema.String.pipe(Schema.maxLength(280)).optional(),
34
+ // Optional publish date the consumer app can order/filter by.
35
+ publishedAt: Schema.DateTime.optional(),
36
+ },
37
+ // Which columns the editor's list view shows + its default sort.
38
+ list: { columns: ['title', 'slug', 'status'], defaultSort: { field: 'updatedAt', dir: 'desc' } },
39
+ })
@@ -0,0 +1,18 @@
1
+ // The registry of content types this CMS serves. Adding a type = declaring it
2
+ // in a `*.contentType.ts` file and adding it here (+ its entities in
3
+ // database/schema.ts). The handlers look a type up by name through
4
+ // `contentTypeByName`; the editor lists them via the content.types action.
5
+ import type { ContentType } from '@voltro/cms'
6
+ import { blogPost } from './blogPost.contentType'
7
+ import { page } from './page.contentType'
8
+
9
+ export { blogPost, page }
10
+
11
+ export const contentTypes: ReadonlyArray<ContentType> = [blogPost, page]
12
+
13
+ /** Look a content type up by name — the guard the dynamic handlers run before
14
+ * touching a `<type>_drafts` / `<type>_published` table (an unknown name never
15
+ * reaches a table string). */
16
+ export const contentTypeByName: ReadonlyMap<string, ContentType> = new Map(
17
+ contentTypes.map((t) => [t.name, t]),
18
+ )
@@ -0,0 +1,24 @@
1
+ // A second content type — a generic marketing/site page. Shows that adding a
2
+ // content type is a single file: declare it here, register its entities in
3
+ // database/schema.ts, and the editor picks it up automatically (it fetches the
4
+ // registered types over content.types).
5
+ import { Schema, defineContentType, derivedFrom, slugify } from '@voltro/cms'
6
+
7
+ export const page = defineContentType({
8
+ name: 'page',
9
+ displayName: 'Page',
10
+ pluralName: 'Pages',
11
+ fields: {
12
+ title: Schema.String.pipe(Schema.maxLength(200)),
13
+ slug: Schema.String.pipe(
14
+ Schema.pattern(/^[a-z0-9-]+$/),
15
+ Schema.unique(),
16
+ derivedFrom('title', slugify),
17
+ ),
18
+ body: Schema.RichText(),
19
+ // A closed value set → `<select>` in the editor + a CHECK constraint.
20
+ layout: Schema.Literal('default', 'wide', 'landing'),
21
+ seoTitle: Schema.String.pipe(Schema.maxLength(70)).optional(),
22
+ },
23
+ list: { columns: ['title', 'slug', 'layout', 'status'], defaultSort: { field: 'updatedAt', dir: 'desc' } },
24
+ })
@@ -0,0 +1,64 @@
1
+ // Schema for the {{projectName}} CMS backend.
2
+ //
3
+ // The content TABLES aren't hand-declared — they're COMPILED from the content
4
+ // types. `contentTypeToEntities(blogPost)` returns a `{ draft, published }` pair
5
+ // (`blogPost_drafts` + `blogPost_published`), each already tenant-scoped
6
+ // (`tenant()`, which pulls `audit()` transitively) and carrying the lifecycle
7
+ // `status` column. Registering them in `databaseHandle` is what makes the
8
+ // framework's auto-migrate create them across every dialect. There is no
9
+ // discovery of `*.contentType.ts`; THIS import is the wiring.
10
+ import {
11
+ databaseHandle,
12
+ id,
13
+ table,
14
+ text,
15
+ timestamp,
16
+ type ColumnDefinition,
17
+ type InferRow,
18
+ type Table,
19
+ type TableLike,
20
+ } from '@voltro/database'
21
+ import { contentTypeToEntities } from '@voltro/cms'
22
+ import { blogPost, page } from '../content'
23
+
24
+ // ---------- Core tables (required by the audit / tenant mixins) ----------
25
+
26
+ export const actors = table('actors', {
27
+ id: id(),
28
+ kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
29
+ displayName: text().nullable(),
30
+ createdAt: timestamp().default('now'),
31
+ })
32
+
33
+ export const tenants = table('tenants', {
34
+ id: id(),
35
+ name: text(),
36
+ createdAt: timestamp().default('now'),
37
+ })
38
+
39
+ // ---------- Derived content tables ----------
40
+
41
+ // Compile each content type to its draft/published table pair. Exported so the
42
+ // tests can build a schema registry over the SAME tables the app registers.
43
+ export const blogEntities = contentTypeToEntities(blogPost)
44
+ export const pageEntities = contentTypeToEntities(page)
45
+
46
+ export type BlogPostDraft = InferRow<typeof blogEntities.draft>
47
+ export type PageDraft = InferRow<typeof pageEntities.draft>
48
+
49
+ // `contentTypeToEntities` returns the structural `TableLike` type (it hides the
50
+ // concrete generic), but each value IS a real `table(...).with(tenant())` at
51
+ // runtime — exactly what `databaseHandle` registers. This boundary cast bridges
52
+ // the two: no `any`, no behavioural change, just the concrete table type the
53
+ // handle's inference expects.
54
+ const asTable = (t: TableLike): Table<string, Record<string, ColumnDefinition<unknown>>, boolean, string> =>
55
+ t as unknown as Table<string, Record<string, ColumnDefinition<unknown>>, boolean, string>
56
+
57
+ export const database = databaseHandle({
58
+ actors,
59
+ tenants,
60
+ blogPostDrafts: asTable(blogEntities.draft),
61
+ blogPostPublished: asTable(blogEntities.published),
62
+ pageDrafts: asTable(pageEntities.draft),
63
+ pagePublished: asTable(pageEntities.published),
64
+ })
@@ -0,0 +1,19 @@
1
+ // Effect-mode executor. `publishEffect` runs `publish` inside `store.transactional`
2
+ // (draft copy + status flip land atomically) and fails typed `ContentNotFound`
3
+ // when the id has no draft in the caller's scope — caught into `{ ok: false }`.
4
+ import { Effect } from 'effect'
5
+ import type { AppContext } from '@voltro/runtime'
6
+ import { publishEffect } from '@voltro/cms'
7
+ import { contentTypeByName } from '../content'
8
+
9
+ const execute = (input: { type: string; id: string }, ctx: AppContext) =>
10
+ Effect.gen(function* () {
11
+ const ct = contentTypeByName.get(input.type)
12
+ if (ct === undefined) return { ok: false as const }
13
+ return yield* publishEffect(ctx.store, ct, input.id).pipe(
14
+ Effect.map((row) => ({ ok: true as const, id: row['id'] as string, status: row['status'] as string })),
15
+ Effect.catchTag('ContentNotFound', () => Effect.succeed({ ok: false as const })),
16
+ )
17
+ })
18
+
19
+ export default execute
@@ -0,0 +1,15 @@
1
+ // `content.publish` — copy a draft into `<type>_published` (atomic per row) and
2
+ // mark the draft published. Both tables are reactive, so a publish wakes live
3
+ // consumer subscriptions. A missing draft (including another tenant's, which
4
+ // reads as absent) comes back as `{ ok: false }` rather than an error.
5
+ import { defineMutation } from '@voltro/protocol'
6
+ import { Schema } from 'effect'
7
+
8
+ export const publishContent = defineMutation({
9
+ name: 'content.publish',
10
+ input: Schema.Struct({ type: Schema.NonEmptyString, id: Schema.NonEmptyString }),
11
+ output: Schema.Union(
12
+ Schema.Struct({ ok: Schema.Literal(true), id: Schema.String, status: Schema.String }),
13
+ Schema.Struct({ ok: Schema.Literal(false) }),
14
+ ),
15
+ })
@@ -0,0 +1,36 @@
1
+ // Effect-mode executor. `saveDraftEffect` runs the pipeline over the
2
+ // request-scoped `ctx.store` (a DataStore): it derives the computed fields,
3
+ // validates, and writes to `<type>_drafts` — the tenant/audit columns stamped
4
+ // by the store spine. Its typed failures ride the Effect error channel, so we
5
+ // `catchTag` them into the result union instead of letting them become a 500.
6
+ import { Effect } from 'effect'
7
+ import type { AppContext } from '@voltro/runtime'
8
+ import { saveDraftEffect } from '@voltro/cms'
9
+ import { contentTypeByName } from '../content'
10
+
11
+ const execute = (input: { type: string; values: Record<string, unknown> }, ctx: AppContext) =>
12
+ Effect.gen(function* () {
13
+ const ct = contentTypeByName.get(input.type)
14
+ if (ct === undefined) {
15
+ return {
16
+ ok: false as const,
17
+ violations: [{ field: 'type', rule: 'unknown', message: `unknown content type '${input.type}'` }],
18
+ }
19
+ }
20
+ return yield* saveDraftEffect(ctx.store, ct, input.values).pipe(
21
+ Effect.map((row) => ({ ok: true as const, id: row['id'] as string, status: row['status'] as string })),
22
+ // A rule violation → annotate the form; not an error.
23
+ Effect.catchTag('ContentValidationFailed', (e) =>
24
+ Effect.succeed({ ok: false as const, violations: [...e.violations] }),
25
+ ),
26
+ // A caller-pinned id owned by another tenant → refuse, surfaced the same way.
27
+ Effect.catchTag('ContentIdConflict', (e) =>
28
+ Effect.succeed({
29
+ ok: false as const,
30
+ violations: [{ field: 'id', rule: 'conflict', message: `id '${e.id}' belongs to another tenant` }],
31
+ }),
32
+ ),
33
+ )
34
+ })
35
+
36
+ export default execute
@@ -0,0 +1,32 @@
1
+ // `content.saveDraft` — upsert a draft. The executor runs the CMS pipeline
2
+ // (derive `slug` from `title`, validate every field rule, write to
3
+ // `<type>_drafts`). Validation failures don't crash: they come back as a typed
4
+ // RESULT union (`{ ok: false, violations }`) the editor annotates the form from,
5
+ // mirroring `contentViolations` on the client. Omit `values.id` to create; pass
6
+ // it to update your own draft in place.
7
+ //
8
+ // The descriptor stays pure effect/Schema (no @voltro/cms import) so it is
9
+ // browser-safe with nothing extra to pull into the bundle — the result union is
10
+ // what carries the validation outcome across the wire.
11
+ import { defineMutation } from '@voltro/protocol'
12
+ import { Schema } from 'effect'
13
+
14
+ const Violation = Schema.Struct({
15
+ field: Schema.String,
16
+ rule: Schema.String,
17
+ message: Schema.String,
18
+ })
19
+
20
+ export const saveDraft = defineMutation({
21
+ name: 'content.saveDraft',
22
+ input: Schema.Struct({
23
+ type: Schema.NonEmptyString,
24
+ // Arbitrary per-type field values (may carry `id` to update an existing
25
+ // draft). Validated on the server against the content type's rules.
26
+ values: Schema.Record({ key: Schema.String, value: Schema.Unknown }),
27
+ }),
28
+ output: Schema.Union(
29
+ Schema.Struct({ ok: Schema.Literal(true), id: Schema.String, status: Schema.String }),
30
+ Schema.Struct({ ok: Schema.Literal(false), violations: Schema.Array(Violation) }),
31
+ ),
32
+ })
@@ -0,0 +1,19 @@
1
+ // Effect-mode executor. `unpublishEffect` deletes the published row + reverts
2
+ // the draft inside a transaction; `ContentNotFound` (no published row in scope)
3
+ // is caught into `{ ok: false }`.
4
+ import { Effect } from 'effect'
5
+ import type { AppContext } from '@voltro/runtime'
6
+ import { unpublishEffect } from '@voltro/cms'
7
+ import { contentTypeByName } from '../content'
8
+
9
+ const execute = (input: { type: string; id: string }, ctx: AppContext) =>
10
+ Effect.gen(function* () {
11
+ const ct = contentTypeByName.get(input.type)
12
+ if (ct === undefined) return { ok: false }
13
+ return yield* unpublishEffect(ctx.store, ct, input.id).pipe(
14
+ Effect.map(() => ({ ok: true })),
15
+ Effect.catchTag('ContentNotFound', () => Effect.succeed({ ok: false })),
16
+ )
17
+ })
18
+
19
+ export default execute
@@ -0,0 +1,11 @@
1
+ // `content.unpublish` — take a published row down: delete it from
2
+ // `<type>_published` and flip the draft back to `status: 'draft'` (atomic per
3
+ // row). A row with no published copy in the caller's scope → `{ ok: false }`.
4
+ import { defineMutation } from '@voltro/protocol'
5
+ import { Schema } from 'effect'
6
+
7
+ export const unpublishContent = defineMutation({
8
+ name: 'content.unpublish',
9
+ input: Schema.Struct({ type: Schema.NonEmptyString, id: Schema.NonEmptyString }),
10
+ output: Schema.Struct({ ok: Schema.Boolean }),
11
+ })
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@{{projectName}}/{{appName}}",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "voltro dev .",
8
+ "lint": "voltro doctor .",
9
+ "migrate": "voltro migrate",
10
+ "test": "voltro test",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@effect/platform": "^0.97.0",
15
+ "@effect/rpc": "^0.76.0",
16
+ "@voltro/cli": "0.29.0",
17
+ "@voltro/cms": "0.29.0",
18
+ "@voltro/database": "0.29.0",
19
+ "@voltro/env": "0.29.0",
20
+ "@voltro/plugin-auth": "0.29.0",
21
+ "@voltro/plugin-multitenancy": "0.29.0",
22
+ "@voltro/protocol": "0.29.0",
23
+ "@voltro/runtime": "0.29.0",
24
+ "@voltro/sql-postgres": "0.29.0",
25
+ "effect": "^3.22.0"
26
+ },
27
+ "devDependencies": {
28
+ "@voltro/testing": "0.29.0",
29
+ "typescript": "^6.0.3",
30
+ "vitest": "^4.1.10"
31
+ }
32
+ }