@voltro/cli 0.12.0 → 0.14.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 (189) hide show
  1. package/CHANGELOG.md +378 -0
  2. package/dist/{apiBuild-GefjNI5d.js → apiBuild-370ITNRk.js} +3 -3
  3. package/dist/apiBuild-DatWP5hh.js +2 -0
  4. package/dist/bin.js +3 -3
  5. package/dist/{commands-BmNi1RgZ.js → commands-D-teL_TM.js} +3756 -2539
  6. package/dist/dbCommand-B3CeMU3u.js +2 -0
  7. package/dist/{dbCommand-DNrkb58g.js → dbCommand-Dmi-vHfA.js} +72 -57
  8. package/dist/{dev-d0LjACxE.js → dev-CKyW_LAv.js} +1511 -1331
  9. package/dist/dev-CPKbckaN.js +3 -0
  10. package/dist/fileConventions-COg0ZytD.js +9 -0
  11. package/dist/frameworkTableAssembly-DCLhpHsF.js +601 -0
  12. package/dist/frameworkTableAssembly-DYzb2elg.js +2 -0
  13. package/dist/index.js +1 -1
  14. package/dist/{inspectMetrics-uEfIS1vp.js → inspectMetrics-CyVMeofr.js} +962 -943
  15. package/dist/seedRunner-D6eu-u5U.js +229 -0
  16. package/dist/serveCommand-CU3Sw4ZM.js +1127 -0
  17. package/dist/serveEntry.js +3 -3
  18. package/dist/{start-DKcQd52a.js → start-D8ATvTzo.js} +246 -246
  19. package/dist/startEntry.js +2 -2
  20. package/package.json +17 -17
  21. package/templates/AGENTS.core.md +77 -3
  22. package/templates/AGENTS.md +78 -4
  23. package/templates/agent-docs/_index.md +1 -1
  24. package/templates/agent-docs/_manifest.json +2 -2
  25. package/templates/agent-docs/authentication.md +39 -0
  26. package/templates/agent-docs/cli.md +114 -6
  27. package/templates/agent-docs/configuration.md +48 -0
  28. package/templates/agent-docs/data.md +193 -1
  29. package/templates/agent-docs/database/hosting.md +2 -2
  30. package/templates/agent-docs/database/migrations.md +88 -3
  31. package/templates/agent-docs/database/overview.md +11 -0
  32. package/templates/agent-docs/database/seedsdialects.md +53 -1
  33. package/templates/agent-docs/database/transactions.md +31 -0
  34. package/templates/agent-docs/internationalization.md +3 -3
  35. package/templates/agent-docs/introduction.md +133 -2
  36. package/templates/agent-docs/plugins.md +2 -1
  37. package/templates/agent-docs/reference.md +4 -4
  38. package/templates/agent-docs/routing.md +48 -43
  39. package/templates/agent-docs/schema-driven-ui.md +12 -2
  40. package/templates/agent-docs/templates/apibackends.md +1 -8
  41. package/templates/agent-docs/templates/appshells.md +24 -24
  42. package/templates/agent-docs/testing.md +40 -1
  43. package/templates/agent-docs/whats-new.md +51 -131
  44. package/templates/apps/api-ai/database/schema.ts +0 -2
  45. package/templates/apps/api-ai/package.json +7 -7
  46. package/templates/apps/api-auth/package.json +8 -8
  47. package/templates/apps/api-backend/database/schema.ts +0 -3
  48. package/templates/apps/api-backend/package.json +7 -7
  49. package/templates/apps/api-backend-deactivation/database/schema.ts +0 -1
  50. package/templates/apps/api-backend-deactivation/package.json +7 -7
  51. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +1 -1
  52. package/templates/apps/api-backend-mail/database/schema.ts +0 -3
  53. package/templates/apps/api-backend-mail/package.json +8 -8
  54. package/templates/apps/api-backend-mariadb/database/schema.ts +0 -3
  55. package/templates/apps/api-backend-mariadb/package.json +9 -9
  56. package/templates/apps/api-backend-storage/database/schema.ts +0 -3
  57. package/templates/apps/api-backend-storage/package.json +8 -8
  58. package/templates/apps/api-data-advanced/database/authors.entity.ts +0 -2
  59. package/templates/apps/api-data-advanced/database/books.entity.ts +0 -1
  60. package/templates/apps/api-data-advanced/package.json +8 -8
  61. package/templates/apps/api-data-advanced/tests/queries.test.ts +6 -1
  62. package/templates/apps/api-durable/database/schema.ts +0 -3
  63. package/templates/apps/api-durable/package.json +8 -8
  64. package/templates/apps/api-feature-flags/database/schema.ts +0 -1
  65. package/templates/apps/api-feature-flags/package.json +9 -9
  66. package/templates/apps/api-governance/app.config.ts +0 -1
  67. package/templates/apps/api-governance/database/schema.ts +0 -1
  68. package/templates/apps/api-governance/package.json +8 -8
  69. package/templates/apps/api-kv/database/schema.ts +0 -3
  70. package/templates/apps/api-kv/package.json +8 -8
  71. package/templates/apps/api-kv/tests/sync.test.ts +2 -2
  72. package/templates/apps/api-moderation/database/schema.ts +0 -2
  73. package/templates/apps/api-moderation/package.json +8 -8
  74. package/templates/apps/api-observability/database/schema.ts +0 -1
  75. package/templates/apps/api-observability/package.json +8 -8
  76. package/templates/apps/api-ratelimit/database/schema.ts +0 -1
  77. package/templates/apps/api-ratelimit/package.json +8 -8
  78. package/templates/apps/api-rbac/README.md +53 -14
  79. package/templates/apps/api-rbac/app.config.ts +24 -26
  80. package/templates/apps/api-rbac/authz.ts +76 -0
  81. package/templates/apps/api-rbac/database/schema.ts +22 -7
  82. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +7 -5
  83. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +15 -8
  84. package/templates/apps/api-rbac/mutations/notes.delete.mutation.server.ts +54 -0
  85. package/templates/apps/api-rbac/mutations/notes.delete.mutation.ts +43 -0
  86. package/templates/apps/api-rbac/mutations/teams.rename.mutation.server.ts +19 -0
  87. package/templates/apps/api-rbac/mutations/teams.rename.mutation.ts +37 -0
  88. package/templates/apps/api-rbac/package.json +8 -8
  89. package/templates/apps/api-rbac/queries/notes.list.query.server.ts +12 -0
  90. package/templates/apps/api-rbac/queries/notes.list.query.ts +26 -0
  91. package/templates/apps/api-rbac/template.json +2 -2
  92. package/templates/apps/api-rbac/tests/authz.test.ts +233 -0
  93. package/templates/apps/api-rest/database/schema.ts +0 -3
  94. package/templates/apps/api-rest/package.json +7 -7
  95. package/templates/apps/api-saas/database/schema.ts +0 -1
  96. package/templates/apps/api-saas/package.json +11 -11
  97. package/templates/apps/api-search/database/schema.ts +0 -1
  98. package/templates/apps/api-search/package.json +8 -8
  99. package/templates/apps/api-versioning/database/schema.ts +0 -1
  100. package/templates/apps/api-versioning/package.json +8 -8
  101. package/templates/apps/api-webhooks/database/schema.ts +0 -1
  102. package/templates/apps/api-webhooks/package.json +9 -8
  103. package/templates/apps/changelog/package.json +6 -6
  104. package/templates/apps/changelog/src/pages/[locale]/{[slug].tsx → [slug].page.tsx} +2 -2
  105. package/templates/apps/changelog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  106. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +2 -2
  107. package/templates/apps/changelog/src/pages/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  108. package/templates/apps/changelog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  109. package/templates/apps/edge-functions/package.json +2 -2
  110. package/templates/apps/frontend-admin/package.json +8 -8
  111. package/templates/apps/frontend-admin/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  112. package/templates/apps/frontend-admin/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  113. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +1 -1
  114. package/templates/apps/frontend-admin/src/pages/admin/{index.test.tsx → index.page.test.tsx} +1 -1
  115. package/templates/apps/frontend-app/package.json +8 -8
  116. package/templates/apps/frontend-app/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  117. package/templates/apps/frontend-app/src/pages/{schema-ui.test.tsx → schema-ui.page.test.tsx} +1 -1
  118. package/templates/apps/frontend-blank/package.json +7 -7
  119. package/templates/apps/frontend-blank/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  120. package/templates/apps/frontend-contact/package.json +7 -7
  121. package/templates/apps/frontend-contact/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  122. package/templates/apps/frontend-contact/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  123. package/templates/apps/frontend-dashboard/package.json +7 -7
  124. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.test.tsx → index.page.test.tsx} +1 -1
  125. package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.test.tsx → login.page.test.tsx} +1 -1
  126. package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.test.tsx → index.page.test.tsx} +1 -1
  127. package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.test.tsx → settings.page.test.tsx} +1 -1
  128. package/templates/apps/frontend-docs/package.json +7 -7
  129. package/templates/apps/frontend-docs/src/pages/[locale]/docs/{[...slug].tsx → [...slug].page.tsx} +3 -3
  130. package/templates/apps/frontend-docs/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  131. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +2 -2
  132. package/templates/apps/frontend-docs/src/pages/docs/{[...slug].test.tsx → [...slug].page.test.tsx} +1 -1
  133. package/templates/apps/frontend-docs/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  134. package/templates/apps/frontend-i18n/package.json +6 -6
  135. package/templates/apps/frontend-i18n/src/pages/[locale]/{about.tsx → about.page.tsx} +2 -2
  136. package/templates/apps/frontend-i18n/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  137. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +2 -2
  138. package/templates/apps/frontend-i18n/src/pages/{about.test.tsx → about.page.test.tsx} +1 -1
  139. package/templates/apps/frontend-i18n/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  140. package/templates/apps/frontend-landing/package.json +7 -7
  141. package/templates/apps/frontend-landing/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  142. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +1 -1
  143. package/templates/apps/frontend-landing/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  144. package/templates/apps/frontend-spa/package.json +7 -7
  145. package/templates/apps/frontend-spa/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  146. package/templates/apps/frontend-ssr/package.json +7 -7
  147. package/templates/apps/frontend-ssr/src/pages/{feed-swr.test.tsx → feed-swr.page.test.tsx} +1 -1
  148. package/templates/apps/frontend-ssr/src/pages/{feed.test.tsx → feed.page.test.tsx} +1 -1
  149. package/templates/apps/frontend-ssr/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  150. package/templates/apps/frontend-ssr-api/package.json +8 -8
  151. package/templates/apps/frontend-ssr-api/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  152. package/templates/apps/frontend-static-blog/package.json +6 -6
  153. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/{[slug].tsx → [slug].page.tsx} +2 -2
  154. package/templates/apps/frontend-static-blog/src/pages/[locale]/{index.tsx → index.page.tsx} +2 -2
  155. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +2 -2
  156. package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].test.tsx → [slug].page.test.tsx} +1 -1
  157. package/templates/apps/frontend-static-blog/src/pages/{index.test.tsx → index.page.test.tsx} +1 -1
  158. package/dist/apiBuild-s7swuEU5.js +0 -2
  159. package/dist/dbCommand-Bt6BCG7g.js +0 -2
  160. package/dist/dev-YHbHmeXt.js +0 -3
  161. package/dist/seedRunner-DZmhoqNt.js +0 -821
  162. package/dist/serveCommand-B2-YV9TU.js +0 -1112
  163. package/templates/apps/api-rbac/tests/notes.create.test.ts +0 -80
  164. /package/templates/apps/changelog/src/pages/{[slug].tsx → [slug].page.tsx} +0 -0
  165. /package/templates/apps/changelog/src/pages/{index.tsx → index.page.tsx} +0 -0
  166. /package/templates/apps/frontend-admin/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  167. /package/templates/apps/frontend-admin/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  168. /package/templates/apps/frontend-admin/src/pages/admin/{[entity].tsx → [entity].page.tsx} +0 -0
  169. /package/templates/apps/frontend-admin/src/pages/admin/{index.tsx → index.page.tsx} +0 -0
  170. /package/templates/apps/frontend-app/src/pages/{index.tsx → index.page.tsx} +0 -0
  171. /package/templates/apps/frontend-app/src/pages/{schema-ui.tsx → schema-ui.page.tsx} +0 -0
  172. /package/templates/apps/frontend-blank/src/pages/{index.tsx → index.page.tsx} +0 -0
  173. /package/templates/apps/frontend-contact/src/pages/{index.tsx → index.page.tsx} +0 -0
  174. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{index.tsx → index.page.tsx} +0 -0
  175. /package/templates/apps/frontend-dashboard/src/pages/(marketing)/{login.tsx → login.page.tsx} +0 -0
  176. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{index.tsx → index.page.tsx} +0 -0
  177. /package/templates/apps/frontend-dashboard/src/pages/dashboard/{settings.tsx → settings.page.tsx} +0 -0
  178. /package/templates/apps/frontend-docs/src/pages/docs/{[...slug].tsx → [...slug].page.tsx} +0 -0
  179. /package/templates/apps/frontend-docs/src/pages/{index.tsx → index.page.tsx} +0 -0
  180. /package/templates/apps/frontend-i18n/src/pages/{about.tsx → about.page.tsx} +0 -0
  181. /package/templates/apps/frontend-i18n/src/pages/{index.tsx → index.page.tsx} +0 -0
  182. /package/templates/apps/frontend-landing/src/pages/{index.tsx → index.page.tsx} +0 -0
  183. /package/templates/apps/frontend-spa/src/pages/{index.tsx → index.page.tsx} +0 -0
  184. /package/templates/apps/frontend-ssr/src/pages/{feed-swr.tsx → feed-swr.page.tsx} +0 -0
  185. /package/templates/apps/frontend-ssr/src/pages/{feed.tsx → feed.page.tsx} +0 -0
  186. /package/templates/apps/frontend-ssr/src/pages/{index.tsx → index.page.tsx} +0 -0
  187. /package/templates/apps/frontend-ssr-api/src/pages/{index.tsx → index.page.tsx} +0 -0
  188. /package/templates/apps/frontend-static-blog/src/pages/blog/{[slug].tsx → [slug].page.tsx} +0 -0
  189. /package/templates/apps/frontend-static-blog/src/pages/{index.tsx → index.page.tsx} +0 -0
