@voltro/cli 0.29.0 → 0.30.1

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 +448 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/apiBuild-Ca6Sq5jy.js +2 -0
  4. package/dist/{apiBuild-BESZbTjA.js → apiBuild-DmkzRyv7.js} +2 -2
  5. package/dist/bin.js +3 -3
  6. package/dist/{checkCommand-BPAtyWs8.js → checkCommand-C4konUy9.js} +435 -304
  7. package/dist/checkCommand-DHD0aBeR.js +2 -0
  8. package/dist/{commands-82BDEktg.js → commands-BkJft9K2.js} +2413 -1814
  9. package/dist/{dbCommand-bCBbk6tz.js → dbCommand-qn__kLEt.js} +344 -265
  10. package/dist/dbCommand-wfGi3FgG.js +2 -0
  11. package/dist/{dev-Cg4BKLmi.js → dev-C_NPcLDp.js} +2716 -1782
  12. package/dist/{dev-DeEJj5T4.js → dev-DXhhZ7-f.js} +1 -1
  13. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +2 -0
  14. package/dist/{frameworkTableAssembly-4Db40V23.js → frameworkTableAssembly-Cpgn2e88.js} +155 -141
  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-CkB2J0Zn.js} +566 -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-wsWLrq4l.js} +1 -1
  23. package/dist/serveCommand-HtI4X5kj.js +1577 -0
  24. package/dist/serveEntry.js +2 -2
  25. package/dist/{start-CsCIaW4-.js → start-egkbpugc.js} +177 -175
  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 +46 -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 +15 -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 +36 -243
  43. package/templates/agent-docs/workflows.md +367 -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,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
