@voltro/cli 0.29.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.
Files changed (169) hide show
  1. package/CHANGELOG.md +377 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/{apiBuild-BESZbTjA.js → apiBuild-BceikpQf.js} +2 -2
  4. package/dist/apiBuild-CxvR1Dwt.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{checkCommand-BPAtyWs8.js → checkCommand-BMr-X48w.js} +23 -16
  7. package/dist/checkCommand-BOk9y264.js +2 -0
  8. package/dist/{commands-82BDEktg.js → commands-D4Y1-Nlu.js} +1936 -1339
  9. package/dist/{dbCommand-bCBbk6tz.js → dbCommand-C7hbl5pk.js} +344 -265
  10. package/dist/dbCommand-D8nhyppR.js +2 -0
  11. package/dist/{dev-DeEJj5T4.js → dev-Cr-teYsz.js} +1 -1
  12. package/dist/{dev-Cg4BKLmi.js → dev-DmnkH-wr.js} +2660 -1742
  13. package/dist/{frameworkTableAssembly-4Db40V23.js → frameworkTableAssembly-BzAajuUv.js} +114 -98
  14. package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.js +1 -1
  17. package/dist/{inspect-BMvQpwqy.js → inspect-BUUjt773.js} +150 -14
  18. package/dist/inspect-mmBuRXmy.js +2 -0
  19. package/dist/{inspectMetrics-DHYygE4s.js → inspectMetrics-BeJdP_3b.js} +564 -512
  20. package/dist/{manifestBuild-C4R1EdHn.js → manifestBuild-BLrVuSlM.js} +1 -1
  21. package/dist/manifestBuild-Dj8Jjoto.js +2 -0
  22. package/dist/{sdkgen-CW5NbYF0.js → sdkgen-CqpOq_fy.js} +1 -1
  23. package/dist/serveCommand-Ciq_wIY8.js +1577 -0
  24. package/dist/serveEntry.js +2 -2
  25. package/dist/{start-CsCIaW4-.js → start-ocqB1seX.js} +19 -19
  26. package/dist/startEntry.js +2 -2
  27. package/package.json +17 -17
  28. package/templates/AGENTS.core.md +50 -0
  29. package/templates/AGENTS.md +53 -2
  30. package/templates/agent-docs/_index.md +3 -2
  31. package/templates/agent-docs/_manifest.json +16 -4
  32. package/templates/agent-docs/ai.md +56 -0
  33. package/templates/agent-docs/cli.md +19 -0
  34. package/templates/agent-docs/internationalization.md +8 -7
  35. package/templates/agent-docs/local-first-mobile.md +18 -6
  36. package/templates/agent-docs/routing.md +1 -1
  37. package/templates/agent-docs/security.md +17 -0
  38. package/templates/agent-docs/templates/apibackends.md +209 -0
  39. package/templates/agent-docs/templates/appshells.md +144 -1
  40. package/templates/agent-docs/templates/mobile.md +60 -0
  41. package/templates/agent-docs/templates/overview.md +17 -7
  42. package/templates/agent-docs/whats-new.md +256 -157
  43. package/templates/agent-docs/workflows.md +365 -0
  44. package/templates/apps/api-ai/package.json +7 -7
  45. package/templates/apps/api-auth/package.json +8 -8
  46. package/templates/apps/api-backend/package.json +7 -7
  47. package/templates/apps/api-backend-deactivation/package.json +7 -7
  48. package/templates/apps/api-backend-mail/package.json +8 -8
  49. package/templates/apps/api-backend-mariadb/package.json +9 -9
  50. package/templates/apps/api-backend-sqlite/package.json +8 -8
  51. package/templates/apps/api-backend-storage/package.json +8 -8
  52. package/templates/apps/api-cms/package.json +10 -10
  53. package/templates/apps/api-collab/README.md +70 -0
  54. package/templates/apps/api-collab/app.config.ts +28 -0
  55. package/templates/apps/api-collab/database/schema.ts +63 -0
  56. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
  57. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
  58. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
  59. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
  60. package/templates/apps/api-collab/package.json +30 -0
  61. package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
  62. package/templates/apps/api-collab/queries/documents.query.ts +23 -0
  63. package/templates/apps/api-collab/template.json +6 -0
  64. package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
  65. package/templates/apps/api-collab/tsconfig.json +5 -0
  66. package/templates/apps/api-data-advanced/package.json +8 -8
  67. package/templates/apps/api-durable/package.json +8 -8
  68. package/templates/apps/api-feature-flags/package.json +9 -9
  69. package/templates/apps/api-governance/package.json +8 -8
  70. package/templates/apps/api-kv/package.json +8 -8
  71. package/templates/apps/api-moderation/package.json +8 -8
  72. package/templates/apps/api-observability/package.json +8 -8
  73. package/templates/apps/api-ratelimit/package.json +8 -8
  74. package/templates/apps/api-rbac/package.json +8 -8
  75. package/templates/apps/api-rest/package.json +7 -7
  76. package/templates/apps/api-saas/package.json +11 -11
  77. package/templates/apps/api-saas-starter/package.json +10 -10
  78. package/templates/apps/api-search/package.json +8 -8
  79. package/templates/apps/api-status/package.json +8 -8
  80. package/templates/apps/api-versioning/package.json +8 -8
  81. package/templates/apps/api-webhooks/package.json +9 -9
  82. package/templates/apps/changelog/package.json +6 -6
  83. package/templates/apps/edge-functions/package.json +2 -2
  84. package/templates/apps/frontend-admin/app.config.ts +1 -1
  85. package/templates/apps/frontend-admin/package.json +8 -8
  86. package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
  87. package/templates/apps/frontend-app/app.config.ts +1 -1
  88. package/templates/apps/frontend-app/package.json +8 -8
  89. package/templates/apps/frontend-app/src/locales/index.ts +1 -1
  90. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
  91. package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
  92. package/templates/apps/frontend-auth/package.json +8 -8
  93. package/templates/apps/frontend-auth/src/locales/index.ts +1 -1
  94. package/templates/apps/frontend-blank/app.config.ts +1 -1
  95. package/templates/apps/frontend-blank/package.json +7 -7
  96. package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
  97. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
  98. package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
  99. package/templates/apps/frontend-cms/package.json +9 -9
  100. package/templates/apps/frontend-cms/src/locales/index.ts +1 -1
  101. package/templates/apps/frontend-collab/README.md +80 -0
  102. package/templates/apps/frontend-collab/app.config.ts +38 -0
  103. package/templates/apps/frontend-collab/package.json +33 -0
  104. package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
  105. package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
  106. package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
  107. package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
  108. package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
  109. package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
  110. package/templates/apps/frontend-collab/template.json +6 -0
  111. package/templates/apps/frontend-collab/tsconfig.json +11 -0
  112. package/templates/apps/frontend-contact/package.json +7 -7
  113. package/templates/apps/frontend-dashboard/app.config.ts +1 -1
  114. package/templates/apps/frontend-dashboard/package.json +7 -7
  115. package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
  116. package/templates/apps/frontend-docs/package.json +7 -7
  117. package/templates/apps/frontend-i18n/package.json +6 -6
  118. package/templates/apps/frontend-landing/package.json +7 -7
  119. package/templates/apps/frontend-portal/package.json +8 -8
  120. package/templates/apps/frontend-portal/src/locales/index.ts +1 -1
  121. package/templates/apps/frontend-saas/README.md +1 -1
  122. package/templates/apps/frontend-saas/app.config.ts +1 -1
  123. package/templates/apps/frontend-saas/package.json +8 -8
  124. package/templates/apps/frontend-saas/src/locales/index.ts +1 -1
  125. package/templates/apps/frontend-spa/app.config.ts +1 -1
  126. package/templates/apps/frontend-spa/package.json +7 -7
  127. package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
  128. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
  129. package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
  130. package/templates/apps/frontend-ssr/app.config.ts +1 -1
  131. package/templates/apps/frontend-ssr/package.json +7 -7
  132. package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
  133. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
  134. package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
  135. package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
  136. package/templates/apps/frontend-ssr-api/package.json +8 -8
  137. package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
  138. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
  139. package/templates/apps/frontend-static-blog/package.json +6 -6
  140. package/templates/apps/frontend-status/package.json +8 -8
  141. package/templates/apps/frontend-status/src/locales/index.ts +1 -1
  142. package/templates/apps/mobile-app/README.md +58 -0
  143. package/templates/apps/mobile-app/app.config.ts +33 -0
  144. package/templates/apps/mobile-app/babel.config.js +5 -0
  145. package/templates/apps/mobile-app/metro.config.js +20 -0
  146. package/templates/apps/mobile-app/package.json +33 -0
  147. package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
  148. package/templates/apps/mobile-app/src/app/index.tsx +62 -0
  149. package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
  150. package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
  151. package/templates/apps/mobile-app/src/client.ts +74 -0
  152. package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
  153. package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
  154. package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
  155. package/templates/apps/mobile-app/src/persistence.ts +38 -0
  156. package/templates/apps/mobile-app/template.json +6 -0
  157. package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
  158. package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
  159. package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
  160. package/templates/apps/mobile-app/tsconfig.json +10 -0
  161. package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
  162. package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
  163. package/dist/apiBuild-C8d74T6g.js +0 -2
  164. package/dist/checkCommand-DBvZvCh1.js +0 -2
  165. package/dist/dbCommand-CaIVTp-a.js +0 -2
  166. package/dist/frameworkTableAssembly-BTNirAAk.js +0 -2
  167. package/dist/inspect-DHeaMZak.js +0 -2
  168. package/dist/manifestBuild-Ck_90gZy.js +0 -2
  169. package/dist/serveCommand-BXl8mhZD.js +0 -1489