@@ -10,17 +10,17 @@
10
10
  "test": "voltro test"
11
11
  },
12
12
  "dependencies": {
13
- "@voltro/cli": "0.12.0",
14
- "@voltro/database": "0.12.0",
15
- "@voltro/env": "0.12.0",
16
- "@voltro/plugin-multitenancy": "0.12.0",
17
- "@voltro/plugin-storage": "0.12.0",
18
- "@voltro/protocol": "0.12.0",
19
- "@voltro/runtime": "0.12.0",
13
+ "@voltro/cli": "0.14.0",
14
+ "@voltro/database": "0.14.0",
15
+ "@voltro/env": "0.14.0",
16
+ "@voltro/plugin-multitenancy": "0.14.0",
17
+ "@voltro/plugin-storage": "0.14.0",
18
+ "@voltro/protocol": "0.14.0",
19
+ "@voltro/runtime": "0.14.0",
20
20
  "effect": "^3.21.2"
21
21
  },
22
22
  "devDependencies": {
23
- "@voltro/testing": "0.12.0",
23
+ "@voltro/testing": "0.14.0",
24
24
  "typescript": "^5.7.0",
25
25
  "vitest": "^3.0.0"
26
26
  }
@@ -26,5 +26,3 @@ export const authors = table('authors', {
26
26
  // tenant() pulls audit() transitively → adds tenantId + createdAt /
27
27
  // updatedAt / createdBy / updatedBy (auto-stamped by the runtime).
28
28
  .with(tenant())
29
- // Opt into the matcher engine so subscriptions react to writes.
30
- .reactive()
@@ -56,4 +56,3 @@ export const books = table('books', {
56
56
  })
57
57
  // tenant() pulls audit() transitively (tenantId + audit columns).
58
58
  .with(tenant())
59
- .reactive()
@@ -11,17 +11,17 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/cli": "0.12.0",
15
- "@voltro/database": "0.12.0",
16
- "@voltro/env": "0.12.0",
17
- "@voltro/plugin-governance": "0.12.0",
18
- "@voltro/plugin-multitenancy": "0.12.0",
19
- "@voltro/protocol": "0.12.0",
20
- "@voltro/runtime": "0.12.0",
14
+ "@voltro/cli": "0.14.0",
15
+ "@voltro/database": "0.14.0",
16
+ "@voltro/env": "0.14.0",
17
+ "@voltro/plugin-governance": "0.14.0",
18
+ "@voltro/plugin-multitenancy": "0.14.0",
19
+ "@voltro/protocol": "0.14.0",
20
+ "@voltro/runtime": "0.14.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.12.0",
24
+ "@voltro/testing": "0.14.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -71,7 +71,12 @@ describe('authors.withBooks', () => {
71
71
  const rows = await ctx.store.query(authorsWithBooksExecute({}, ctx).descriptor)
72
72
 
73
73
  expect(rows).toHaveLength(1)
74
- const ada = rows[0] as { id: string; name: string; books: ReadonlyArray<{ title: string }> }
74
+ // Narrow BEFORE describing the shape: under `noUncheckedIndexedAccess`
75
+ // `rows[0]` is `Row | undefined`, and a cast that spans that gap is the one
76
+ // TypeScript refuses outright ("neither type sufficiently overlaps").
77
+ const first = rows[0]
78
+ if (first === undefined) throw new Error('expected one author row')
79
+ const ada = first as unknown as { id: string; name: string; books: ReadonlyArray<{ title: string }> }
75
80
  expect(ada.name).toBe('Ada')
76
81
  expect(ada.books.map((b) => b.title).sort()).toEqual(['Analytical Engine', 'Notes'])
77
82
  })
