@voltro/cli 0.28.0 → 0.30.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.
- package/CHANGELOG.md +655 -0
- package/THIRD-PARTY-NOTICES.md +90 -4
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BceikpQf.js} +3 -3
- package/dist/apiBuild-CxvR1Dwt.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BMr-X48w.js +2427 -0
- package/dist/checkCommand-BOk9y264.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-CSkrUI1h.js → commands-D4Y1-Nlu.js} +8575 -9366
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-C7hbl5pk.js} +345 -266
- package/dist/dbCommand-D8nhyppR.js +2 -0
- package/dist/{dev-DlBWWnJQ.js → dev-Cr-teYsz.js} +1 -1
- package/dist/{dev-CEZwJhmb.js → dev-DmnkH-wr.js} +2939 -1832
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-BzAajuUv.js} +115 -99
- package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BUUjt773.js} +349 -192
- package/dist/inspect-mmBuRXmy.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-BeJdP_3b.js} +1068 -978
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-BLrVuSlM.js} +1 -1
- package/dist/manifestBuild-Dj8Jjoto.js +2 -0
- package/dist/sdkgen-CqpOq_fy.js +582 -0
- package/dist/serveCommand-Ciq_wIY8.js +1577 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BGXIf6zT.js → start-ocqB1seX.js} +283 -270
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +50 -0
- package/templates/AGENTS.md +54 -2
- package/templates/agent-docs/_index.md +4 -2
- package/templates/agent-docs/_manifest.json +26 -5
- package/templates/agent-docs/ai.md +191 -0
- package/templates/agent-docs/cli.md +143 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/internationalization.md +169 -8
- package/templates/agent-docs/local-first-mobile.md +426 -0
- package/templates/agent-docs/routing.md +94 -3
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/security.md +17 -0
- package/templates/agent-docs/templates/apibackends.md +210 -1
- package/templates/agent-docs/templates/appshells.md +144 -1
- package/templates/agent-docs/templates/mobile.md +60 -0
- package/templates/agent-docs/templates/overview.md +17 -7
- package/templates/agent-docs/whats-new.md +238 -158
- package/templates/agent-docs/workflows.md +376 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-collab/README.md +70 -0
- package/templates/apps/api-collab/app.config.ts +28 -0
- package/templates/apps/api-collab/database/schema.ts +63 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
- package/templates/apps/api-collab/package.json +30 -0
- package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
- package/templates/apps/api-collab/queries/documents.query.ts +23 -0
- package/templates/apps/api-collab/template.json +6 -0
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
- package/templates/apps/api-collab/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/app.config.ts +1 -1
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/app.config.ts +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/app.config.ts +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-collab/README.md +80 -0
- package/templates/apps/frontend-collab/app.config.ts +38 -0
- package/templates/apps/frontend-collab/package.json +33 -0
- package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
- package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
- package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
- package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
- package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
- package/templates/apps/frontend-collab/template.json +6 -0
- package/templates/apps/frontend-collab/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/app.config.ts +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/app.config.ts +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr/app.config.ts +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/apps/mobile-app/README.md +58 -0
- package/templates/apps/mobile-app/app.config.ts +33 -0
- package/templates/apps/mobile-app/babel.config.js +5 -0
- package/templates/apps/mobile-app/metro.config.js +20 -0
- package/templates/apps/mobile-app/package.json +33 -0
- package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
- package/templates/apps/mobile-app/src/app/index.tsx +62 -0
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
- package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
- package/templates/apps/mobile-app/src/client.ts +74 -0
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
- package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
- package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
- package/templates/apps/mobile-app/src/persistence.ts +38 -0
- package/templates/apps/mobile-app/template.json +6 -0
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
- package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
- package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
- package/templates/apps/mobile-app/tsconfig.json +10 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
- package/dist/apiBuild-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-ZTn-dPFa.js +0 -1425
|
@@ -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.30.0",
|
|
17
|
+
"@voltro/cms": "0.30.0",
|
|
18
|
+
"@voltro/database": "0.30.0",
|
|
19
|
+
"@voltro/env": "0.30.0",
|
|
20
|
+
"@voltro/plugin-auth": "0.30.0",
|
|
21
|
+
"@voltro/plugin-multitenancy": "0.30.0",
|
|
22
|
+
"@voltro/protocol": "0.30.0",
|
|
23
|
+
"@voltro/runtime": "0.30.0",
|
|
24
|
+
"@voltro/sql-postgres": "0.30.0",
|
|
25
|
+
"effect": "^3.22.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@voltro/testing": "0.30.0",
|
|
29
|
+
"typescript": "^6.0.3",
|
|
30
|
+
"vitest": "^4.1.10"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// `content.list` — a LIVE, tenant-scoped list of one content type's rows. The
|
|
2
|
+
// editor subscribes with `useSubscription('cms', 'content.list', { type })`; a
|
|
3
|
+
// publish/save wakes the subscription so the list updates without a refetch.
|
|
4
|
+
// `status` picks the table: 'draft' (the editor's working set, the default) or
|
|
5
|
+
// 'published' (what consumers read).
|
|
6
|
+
//
|
|
7
|
+
// The output pins only the columns a list view needs — the runtime drops the
|
|
8
|
+
// rest (`Schema.Struct` ignores excess), so ONE query shape serves every content
|
|
9
|
+
// type even though their full rows differ. `title`/`slug` are optional so a type
|
|
10
|
+
// without them still decodes.
|
|
11
|
+
import { defineQuery } from '@voltro/protocol'
|
|
12
|
+
import { Schema } from 'effect'
|
|
13
|
+
|
|
14
|
+
export const listContent = defineQuery({
|
|
15
|
+
name: 'content.list',
|
|
16
|
+
input: Schema.Struct({
|
|
17
|
+
type: Schema.NonEmptyString,
|
|
18
|
+
status: Schema.optional(Schema.Literal('draft', 'published')),
|
|
19
|
+
}),
|
|
20
|
+
output: Schema.Struct({
|
|
21
|
+
id: Schema.String,
|
|
22
|
+
status: Schema.String,
|
|
23
|
+
title: Schema.optional(Schema.String),
|
|
24
|
+
slug: Schema.optional(Schema.String),
|
|
25
|
+
updatedAt: Schema.Date,
|
|
26
|
+
}),
|
|
27
|
+
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "api-cms",
|
|
3
|
+
"kind": "api",
|
|
4
|
+
"summary": "A headless CMS backend composed from @voltro/cms: content types declared as code (defineContentType + the Schema field DSL) compile to draft/published tables, the write pipeline (saveDraft → derive+validate, publish/unpublish) drives the lifecycle, and a typed rpc surface (content.list/get/types/saveDraft/publish/unpublish) feeds an editor. Auth via @voltro/plugin-auth; tenant-scoped content. Pair with the frontend-cms editor web template. Boots zero-infra.",
|
|
5
|
+
"tags": ["api", "cms", "content", "headless", "drafts", "publishing", "editor", "auth"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Descriptor + executor contract pins — the shapes the client codegen and the
|
|
2
|
+
// editor depend on. No database, no running server. Run with `voltro test`.
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { Schema } from 'effect'
|
|
6
|
+
import { makeTestContext } from '@voltro/testing'
|
|
7
|
+
import '../database/schema' // registers the core + derived content tables
|
|
8
|
+
import { saveDraft } from '../mutations/content.saveDraft.mutation'
|
|
9
|
+
import { listContent } from '../queries/content.list.query'
|
|
10
|
+
import contentTypesExecute from '../actions/content.types.action.server'
|
|
11
|
+
|
|
12
|
+
describe('content.saveDraft descriptor', () => {
|
|
13
|
+
it('decodes an input carrying arbitrary per-type values', () => {
|
|
14
|
+
const decode = Schema.decodeUnknownSync(saveDraft.input)
|
|
15
|
+
const input = { type: 'blogPost', values: { title: 'Hi', body: 'x' } }
|
|
16
|
+
expect(decode(input)).toEqual(input)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('encodes both arms of the result union', () => {
|
|
20
|
+
const encode = Schema.encodeUnknownSync(saveDraft.output)
|
|
21
|
+
expect(encode({ ok: true, id: 'blogPost_1', status: 'draft' })).toEqual({ ok: true, id: 'blogPost_1', status: 'draft' })
|
|
22
|
+
const fail = { ok: false, violations: [{ field: 'title', rule: 'required', message: 'missing' }] }
|
|
23
|
+
expect(encode(fail)).toEqual(fail)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('content.list descriptor', () => {
|
|
28
|
+
it('accepts a type + optional status and pins the list projection shape', () => {
|
|
29
|
+
const decodeIn = Schema.decodeUnknownSync(listContent.input)
|
|
30
|
+
expect(decodeIn({ type: 'blogPost', status: 'draft' })).toEqual({ type: 'blogPost', status: 'draft' })
|
|
31
|
+
// The output carries only what a list view needs — id/status/title?/slug?/
|
|
32
|
+
// updatedAt; the runtime drops a row's other columns against this shape.
|
|
33
|
+
const encode = Schema.encodeUnknownSync(listContent.output)
|
|
34
|
+
const wire = encode({ id: 'p1', status: 'published', title: 'T', slug: 't', updatedAt: new Date(0) })
|
|
35
|
+
expect(wire.id).toBe('p1')
|
|
36
|
+
expect(wire.status).toBe('published')
|
|
37
|
+
expect(wire.title).toBe('T')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('content.types executor', () => {
|
|
42
|
+
it('projects the registered types to a wire-safe, order-preserving descriptor', async () => {
|
|
43
|
+
const ctx = makeTestContext({ subject: { type: 'user', id: 'u1', tenantId: 'acme' } })
|
|
44
|
+
const out = await contentTypesExecute({}, ctx)
|
|
45
|
+
|
|
46
|
+
const names = out.types.map((t) => t.name)
|
|
47
|
+
expect(names).toContain('blogPost')
|
|
48
|
+
expect(names).toContain('page')
|
|
49
|
+
|
|
50
|
+
const blog = out.types.find((t) => t.name === 'blogPost')!
|
|
51
|
+
const fieldNames = blog.fields.map((f) => f.name)
|
|
52
|
+
expect(fieldNames).toEqual(['title', 'slug', 'body', 'excerpt', 'publishedAt'])
|
|
53
|
+
// The slug field is a string with a richer widget than plain text is fine —
|
|
54
|
+
// and its derived transform (a function) is NOT on the wire shape.
|
|
55
|
+
const slug = blog.fields.find((f) => f.name === 'slug')!
|
|
56
|
+
expect(slug.kind).toBe('string')
|
|
57
|
+
expect('transform' in slug).toBe(false)
|
|
58
|
+
// The Literal `layout` on `page` carries its value set for the <select>.
|
|
59
|
+
const page = out.types.find((t) => t.name === 'page')!
|
|
60
|
+
const layout = page.fields.find((f) => f.name === 'layout')!
|
|
61
|
+
expect(layout.editorHint).toBe('select')
|
|
62
|
+
expect(layout.literals).toEqual(['default', 'wide', 'landing'])
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The CMS write pipeline against the REAL runtime store spine — an
|
|
2
|
+
// InMemoryDataStore wrapped by `wrapStoreWithMixinBehaviour` with a registry
|
|
3
|
+
// built from the SAME derived tables the app registers. That's what makes these
|
|
4
|
+
// assertions honest: derivation + validation come from @voltro/cms, and
|
|
5
|
+
// tenant-scoping/stamping come from the spine, not a mock. Run with `voltro test`.
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest'
|
|
8
|
+
import {
|
|
9
|
+
InMemoryDataStore,
|
|
10
|
+
makeSchemaRegistry,
|
|
11
|
+
wrapStoreWithMixinBehaviour,
|
|
12
|
+
type RegistryTableLike,
|
|
13
|
+
} from '@voltro/runtime'
|
|
14
|
+
import { ContentValidationFailed, publish, saveDraft } from '@voltro/cms'
|
|
15
|
+
import { blogPost } from '../content'
|
|
16
|
+
import { blogEntities, pageEntities } from '../database/schema'
|
|
17
|
+
|
|
18
|
+
// Registry over the real derived tables → hasTenant/hasAudit/idScheme reflect a
|
|
19
|
+
// booted app exactly.
|
|
20
|
+
const registry = makeSchemaRegistry([
|
|
21
|
+
blogEntities.draft, blogEntities.published,
|
|
22
|
+
pageEntities.draft, pageEntities.published,
|
|
23
|
+
] as ReadonlyArray<RegistryTableLike>)
|
|
24
|
+
|
|
25
|
+
// A request-scoped store for one tenant over a shared underlying store.
|
|
26
|
+
const storeFor = (underlying: InMemoryDataStore, tenantId: string) =>
|
|
27
|
+
wrapStoreWithMixinBehaviour(underlying, {
|
|
28
|
+
subject: { type: 'user', id: 'usr_editor', tenantId },
|
|
29
|
+
schemaRegistry: registry,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const validInput = {
|
|
33
|
+
title: 'Hello World',
|
|
34
|
+
body: { type: 'doc', content: [{ type: 'paragraph' }] },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('saveDraft → derive + validate + write', () => {
|
|
38
|
+
it('derives the slug from the title, stamps status/tenant, generates an id', async () => {
|
|
39
|
+
const store = storeFor(new InMemoryDataStore(), 'tnt_A')
|
|
40
|
+
const draft = await saveDraft(store, blogPost, validInput)
|
|
41
|
+
|
|
42
|
+
expect(draft['slug']).toBe('hello-world') // derivedFrom('title', slugify)
|
|
43
|
+
expect(draft['status']).toBe('draft')
|
|
44
|
+
expect(draft['tenantId']).toBe('tnt_A') // stamped by the spine
|
|
45
|
+
expect(typeof draft['id']).toBe('string')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('rejects an invalid row with ContentValidationFailed carrying per-field violations', async () => {
|
|
49
|
+
const store = storeFor(new InMemoryDataStore(), 'tnt_A')
|
|
50
|
+
// Missing required `title` (so the derived slug is empty → pattern fails too).
|
|
51
|
+
await expect(saveDraft(store, blogPost, { body: 'x' })).rejects.toBeInstanceOf(ContentValidationFailed)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('publish → copy draft to published', () => {
|
|
56
|
+
it('publishes a draft and the published row keeps the same id + slug', async () => {
|
|
57
|
+
const store = storeFor(new InMemoryDataStore(), 'tnt_A')
|
|
58
|
+
const draft = await saveDraft(store, blogPost, validInput)
|
|
59
|
+
|
|
60
|
+
const published = await publish(store, blogPost, draft['id'] as string)
|
|
61
|
+
expect(published['id']).toBe(draft['id'])
|
|
62
|
+
expect(published['slug']).toBe('hello-world')
|
|
63
|
+
expect(published['status']).toBe('published')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('tenant isolation (REAL scoping)', () => {
|
|
68
|
+
it("tenant B never sees tenant A's published content", async () => {
|
|
69
|
+
const underlying = new InMemoryDataStore()
|
|
70
|
+
const a = storeFor(underlying, 'tnt_A')
|
|
71
|
+
const b = storeFor(underlying, 'tnt_B')
|
|
72
|
+
|
|
73
|
+
const draft = await saveDraft(a, blogPost, validInput)
|
|
74
|
+
await publish(a, blogPost, draft['id'] as string)
|
|
75
|
+
|
|
76
|
+
const seenByA = await a.query({
|
|
77
|
+
table: 'blogPost_published', predicate: undefined, order: [], take: 10, skip: undefined, projection: undefined,
|
|
78
|
+
})
|
|
79
|
+
const seenByB = await b.query({
|
|
80
|
+
table: 'blogPost_published', predicate: undefined, order: [], take: 10, skip: undefined, projection: undefined,
|
|
81
|
+
})
|
|
82
|
+
expect(seenByA).toHaveLength(1)
|
|
83
|
+
expect(seenByB).toHaveLength(0)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# {{projectName}} / {{appName}}
|
|
2
|
+
|
|
3
|
+
Voltro collaborative-editing backend (template: **api-collab**).
|
|
4
|
+
|
|
5
|
+
Real-time **convergent** editing on a `crdtText()` column — concurrent edits
|
|
6
|
+
from many clients merge without a last-write-wins loser, authoritatively on the
|
|
7
|
+
server, then broadcast over the reactive engine. Zero infra (`store: 'memory'`):
|
|
8
|
+
the merge and the broadcast are both in-process, so two browser tabs pointed at
|
|
9
|
+
one `voltro dev` collaborate with no database and no external service.
|
|
10
|
+
|
|
11
|
+
## Boot
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm install # at the repo root
|
|
15
|
+
pnpm --filter @{{projectName}}/{{appName}} dev
|
|
16
|
+
# → http://localhost:4000
|
|
17
|
+
# → ws://localhost:4000/ws
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Pair it with a frontend
|
|
21
|
+
|
|
22
|
+
`api-collab` exposes the `documents.list` subscription + `documents.create` /
|
|
23
|
+
`documents.setBody` mutations that [`frontend-collab`](../frontend-collab)
|
|
24
|
+
binds a collaborative editor to. Scaffold both together:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
voltro create-project collab --api=api-collab --web=frontend-collab
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## What's in here
|
|
31
|
+
|
|
32
|
+
| File | Role |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `app.config.ts` | App declaration (`store: 'memory'`). |
|
|
35
|
+
| `database/schema.ts` | `documents` table — a `crdtText()` `body`, plus `tenant()` + `localFirst()`. |
|
|
36
|
+
| `mutations/documents.create.mutation.ts` | Create a document (title only; `body` starts empty). |
|
|
37
|
+
| `mutations/documents.setBody.mutation.ts` | The CRDT write — a client's encoded update; the runtime merges it. |
|
|
38
|
+
| `queries/documents.query.ts` | `documents.list` streaming subscription. |
|
|
39
|
+
| `tests/documents.setBody.test.ts` | Convergence proof — two concurrent edits merge, order-independently. |
|
|
40
|
+
|
|
41
|
+
## How the CRDT merge works
|
|
42
|
+
|
|
43
|
+
`body: crdtText()` stores the encoded CRDT state as an opaque `bytes` blob. A
|
|
44
|
+
client edits its LOCAL handle and sends the encoded update to
|
|
45
|
+
`documents.setBody`. The handler writes it like any column — but because `body`
|
|
46
|
+
is CRDT-managed, the runtime's MutationStore intercepts the write, reads the
|
|
47
|
+
stored state, and folds the incoming update in with `mergeCrdtStates` (from
|
|
48
|
+
`@voltro/local-first`) **before** persisting. That authoritative server merge is
|
|
49
|
+
the convergence guarantee; the reactive engine then pushes the merged row to
|
|
50
|
+
every subscriber.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
Client A ──update A──►┐
|
|
54
|
+
├─► documents.setBody ─► store.update('documents', id, { body })
|
|
55
|
+
Client B ──update B──►┘ └─ runtime folds update into STORED
|
|
56
|
+
state (mergeCrdtStates) → converges
|
|
57
|
+
└─► reactive delta ─► all subscribers
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`tests/documents.setBody.test.ts` proves it with no database: `ctx.store` from
|
|
61
|
+
`@voltro/testing` is the same mixin-wrapped store as production, so the merge
|
|
62
|
+
runs exactly as it does at runtime.
|
|
63
|
+
|
|
64
|
+
## Multi-tenancy note
|
|
65
|
+
|
|
66
|
+
The dev `AuthMiddleware` resolves the tenant from the `x-tenant` header,
|
|
67
|
+
defaulting to `'acme'`. The `documents` table carries the `tenant()` mixin, so
|
|
68
|
+
reads/writes are auto-scoped and `documents.create` guards that the submitted
|
|
69
|
+
`tenantId` matches the caller's (a typed `TenantMismatch` otherwise). Wire real
|
|
70
|
+
auth before production (see the authentication docs).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Collaborative-editing backend for the {{projectName}} project. Read by `voltro dev`.
|
|
2
|
+
//
|
|
3
|
+
// This is the LOCAL-FIRST / CRDT template — it exposes a `documents` table
|
|
4
|
+
// whose `body` is a `crdtText()` column. When two clients edit the same body
|
|
5
|
+
// concurrently, the runtime folds each incoming update into the stored state
|
|
6
|
+
// with an authoritative SERVER-SIDE CRDT merge (on the write path, before the
|
|
7
|
+
// row is written) so their edits CONVERGE — no last-write-wins loser — and the
|
|
8
|
+
// reactive engine broadcasts the merged result to every subscriber.
|
|
9
|
+
//
|
|
10
|
+
// `store: 'memory'` keeps it ZERO-INFRA: the merge + the broadcast are both
|
|
11
|
+
// in-process, so two browser tabs pointed at one `voltro dev` collaborate with
|
|
12
|
+
// no database, no Redis, no external service. Switch to `'postgres'` (or run
|
|
13
|
+
// `voltro add redis` for a cross-instance broadcast) when you go distributed.
|
|
14
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
15
|
+
|
|
16
|
+
// Typed environment — declared once, validated at boot (fail-fast) and read on
|
|
17
|
+
// the server via `serverEnv` / `getSecret` from '@voltro/env/server'. Run
|
|
18
|
+
// `voltro env` for the manifest.
|
|
19
|
+
export const env = defineEnv({
|
|
20
|
+
LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
type: 'api' as const,
|
|
25
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
26
|
+
store: 'memory' as const,
|
|
27
|
+
env,
|
|
28
|
+
}
|