+ }
@@ -0,0 +1,63 @@
1
+ // Schema for the {{projectName}} collaborative-editing backend.
2
+ //
3
+ // Tables are auto-discovered: every exported `table(...)` here (or in any
4
+ // `*.entity.ts` / `*.schema.ts` file) is registered by `voltro dev` — no manual
5
+ // barrel. `actors` + `tenants` are the framework's core tables the
6
+ // `audit()` / `tenant()` mixins reference, so they must be declared.
7
+
8
+ import {
9
+ crdtText,
10
+ databaseHandle,
11
+ id,
12
+ localFirst,
13
+ table,
14
+ text,
15
+ timestamp,
16
+ type InferRow,
17
+ } from '@voltro/database'
18
+ import { tenant } from '@voltro/plugin-multitenancy'
19
+
20
+ // ---------- Core tables (required by the audit / tenant mixins) ----------
21
+
22
+ export const actors = table('actors', {
23
+ id: id(),
24
+ kind: text().oneOf(['user', 'serviceAccount', 'apiKey', 'system']),
25
+ displayName: text().nullable(),
26
+ createdAt: timestamp().default('now'),
27
+ })
28
+
29
+ export const tenants = table('tenants', {
30
+ id: id(),
31
+ name: text(),
32
+ createdAt: timestamp().default('now'),
33
+ })
34
+
35
+ // ---------- The collaborative document ----------
36
+
37
+ // `body` is a `crdtText()` column — a CRDT-managed field stored as the encoded
38
+ // CRDT state (an opaque `bytes` blob; BYTEA / BLOB / VARBINARY per dialect).
39
+ // There is no special DDL: to the declarative differ it is an ordinary nullable
40
+ // `bytes` column, so it plans + round-trips like any other.
41
+ //
42
+ // The convergence guarantee is AUTHORITATIVE and SERVER-SIDE: when a client
43
+ // writes an encoded update to `body`, the runtime folds it into the STORED
44
+ // state with `mergeCrdtStates` on the write path (see
45
+ // `mutations/documents.setBody.mutation.server.ts`) — so two concurrent edits
46
+ // both survive, regardless of the order the server processes them.
47
+ //
48
+ // `localFirst()` marks the table local-first (client mirror + bi-directional
49
+ // sync + CRDT convergence for its `crdtText()` fields). It adds no column — it
50
+ // is a property the framework reflects on (`isLocalFirst` / `hasLocalFirst`).
51
+ export const documents = table('documents', {
52
+ id: id({ prefix: 'doc' }),
53
+ title: text(),
54
+ body: crdtText(), // CRDT-managed — Uint8Array | null; decode with decodeCrdtText()
55
+ })
56
+ // tenant() pulls audit() transitively → adds tenantId + createdAt /
57
+ // updatedAt / createdBy / updatedBy (auto-stamped by the runtime), and
58
+ // auto-scopes every read/write to the caller's tenant.
59
+ .with(tenant(), localFirst())
60
+
61
+ export type Document = InferRow<typeof documents>
62
+
63
+ export const database = databaseHandle({ actors, tenants, documents })
@@ -0,0 +1,19 @@
1
+ import { assertOwnTenant } from '@voltro/plugin-multitenancy/guard'
2
+ import type { AppContext } from '@voltro/runtime'
3
+
4
+ const execute = async (
5
+ input: { tenantId: string; title: string },
6
+ ctx: AppContext,
7
+ ) => {
8
+ assertOwnTenant(input.tenantId, ctx.request.subject)
9
+ // Framework auto-injects a `doc_…` id from the table's id() decl. `body` is
10
+ // omitted — a `crdtText()` column is nullable and the first setBody write
11
+ // establishes the document.
12
+ return ctx.store.insert('documents', {
13
+ title: input.title,
14
+ tenantId: input.tenantId,
15
+ createdAt: new Date(),
16
+ })
17
+ }
18
+
19
+ export default execute
@@ -0,0 +1,36 @@
1
+ // Creates a document (title only — the CRDT `body` starts empty and is
2
+ // established by the first `documents.setBody` write). The handler's
3
+ // `assertOwnTenant` guard rejects cross-tenant spoofing: input.tenantId MUST
4
+ // match the resolved subject's tenantId, else a typed `TenantMismatch`.
5
+
6
+ import { defineMutation } from '@voltro/protocol'
7
+ import { TenantMismatch } from '@voltro/plugin-multitenancy/guard'
8
+ import { Schema } from 'effect'
9
+
10
+ export const createDocument = defineMutation({
11
+ name: 'documents.create',
12
+ target: {
13
+ table: 'documents',
14
+ op: 'insert',
15
+ shape: (input: { tenantId: string; title: string }) => ({
16
+ title: input.title,
17
+ body: null,
18
+ tenantId: input.tenantId,
19
+ createdAt: new Date(),
20
+ }),
21
+ },
22
+ input: Schema.Struct({
23
+ tenantId: Schema.String,
24
+ title: Schema.NonEmptyString,
25
+ }),
26
+ output: Schema.Struct({
27
+ id: Schema.String,
28
+ title: Schema.String,
29
+ // The CRDT `body` rides the wire as its encoded bytes (nullable until the
30
+ // first edit establishes it). Decode with `decodeCrdtText()` on the client.
31
+ body: Schema.NullOr(Schema.Uint8ArrayFromBase64),
32
+ tenantId: Schema.String,
33
+ createdAt: Schema.Date,
34
+ }),
35
+ error: TenantMismatch,
36
+ })
@@ -0,0 +1,20 @@
1
+ import type { AppContext } from '@voltro/runtime'
2
+
3
+ // Write the incoming CRDT update to the `body` column. The handler looks like a
4
+ // plain overwrite — but `body` is a `crdtText()` column, so the runtime's
5
+ // MutationStore intercepts the write: it reads the STORED state and folds the
6
+ // incoming update in with `mergeCrdtStates` (from @voltro/local-first) BEFORE
7
+ // persisting. That authoritative server merge is what makes concurrent edits
8
+ // converge; the reactive engine then broadcasts the merged row to every
9
+ // subscriber. This handler stays a one-liner precisely because the convergence
10
+ // lives in the write path, not here.
11
+ const execute = async (
12
+ input: { id: string; update: Uint8Array },
13
+ ctx: AppContext,
14
+ ) => {
15
+ const row = await ctx.store.update('documents', input.id, { body: input.update })
16
+ if (row === null) throw { status: 404, message: `document ${input.id} not found` }
17
+ return row
18
+ }
19
+
20
+ export default execute
@@ -0,0 +1,30 @@
1
+ // The CRDT write. A client produces an encoded update from its local
2
+ // `crdtText()` handle (`handle.encode()`) and sends it here. The server does
3
+ // NOT overwrite `body` with it — the runtime folds the incoming update into the
4
+ // STORED state with `mergeCrdtStates` on the write path (see the `.server.ts`
5
+ // comment), so two clients that edited the same base CONVERGE. `update` is the
6
+ // opaque encoded CRDT state/delta (`bytes`); the same wire shape a Yjs update
7
+ // blob carries.
8
+ //
9
+ // `op: 'update'` targets the existing row by its primary key. There is no
10
+ // tenant field in the input: the `tenant()` mixin auto-scopes the update to the
11
+ // caller's tenant, so a foreign `id` simply matches no row.
12
+
13
+ import { defineMutation } from '@voltro/protocol'
14
+ import { Schema } from 'effect'
15
+
16
+ export const setDocumentBody = defineMutation({
17
+ name: 'documents.setBody',
18
+ target: { table: 'documents', op: 'update' },
19
+ input: Schema.Struct({
20
+ id: Schema.NonEmptyString,
21
+ update: Schema.Uint8ArrayFromBase64,
22
+ }),
23
+ output: Schema.Struct({
24
+ id: Schema.String,
25
+ title: Schema.String,
26
+ // The MERGED body — what every subscriber now converges to.
27
+ body: Schema.NullOr(Schema.Uint8ArrayFromBase64),
28
+ tenantId: Schema.String,
29
+ }),
30
+ })
@@ -0,0 +1,30 @@
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.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/local-first": "0.30.1",
20
+ "@voltro/plugin-multitenancy": "0.30.1",
21
+ "@voltro/protocol": "0.30.1",
22
+ "@voltro/runtime": "0.30.1",
23
+ "effect": "^3.22.0"
24
+ },
25
+ "devDependencies": {
26
+ "@voltro/testing": "0.30.1",
27
+ "typescript": "^6.0.3",
28
+ "vitest": "^4.1.10"
29
+ }
30
+ }
@@ -0,0 +1,16 @@
1
+ import type { AppContext } from '@voltro/runtime'
2
+
3
+ // Oldest-first so `documents[0]` is stable across clients — the collab page
4
+ // binds its editor to that first document, and every tab agrees on which one.
5
+ const execute = (_input: Record<string, never>, _ctx: AppContext) => ({
6
+ descriptor: {
7
+ table: 'documents' as const,
8
+ predicate: undefined,
9
+ order: [{ column: 'createdAt' as const, direction: 'asc' as const }],
10
+ take: 100,
11
+ skip: undefined,
12
+ projection: undefined,
13
+ },
14
+ })
15
+
16
+ export default execute
@@ -0,0 +1,23 @@
1
+ // Streaming subscription: every change to `documents` for the caller's tenant
2
+ // lands as a delta — including the MERGED `body` after any client's CRDT write.
3
+ // The runtime AND-merges tenant scope into the predicate via the `tenant()`
4
+ // mixin on the table, so no manual `eq('tenantId', ...)` is needed.
5
+ //
6
+ // `body` crosses the wire as base64 (`Schema.Uint8ArrayFromBase64` — JSON-safe,
7
+ // unlike raw bytes) and the rpc client decodes it back to a `Uint8Array`; the
8
+ // web client then folds it into its local CRDT handle to converge.
9
+
10
+ import { defineQuery } from '@voltro/protocol'
11
+ import { Schema } from 'effect'
12
+
13
+ export const listDocuments = defineQuery({
14
+ name: 'documents.list',
15
+ input: Schema.Struct({}),
16
+ output: Schema.Struct({
17
+ id: Schema.String,
18
+ title: Schema.String,
19
+ body: Schema.NullOr(Schema.Uint8ArrayFromBase64),
20
+ tenantId: Schema.String,
21
+ createdAt: Schema.Date,
22
+ }),
23
+ })
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "api-collab",
3
+ "kind": "api",
4
+ "summary": "Real-time collaborative editing — a `documents` table whose `body` is a `crdtText()` column. Concurrent edits from many clients CONVERGE via the runtime's authoritative server-side CRDT merge on the write path (no last-write-wins loser), then broadcast over the reactive engine. Zero infra (`store: 'memory'`). Pairs with frontend-collab: scaffold both with `create-project --api=api-collab --web=frontend-collab`.",
5
+ "tags": ["api", "backend", "local-first", "crdt", "collaborative", "realtime", "reactive", "subscription", "mutation", "frontend-collab"]
6
+ }
@@ -0,0 +1,89 @@
1
+ // The property that matters for a `crdtText()` column is CONVERGENCE: two
2
+ // clients that edit the same document body concurrently must end at the same
3
+ // text, no matter which write the server processes first, with BOTH edits
4
+ // surviving (no last-write-wins loser).
5
+ //
6
+ // This runs the real thing with no database and no server: `ctx.store` from
7
+ // `makeTestContext` is the SAME `wrapStoreWithMixinBehaviour` store the handler
8
+ // gets in production, so the authoritative server-side CRDT merge on the write
9
+ // path executes exactly as at runtime — the mutation just writes the incoming
10
+ // update bytes, and `ctx.store.update` folds them into the stored state.
11
+ // Importing `../database/schema` registers `documents` / `actors` / `tenants`
12
+ // so the store knows `body` is a CRDT-managed column. Run with `voltro test`.
13
+
14
+ import { describe, it, expect } from 'vitest'
15
+ import { makeTestContext, mockStore } from '@voltro/testing'
16
+ import { crdtText, decodeCrdtText } from '@voltro/local-first'
17
+ import '../database/schema' // registers documents / actors / tenants
18
+ import createDocument from '../mutations/documents.create.mutation.server'
19
+ import setBody from '../mutations/documents.setBody.mutation.server'
20
+
21
+ const bodyText = (row: { body?: unknown }): string =>
22
+ decodeCrdtText(row.body as Uint8Array)
23
+
24
+ const freshCtx = () =>
25
+ makeTestContext({
26
+ subject: { type: 'user', id: 'u1', tenantId: 'acme' },
27
+ store: mockStore({ documents: [] }),
28
+ })
29
+
30
+ describe('documents.setBody — authoritative server-side CRDT merge', () => {
31
+ it('folds two concurrent edits together so BOTH survive', async () => {
32
+ const ctx = freshCtx()
33
+ const doc = await createDocument({ tenantId: 'acme', title: 'Design doc' }, ctx)
34
+
35
+ await setBody({ id: String(doc['id']), update: crdtText().insert(0, 'Hello ').encode() }, ctx)
36
+ const merged = await setBody({ id: String(doc['id']), update: crdtText().insert(0, 'World').encode() }, ctx)
37
+
38
+ const text = bodyText(merged)
39
+ expect(text).toContain('Hello')
40
+ expect(text).toContain('World')
41
+ })
42
+
43
+ it('converges to the SAME text whichever order the server processes the writes', async () => {
44
+ // Capture the two updates ONCE and replay the SAME bytes in both orders —
45
+ // reusing the encoded state (not re-generating it) is what makes the CRDT
46
+ // tie-break deterministic, so this asserts order-independence, not chance.
47
+ const clientA = crdtText().insert(0, 'Hello ').encode()
48
+ const clientB = crdtText().insert(0, 'World').encode()
49
+
50
+ const forward = freshCtx()
51
+ const d1 = await createDocument({ tenantId: 'acme', title: 'Doc' }, forward)
52
+ await setBody({ id: String(d1['id']), update: clientA }, forward)
53
+ const fwd = await setBody({ id: String(d1['id']), update: clientB }, forward)
54
+
55
+ const reverse = freshCtx()
56
+ const d2 = await createDocument({ tenantId: 'acme', title: 'Doc' }, reverse)
57
+ await setBody({ id: String(d2['id']), update: clientB }, reverse)
58
+ const rev = await setBody({ id: String(d2['id']), update: clientA }, reverse)
59
+
60
+ expect(bodyText(fwd)).toBe(bodyText(rev)) // order-independent convergence
61
+ })
62
+
63
+ it('is idempotent — re-applying an update already folded in changes nothing', async () => {
64
+ const ctx = freshCtx()
65
+ const doc = await createDocument({ tenantId: 'acme', title: 'Doc' }, ctx)
66
+
67
+ const update = crdtText().insert(0, 'once').encode()
68
+ const first = await setBody({ id: String(doc['id']), update }, ctx)
69
+ const again = await setBody({ id: String(doc['id']), update }, ctx)
70
+
71
+ expect(bodyText(again)).toBe(bodyText(first))
72
+ expect(bodyText(again)).toBe('once')
73
+ })
74
+
75
+ it('a first write establishes the document (no stored state yet)', async () => {
76
+ const ctx = freshCtx()
77
+ const doc = await createDocument({ tenantId: 'acme', title: 'Doc' }, ctx)
78
+
79
+ const merged = await setBody({ id: String(doc['id']), update: crdtText().insert(0, 'seed').encode() }, ctx)
80
+ expect(bodyText(merged)).toBe('seed')
81
+ })
82
+
83
+ it('404s a write to a document the caller cannot see (tenant auto-scoped)', async () => {
84
+ const ctx = freshCtx()
85
+ await expect(
86
+ setBody({ id: 'doc_does_not_exist', update: crdtText().insert(0, 'x').encode() }, ctx),
87
+ ).rejects.toMatchObject({ status: 404 })
88
+ })
89
+ })
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "exclude": ["node_modules", "dist"]
5
+ }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/cli": "0.29.0",
16
- "@voltro/database": "0.29.0",
17
- "@voltro/env": "0.29.0",
18
- "@voltro/plugin-governance": "0.29.0",
19
- "@voltro/plugin-multitenancy": "0.29.0",
20
- "@voltro/protocol": "0.29.0",
21
- "@voltro/runtime": "0.29.0",
15
+ "@voltro/cli": "0.30.1",
16
+ "@voltro/database": "0.30.1",
17
+ "@voltro/env": "0.30.1",
18
+ "@voltro/plugin-governance": "0.30.1",
19
+ "@voltro/plugin-multitenancy": "0.30.1",
20
+ "@voltro/protocol": "0.30.1",
21
+ "@voltro/runtime": "0.30.1",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.29.0",
25
+ "@voltro/testing": "0.30.1",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/cli": "0.29.0",
16
- "@voltro/database": "0.29.0",
17
- "@voltro/env": "0.29.0",
18
- "@voltro/plugin-multitenancy": "0.29.0",
19
- "@voltro/protocol": "0.29.0",
20
- "@voltro/runtime": "0.29.0",
21
- "@voltro/workflow": "0.29.0",
15
+ "@voltro/cli": "0.30.1",
16
+ "@voltro/database": "0.30.1",
17
+ "@voltro/env": "0.30.1",
18
+ "@voltro/plugin-multitenancy": "0.30.1",
19
+ "@voltro/protocol": "0.30.1",
20
+ "@voltro/runtime": "0.30.1",
21
+ "@voltro/workflow": "0.30.1",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.29.0",
25
+ "@voltro/testing": "0.30.1",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -13,18 +13,18 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/plugin-flags": "0.29.0",
20
- "@voltro/plugin-multitenancy": "0.29.0",
21
- "@voltro/protocol": "0.29.0",
22
- "@voltro/runtime": "0.29.0",
23
- "@voltro/sql-postgres": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/plugin-flags": "0.30.1",
20
+ "@voltro/plugin-multitenancy": "0.30.1",
21
+ "@voltro/protocol": "0.30.1",
22
+ "@voltro/runtime": "0.30.1",
23
+ "@voltro/sql-postgres": "0.30.1",
24
24
  "effect": "^3.22.0"