@@ -56,9 +56,6 @@ export const orders = table('orders', {
56
56
  // tenant() pulls audit() transitively → adds tenantId + createdAt /
57
57
  // updatedAt / createdBy / updatedBy (auto-stamped by the runtime).
58
58
  .with(tenant())
59
- // Opt into the matcher engine: the `orderChanges` subscriber + any
60
- // live subscription against `orders` fire on every mutation write.
61
- .reactive()
62
59
 
63
60
  export type Order = InferRow<typeof orders>
64
61
 
@@ -11,17 +11,17 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/cli": "0.12.0",
15
- "@voltro/database": "0.12.0",
16
- "@voltro/env": "0.12.0",
17
- "@voltro/plugin-multitenancy": "0.12.0",
18
- "@voltro/protocol": "0.12.0",
19
- "@voltro/runtime": "0.12.0",
20
- "@voltro/workflow": "0.12.0",
14
+ "@voltro/cli": "0.14.0",
15
+ "@voltro/database": "0.14.0",
16
+ "@voltro/env": "0.14.0",
17
+ "@voltro/plugin-multitenancy": "0.14.0",
18
+ "@voltro/protocol": "0.14.0",
19
+ "@voltro/runtime": "0.14.0",
20
+ "@voltro/workflow": "0.14.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.12.0",
24
+ "@voltro/testing": "0.14.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -30,7 +30,6 @@ export const notes = table('notes', {
30
30
  body: text(),
31
31
  })
