@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
@@ -1197,7 +1197,7 @@ export const meta = ({ loaderData }: { loaderData: { note?: { title: string; bod
1197
1197
  })
1198
1198
  ```
1199
1199
 
1200
- The third context field — `locale: string` — is the active i18n locale for this render. For `[locale]/…` routes it carries the URL-prefix locale (`'de'` on `/de/notes/42`). For bare-path routes it carries the active locale from the framework's `voltro:lang` cookie when set — so **cookie-based i18n works too** (an authed dashboard with no `[locale]` URL still gets a translated `<title>` that tracks the language switch) — otherwise the app's `defaultLocale`. Use it to localise title / description / canonical / OG per locale at SSG time so search engines see translated head tags on every variant, and to give cookie-i18n pages a translated tab title:
1200
+ The third context field — `locale: string` — is the active i18n locale for this render. For `[locale]/…` routes it carries the URL-prefix locale (`'de'` on `/de/notes/42`). For bare-path routes it carries the active locale from the framework's `voltro:locale` cookie when set — so **cookie-based i18n works too** (an authed dashboard with no `[locale]` URL still gets a translated `<title>` that tracks the language switch) — otherwise the app's `defaultLocale`. Use it to localise title / description / canonical / OG per locale at SSG time so search engines see translated head tags on every variant, and to give cookie-i18n pages a translated tab title:
1201
1201
 
1202
1202
  ```tsx
1203
1203
  import { getCatalog } from '../lib/locale'
@@ -88,6 +88,23 @@ DNS is not resolved. A public hostname that *resolves* to a private address (DNS
88
88
  rebinding) still passes. That vector needs network-layer egress control; it is
89
89
  stated here rather than silently implied.
90
90
 
91
+ ## Auditing what your log tables actually hold
92
+
93
+ ```bash
94
+ voltro db scan-credentials
95
+ voltro db scan-credentials --table my_events:actor
96
+ ```
97
+
98
+ Counts rows whose Subject carries a credential-shaped key — `token`, `secret`, `password`, `apikey`, `credential`, `privatekey` — in `_voltro_audit_log` and `_voltro_row_history`, plus any table you name with `--table <name>[:<column>]` (the column defaults to `subject`). Exit code `1` on a hit, so CI can gate on it.
99
+
100
+ **It is a command and not a documented query on purpose.** The same check once shipped as SQL you were asked to run yourself, in its postgres spelling (`subject::text ILIKE '%token%'`). On MySQL/MariaDB the natural translation is a bare `LIKE` — and against the `utf8mb4_bin` collation the migrator emits for a `json()` column, `LIKE` is case-**sensitive**. So `'%token%'` does not match `jiraToken`, and almost every JSON key that carries a credential is camelCase. A team ran the translated query over 141 rows, got `0`, and nearly reported themselves clean; 117 of those rows held a working credential. Every dialect now casts to its own text type before lowering, in code you do not have to translate.
101
+
102
+ **A `0` here never means two things.** Each line prints the number of rows *scanned* beside the number of hits. An empty table says so in words rather than reading as clean, a missing table reports as missing rather than as zero, and a run that examined nothing exits `2`.
103
+
104
+ Run it on every environment. A development database is not a sample of production.
105
+
106
+ If it finds something: purge the rows **and** rotate the credentials — assume anything written to a log table has been read — then move the credential off the Subject entirely with `connectionCredentials(...)`, which keeps it in the framework vault.
107
+
91
108
  ## Supply-chain assurance
92
109
 
93
110
  Every release passes automated supply-chain gates in CI before a single package is published:
@@ -3291,3 +3291,212 @@ The synced rows stream in live via the `events.list` subscription.
3291
3291
 
3292
3292
  - [`store: 'postgres'`](/docs/database/dialects) — the cursor + markers survive restarts and are shared across replicas.
3293
3293
  - [Key-value backends](/docs/caching/kv-backends) — put just the KV on a persistent Redis with `KV_BACKEND=redis`, no handler change.
3294
+
3295
+
3296
+
3297
+ ---
3298
+
3299
+ <!-- source: en/templates/api-collab.md -->
3300
+ ## API · Collab (CRDT)
3301
+
3302
+ _Real-time collaborative editing — a documents table whose body is a crdtText() column. Concurrent edits converge via an authoritative server-side CRDT merge on the write path, then broadcast over the reactive engine. Zero infra._
3303
+
3304
+ The local-first / CRDT backend: a `documents` table whose `body` is a **`crdtText()`** column. When two clients edit the same body concurrently, the runtime folds each incoming update into the stored state with an **authoritative server-side merge on the write path** — so their edits **converge**, with no last-write-wins loser — and the reactive engine broadcasts the merged row to every subscriber. Zero-infra (`store: 'memory'`): the merge and the broadcast are both in-process, so two browser tabs pointed at one `voltro dev` collaborate with no database, no Redis, no external service. Template id: **`api-collab`**.
3305
+
3306
+ ## Scaffold the pair
3307
+
3308
+ `api-collab` is the backend half — it exposes the `documents.list` subscription + `documents.create` / `documents.setBody` mutations that [`frontend-collab`](/docs/templates/collab) binds a collaborative editor to. Scaffold both together:
3309
+
3310
+ ```bash
3311
+ voltro create-project collab --api=api-collab --web=frontend-collab
3312
+ ```
3313
+
3314
+ ## What ships
3315
+
3316
+ ```text
3317
+ apps/collab/api/
3318
+ ├── app.config.ts # type:api, store:'memory'
3319
+ ├── database/
3320
+ │ └── schema.ts # documents — a crdtText() body + tenant() + localFirst()
3321
+ ├── queries/
3322
+ │ ├── documents.query.ts # documents.list — streaming subscription
3323
+ │ └── documents.query.server.ts
3324
+ ├── mutations/
3325
+ │ ├── documents.create.mutation.ts # create a document (title only)
3326
+ │ ├── documents.create.mutation.server.ts
3327
+ │ ├── documents.setBody.mutation.ts # the CRDT write — a client's encoded update
3328
+ │ └── documents.setBody.mutation.server.ts
3329
+ └── tests/
3330
+ └── documents.setBody.test.ts # convergence proof — two edits merge, order-independently
3331
+ ```
3332
+
3333
+ ## The headline: a `crdtText()` column + server merge
3334
+
3335
+ `body: crdtText()` stores the encoded CRDT state as an opaque `bytes` blob. To the declarative differ it is an ordinary nullable `bytes` column — no special DDL — so it plans and round-trips on every dialect like any other column. `localFirst()` marks the table local-first (client mirror + CRDT convergence for its `crdtText()` fields); it adds no column, it is a property the framework reflects on.
3336
+
3337
+ ```ts
3338
+ // database/schema.ts
3339
+ import { crdtText, databaseHandle, id, localFirst, table, text, timestamp, type InferRow } from '@voltro/database'
3340
+ import { tenant } from '@voltro/plugin-multitenancy'
3341
+
3342
+ // Core tables the audit() / tenant() mixins reference.
3343
+ export const actors = table('actors', {
3344
+ id: id(), kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
3345
+ displayName: text().nullable(), createdAt: timestamp().default('now'),
3346
+ })
3347
+ export const tenants = table('tenants', { id: id(), name: text(), createdAt: timestamp().default('now') })
3348
+
3349
+ // The collaborative document. `body: crdtText()` is a CRDT-managed column —
3350
+ // stored as the encoded CRDT state (an opaque `bytes` blob; BYTEA / BLOB /
3351
+ // VARBINARY per dialect). Decode it with decodeCrdtText().
3352
+ export const documents = table('documents', {
3353
+ id: id({ prefix: 'doc' }),
3354
+ title: text(),
3355
+ body: crdtText(), // Uint8Array | null
3356
+ })
3357
+ // tenant() pulls audit() transitively (tenantId + timestamps + actor stamps)
3358
+ // and auto-scopes every read/write. localFirst() marks the table local-first.
3359
+ .with(tenant(), localFirst())
3360
+
3361
+ export type Document = InferRow<typeof documents>
3362
+ export const database = databaseHandle({ actors, tenants, documents })
3363
+ ```
3364
+
3365
+ ## The CRDT write — `documents.setBody`
3366
+
3367
+ A client produces an encoded update from its local `crdtText()` handle (`handle.encode()`) and sends it here. The server does **not** overwrite `body` with it. Because `body` is a `crdtText()` column, the runtime's MutationStore intercepts the write, reads the **stored** state, and folds the incoming update in with `mergeCrdtStates` (from `@voltro/local-first`) **before** persisting. That authoritative server merge is the convergence guarantee; the reactive engine then broadcasts the merged row.
3368
+
3369
+ ```ts
3370
+ // mutations/documents.setBody.mutation.ts — descriptor (browser-safe)
3371
+ import { defineMutation } from '@voltro/protocol'
3372
+ import { Schema } from 'effect'
3373
+
3374
+ // `op: 'update'` targets the row by primary key. `update` is the opaque encoded
3375
+ // CRDT state a client produced from its local handle (`handle.encode()`). No
3376
+ // tenant field: the tenant() mixin auto-scopes the update, so a foreign id
3377
+ // simply matches no row.
3378
+ export const setDocumentBody = defineMutation({
3379
+ name: 'documents.setBody',
3380
+ target: { table: 'documents', op: 'update' },
3381
+ input: Schema.Struct({
3382
+ id: Schema.NonEmptyString,
3383
+ update: Schema.Uint8ArrayFromBase64,
3384
+ }),
3385
+ output: Schema.Struct({
3386
+ id: Schema.String,
3387
+ title: Schema.String,
3388
+ // The MERGED body — what every subscriber now converges to.
3389
+ body: Schema.NullOr(Schema.Uint8ArrayFromBase64),
3390
+ tenantId: Schema.String,
3391
+ }),
3392
+ })
3393
+ ```
3394
+
3395
+ ```ts
3396
+ // mutations/documents.setBody.mutation.server.ts — executor (default export)
3397
+ import type { AppContext } from '@voltro/runtime'
3398
+
3399
+ // The handler looks like a plain overwrite — but `body` is a crdtText() column,
3400
+ // so the runtime's MutationStore intercepts the write: it reads the STORED state
3401
+ // and folds the incoming update in with mergeCrdtStates (@voltro/local-first)
3402
+ // BEFORE persisting. That authoritative server merge is what makes concurrent
3403
+ // edits converge; the reactive engine then broadcasts the merged row. This
3404
+ // handler stays a one-liner precisely because convergence lives in the write path.
3405
+ const execute = async (input: { id: string; update: Uint8Array }, ctx: AppContext) => {
3406
+ const row = await ctx.store.update('documents', input.id, { body: input.update })
3407
+ if (row === null) throw { status: 404, message: `document ${input.id} not found` }
3408
+ return row
3409
+ }
3410
+
3411
+ export default execute
3412
+ ```
3413
+
3414
+ The `body` field crosses the wire as base64 (`Schema.Uint8ArrayFromBase64` — JSON-safe, unlike raw bytes) and the rpc client decodes it back to a `Uint8Array`.
3415
+
3416
+ ## The streaming query — `documents.list`
3417
+
3418
+ Every change to `documents` for the caller's tenant lands as a delta — including the merged `body` after any client's CRDT write. Oldest-first, so `documents[0]` is stable across clients (the collab page binds its editor to that first document, and every tab agrees on which one). The `tenant()` mixin AND-merges tenant scope into the predicate, so no manual `eq('tenantId', …)` is needed.
3419
+
3420
+ ```ts
3421
+ // queries/documents.query.ts — descriptor (browser-safe)
3422
+ import { defineQuery } from '@voltro/protocol'
3423
+ import { Schema } from 'effect'
3424
+
3425
+ export const listDocuments = defineQuery({
3426
+ name: 'documents.list',
3427
+ input: Schema.Struct({}),
3428
+ output: Schema.Struct({
3429
+ id: Schema.String,
3430
+ title: Schema.String,
3431
+ body: Schema.NullOr(Schema.Uint8ArrayFromBase64),
3432
+ tenantId: Schema.String,
3433
+ createdAt: Schema.Date,
3434
+ }),
3435
+ })
3436
+ ```
3437
+
3438
+ ```ts
3439
+ // queries/documents.query.server.ts — executor
3440
+ import type { AppContext } from '@voltro/runtime'
3441
+
3442
+ const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
3443
+ descriptor: {
3444
+ table: 'documents' as const,
3445
+ order: [{ column: 'createdAt' as const, direction: 'asc' as const }],
3446
+ take: 100,
3447
+ // tenant scope is AND-merged by the runtime — no manual eq('tenantId', …)
3448
+ },
3449
+ })
3450
+
3451
+ export default execute
3452
+ ```
3453
+
3454
+ ## The convergence proof — with no database, no server
3455
+
3456
+ The property that matters for a `crdtText()` column is **convergence**: two clients that edit the same body concurrently end at the same text, whichever write the server processes first, with **both** edits surviving. `ctx.store` from `@voltro/testing` is the same mixin-wrapped store the handler gets in production, so the authoritative server-side merge on the write path executes exactly as at runtime — the test needs no database.
3457
+
3458
+ ```ts
3459
+ // tests/documents.setBody.test.ts — run with `voltro test`
3460
+ import { describe, it, expect } from 'vitest'
3461
+ import { makeTestContext, mockStore } from '@voltro/testing'
3462
+ import { crdtText, decodeCrdtText } from '@voltro/local-first'
3463
+ import createDocument from '../mutations/documents.create.mutation.server'
3464
+ import setBody from '../mutations/documents.setBody.mutation.server'
3465
+
3466
+ const freshCtx = () =>
3467
+ makeTestContext({
3468
+ subject: { type: 'user', id: 'u1', tenantId: 'acme' },
3469
+ store: mockStore({ documents: [] }),
3470
+ })
3471
+
3472
+ describe('documents.setBody — authoritative server-side CRDT merge', () => {
3473
+ it('folds two concurrent edits together so BOTH survive', async () => {
3474
+ const ctx = freshCtx()
3475
+ const doc = await createDocument({ tenantId: 'acme', title: 'Design doc' }, ctx)
3476
+
3477
+ await setBody({ id: String(doc['id']), update: crdtText().insert(0, 'Hello ').encode() }, ctx)
3478
+ const merged = await setBody({ id: String(doc['id']), update: crdtText().insert(0, 'World').encode() }, ctx)
3479
+
3480
+ const text = decodeCrdtText(merged.body as Uint8Array)
3481
+ expect(text).toContain('Hello')
3482
+ expect(text).toContain('World')
3483
+ })
3484
+ })
3485
+ ```
3486
+
3487
+ Note the second test in the shipped suite reuses the **same** two encoded updates in both orders and asserts the decoded text is identical — that is order-independent convergence, not chance. The suite also pins idempotency (re-applying a folded update is a no-op) and that a write to an unseen document 404s (tenant auto-scoped).
3488
+
3489
+ ## Store — memory by default
3490
+
3491
+ `app.config.ts` ships `store: 'memory'` — the merge and the broadcast are in-process, so collaboration works across browser tabs on a single `voltro dev` with zero infra. Switch to `'postgres'` (the encoded state is a plain `bytes` column, so it persists like any other) when you go durable; run `voltro add redis` for a cross-instance broadcast when you scale past one process.
3492
+
3493
+ ## Pairs well with
3494
+
3495
+ - [`frontend-collab`](/docs/templates/collab) — the editor UI that binds a textarea to this `crdtText()` body. Scaffold the pair with `--api=api-collab --web=frontend-collab`.
3496
+
3497
+ ## Anti-patterns
3498
+
3499
+ - **Treating `documents.setBody` as an overwrite.** It is not last-write-wins — the runtime folds the incoming update into the stored state. Do not "read-modify-write" the body in the handler; send the client's encoded update and let the write path merge it.
3500
+ - **Putting `node:*` / database imports in a descriptor (`*.mutation.ts`).** Those files reach the browser bundle through codegen. Keep server-only code in the `.server.ts` half.
3501
+ - **Sending raw bytes over the wire.** `body` is declared `Schema.Uint8ArrayFromBase64` so it is JSON-safe; a raw `Uint8Array` in the schema would not round-trip through the rpc layer.
3502
+ - **Dropping the cross-tenant write guard on create.** `documents.create` calls `assertOwnTenant(input.tenantId, ctx.request.subject)`. Reads are auto-scoped by `tenant()`, but a mutation writing a raw `tenantId` still needs the guard.
@@ -1333,7 +1333,7 @@ Then point the form at the deployed function: set `CONTACT_ENDPOINT` in `src/con
1333
1333
 
1334
1334
  _The reactive end-to-end loop in one page — a web frontend wired to an api. A live list (useSubscription) + a create form (useMutation) with zero-boilerplate auto-optimistic updates._
1335
1335
 
1336
- The framework's headline feature — a web client subscribing to a server query and getting WebSocket deltas on every write — in one page. `frontend-app` is the **only** template that wires a web frontend to an api; every other `frontend-*` template is backend-less and every `api-*` template is frontend-less. Template id: **`frontend-app`**.
1336
+ The framework's headline feature — a web client subscribing to a server query and getting WebSocket deltas on every write — in one page. `frontend-app` is one of the two fullstack templates that wire a web frontend to an api (the other is [`collab`](/docs/templates/collab), its local-first / CRDT sibling); every other `frontend-*` template is backend-less and every `api-*` template is frontend-less. Template id: **`frontend-app`**.
1337
1337
 
1338
1338
  ## Scaffold the pair
1339
1339
 
@@ -2060,3 +2060,146 @@ Everything under `src/pages/admin/` is yours. Swap the cookie gate (`lib/auth.ts
2060
2060
  - **Shipping the demo `admin:full` scopes to production.** That bypasses every `useCan` gate. Feed the subject's real scopes to `<PermissionProvider>`.
2061
2061
  - **Treating `useCan` as authorization.** It hides buttons; the server's `permission()` guard is the real gate. A hidden action is still callable over rpc by a crafted client.
2062
2062
  - **Assuming a naming convention.** The admin binds to discovered tags, not `<table>.create`-style guesses — so it works even when your procedures are named differently.
2063
+
2064
+
2065
+
2066
+ ---
2067
+
2068
+ <!-- source: en/templates/collab.md -->
2069
+ ## Collaborative editor
2070
+
2071
+ _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._
2072
+
2073
+ A live document editor whose textarea is bound to a **`crdtText()` body** over the framework's reactive loop. Type in **two browser tabs at once** and the edits **converge** — no last-write-wins loser — because the api merges every update into the shared document server-side, then broadcasts the merged result back live. Zero-infra: the merge and the broadcast are in-process on a single `voltro dev`. `frontend-collab` is a fullstack template — it consumes a sibling api. Template id: **`frontend-collab`**.
2074
+
2075
+ ## Scaffold the pair
2076
+
2077
+ `frontend-collab` consumes a sibling api, so scaffold it **together with** [`api-collab`](/docs/templates/api-collab) — which exposes the `documents.list` subscription + `documents.create` / `documents.setBody` mutations this page binds to:
2078
+
2079
+ ```bash
2080
+ voltro create-project collab --api=api-collab --web=frontend-collab
2081
+ cd collab
2082
+ pnpm install
2083
+ pnpm dev # boots BOTH apps — the api and the web
2084
+ ```
2085
+
2086
+ Open the web app, click **Create document**, then open a **second tab** on the same URL and type in both. Every keystroke merges — both tabs' edits survive.
2087
+
2088
+ ## What ships
2089
+
2090
+ ```text
2091
+ apps/collab/web/
2092
+ ├── app.config.ts # type:web + the apis: { app } wiring + locales
2093
+ ├── package.json # depends on @collab/api + @voltro/client + @voltro/local-first
2094
+ └── src/
2095
+ ├── locales/{en,de}.ts # bilingual UI strings
2096
+ └── pages/
2097
+ ├── layout.tsx # root shell (header + <main> + <LocaleSwitcher>)
2098
+ └── page.tsx # the editor — subscription + CRDT handle + setBody
2099
+ ```
2100
+
2101
+ ## The `apis` wiring
2102
+
2103
+ A web app declares the apis it consumes in `app.config.ts`. The map KEY (`app`) is the lookup name every hook takes as its first argument:
2104
+
2105
+ ```ts
2106
+ // app.config.ts
2107
+ export default {
2108
+ type: 'web' as const,
2109
+ name: 'CollabWeb',
2110
+ apis: {
2111
+ app: { package: '@collab/api' }, // the sibling api-collab in this project
2112
+ },
2113
+ }
2114
+ ```
2115
+
2116
+ The `package` resolves the sibling api workspace package; its port is auto-discovered, and codegen pulls its typed rpc surface so the hooks are end-to-end typed.
2117
+
2118
+ ## The collaborative loop
2119
+
2120
+ The page keeps a **local `crdtText()` handle** (`@voltro/local-first`) seeded from the server state. Each keystroke applies a contiguous insert/delete to that handle and sends `handle.encode()` via `documents.setBody`; every incoming server body is **folded back** into the handle (idempotent), keeping every tab in sync. The convergence is authoritative and server-side — the api merges each update into the stored state before broadcasting.
2121
+
2122
+ ```tsx
2123
+ // src/pages/page.tsx
2124
+ import { useMutation, useSubscription } from '@voltro/client'
2125
+ import { useEffect, useRef, useState } from 'react'
2126
+ import { crdtText, emptyCrdtState, type CrdtState, type CrdtText } from '@voltro/local-first'
2127
+
2128
+ interface DocRow {
2129
+ readonly id: string
2130
+ readonly title: string
2131
+ readonly body: Uint8Array | null // encoded CRDT state (base64 on the wire)
2132
+ }
2133
+
2134
+ const Editor = ({ doc, onUpdate }: {
2135
+ readonly doc: DocRow
2136
+ readonly onUpdate: (update: CrdtState) => void
2137
+ }) => {
2138
+ // One LOCAL CRDT handle per mount, seeded from the current server state.
2139
+ const handleRef = useRef<CrdtText | null>(null)
2140
+ if (handleRef.current === null) handleRef.current = crdtText().merge(doc.body ?? emptyCrdtState())
2141
+ const [text, setText] = useState(() => handleRef.current!.toString())
2142
+
2143
+ // Fold every incoming server body back into the local handle (idempotent) —
2144
+ // remote edits appear and converge.
2145
+ useEffect(() => {
2146
+ const handle = handleRef.current!
2147
+ handle.merge(doc.body ?? emptyCrdtState())
2148
+ setText(handle.toString())
2149
+ }, [doc.body])
2150
+
2151
+ const onChange = (next: string) => {
2152
+ const handle = handleRef.current!
2153
+ // (diff prev → next into a contiguous insert/delete — see the shipped page)
2154
+ setText(next)
2155
+ onUpdate(handle.encode()) // full encoded state; the server folds it in
2156
+ }
2157
+
2158
+ return <textarea value={text} onChange={(event) => onChange(event.target.value)} rows={12} />
2159
+ }
2160
+
2161
+ const IndexPage = () => {
2162
+ // ONE live subscription; `data` re-renders on every write (this tab or another).
2163
+ const { data } = useSubscription<ReadonlyArray<DocRow>>('app', 'documents.list')
2164
+ const setBody = useMutation<{ id: string; update: CrdtState }>('app', 'documents.setBody')
2165
+
2166
+ const doc = (data ?? [])[0]
2167
+ if (doc === undefined) return null
2168
+
2169
+ // Remount (key={doc.id}) when the bound document changes, so the handle's
2170
+ // lifecycle is scoped to it.
2171
+ return <Editor key={doc.id} doc={doc} onUpdate={(update) => setBody.mutate({ id: doc.id, update })} />
2172
+ }
2173
+
2174
+ export default IndexPage
2175
+ ```
2176
+
2177
+ 1. **`useSubscription('app', 'documents.list')`** streams the shared document, including its **merged** `body`, on every write.
2178
+ 2. Each keystroke applies to the local handle and sends `handle.encode()` via **`useMutation('app', 'documents.setBody')`** — no `.withOptimistic`, no polling.
2179
+ 3. The api's **authoritative server-side merge** folds the update into the stored state before broadcasting, so concurrent edits converge.
2180
+ 4. Incoming server bodies are folded back into the handle (idempotent), keeping every tab in sync.
2181
+
2182
+ This page is exactly the app-level binding the local-first docs describe as the `SyncClient` transport: `push` is the `setBody` mutation, `onRemoteState` is the `documents` subscription.
2183
+
2184
+ ## Not wired here: presence cursors
2185
+
2186
+ Live "who else is editing" cursors (`usePresence` from `@voltro/local-first/react`) need a `PresenceChannel` bound to the app's broadcast broker — a runtime binding, not zero-infra in a single dev process across tabs — so this template omits them. The CRDT **text** convergence above needs none of that; it rides the reactive engine you already have. Wire presence when you add the broker binding.
2187
+
2188
+ ## Bilingual by default
2189
+
2190
+ Like every frontend template, `frontend-collab` ships en/de with cookie i18n: nav labels and page copy come from `src/locales/{en,de}.ts` via `<T>` / `useT()`, and `<LocaleSwitcher>` writes the `voltro:locale` cookie. The `de.ts` catalog mirrors every key in `en.ts` — a missing translation fails typecheck.
2191
+
2192
+ ## When to use
2193
+
2194
+ | You want… | Pick |
2195
+ |---|---|
2196
+ | A collaborative / local-first editor over the reactive loop | `frontend-collab` (+ `api-collab`) |
2197
+ | The plain reactive list+form loop (no CRDT) | [`frontend-app`](/docs/templates/app) (+ `api-backend`) |
2198
+ | Just the CRDT backend | [`api-collab`](/docs/templates/api-collab) |
2199
+
2200
+ ## Anti-patterns
2201
+
2202
+ - **Scaffolding `frontend-collab` without `api-collab`.** It depends on the sibling `@<project>/api` workspace package — `pnpm install` fails without it. Always scaffold the pair.
2203
+ - **Binding the textarea to the raw server bytes.** Bind it to the **local handle's** string; fold incoming server bodies into that handle. Re-seeding the handle on every delta would drop in-flight local keystrokes.
2204
+ - **Sending the whole textarea value as text.** Send the handle's `encode()` — the encoded CRDT state is what the server merges. Overwriting with plain text throws away the convergence guarantee.
2205
+ - **Reaching for presence before the broker exists.** `usePresence` needs a bound `PresenceChannel`; it is a documented seam, not part of the zero-infra text-convergence path.
@@ -0,0 +1,60 @@
1
+ # templates.mobile
2
+
3
+ > An Expo (React Native) app that is the third consumer of your api — the same typed hooks (useSubscription / useMutation), offline-first by default, typed deep links, and device registration for push. Expo owns Metro; voltro dev runs the sibling api.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/templates/mobile-app.md -->
10
+ ## Mobile app (Expo)
11
+
12
+ _An Expo (React Native) app that is the third consumer of your api — the same typed hooks (useSubscription / useMutation), offline-first by default, typed deep links, and device registration for push. Expo owns Metro; voltro dev runs the sibling api._
13
+
14
+ An [Expo](https://expo.dev) (React Native) app that consumes your Voltro api as a **third client** — alongside the web frontend — with the **same typed hooks** you already use. Template id: **`mobile-app`** (kind: `mobile`).
15
+
16
+ > **Expo owns the dev loop, not `voltro dev`.** A mobile app is started with `expo start` / `expo run:ios`, not `voltro dev`. The sibling api still runs under `voltro dev`; the app connects to it over the network. That is why a `mobile` app gets no port and is not part of `voltro dev`'s orchestration.
17
+
18
+ ## Scaffold
19
+
20
+ ```bash
21
+ # A project with a backend + a mobile app:
22
+ voltro create-project acme --api=api-backend --mobile
23
+
24
+ # …or add one to an existing project later:
25
+ voltro add-app mobile --template=mobile-app --to acme
26
+ ```
27
+
28
+ ## What ships
29
+
30
+ ```text
31
+ mobile-app/
32
+ app.config.ts # EXPO config (scheme for deep links, plugins)
33
+ metro.config.js # monorepo watchFolders so @voltro/* resolve
34
+ voltro.mobile.ts # which api(s) this app consumes
35
+ src/
36
+ client.ts # the Voltro client + the M0 seam (read its header)
37
+ persistence.ts # RN key-value with a secure/plain split
38
+ lib/
39
+ deeplinks.ts # the typed deep-link table + pure resolver (tested)
40
+ sync.ts # the background-sync decision layer (tested)
41
+ notifications.ts # build a _voltro_devices row from a token (tested)
42
+ app/ # Expo Router screens (list · detail · settings)
43
+ tests/ # pure-logic tests (no simulator needed)
44
+ ```
45
+
46
+ ## The reactive loop, on a phone
47
+
48
+ The screens use the **same hooks as the web frontend** — `useSubscription('app', 'notes.list')` streams the server query; `useMutation('app', 'notes.create')` writes it; a server delta re-renders the list. No refetch, no polling. Offline-first is the default: `offlineFirstDefaults` + an RN persistence adapter mean the app opens offline, queues mutations, and reconciles on reconnect, with a first-class connection banner.
49
+
50
+ Deep links are typed and go through one table: a universal link, a custom-scheme URL, and a push payload's `data.deepLink` all resolve via the same `matchFirstDeepLink` path.
51
+
52
+ ## Honest status — read before you expect the loop on a device
53
+
54
+ The **pure logic** (`src/lib/*`) is real and unit-tested (`pnpm test`, no simulator). The **client wiring** — building the runtime + subscription cache + rpc client the hooks read — is **not yet implemented for React Native**: on the web it lives in `@voltro/web`'s web-coupled boot. Wiring an RN-safe equivalent is Phase **M0** of the framework's mobile plan. Until it lands, `buildApiHandles()` in `src/client.ts` returns an empty map and the screens show their loading state — deliberately, not a crash. Its header spells out exactly what M0 must build.
55
+
56
+ Booting the app on a device (`expo run:ios`) is your Expo/EAS CI step — there is no iOS/Android toolchain in the framework repo.
57
+
58
+ ## Prefer pure-native (SwiftUI / Compose)?
59
+
60
+ You do not need React Native. `voltro build api --target swift` (or `--target kotlin`) emits a typed native SDK package from your api — see [React Native & mobile](/docs/react-native/overview).
@@ -1,6 +1,6 @@
1
1
  # Templates
2
2
 
3
- > Thirty-four dogfooded starter templates ship with the framework — twenty backend shapes, thirteen frontend shapes, and a serverless function library. Scaffold any of them with one CLI call.
3
+ > Thirty-eight dogfooded starter templates ship with the framework — twenty-two backend shapes, fourteen frontend shapes, a serverless function library, and an Expo mobile app. Scaffold any of them with one CLI call.
4
4
 
5
5
 
6
6
 
@@ -9,9 +9,9 @@
9
9
  <!-- source: en/templates/overview.md -->
10
10
  ## Overview
11
11
 
12
- _Thirty-four dogfooded starter templates ship with the framework — twenty backend shapes, thirteen frontend shapes, and a serverless function library. Scaffold any of them with one CLI call._
12
+ _Thirty-eight dogfooded starter templates ship with the framework — twenty-two backend shapes, fourteen frontend shapes, a serverless function library, and an Expo mobile app. Scaffold any of them with one CLI call._
13
13
 
14
- Voltro ships **thirty-four** starter templates across the **three kinds** you actually deploy: **twenty** backends (`api-*`), **thirteen** frontends (`frontend-*` plus `changelog`), and **one** serverless library (`edge-functions`). Each is a small, opinionated, dogfooded reference you scaffold once and then own — no update channel, no lock-in.
14
+ Voltro ships **thirty-eight** starter templates across the **four kinds** you actually deploy: **twenty-two** backends (`api-*`), **fourteen** frontends (`frontend-*` plus `changelog`), **one** serverless library (`edge-functions`), and **one** Expo mobile app (`mobile-app`). Each is a small, opinionated, dogfooded reference you scaffold once and then own — no update channel, no lock-in.
15
15
 
16
16
  The CLI loads templates by walking `voltro-templates/apps/<id>/`; the directory name IS the template id you pass to `--api` / `--web` / `--template`. Run `voltro list-templates` to see them live.
17
17
 
@@ -52,19 +52,21 @@ The four `api-backend*` shapes share the same minimal `notes` domain and differ
52
52
  | [`api-versioning`](/docs/templates/api-versioning) | **Row history + time-travel** — `@voltro/plugin-versioning` snapshots every write via the change tap; `rowHistory()` (every version) + `rowAsOf()` (value at a past instant), read through handlers. Memory store. |
53
53
  | [`api-backend-deactivation`](/docs/templates/api-backend-deactivation) | **The `deactivation()` mixin** — lock a user out (`deactivatedAt`) while the row stays VISIBLE/queryable — the deliberate opposite of `softDelete()`'s hide+anonymise. Pure schema. |
54
54
  | [`api-governance`](/docs/templates/api-governance) | **Field encryption + GDPR + consent + retention** — `@voltro/plugin-governance`: AES-256-GCM `.encrypted()` columns (plaintext to handlers, ciphertext at rest), admin-gated `governance.export`/`erase`, a consent ledger, retention sweeps. |
55
+ | [`api-collab`](/docs/templates/api-collab) | **Local-first / CRDT collaborative editing** — a `documents` table with a `crdtText()` `body`; concurrent edits from many clients CONVERGE via an authoritative server-side merge on the write path (no last-write-wins loser), then broadcast over the reactive engine. Zero-infra; pairs with `frontend-collab`. |
55
56
 
56
- All twenty are `kind: 'api'`.
57
+ All twenty-two are `kind: 'api'`.
57
58
 
58
59
  ## Frontend templates (`--web=<id>`)
59
60
 
60
- Thirteen web shapes spanning every render mode — `static`, `spa`, `ssr`, `isr`, selective `islands` hydration — plus the reactive fullstack loop.
61
+ Fourteen web shapes spanning every render mode — `static`, `spa`, `ssr`, `isr`, selective `islands` hydration — plus the reactive fullstack loop (and its local-first / CRDT variant).
61
62
 
62
63
  **Every frontend template is bilingual (en/de) out of the box**, with the i18n strategy matched to its render mode: the hydrated/SSR shells use cookie i18n (`<LocaleSwitcher>`), the static/islands sites use URL-prefix i18n (`/de/…`, one pre-rendered HTML file per locale). `frontend-i18n` remains the dedicated URL-prefix reference. See [URL strategies](/docs/i18n/url-strategies) for why render mode decides.
63
64
 
64
65
  | Template | Render mode | Best for |
65
66
  |---|---|---|
66
67
  | [`frontend-blank`](/docs/templates/blank) | — | Empty React + Vite shell with a layout and an index page. Start here for any custom UI. |
67
- | [`frontend-app`](/docs/templates/app) | `full` · reactive | **The reactive end-to-end loop** — a web frontend WIRED to an api (`useSubscription` + `useMutation` + auto-optimistic). The only fullstack template; pairs with `api-backend`. |
68
+ | [`frontend-app`](/docs/templates/app) | `full` · reactive | **The reactive end-to-end loop** — a web frontend WIRED to an api (`useSubscription` + `useMutation` + auto-optimistic). Pairs with `api-backend`. |
69
+ | [`collab`](/docs/templates/collab) | `full` · reactive · CRDT | **Local-first collaborative editor** — a textarea bound to a `crdtText()` body; type in two tabs at once and the edits CONVERGE (server-authoritative CRDT merge, no last-write-wins loser). Fullstack; pairs with `api-collab`. |
68
70
  | [`frontend-dashboard`](/docs/templates/dashboard) | `static` + `ssr` | **Auth-gated app shell** — a silent `(marketing)` route group + a `/dashboard` area gated by a nested layout loader that `RedirectError`s to `/login`. Scoped error/loading/not-found. No backend. |
69
71
  | [`frontend-admin`](/docs/templates/admin) | `ssr` · reactive | **Auto-admin back-office** — discovers your api's entities at runtime (capability map) and renders a live, permission-gated `<DataTable>` + `<AutoForm>` per entity, with provenance + undo. Editable template code; pairs with any api. |
70
72
  | [`frontend-landing`](/docs/templates/landing) | `static` · `islands` | Marketing landing — hero, features grid, CTA. Zero JS by default; flip `interactive: 'islands'` for interactive pieces. |
@@ -77,7 +79,7 @@ Thirteen web shapes spanning every render mode — `static`, `spa`, `ssr`, `isr`
77
79
  | [`frontend-ssr-api`](/docs/templates/ssr-api) | `ssr` · fed by api | **SSR from your backend** — an `ssr` loader calls `query('notes.list', {})` over the sibling api's POST /rpc, so real rows are in the first paint + `<title>`; `useSubscription` upgrades to live. Pairs with `api-backend`. |
78
80
  | [`frontend-contact`](/docs/templates/contact) | `static` + serverless | **Static page + a serverless email form** — the headline "static frontend, serverless backend" combo. The page ships to a CDN; the function scales to zero. |
79
81
 
80
- All thirteen are `kind: 'web'`. The CLI rejects an api template passed to `--web` (and vice-versa) on a kind mismatch.
82
+ All fourteen are `kind: 'web'`. The CLI rejects an api template passed to `--web` (and vice-versa) on a kind mismatch.
81
83
 
82
84
  ## Serverless template (`--template=<id>` via `add-app`)
83
85
 
@@ -87,6 +89,14 @@ All thirteen are `kind: 'web'`. The CLI rejects an api template passed to `--web
87
89
 
88
90
  `kind: 'serverless'` — added to a project with `voltro add-app <name> --template=edge-functions`.
89
91
 
92
+ ## Mobile template (`--mobile`)
93
+
94
+ | Template | Best for |
95
+ |---|---|
96
+ | [`mobile-app`](/docs/templates/mobile-app) | An **Expo (React Native) app** — the third consumer of your api, with the same typed hooks, offline-first defaults, typed deep links, and device registration for push. |
97
+
98
+ `kind: 'mobile'` — scaffold with `voltro create-project <name> --mobile` or `voltro add-app <name> --template=mobile-app`. A mobile app gets **no port** and is **not** run by `voltro dev`: Expo owns Metro (`expo start` / `expo run:ios`), and the app connects to the sibling api over the network. Its pure logic is unit-tested; the on-device reactive loop depends on the mobile plan's M0 (see [React Native & mobile](/docs/react-native/overview)).
99
+
90
100
  ## Static + serverless combo
91
101
 
92
102
  The most common no-always-on-server shape: **a static page on a CDN + a serverless function for the one dynamic bit.** `frontend-contact` ships it whole — a static page whose island form POSTs to a bundled `*.serverless.ts` that sends mail. Or add `edge-functions` to any static web template and wire the page to whichever function it needs. The function's local dev server sends CORS headers (same as the edge hosts), so the cross-origin form works out of the box.