@@ -0,0 +1,38 @@
1
+ // Web app for the {{projectName}} project. Read by `voltro dev` / `voltro start`.
2
+ //
3
+ // This is the COLLABORATIVE-EDITING frontend — a textarea bound to a
4
+ // `crdtText()` document body over the framework's reactive loop. Open it in two
5
+ // browser tabs and type in both: the edits CONVERGE (no last-write-wins loser)
6
+ // because the api's server-authoritative CRDT merge folds every update into the
7
+ // stored state, then the reactive engine broadcasts the merged row back. Zero
8
+ // infra — the merge + broadcast are in-process on a single `voltro dev`.
9
+ //
10
+ // `port` is allocated by `voltro create-project` / `voltro add-app` from the
11
+ // project's portRange (in project.json), hard-pinned per app.
12
+ import { defineEnv, envVar } from '@voltro/env'
13
+
14
+ // Typed PUBLIC environment — public vars are validated + baked into the browser
15
+ // bundle at build time and read via `publicEnv` from '@voltro/env/public'.
16
+ // NEVER put a secret here (it ships to every visitor).
17
+ export const env = defineEnv({
18
+ VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
19
+ })
20
+
21
+ export default {
22
+ type: 'web' as const,
23
+ name: '{{capProjectName}}{{capAppName}}',
24
+ port: {{port}},
25
+ env,
26
+ // Bilingual by default — the framework auto-wires a cookie-driven
27
+ // <I18nProvider> from these codes and loads the matching
28
+ // `src/locales/<code>.ts` catalog, so `<T id="…" />` / `useT()` work app-wide.
29
+ locales: ['en', 'de'] as const,
30
+ defaultLocale: 'en' as const,
31
+ // The api this frontend consumes. The NAME ('app') is the lookup key the
32
+ // hooks use — `useSubscription('app', 'documents.list')`, `useMutation('app',
33
+ // 'documents.setBody')`. The `package` resolves the sibling api in this
34
+ // project (scaffold it with `--api=api-collab`).
35
+ apis: {
36
+ app: { package: '@{{projectName}}/api' },
37
+ },
38
+ }
@@ -0,0 +1,33 @@
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
+ "build": "voltro build .",
10
+ "start": "voltro start .",
11
+ "test": "voltro test",
12
+ "typecheck": "tsc --noEmit"
13
+ },
14
+ "dependencies": {
15
+ "@{{projectName}}/api": "0.30.0",
16
+ "@voltro/cli": "0.30.0",
17
+ "@voltro/client": "0.30.0",
18
+ "@voltro/env": "0.30.0",
19
+ "@voltro/i18n": "0.30.0",
20
+ "@voltro/local-first": "0.30.0",
21
+ "@voltro/ui-shadcn": "0.30.0",
22
+ "@voltro/web": "0.30.0",
23
+ "react": "^19.0.0",
24
+ "react-dom": "^19.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@voltro/devtools": "0.30.0",
28
+ "@types/react": "^19.0.0",
29
+ "@types/react-dom": "^19.0.0",
30
+ "typescript": "^6.0.3",
31
+ "vitest": "^4.1.10"
32
+ }
33
+ }
@@ -0,0 +1,29 @@
1
+ // German catalog. Mirrors every key in `en.ts` — `defineLocale<typeof en>()`
2
+ // fails the build if a key is missing or misspelled. Translate the prose only;
3
+ // keep ICU placeholders (`{tenant}`, `{revision}`, `{title}`, `{error}`) and
4
+ // rich tags (`<code>`, `<strong>`) identical to `en.ts`.
5
+
6
+ import { defineLocale } from '@voltro/i18n'
7
+ import en from './en'
8
+
9
+ export default defineLocale<typeof en>()({
10
+ 'meta.editor.title': 'Kollaborativer Editor',
11
+
12
+ 'nav.editor': 'Editor',
13
+ 'nav.tagline': 'Local-First · CRDT · reaktiv',
14
+ 'lang.label': 'Sprache',
15
+
16
+ 'editor.heading': 'Kollaborativer Editor',
17
+ 'editor.intro': 'Öffne diese Seite in <strong>zwei Browser-Tabs</strong> und tippe in beiden. Die Änderungen <strong>konvergieren</strong> — kein Last-Write-Wins-Verlierer — weil die API jedes Update serverseitig in den gemeinsamen <code>crdtText()</code>-Body mergt und live zurücksendet.',
18
+ 'editor.status.tenant': 'Mandant <code>{tenant}</code> · Revision {revision}',
19
+ 'editor.status.saving': ' · synchronisiere…',
20
+ 'editor.status.loading': ' · lädt…',
21
+ 'editor.error': 'Subscription-Fehler — läuft die API? {error}',
22
+ 'editor.empty.heading': 'Noch kein Dokument',
23
+ 'editor.empty.body': 'Erstelle eines, um kollaborativ zu bearbeiten.',
24
+ 'editor.create': 'Dokument erstellen',
25
+ 'editor.creating': 'Wird erstellt…',
26
+ 'editor.docLabel': 'Bearbeite <strong>{title}</strong>',
27
+ 'editor.placeholder': 'Fang an zu tippen — jeder Tastendruck synchronisiert und mergt.',
28
+ 'editor.hint': 'Tipp: Öffne einen zweiten Tab und tippe gleichzeitig. Beide Änderungen bleiben erhalten.',
29
+ })
@@ -0,0 +1,31 @@
1
+ // Base catalog — the source of truth for this app's UI strings. Every key here
2
+ // MUST be mirrored in `de.ts`; `defineLocale<typeof en>()` enforces that parity
3
+ // at build time, so a missing translation fails typecheck rather than silently
4
+ // falling back.
5
+
6
+ import { defineCatalog } from '@voltro/i18n'
7
+
8
+ export default defineCatalog({
9
+ // Browser tab title — locale-aware via the page's `meta({ locale })`.
10
+ 'meta.editor.title': 'Collaborative editor',
11
+
12
+ // ---- Layout shell (pages/layout.tsx) ----
13
+ 'nav.editor': 'Editor',
14
+ 'nav.tagline': 'local-first · CRDT · reactive',
15
+ 'lang.label': 'Language',
16
+
17
+ // ---- Editor page (pages/page.tsx) ----
18
+ 'editor.heading': 'Collaborative editor',
19
+ 'editor.intro': 'Open this page in <strong>two browser tabs</strong> and type in both. Edits <strong>converge</strong> — no last-write-wins loser — because the api merges every update into the shared <code>crdtText()</code> body server-side, then broadcasts it back live.',
20
+ 'editor.status.tenant': 'Tenant <code>{tenant}</code> · revision {revision}',
21
+ 'editor.status.saving': ' · syncing…',
22
+ 'editor.status.loading': ' · loading…',
23
+ 'editor.error': 'subscription error — is the api running? {error}',
24
+ 'editor.empty.heading': 'No document yet',
25
+ 'editor.empty.body': 'Create one to start editing collaboratively.',
26
+ 'editor.create': 'Create document',
27
+ 'editor.creating': 'Creating…',
28
+ 'editor.docLabel': 'Editing <strong>{title}</strong>',
29
+ 'editor.placeholder': 'Start typing — every keystroke syncs and merges.',
30
+ 'editor.hint': 'Tip: open a second tab and type at the same time. Both edits survive.',
31
+ } as const)
@@ -0,0 +1,15 @@
1
+ // Catalog lookup OUTSIDE React — for the page's `meta({ locale })` locale-aware
2
+ // <title>. This app is cookie-i18n (no `/<locale>` URL prefix), so @voltro/web
3
+ // hands `meta` the active locale from the `voltro:locale` cookie. Inside React,
4
+ // use `<T>` / `useT()` via the framework's auto-wired <I18nProvider> instead.
5
+
6
+ import en from './en'
7
+ import de from './de'
8
+
9
+ // Both catalogs share `en`'s keys (defineLocale enforces the mirror), so a
10
+ // known-key lookup returns `string` — which is what `PageMeta.title` requires
11
+ // under exactOptionalPropertyTypes.
12
+ export type Messages = Record<keyof typeof en, string>
13
+
14
+ export const getCatalog = (locale?: string): Messages =>
15
+ (locale === 'de' ? de : en) as Messages
@@ -0,0 +1,39 @@
1
+ // Root layout that wraps every page in this app. `<html>` / `<head>` /
2
+ // `<body>` are owned by the framework's index.html shell — don't render them
3
+ // here. Use a page's `meta` export for title, and `theme` in app.config.ts for
4
+ // the dark class.
5
+ //
6
+ // The app is bilingual: nav labels come from the catalog via `<T>`, and
7
+ // <LocaleSwitcher> writes the `voltro:locale` cookie + reloads so the framework
8
+ // re-renders in the chosen language.
9
+
10
+ import type { ReactNode } from 'react'
11
+ import { T, useLocale, useT } from '@voltro/i18n'
12
+ import { LocaleSwitcher } from '@voltro/ui-shadcn'
13
+
14
+ interface LayoutProps {
15
+ readonly children: ReactNode
16
+ }
17
+
18
+ const LOCALES = [
19
+ { code: 'en', label: 'English' },
20
+ { code: 'de', label: 'Deutsch' },
21
+ ]
22
+
23
+ export default function Layout({ children }: LayoutProps): ReactNode {
24
+ const locale = useLocale()
25
+ const langLabel = useT('lang.label')
26
+ return (
27
+ <div style={{ maxWidth: 720, margin: '0 auto', padding: '24px 16px', fontFamily: 'system-ui, sans-serif' }}>
28
+ <header style={{ display: 'flex', gap: 16, alignItems: 'baseline', marginBottom: 24 }}>
29
+ <strong style={{ fontSize: 16 }}>{{capProjectName}}</strong>
30
+ <nav style={{ display: 'flex', gap: 12, fontSize: 13 }}>
31
+ <a href="/"><T id="nav.editor" /></a>
32
+ </nav>
33
+ <span style={{ color: '#999', fontSize: 12, marginLeft: 'auto' }}><T id="nav.tagline" /></span>
34
+ <LocaleSwitcher locales={LOCALES} current={locale} ariaLabel={langLabel} />
35
+ </header>
36
+ <main>{children}</main>
37
+ </div>
38
+ )
39
+ }
@@ -0,0 +1,150 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // `/` — the collaborative editor. It binds `useSubscription('app',
4
+ // 'documents.list')` + two `useMutation`s ('documents.create',
5
+ // 'documents.setBody') from @voltro/client, which we mock so the render is
6
+ // deterministic. `@voltro/local-first` is NOT mocked — the editor's real local
7
+ // CRDT handle decodes the seeded body and turns keystrokes into encoded updates,
8
+ // which is the behaviour worth asserting.
9
+
10
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
11
+ import { act, createElement, type ReactNode } from 'react'
12
+ import { createRoot, type Root } from 'react-dom/client'
13
+ import { I18nProvider } from '@voltro/i18n'
14
+ import { crdtText } from '@voltro/local-first'
15
+ import enCatalog from '../locales/en'
16
+
17
+ interface SubState {
18
+ data: unknown
19
+ revision?: number
20
+ error?: unknown
21
+ }
22
+
23
+ const useSubscription = vi.fn<() => SubState>()
24
+ const createMutate = vi.fn<(input: unknown) => Promise<unknown>>()
25
+ const setBodyMutate = vi.fn<(input: unknown) => Promise<unknown>>()
26
+
27
+ // useMutation is called once per tag — dispatch to the right mock by tag.
28
+ const useMutation = vi.fn((_api: unknown, tag: unknown) =>
29
+ tag === 'documents.create'
30
+ ? { mutate: createMutate, pending: false, error: undefined, data: undefined }
31
+ : { mutate: setBodyMutate, pending: false, error: undefined, data: undefined },
32
+ )
33
+
34
+ vi.mock('@voltro/client', () => ({
35
+ useSubscription: (...a: unknown[]) => useSubscription(...(a as [])),
36
+ useMutation: (...a: unknown[]) => useMutation(...(a as Parameters<typeof useMutation>)),
37
+ }))
38
+
39
+ const { default: IndexPage } = await import('./page')
40
+
41
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
42
+
43
+ let container: HTMLDivElement
44
+ let root: Root
45
+
46
+ const render = (node: ReactNode): void => {
47
+ container = document.createElement('div')
48
+ document.body.appendChild(container)
49
+ act(() => {
50
+ root = createRoot(container)
51
+ root.render(
52
+ createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
53
+ )
54
+ })
55
+ }
56
+
57
+ const setTextarea = (el: HTMLTextAreaElement, value: string): void => {
58
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!
59
+ act(() => {
60
+ setter.call(el, value)
61
+ el.dispatchEvent(new Event('input', { bubbles: true }))
62
+ })
63
+ }
64
+
65
+ // A real encoded CRDT body seeded with `text`, exactly what the subscription
66
+ // streams for a document that already has content.
67
+ const bodyWith = (text: string): Uint8Array => crdtText().insert(0, text).encode()
68
+
69
+ const docRow = (over: Partial<{ id: string; title: string; body: Uint8Array | null }> = {}) => ({
70
+ id: over.id ?? 'doc_1',
71
+ title: over.title ?? 'Shared document',
72
+ body: over.body ?? null,
73
+ tenantId: 'acme',
74
+ createdAt: new Date(0),
75
+ })
76
+
77
+ beforeEach(() => {
78
+ useSubscription.mockReset()
79
+ createMutate.mockReset()
80
+ createMutate.mockResolvedValue(undefined)
81
+ setBodyMutate.mockReset()
82
+ setBodyMutate.mockResolvedValue(undefined)
83
+ useMutation.mockClear()
84
+ })
85
+
86
+ afterEach(() => {
87
+ if (root) act(() => root.unmount())
88
+ container?.remove()
89
+ document.body.innerHTML = ''
90
+ })
91
+
92
+ describe('collab editor — subscription states', () => {
93
+ test('shows the loading hint before the first snapshot (data undefined)', () => {
94
+ useSubscription.mockReturnValue({ data: undefined, revision: -1 })
95
+ render(createElement(IndexPage))
96
+ expect(container.textContent).toContain('loading…')
97
+ expect(container.querySelector('textarea')).toBeNull()
98
+ })
99
+
100
+ test('shows the empty state + create button once a snapshot arrives with no documents', () => {
101
+ useSubscription.mockReturnValue({ data: [], revision: 0 })
102
+ render(createElement(IndexPage))
103
+ expect(container.textContent).toContain('No document yet')
104
+ expect(container.querySelector('button')).not.toBeNull()
105
+ expect(container.querySelector('textarea')).toBeNull()
106
+ })
107
+
108
+ test('renders the error banner when the subscription errors', () => {
109
+ useSubscription.mockReturnValue({ data: undefined, error: 'boom', revision: -1 })
110
+ render(createElement(IndexPage))
111
+ expect(container.textContent).toContain('subscription error')
112
+ expect(container.textContent).toContain('boom')
113
+ })
114
+ })
115
+
116
+ describe('collab editor — create', () => {
117
+ test('clicking Create document calls documents.create with the tenant + a title', async () => {
118
+ useSubscription.mockReturnValue({ data: [], revision: 0 })
119
+ render(createElement(IndexPage))
120
+ const button = container.querySelector('button') as HTMLButtonElement
121
+ await act(async () => {
122
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
123
+ })
124
+ expect(createMutate).toHaveBeenCalledTimes(1)
125
+ expect(createMutate).toHaveBeenCalledWith({ tenantId: 'acme', title: 'Shared document' })
126
+ })
127
+ })
128
+
129
+ describe('collab editor — editing a document', () => {
130
+ test('binds the textarea to the decoded CRDT body', () => {
131
+ useSubscription.mockReturnValue({ data: [docRow({ body: bodyWith('Hello') })], revision: 3 })
132
+ render(createElement(IndexPage))
133
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement
134
+ expect(textarea).not.toBeNull()
135
+ expect(textarea.value).toBe('Hello')
136
+ expect(container.textContent).toContain('Shared document')
137
+ })
138
+
139
+ test('typing sends an encoded CRDT update via documents.setBody', () => {
140
+ useSubscription.mockReturnValue({ data: [docRow({ body: bodyWith('Hello') })], revision: 3 })
141
+ render(createElement(IndexPage))
142
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement
143
+ setTextarea(textarea, 'Hello!')
144
+
145
+ expect(setBodyMutate).toHaveBeenCalledTimes(1)
146
+ const arg = setBodyMutate.mock.calls[0]![0] as { id: string; update: unknown }
147
+ expect(arg.id).toBe('doc_1')
148
+ expect(arg.update).toBeInstanceOf(Uint8Array)
149
+ })
150
+ })
@@ -0,0 +1,186 @@
1
+ // `/` — the collaborative document editor. Auto-mapped from src/pages/page.tsx
2
+ // by the framework's file-convention page discovery.
3
+ //
4
+ // THIS is local-first collaboration over the reactive loop. The api's
5
+ // `documents` table has a `crdtText()` `body`; this page:
6
+ // 1. `useSubscription('app','documents.list')` — streams the shared document
7
+ // (with its MERGED body) live over WebSocket,
8
+ // 2. keeps a LOCAL CRDT handle (`@voltro/local-first`'s `crdtText()`) seeded
9
+ // from the server state — every keystroke applies a local insert/delete
10
+ // and sends the encoded update via `useMutation('app','documents.setBody')`,
11
+ // 3. folds every incoming server body back into that handle.
12
+ //
13
+ // The convergence is authoritative and SERVER-SIDE: the api merges each update
14
+ // into the stored state before broadcasting, so two tabs typing at once both
15
+ // survive — no last-write-wins loser. Open this page in two tabs to see it.
16
+ //
17
+ // This is the app-level binding the docs describe as the SyncClient transport:
18
+ // `push` = the setBody mutation, `onRemoteState` = the documents subscription.
19
+
20
+ import { useMutation, useSubscription } from '@voltro/client'
21
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
22
+ import type { PageMeta } from '@voltro/web'
23
+ import { T, useTFn } from '@voltro/i18n'
24
+ import { crdtText, emptyCrdtState, type CrdtState, type CrdtText } from '@voltro/local-first'
25
+ import { getCatalog } from '../locales'
26
+
27
+ export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
28
+ title: getCatalog(locale)['meta.editor.title'],
29
+ })
30
+
31
+ // The dev AuthMiddleware resolves the tenant from the `x-tenant` header,
32
+ // defaulting to 'acme'; the `documents` table's `tenant()` mixin auto-scopes
33
+ // reads/writes to it.
34
+ const TENANT = 'acme'
35
+
36
+ interface DocRow {
37
+ readonly id: string
38
+ readonly title: string
39
+ // The encoded CRDT state. The api declares `body` as
40
+ // `Schema.Uint8ArrayFromBase64`, so the rpc client hands it back as a
41
+ // `Uint8Array`; we still accept a raw base64 `string` defensively.
42
+ readonly body: Uint8Array | string | null
43
+ readonly tenantId: string
44
+ readonly createdAt: Date
45
+ }
46
+
47
+ const base64ToBytes = (b64: string): Uint8Array =>
48
+ Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0))
49
+
50
+ // A null/absent body decodes to the empty document — the CRDT merge identity.
51
+ const toState = (body: Uint8Array | string | null): CrdtState =>
52
+ body instanceof Uint8Array ? body : typeof body === 'string' ? base64ToBytes(body) : emptyCrdtState()
53
+
54
+ // Turn a whole-string edit into a single contiguous CRDT insert/delete: the
55
+ // common prefix + suffix are unchanged; the middle is what the user replaced.
56
+ // Covers ordinary typing, deletion, and paste (one contiguous run).
57
+ const applyDiff = (handle: CrdtText, prev: string, next: string): void => {
58
+ if (prev === next) return
59
+ let start = 0
60
+ const shorter = Math.min(prev.length, next.length)
61
+ while (start < shorter && prev[start] === next[start]) start += 1
62
+ let end = 0
63
+ while (
64
+ end < shorter - start &&
65
+ prev[prev.length - 1 - end] === next[next.length - 1 - end]
66
+ ) {
67
+ end += 1
68
+ }
69
+ const deleteLen = prev.length - start - end
70
+ const inserted = next.slice(start, next.length - end)
71
+ if (deleteLen > 0) handle.delete(start, deleteLen)
72
+ if (inserted.length > 0) handle.insert(start, inserted)
73
+ }
74
+
75
+ // One editor bound to ONE document. Remounted (via `key={doc.id}`) when the
76
+ // bound document changes, so the local CRDT handle's lifecycle is scoped to it.
77
+ const DocumentEditor = ({
78
+ doc,
79
+ saving,
80
+ onUpdate,
81
+ }: {
82
+ readonly doc: DocRow
83
+ readonly saving: boolean
84
+ readonly onUpdate: (update: CrdtState) => void
85
+ }): ReactNode => {
86
+ const t = useTFn()
87
+ // One local CRDT handle per mount, seeded from the current server state.
88
+ const handleRef = useRef<CrdtText | null>(null)
89
+ if (handleRef.current === null) handleRef.current = crdtText().merge(toState(doc.body))
90
+ const [text, setText] = useState<string>(() => handleRef.current!.toString())
91
+
92
+ // Fold every incoming server body into the local handle — remote edits appear
93
+ // and converge. Idempotent, so re-merging our own echoed update is a no-op.
94
+ const serverBody = doc.body
95
+ useEffect(() => {
96
+ const handle = handleRef.current!
97
+ handle.merge(toState(serverBody))
98
+ const merged = handle.toString()
99
+ setText((prev) => (prev === merged ? prev : merged))
100
+ }, [serverBody])
101
+
102
+ const handleChange = (next: string): void => {
103
+ const handle = handleRef.current!
104
+ applyDiff(handle, handle.toString(), next)
105
+ setText(handle.toString())
106
+ onUpdate(handle.encode()) // full encoded state; the server folds it in
107
+ }
108
+
109
+ return (
110
+ <section>
111
+ <p style={{ color: '#666', fontSize: 13, margin: '0 0 8px' }}>
112
+ <T id="editor.docLabel" values={{ title: doc.title, strong: (c: ReactNode) => <strong>{c}</strong> }} />
113
+ {saving ? <T id="editor.status.saving" /> : null}
114
+ </p>
115
+ <textarea
116
+ value={text}
117
+ onChange={(event) => handleChange(event.target.value)}
118
+ placeholder={t('editor.placeholder')}
119
+ rows={12}
120
+ style={{ width: '100%', padding: '10px 12px', fontSize: 14, fontFamily: 'ui-monospace, monospace', resize: 'vertical', boxSizing: 'border-box' }}
121
+ />
122
+ <p style={{ color: '#999', fontSize: 12, marginTop: 8 }}><T id="editor.hint" /></p>
123
+ </section>
124
+ )
125
+ }
126
+
127
+ const IndexPage = (): ReactNode => {
128
+ const { data, revision, error } = useSubscription<ReadonlyArray<DocRow>>('app', 'documents.list')
129
+ const create = useMutation<{ tenantId: string; title: string }>('app', 'documents.create')
130
+ const setBody = useMutation<{ id: string; update: CrdtState }>('app', 'documents.setBody')
131
+
132
+ const docs = data ?? []
133
+ const doc = docs[0]
134
+
135
+ const handleCreate = async (): Promise<void> => {
136
+ try {
137
+ await create.mutate({ tenantId: TENANT, title: 'Shared document' })
138
+ } catch (mutationError) {
139
+ console.error('documents.create failed:', mutationError)
140
+ }
141
+ }
142
+
143
+ const handleUpdate = (id: string) => (update: CrdtState): void => {
144
+ setBody.mutate({ id, update }).catch((mutationError) => {
145
+ console.error('documents.setBody failed:', mutationError)
146
+ })
147
+ }
148
+
149
+ return (
150
+ <div>
151
+ <h1 style={{ fontSize: 24, marginBottom: 4 }}><T id="editor.heading" /></h1>
152
+ <p style={{ color: '#444', marginTop: 0, fontSize: 14 }}>
153
+ <T
154
+ id="editor.intro"
155
+ values={{ strong: (c: ReactNode) => <strong>{c}</strong>, code: (c: ReactNode) => <code>{c}</code> }}
156
+ />
157
+ </p>
158
+ <p style={{ color: '#666', marginTop: 0, fontSize: 13 }}>
159
+ <T id="editor.status.tenant" values={{ tenant: TENANT, revision, code: (c: ReactNode) => <code>{c}</code> }} />
160
+ {data === undefined && error === undefined && <T id="editor.status.loading" />}
161
+ </p>
162
+
163
+ {error !== undefined && (
164
+ <p style={{ color: '#c00', fontSize: 13 }}>
165
+ <T id="editor.error" values={{ error: String(error) }} />
166
+ </p>
167
+ )}
168
+
169
+ {doc === undefined && data !== undefined ? (
170
+ <section style={{ border: '1px dashed #ccc', borderRadius: 8, padding: 24, textAlign: 'center' }}>
171
+ <h2 style={{ fontSize: 16, margin: '0 0 4px' }}><T id="editor.empty.heading" /></h2>
172
+ <p style={{ color: '#777', fontSize: 13, marginTop: 0 }}><T id="editor.empty.body" /></p>
173
+ <button type="button" onClick={handleCreate} disabled={create.pending}>
174
+ {create.pending ? <T id="editor.creating" /> : <T id="editor.create" />}
175
+ </button>
176
+ </section>
177
+ ) : null}
178
+
179
+ {doc !== undefined ? (
180
+ <DocumentEditor key={doc.id} doc={doc} saving={setBody.pending} onUpdate={handleUpdate(doc.id)} />
181
+ ) : null}
182
+ </div>
183
+ )
184
+ }
185
+
186
+ export default IndexPage
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "frontend-collab",
3
+ "kind": "web",
4
+ "summary": "A collaborative document editor — a textarea bound to a `crdtText()` body over the reactive engine. Type in two browser tabs at once and the edits CONVERGE (server-authoritative CRDT merge), no last-write-wins loser. Pairs with api-collab: scaffold both with `create-project --api=api-collab --web=frontend-collab`.",
5
+ "tags": ["web", "frontend", "fullstack", "local-first", "crdt", "collaborative", "realtime", "reactive", "api-collab"]
6
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "react-jsx",
5
+ "lib": ["ES2024", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "moduleResolution": "Bundler"
8
+ },
9
+ "include": ["src/**/*"],
10
+ "exclude": ["node_modules", "dist", ".framework"]
11
+ }
@@ -15,18 +15,18 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@effect/platform": "^0.97.0",
18
- "@voltro/cli": "0.29.0",
19
- "@voltro/client": "0.29.0",
20
- "@voltro/env": "0.29.0",
21
- "@voltro/i18n": "0.29.0",
22
- "@voltro/serverless": "0.29.0",
23
- "@voltro/web": "0.29.0",
18
+ "@voltro/cli": "0.30.0",
19
+ "@voltro/client": "0.30.0",
20
+ "@voltro/env": "0.30.0",
21
+ "@voltro/i18n": "0.30.0",
22
+ "@voltro/serverless": "0.30.0",
23
+ "@voltro/web": "0.30.0",
24
24
  "effect": "^3.22.0",