32
32
  .with(tenant())
33
- .reactive()
34
33
 
35
34
  export type Note = InferRow<typeof notes>
36
35
 
@@ -12,18 +12,18 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.12.0",
16
- "@voltro/database": "0.12.0",
17
- "@voltro/env": "0.12.0",
18
- "@voltro/plugin-flags": "0.12.0",
19
- "@voltro/plugin-multitenancy": "0.12.0",
20
- "@voltro/protocol": "0.12.0",
21
- "@voltro/runtime": "0.12.0",
22
- "@voltro/sql-postgres": "0.12.0",
15
+ "@voltro/cli": "0.14.0",
16
+ "@voltro/database": "0.14.0",
17
+ "@voltro/env": "0.14.0",
18
+ "@voltro/plugin-flags": "0.14.0",
19
+ "@voltro/plugin-multitenancy": "0.14.0",
20
+ "@voltro/protocol": "0.14.0",
21
+ "@voltro/runtime": "0.14.0",
22
+ "@voltro/sql-postgres": "0.14.0",
23
23
  "effect": "^3.21.2"
24
24
  },
25
25
  "devDependencies": {
26
- "@voltro/testing": "0.12.0",
26
+ "@voltro/testing": "0.14.0",
27
27
  "typescript": "^5.7.0",
28
28
  "vitest": "^3.0.0"
29
29
  }