25
25
  },
26
26
  "devDependencies": {
27
- "@voltro/testing": "0.29.0",
27
+ "@voltro/testing": "0.30.1",
28
28
  "typescript": "^6.0.3",
29
29
  "vitest": "^4.1.10"
30
30
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/plugin-audit": "0.29.0",
20
- "@voltro/plugin-governance": "0.29.0",
21
- "@voltro/protocol": "0.29.0",
22
- "@voltro/runtime": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/plugin-audit": "0.30.1",
20
+ "@voltro/plugin-governance": "0.30.1",
21
+ "@voltro/protocol": "0.30.1",
22
+ "@voltro/runtime": "0.30.1",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.29.0",
26
+ "@voltro/testing": "0.30.1",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.97.0",
14
14
  "@effect/rpc": "^0.76.0",
15
- "@voltro/cli": "0.29.0",
16
- "@voltro/database": "0.29.0",
17
- "@voltro/env": "0.29.0",
18
- "@voltro/kv": "0.29.0",
19
- "@voltro/plugin-multitenancy": "0.29.0",
20
- "@voltro/protocol": "0.29.0",
21
- "@voltro/runtime": "0.29.0",
15
+ "@voltro/cli": "0.30.1",
16
+ "@voltro/database": "0.30.1",
17
+ "@voltro/env": "0.30.1",
18
+ "@voltro/kv": "0.30.1",
19
+ "@voltro/plugin-multitenancy": "0.30.1",
20
+ "@voltro/protocol": "0.30.1",
21
+ "@voltro/runtime": "0.30.1",
22
22
  "effect": "^3.22.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.29.0",
25
+ "@voltro/testing": "0.30.1",
26
26
  "typescript": "^6.0.3",
27
27
  "vitest": "^4.1.10"
28
28
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/env": "0.29.0",
19
- "@voltro/plugin-moderation": "0.29.0",
20
- "@voltro/plugin-multitenancy": "0.29.0",
21
- "@voltro/protocol": "0.29.0",
22
- "@voltro/runtime": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/env": "0.30.1",
19
+ "@voltro/plugin-moderation": "0.30.1",
20
+ "@voltro/plugin-multitenancy": "0.30.1",
21
+ "@voltro/protocol": "0.30.1",
22
+ "@voltro/runtime": "0.30.1",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.29.0",
26
+ "@voltro/testing": "0.30.1",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }
@@ -13,17 +13,17 @@
13
13
  "dependencies": {
14
14
  "@effect/platform": "^0.97.0",
15
15
  "@effect/rpc": "^0.76.0",
16
- "@voltro/cli": "0.29.0",
17
- "@voltro/database": "0.29.0",
18
- "@voltro/plugin-multitenancy": "0.29.0",
19
- "@voltro/plugin-prometheus": "0.29.0",
20
- "@voltro/plugin-sentry": "0.29.0",
21
- "@voltro/protocol": "0.29.0",
22
- "@voltro/runtime": "0.29.0",
16
+ "@voltro/cli": "0.30.1",
17
+ "@voltro/database": "0.30.1",
18
+ "@voltro/plugin-multitenancy": "0.30.1",
19
+ "@voltro/plugin-prometheus": "0.30.1",
20
+ "@voltro/plugin-sentry": "0.30.1",
21
+ "@voltro/protocol": "0.30.1",
22
+ "@voltro/runtime": "0.30.1",
23
23
  "effect": "^3.22.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.29.0",
26
+ "@voltro/testing": "0.30.1",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.10"
29
29
  }