25
25
  "react": "^19.0.0",
26
26
  "react-dom": "^19.0.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@voltro/devtools": "0.29.0",
29
+ "@voltro/devtools": "0.30.0",
30
30
  "@types/react": "^19.0.0",
31
31
  "@types/react-dom": "^19.0.0",
32
32
  "typescript": "^6.0.3",
@@ -32,7 +32,7 @@ export default {
32
32
  // <I18nProvider> from these codes and loads the matching
33
33
  // `src/locales/<code>.ts` catalog — so `<T id="…" />` / `useT()` /
34
34
  // `useLocale()` work app-wide with no manual setup. The <LocaleSwitcher>
35
- // in the layout writes the `voltro:lang` cookie + reloads. Grow the app to
35
+ // in the layout writes the `voltro:locale` cookie + reloads. Grow the app to
36
36
  // more languages by adding a code here + a `src/locales/<code>.ts`.
37
37
  locales: ['en', 'de'] as const,
38
38
  defaultLocale: 'en' as const,
@@ -12,17 +12,17 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.29.0",
16
- "@voltro/client": "0.29.0",
17
- "@voltro/env": "0.29.0",
18
- "@voltro/i18n": "0.29.0",
19
- "@voltro/ui-shadcn": "0.29.0",
20
- "@voltro/web": "0.29.0",
15
+ "@voltro/cli": "0.30.0",
16
+ "@voltro/client": "0.30.0",
17
+ "@voltro/env": "0.30.0",
18
+ "@voltro/i18n": "0.30.0",
19
+ "@voltro/ui-shadcn": "0.30.0",
20
+ "@voltro/web": "0.30.0",
21
21
  "react": "^19.0.0",
22
22
  "react-dom": "^19.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/devtools": "0.29.0",
25
+ "@voltro/devtools": "0.30.0",
26
26
  "@types/react": "^19.0.0",
27
27
  "@types/react-dom": "^19.0.0",
28
28
  "typescript": "^6.0.3",
@@ -1,6 +1,6 @@
1
1
  // Catalog lookup OUTSIDE React — for a page's `meta({ locale })` locale-aware
2
2
  // <title>. This app is cookie-i18n (no `/<locale>` URL prefix), so @voltro/web
3
- // hands `meta` the active locale from the `voltro:lang` cookie. Inside React,
3
+ // hands `meta` the active locale from the `voltro:locale` cookie. Inside React,
4
4
  // use `<T>` / `useT()` via the framework's auto-wired <I18nProvider> instead.
5
5
 
6
6
  import en from './en'
@@ -12,17 +12,17 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.29.0",
16
- "@voltro/client": "0.29.0",
17
- "@voltro/env": "0.29.0",
18
- "@voltro/i18n": "0.29.0",
19
- "@voltro/ui-shadcn": "0.29.0",
20
- "@voltro/web": "0.29.0",
15
+ "@voltro/cli": "0.30.0",
16
+ "@voltro/client": "0.30.0",
17
+ "@voltro/env": "0.30.0",
18
+ "@voltro/i18n": "0.30.0",
19
+ "@voltro/ui-shadcn": "0.30.0",
20
+ "@voltro/web": "0.30.0",
21
21
  "react": "^19.0.0",
22
22
  "react-dom": "^19.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/devtools": "0.29.0",
25
+ "@voltro/devtools": "0.30.0",
26
26
  "@types/react": "^19.0.0",
27
27
  "@types/react-dom": "^19.0.0",
28
28
  "typescript": "^6.0.3",