@@ -44,5 +44,4 @@ export default {
44
44
  retention: [{ table: 'profiles', ttlMs: 365 * 86_400_000, action: 'delete' }],
45
45
  }),
46
46
  ],
47
- env,
48
47
  }
@@ -34,7 +34,6 @@ export const profiles = table('profiles', {
34
34
  ssn: text().encrypted(),
35
35
  })
36
36
  .with(audit()) // createdAt drives the retention sweep
37
- .reactive()
38
37
 
39
38
  export type Profile = InferRow<typeof profiles>
40
39
 
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.12.0",
16
- "@voltro/database": "0.12.0",
17
- "@voltro/env": "0.12.0",
18
- "@voltro/plugin-audit": "0.12.0",
19
- "@voltro/plugin-governance": "0.12.0",
20
- "@voltro/protocol": "0.12.0",
21
- "@voltro/runtime": "0.12.0",
15
+ "@voltro/cli": "0.14.0",
16
+ "@voltro/database": "0.14.0",
17
+ "@voltro/env": "0.14.0",
18
+ "@voltro/plugin-audit": "0.14.0",
19
+ "@voltro/plugin-governance": "0.14.0",
20
+ "@voltro/protocol": "0.14.0",
21
+ "@voltro/runtime": "0.14.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.12.0",
25
+ "@voltro/testing": "0.14.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -53,9 +53,6 @@ export const syncedEvents = table('synced_events', {
53
53
  // tenant() pulls audit() transitively → tenantId + created/updated columns,
54
54
  // auto-stamped by the runtime from the caller's subject.
55
55
  .with(tenant())
56
- // Opt into the matcher engine so the `events.list` subscription wakes on
57
- // every insert.
58
- .reactive()
59
56
 
60
57
  export type SyncedEvent = InferRow<typeof syncedEvents>
61
58
 
@@ -11,17 +11,17 @@
11
11
  "dependencies": {
12
12
  "@effect/platform": "^0.96.1",
13
13
  "@effect/rpc": "^0.75.1",
14
- "@voltro/cli": "0.12.0",
15
- "@voltro/database": "0.12.0",
16
- "@voltro/env": "0.12.0",
17
- "@voltro/kv": "0.12.0",
18
- "@voltro/plugin-multitenancy": "0.12.0",
19
- "@voltro/protocol": "0.12.0",
20
- "@voltro/runtime": "0.12.0",
14
+ "@voltro/cli": "0.14.0",
15
+ "@voltro/database": "0.14.0",
16
+ "@voltro/env": "0.14.0",
17
+ "@voltro/kv": "0.14.0",
18
+ "@voltro/plugin-multitenancy": "0.14.0",
19
+ "@voltro/protocol": "0.14.0",
20
+ "@voltro/runtime": "0.14.0",
21
21
  "effect": "^3.21.2"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/testing": "0.12.0",
24
+ "@voltro/testing": "0.14.0",
25
25
  "typescript": "^5.7.0",
26
26
  "vitest": "^3.0.0"
27
27
  }
@@ -38,7 +38,7 @@ describe('sync.pull (durable cursor + idempotency markers)', () => {
38
38
  const rows = await ctx.store.query(database.syncedEvents.descriptor)
39
39
  expect(rows).toHaveLength(3)
40
40
  expect(rows.every((r) => r['tenantId'] === 'acme')).toBe(true)
41
- expect((rows.map((r) => r['externalId']) as ReadonlyArray<string>).sort()).toEqual([
41
+ expect((rows.map((r) => r['externalId']) as string[]).sort()).toEqual([
42
42
  'ext-1',
43
43
  'ext-2',
44
44
  'ext-3',
@@ -63,7 +63,7 @@ describe('sync.pull (durable cursor + idempotency markers)', () => {
63
63
 
64
64
  const rows = await ctx.store.query(database.syncedEvents.descriptor)
65
65
  expect(rows).toHaveLength(4) // 2 + 2, no duplicates
66
- expect((rows.map((r) => r['sequence']) as ReadonlyArray<number>).sort((a, b) => a - b)).toEqual([
66
+ expect((rows.map((r) => r['sequence']) as number[]).sort((a, b) => a - b)).toEqual([
67
67
  1, 2, 3, 4,
68
68
  ])
69
69
  })
@@ -30,14 +30,12 @@ export const posts = table('posts', {
30
30
  body: text(),
31
31
  })
32
32
  .with(tenant())
33
- .reactive()
34
33
 
35
34
  export const comments = table('comments', {
36
35
  id: id({ prefix: 'cmt' }),
37
36
  body: text(),
38
37
  })
39
38
  .with(tenant())
40
- .reactive()
41
39
 
42
40
  export type Post = InferRow<typeof posts>
43
41
  export type Comment = InferRow<typeof comments>
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.12.0",
16
- "@voltro/database": "0.12.0",
17
- "@voltro/env": "0.12.0",
18
- "@voltro/plugin-moderation": "0.12.0",
19
- "@voltro/plugin-multitenancy": "0.12.0",
20
- "@voltro/protocol": "0.12.0",
21
- "@voltro/runtime": "0.12.0",
15
+ "@voltro/cli": "0.14.0",
16
+ "@voltro/database": "0.14.0",
17
+ "@voltro/env": "0.14.0",
18
+ "@voltro/plugin-moderation": "0.14.0",
19
+ "@voltro/plugin-multitenancy": "0.14.0",
20
+ "@voltro/protocol": "0.14.0",
21
+ "@voltro/runtime": "0.14.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.12.0",
25
+ "@voltro/testing": "0.14.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -31,7 +31,6 @@ export const notes = table('notes', {
31
31
  done: boolean().default(false),
32
32
  })
33
33
  .with(tenant()) // tenantId + audit columns, auto-stamped
34
- .reactive()
35
34
 
36
35
  export type Note = InferRow<typeof notes>
37
36
 
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.12.0",
16
- "@voltro/database": "0.12.0",
17
- "@voltro/plugin-multitenancy": "0.12.0",
18
- "@voltro/plugin-prometheus": "0.12.0",
19
- "@voltro/plugin-sentry": "0.12.0",
20
- "@voltro/protocol": "0.12.0",
21
- "@voltro/runtime": "0.12.0",
15
+ "@voltro/cli": "0.14.0",
16
+ "@voltro/database": "0.14.0",
17
+ "@voltro/plugin-multitenancy": "0.14.0",
18
+ "@voltro/plugin-prometheus": "0.14.0",
19
+ "@voltro/plugin-sentry": "0.14.0",
20
+ "@voltro/protocol": "0.14.0",
21
+ "@voltro/runtime": "0.14.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.12.0",
25
+ "@voltro/testing": "0.14.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -30,7 +30,6 @@ export const notes = table('notes', {
30
30
  body: text(),
31
31
  })
32
32
  .with(tenant())
33
- .reactive()
34
33
 
35
34
  export type Note = InferRow<typeof notes>
36
35
 
@@ -12,17 +12,17 @@
12
12
  "dependencies": {
13
13
  "@effect/platform": "^0.96.1",
14
14
  "@effect/rpc": "^0.75.1",
15
- "@voltro/cli": "0.12.0",
16
- "@voltro/database": "0.12.0",
17
- "@voltro/env": "0.12.0",
18
- "@voltro/plugin-multitenancy": "0.12.0",
19
- "@voltro/plugin-ratelimit": "0.12.0",
20
- "@voltro/protocol": "0.12.0",
21
- "@voltro/runtime": "0.12.0",
15
+ "@voltro/cli": "0.14.0",
16
+ "@voltro/database": "0.14.0",
17
+ "@voltro/env": "0.14.0",
18
+ "@voltro/plugin-multitenancy": "0.14.0",
19
+ "@voltro/plugin-ratelimit": "0.14.0",
20
+ "@voltro/protocol": "0.14.0",
21
+ "@voltro/runtime": "0.14.0",
22
22
  "effect": "^3.21.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/testing": "0.12.0",
25
+ "@voltro/testing": "0.14.0",
26
26
  "typescript": "^5.7.0",
27
27
  "vitest": "^3.0.0"
28
28
  }
@@ -13,19 +13,39 @@ pnpm --filter @{{projectName}}/{{appName}} dev
13
13
 
14
14
  ## What this shows
15
15
 
16
- - **Roles → scopes, as config** — `app.config.ts` declares a role map
17
- (`viewer notes:read`, `editor notes:read + notes:write`,
18
- `admin admin:full`). An rpc interceptor resolves each caller's roles to
19
- scopes and stamps them on the subject.
20
- - **Guard with `permission()`** — `notes.create` calls
21
- `permission(ctx, 'notes:write')`, the Effect-native guard. It fails typed
22
- `ScopeError` when the caller lacks the scope; `admin:full` is a blanket bypass.
23
- Companions: `anyPermission(ctx, [...])` (OR), `assertPermission` (sync throw),
24
- `can(ctx, scope)` (boolean).
25
- - **Where roles come from** the shipped `resolveRoles` is a DEMO that maps
26
- the request's **tenant** to a role so you can try each with the `x-tenant`
27
- header. Production reads the caller's real roles: the default resolver reads
28
- `subject.metadata.roles` (set by your auth strategy), or do a DB lookup.
16
+ - **Roles → scopes, as config** — `authz.ts` declares the role map; an rpc
17
+ interceptor resolves each caller's roles to scopes and publishes them to the
18
+ framework's effective-scope seam. That map is also the app's **declared scope
19
+ vocabulary**: `voltro check` fails on a guard requiring a scope no role grants.
20
+ - **Declarative guards** (`notes.create`, `notes.list`) — `guards: [{ scope }]`
21
+ on the descriptor. Enforced before the executor and before the transaction,
22
+ and the only form `voltro check` can inspect. **Prefer this.**
23
+ - **Resource-scoped guards** (`teams.rename`) `guards: [{ scope, resource }]`
24
+ plus `resolveResourceRoles`, so "owner" means owner *of that team*.
25
+ - **In-handler guards** (`notes.delete`)`can()` / `permission()` for the
26
+ decision a descriptor cannot make, because it depends on the loaded row.
27
+ - **Where roles come from** — the shipped `resolveRoles` is a DEMO mapping the
28
+ request's **tenant** to a role, so you can try each with an `x-tenant` header.
29
+ Production reads the caller's real roles (`subject.metadata.roles`, or a DB
30
+ lookup).
31
+
32
+ ## Which guard form?
33
+
34
+ Put in `guards:` everything decidable from the **subject** and the **input**.
35
+ Reach for `permission()` / `can()` only for what needs loaded data.
36
+
37
+ The difference is not style. A declarative guard lands in the capability
38
+ manifest, so `voltro check` catches a scope no role grants — a bug that is
39
+ otherwise invisible, because the procedure simply becomes uncallable by
40
+ everyone, forever, with no error anywhere. An in-handler call is invisible to
41
+ that check; a typo there is a branch that is silently never taken.
42
+
43
+ ## Failure postures
44
+
45
+ | Resolver | On throw / reject |
46
+ | --- | --- |
47
+ | `resolveRoles` / `resolvePermissions` | logs, degrades to the subject's **own** scopes — never grants |
48
+ | `resolveResourceRoles` | **denies** (fail-closed) |
29
49
 
30
50
  ## Try it
31
51
 
@@ -41,6 +61,25 @@ curl -s localhost:4000/_voltro/inspect/invoke -H 'content-type: application/json
41
61
  # → { ok:false, error:{ _tag:"ScopeError", required:"notes:write" } }
42
62
  ```
43
63
 
64
+ ```bash
65
+ voltro check --offline # no running app needed — usable as a CI gate
66
+ ```
67
+
68
+ ## Tests
69
+
70
+ `tests/authz.test.ts` drives the **real** plugin — `makeTestContext({ plugins })`
71
+ composes the actual interceptor chain and `invoke` enforces the descriptor's
72
+ guards with the same function the serve pipeline calls.
73
+
74
+ Do not hand-set `subject.scopes` to the value you expect. That tests the guard
75
+ while assuming the resolution that produces it, and the resolution is the half
76
+ that actually breaks (a renamed role, a throwing resolver, a scope no role
77
+ grants) — all of which pass a pre-stamped test.
78
+
79
+ ```bash
80
+ pnpm --filter @{{projectName}}/{{appName}} test
81
+ ```
82
+
44
83
  ## On the web side
45
84
 
46
85
  ```tsx
@@ -54,7 +93,7 @@ const canWrite = useCan('notes:write')
54
93
  The UI gate ships in `@voltro/client`, not in the plugin: scopes are a framework
55
94
  concept, so gating a button must not require an rbac dependency.
56
95
 
57
- `useCan` is a UI affordance only — the server `permission()` guard is the real
96
+ `useCan` is a UI affordance only — the server-side guard is the real
58
97
  enforcement. Never trust the client.
59
98
 
60
99
  ## Persisted roles
@@ -1,26 +1,31 @@
1
1
  // RBAC backend for the {{projectName}} project.
2
2
  //
3
3
  // `rbacPlugin` compiles a role→scope map; an rpc interceptor resolves each
4
- // caller's ROLES to SCOPES and stamps them on the subject, so in-handler
5
- // guards like `permission(ctx, 'notes:write')` (Effect-native, fails typed
6
- // `Forbidden`) just work. The `admin:full` scope is a blanket bypass.
4
+ // caller's ROLES to SCOPES and publishes them to the framework's effective-
5
+ // scope seam. From there BOTH authorization forms see them:
7
6
  //
8
- // Config-onlyno tables, no infra.
7
+ // declarative `guards:` on a descriptor enforced by the framework in the
8
+ // dispatch spine, before the executor and (for a mutation) before the
9
+ // transaction opens. PREFER THIS. It is the only form `voltro check` can
10
+ // inspect, so a guard requiring a scope no role grants is caught
11
+ // statically instead of becoming a permanently uncallable procedure.
12
+ // • in-handler `permission()` / `can()` — for authz that needs LOADED data a
13
+ // descriptor guard cannot see (see `notes.delete`).
14
+ //
15
+ // The `admin:full` scope is a blanket bypass for both.
16
+ //
17
+ // The role map itself lives in `authz.ts` so the tests can build the real
18
+ // plugin from the real roles.
19
+ //
20
+ // Config-only — no external infra.
9
21
  import { defineEnv, envVar } from '@voltro/env'
10
22
  import { rbacPlugin } from '@voltro/plugin-rbac'
23
+ import { demoRolesForTenant, roles, rolesOnTeam } from './authz'
11
24
 
12
25
  export const env = defineEnv({
13
26
  LOG_LEVEL: envVar.enum(['debug', 'info', 'warn', 'error'], { access: 'public', default: 'info' }),
14
27
  })
15
28
 
16
- // role → the scopes it grants. Scopes are `domain:action`; `admin:full` is the
17
- // framework's blanket bypass (passes every permission check).
18
- const roles = {
19
- viewer: ['notes:read'],
20
- editor: ['notes:read', 'notes:write'],
21
- admin: ['admin:full'],
22
- }
23
-
24
29
  export default {
25
30
  type: 'api' as const,
26
31
  name: '{{capProjectName}}{{capAppName}}',
@@ -28,20 +33,13 @@ export default {
28
33
  plugins: [
29
34
  rbacPlugin({
30
35
  roles,
31
- // DEMO resolver — assigns a role by the request's tenant so you can try
32
- // each role via the `x-tenant` header with zero auth setup. PRODUCTION
33
- // reads the caller's REAL roles: the default resolver reads
34
- // `subject.metadata.roles` (set by your auth strategy), or do a DB
35
- // lookup here (`subject.id` / `subject.tenantId` roles). Delete this
36
- // whole `resolveRoles` to fall back to the metadata default.
37
- resolveRoles: (subject) => {
38
- const demo: Record<string, ReadonlyArray<string>> = {
39
- acme: ['admin'],
40
- editors: ['editor'],
41
- readers: ['viewer'],
42
- }
43
- return demo[subject.tenantId ?? ''] ?? ['viewer']
44
- },
36
+ resolveRoles: (subject) => demoRolesForTenant(subject.tenantId),
37
+ // PER-RESOURCE roles what makes `guards: [{ scope, resource }]` mean
38
+ // "owner OF THIS TEAM" instead of "owner globally". Consulted only for
39
+ // the gap: a globally-held scope (or `admin:full`) passes without ever
40
+ // calling this. Fail-CLOSED if it throws or rejects the guard DENIES,
41
+ // it does not fall back to the global answer.
42
+ resolveResourceRoles: (subject, resource) => rolesOnTeam(subject.id, resource),
45
43
  }),
46
44
  ],
47
45
  env,
@@ -0,0 +1,76 @@
1
+ // The app's authorization vocabulary, in ONE place.
2
+ //
3
+ // Split out of `app.config.ts` so the tests can build the real plugin from the
4
+ // real role map. A test that redeclares the roles it is testing proves only
5
+ // that its own copy is self-consistent — the copy drifts, the test stays
6
+ // green, and the drift is exactly the thing worth catching.
7
+
8
+ /**
9
+ * role → the scopes it grants. Scopes are `domain:action`; `'*'` expands to
10
+ * the framework's blanket bypass (`admin:full`), which passes every check.
11
+ *
12
+ * This map is ALSO the app's declared scope vocabulary: `rbacPlugin` publishes
13
+ * the union of it, and `voltro check` errors on any descriptor guard requiring
14
+ * a scope that appears nowhere here. So: add a scope to a guard → add it to a
15
+ * role, or the build tells you the procedure is uncallable.
16
+ *
17
+ * (`notes:purge`, used by `notes.delete` via `can()`, is deliberately absent —
18
+ * only the wildcard `admin` role reaches it. See that handler for why that is
19
+ * a lesson rather than an oversight.)
20
+ */
21
+ export const roles = {
22
+ viewer: ['notes:read'],
23
+ editor: ['notes:read', 'notes:write'],
24
+ owner: ['notes:read', 'notes:write', 'notes:delete', 'teams:rename'],
25
+ admin: ['*'],
26
+ }
27
+
28
+ /**
29
+ * DEMO membership table — which roles a caller holds ON A SPECIFIC team.
30
+ * PRODUCTION does a DB read (a `memberships` lookup keyed by `subject.id` +
31
+ * the resource id); this literal keeps the template infra-free.
32
+ */
33
+ export const teamMemberships: Record<string, Record<string, ReadonlyArray<string>>> = {
34
+ // subjectId → teamId → the roles held on THAT team
35
+ u_alice: { team_core: ['owner'], team_marketing: ['viewer'] },
36
+ u_bob: { team_marketing: ['owner'] },
37
+ }
38
+
39
+ /**
40
+ * Roles the caller holds on ONE resource. `Subject.id` is `string | null`
41
+ * (an anonymous caller has no id), and an anonymous caller is a member of
42
+ * nothing — so the null case returns `[]` rather than being coerced into a
43
+ * lookup key that could collide.
44
+ */
45
+ export const rolesOnTeam = (
46
+ subjectId: string | null | undefined,
47
+ teamId: string,
48
+ ): ReadonlyArray<string> =>
49
+ subjectId == null ? [] : teamMemberships[subjectId]?.[teamId] ?? []
50
+
51
+ /**
52
+ * DEMO role resolver — assigns a role by the request's TENANT so every role is
53
+ * reachable with an `x-tenant` header and zero auth setup.
54
+ *
55
+ * PRODUCTION reads the caller's REAL roles: the default resolver reads
56
+ * `subject.metadata.roles` (set by your auth strategy), or do a DB lookup here
57
+ * (`subject.id` / `subject.tenantId` → roles). Delete this and omit
58
+ * `resolveRoles` entirely to fall back to the metadata default.
59
+ *
60
+ * Know the failure posture whatever you put here: if this throws or rejects,
61
+ * rbac logs and degrades to the subject's OWN scopes. It never grants on
62
+ * failure — but it also does not deny a caller whose api key already carried
63
+ * valid scopes.
64
+ */
65
+ export const demoRolesForTenant = (
66
+ // `Subject.tenantId` is `string | null` — an anonymous caller has no tenant.
67
+ tenantId: string | null | undefined,
68
+ ): ReadonlyArray<string> => {
69
+ const demo: Record<string, ReadonlyArray<string>> = {
70
+ acme: ['admin'],
71
+ owners: ['owner'],
72
+ editors: ['editor'],
73
+ readers: ['viewer'],
74
+ }
75
+ return demo[tenantId ?? ''] ?? ['viewer']
76
+ }