@voltro/cli 0.5.0 → 0.7.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 (87) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/bin/voltro.mjs +63 -2
  3. package/dist/apiBuild-CSM74oEZ.js +2 -0
  4. package/dist/{apiBuild-CZugTK46.js → apiBuild-deIjJKJm.js} +2 -1
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-CJheDVOr.js → commands-BHdc71DG.js} +2671 -2960
  7. package/dist/dev-BYfrYwrT.js +2 -0
  8. package/dist/dev-Bqyq62Ak.js +6492 -0
  9. package/dist/devActivity-1WtIVyHc.js +153 -0
  10. package/dist/devActivity.d.ts +46 -0
  11. package/dist/devActivity.js +2 -0
  12. package/dist/index.js +1 -1
  13. package/dist/inspectMetrics-BLUBAWLb.js +3711 -0
  14. package/dist/serveCommand-Dn6--0Bv.js +1111 -0
  15. package/dist/serveEntry.js +3 -2
  16. package/dist/start-CWZW_4Et.js +941 -0
  17. package/dist/startEntry.d.ts +5 -0
  18. package/dist/startEntry.js +3 -0
  19. package/package.json +22 -17
  20. package/templates/AGENTS.core.md +36 -1
  21. package/templates/AGENTS.md +36 -1
  22. package/templates/agent-docs/_manifest.json +3 -3
  23. package/templates/agent-docs/ai.md +35 -3
  24. package/templates/agent-docs/authentication.md +264 -0
  25. package/templates/agent-docs/cli.md +126 -3
  26. package/templates/agent-docs/configuration.md +38 -0
  27. package/templates/agent-docs/data.md +616 -40
  28. package/templates/agent-docs/database/querying.md +151 -3
  29. package/templates/agent-docs/database/schema.md +1 -0
  30. package/templates/agent-docs/deployment.md +171 -0
  31. package/templates/agent-docs/internationalization.md +2 -0
  32. package/templates/agent-docs/plugins.md +4 -4
  33. package/templates/agent-docs/reference.md +56 -2
  34. package/templates/agent-docs/templates/apibackends.md +13 -2
  35. package/templates/agent-docs/testing.md +215 -3
  36. package/templates/apps/api-ai/package.json +7 -7
  37. package/templates/apps/api-auth/app.config.ts +20 -13
  38. package/templates/apps/api-auth/package.json +8 -8
  39. package/templates/apps/api-backend/package.json +7 -7
  40. package/templates/apps/api-backend-deactivation/package.json +7 -7
  41. package/templates/apps/api-backend-mail/package.json +8 -8
  42. package/templates/apps/api-backend-mariadb/.env.example +10 -4
  43. package/templates/apps/api-backend-mariadb/package.json +9 -9
  44. package/templates/apps/api-backend-storage/package.json +8 -8
  45. package/templates/apps/api-data-advanced/app.config.ts +21 -8
  46. package/templates/apps/api-data-advanced/package.json +8 -8
  47. package/templates/apps/api-durable/package.json +8 -8
  48. package/templates/apps/api-feature-flags/package.json +9 -9
  49. package/templates/apps/api-governance/app.config.ts +15 -2
  50. package/templates/apps/api-governance/package.json +8 -8
  51. package/templates/apps/api-kv/package.json +8 -8
  52. package/templates/apps/api-moderation/package.json +8 -8
  53. package/templates/apps/api-observability/package.json +8 -8
  54. package/templates/apps/api-ratelimit/package.json +8 -8
  55. package/templates/apps/api-rbac/package.json +8 -8
  56. package/templates/apps/api-rest/package.json +7 -7
  57. package/templates/apps/api-saas/package.json +11 -11
  58. package/templates/apps/api-search/package.json +8 -8
  59. package/templates/apps/api-versioning/package.json +8 -8
  60. package/templates/apps/api-webhooks/app.config.ts +20 -1
  61. package/templates/apps/api-webhooks/package.json +8 -8
  62. package/templates/apps/changelog/package.json +6 -6
  63. package/templates/apps/edge-functions/package.json +2 -2
  64. package/templates/apps/frontend-admin/package.json +8 -8
  65. package/templates/apps/frontend-app/package.json +8 -8
  66. package/templates/apps/frontend-blank/package.json +7 -7
  67. package/templates/apps/frontend-contact/package.json +7 -7
  68. package/templates/apps/frontend-dashboard/package.json +7 -7
  69. package/templates/apps/frontend-docs/package.json +7 -7
  70. package/templates/apps/frontend-i18n/package.json +6 -6
  71. package/templates/apps/frontend-landing/package.json +7 -7
  72. package/templates/apps/frontend-spa/package.json +7 -7
  73. package/templates/apps/frontend-ssr/package.json +7 -7
  74. package/templates/apps/frontend-ssr-api/package.json +8 -8
  75. package/templates/apps/frontend-static-blog/package.json +6 -6
  76. package/templates/baselines/bare/.env.example +10 -3
  77. package/templates/baselines/compose/.env.example +10 -2
  78. package/templates/baselines/compose-mariadb/.env.example +10 -2
  79. package/templates/baselines/helm/.env.example +10 -3
  80. package/dist/apiBuild-D-iBYKa3.js +0 -2
  81. package/dist/dev-B71ICpXJ.js +0 -9896
  82. package/dist/dev-DNUetyG2.js +0 -2
  83. package/dist/serveCommand-C0WFA396.js +0 -1084
  84. package/templates/apps/api-auth/.env +0 -17
  85. package/templates/apps/api-data-advanced/.env +0 -17
  86. package/templates/apps/api-governance/.env +0 -4
  87. package/templates/apps/api-webhooks/.env +0 -6
@@ -44,6 +44,45 @@ ctx.store.select('notes').select('id', 'title') // SELECT id, title
44
44
 
45
45
  The return type narrows automatically — `{ id: string; title: string }[]`.
46
46
 
47
+ ## Single-row terminals on the typed builder
48
+
49
+ `ctx.store.query(...)` takes the typed `database.<table>` builder and returns typed rows. `ctx.store.one` / `.first` / `.maybeOne` are the single-row terminals of that same path — so "I want one row" and "I want typed rows" compose, instead of forcing you to pick one:
50
+
51
+ ```ts
52
+ import { eq } from '@voltro/database'
53
+
54
+ // ONE row, typed. No cast, no explicit type argument — the row type
55
+ // rides in on the builder.
56
+ const user = await ctx.store.one(database.users.where(eq('id', id)))
57
+ user.email // string — not `row['email'] as string`
58
+ ```
59
+
60
+ They accept the builder itself or its `.descriptor`, so a call site never reaches for `.descriptor` just to use a terminal:
61
+
62
+ ```ts
63
+ await ctx.store.one(database.users.where(eq('id', id)))
64
+ await ctx.store.one(database.users.where(eq('id', id)).descriptor) // same thing
65
+ ```
66
+
67
+ | Terminal | Returns | On zero rows | On 2+ rows |
68
+ |---|---|---|---|
69
+ | `.one(query)` | `R` | throws `NoRowFound` | throws `NoRowFound` |
70
+ | `.first(query)` | `R \| null` | `null` | returns the first |
71
+ | `.maybeOne(query)` | `R \| null` | `null` | returns the first |
72
+
73
+ `one()` probes with `LIMIT 2`, not `LIMIT 1`. A one-row probe cannot tell "the row you meant" from "the first of several", so a filter that silently stopped being unique would keep handing back an arbitrary row. One extra row buys a loud failure at the moment the assumption breaks.
74
+
75
+ Scoping is identical to `ctx.store.query()` — the tenant filter and the soft-delete filter both apply — because the terminals *are* `query()` underneath rather than a second read path. (A second scoping path is exactly the drift that makes one of them quietly leak across tenants.)
76
+
77
+ `NoRowFound` is typed, so the hand-written not-found branch goes away:
78
+
79
+ ```ts
80
+ // instead of: const rows = await ctx.store.query(...); if (!rows[0]) throw new NotFound()
81
+ const note = await ctx.store.one(database.notes.where(eq('id', input.id)))
82
+ ```
83
+
84
+ `voltro doctor` flags the hand-written version — see [the hand-roll detector](/docs/cli/build-and-start#the-hand-roll-detector).
85
+
47
86
  ## `where`
48
87
 
49
88
  Filters chain (AND-merged):
@@ -445,6 +484,72 @@ export const usersRelations = relations(users, ({ one, many, manyToMany }) => ({
445
484
 
446
485
  The framework auto-registers every `relations(...)` call at boot — you don't write a manual barrel.
447
486
 
487
+ ## `sourceKey` vs `foreignKey` — opposite directions
488
+
489
+ Both options name exactly one column, and they name it on **opposite tables**. Read them as a sentence about the row you are standing on:
490
+
491
+ | Option | The column lives on | It means |
492
+ |---|---|---|
493
+ | `sourceKey` | the **SOURCE** table | "the row **my** column points at" — **my parent** |
494
+ | `foreignKey` | the **TARGET** table | "the rows that point **at me**" — **my children** |
495
+
496
+ ```typescript
497
+ // sourceKey — users.defaultOrgId holds an ORG's id.
498
+ // "The org my column points at." One org per user.
499
+ relations(users, ({ one }) => ({
500
+ defaultOrg: one(orgs, { sourceKey: 'defaultOrgId' }),
501
+ }))
502
+
503
+ // foreignKey — posts.authorId holds a USER's id.
504
+ // "The posts that point back at me." Many posts per user.
505
+ relations(users, ({ many }) => ({
506
+ posts: many(posts, { foreignKey: 'authorId' }),
507
+ }))
508
+ ```
509
+
510
+ The mnemonic is in the option name: `sourceKey` is a key on the source, `foreignKey` is the foreign table's key back.
511
+
512
+ `manyToMany` is the exception that proves it — **both** of its keys name columns on the JUNCTION table (`sourceKey` the one pointing at the source, `targetKey` the one pointing at the target), never on the source or target themselves.
513
+
514
+ ### The keys are validated at boot
515
+
516
+ Once every table and every `relations()` block is loaded, the framework checks each relation's key options against the real column lists and refuses to boot on a mismatch — naming the option that would have been right:
517
+
518
+ ```text
519
+ relation 'posts' on 'users': foreignKey 'defaultOrgId' is not a column on the
520
+ target table 'posts'. It IS a column on the source 'users' — you want
521
+ { sourceKey: 'defaultOrgId' }. foreignKey names the column on the TARGET that
522
+ points back at this table; sourceKey names the column on THIS table that holds
523
+ the target's id.
524
+ ```
525
+
526
+ Passing **both** keys is refused too. It is contradictory rather than merely redundant: a resolvable `sourceKey` decides the shape and `foreignKey` is then never read, so the declaration would silently mean less than it says.
527
+
528
+ For `manyToMany`, a `sourceKey` / `targetKey` that is not a column on the through-table fails the same way, with the junction's column list in the message.
529
+
530
+ ### What the static check cannot catch: self-references
531
+
532
+ When source and target are the **same table**, the column exists on both sides by definition — so `foreignKey: 'parentTeamId'` and `sourceKey: 'parentTeamId'` are both well-formed and no column check can tell which you meant. This case is caught by data instead, at query time, through the `one` cardinality contract below.
533
+
534
+ ### `one` means AT MOST one — enforced
535
+
536
+ An eager-load branch now **fails** when a `one` relation matches more than one row for the same parent, instead of silently keeping the last match — on **both** eager paths, with the same message:
537
+
538
+ ```text
539
+ relation 'parent' on 'teams' is declared `one`, but teams.parentTeamId matches
540
+ MORE than one row for the same teams.id — so there is no single related row to
541
+ return. Because this relation is self-referential, check which side the key is
542
+ on: `foreignKey: 'parentTeamId'` means "rows whose parentTeamId points AT me"
543
+ (my children — there can be many). If you meant "the row my parentTeamId points
544
+ to" (my parent), that is `sourceKey: 'parentTeamId'`.
545
+ ```
546
+
547
+ The check applies wherever the foreign key sits on the **target** ("every child points back at me") — the only shape in which a second match is expressible. There the single-query path over-fetches two rows and raises on the second; a `one` resolved through the target's primary key (`sourceKey: '…'`) still takes a single row in SQL, because a second one cannot exist.
548
+
549
+ One honest limit remains: it is a **data** check, so it only fires when the data actually has multiplicity. A parent row with a single child — or a leaf with none — passes the wrong declaration silently and resolves to a plausible-looking `null`. That does not replace reading the table above before you type the option.
550
+
551
+ **The bug this came from.** An app declared `parent: one(() => teams, { foreignKey: 'parentTeamId' })` on a self-referencing team hierarchy. It typechecked, it booted, and the emitted SQL matched the hand-written join it replaced — and `parent` came back `null`, because the declaration was loading *children*, and the test row happened to be a leaf. Both halves of this section exist because that shipped.
552
+
448
553
  ## Quick start — all three cardinalities
449
554
 
450
555
  ```typescript
@@ -695,7 +800,7 @@ A subscription opened with `.with({ profile: true })` registers against BOTH `us
695
800
 
696
801
  - **Two `one()` calls on the same target table need distinct relation names**. The framework keys eager loads by relation name, not by target table — `database.users.with({ profile: true })` reads the relation named `'profile'`. If you declare both `defaultOrg` and `billingOrg` both pointing at `orgs`, they're distinct relations and `with({ defaultOrg: true, billingOrg: true })` reads them independently.
697
802
  - **Self-referential 1:1 needs the thunk form**. `one(() => users)` for a "parent user" lookup. Without the thunk, the table reference would try to resolve before the table is registered and fail.
698
- - **`one()` on a target that has a many-to-the-same-source relation is unusual but legal**. The framework reads the relation declaration verbatim if you say "one profile per user" and the actual data has two profiles for one user, the query returns one (LIMIT 1 in the subquery) and the other is silently invisible. Use `many()` if multiplicity is genuinely possible.
803
+ - **`one()` on a target that has a many-to-the-same-source relation is unusual but legal**. The framework reads the relation declaration verbatim. If the data actually has two profiles for one user, an eager-load branch served by the [walker](./eager-loading#walker-fallback) now **fails** rather than handing you an arbitrary one of them — see [the `one` cardinality contract](./index.md#one-means-at-most-one-enforced). A branch the JSON-aggregation compiler can express resolves it in SQL instead (`LIMIT 1` in the subquery), and the extra row is invisible there. Use `many()` if multiplicity is genuinely possible.
699
804
 
700
805
  ## Where it lives
701
806
 
@@ -987,6 +1092,47 @@ database.users.with({
987
1092
 
988
1093
  `onJunction` composes with the target-side `where` / `orderBy` / `limit` / `offset` — `where` still filters the target (orgs), `onJunction` filters the junction (memberships). It compiles into the correlated subquery's WHERE alongside the source-key correlation, qualified to the junction-table alias, on every dialect (postgres / mysql / mariadb / mssql / sqlite / turso). Ignored on `one` / `many` branches (no junction table exists).
989
1094
 
1095
+ ## Junction columns in the result — `junction`
1096
+
1097
+ Filtering on a junction column is one half; the other is *reading* it. `junction` projects the THROUGH-table row's own columns onto each target row, under `_junction`:
1098
+
1099
+ ```typescript
1100
+ database.users.with({
1101
+ organizations: { junction: ['role', 'joinedAt'] },
1102
+ })
1103
+ // → each org carries org._junction.role / org._junction.joinedAt
1104
+ // — the membership row that linked THIS user to THIS org
1105
+ ```
1106
+
1107
+ `junction: true` takes every column of the junction table instead of naming them:
1108
+
1109
+ ```typescript
1110
+ database.users.with({ organizations: { junction: true } })
1111
+ ```
1112
+
1113
+ It composes with everything else on the branch — `onJunction` still filters the membership, `where` / `orderBy` / `limit` still apply to the target, and nested `.with()` on the target still resolves:
1114
+
1115
+ ```typescript
1116
+ database.users.with({
1117
+ organizations: {
1118
+ onJunction: eq('role', 'admin'),
1119
+ junction: ['role', 'joinedAt'],
1120
+ orderBy: [{ column: 'name', direction: 'asc' }],
1121
+ with: { projects: true },
1122
+ },
1123
+ })
1124
+ ```
1125
+
1126
+ ### Why nested under `_junction` and not merged
1127
+
1128
+ A junction and its target routinely share column names — `createdAt` is the obvious one, and `id` always collides. Merging membership fields onto the target row would silently overwrite real target data with junction data, and a name collision that corrupts a row is a worse failure than one extra level of nesting.
1129
+
1130
+ ### The cost — none; it stays one query
1131
+
1132
+ A branch that asks for junction columns still compiles to the single-query [JSON-aggregation path](./eager-loading). Every dialect builds the `_junction` object natively: postgres carries the projection out of the correlated join as one `jsonb` value, sqlite / mysql / MariaDB carry the columns as aliased scalars and re-assemble them, mssql uses `FOR JSON PATH`'s dotted column aliases. No extra round trip, and nothing in the `.with()` tree is rerouted because one branch asked for junction data.
1133
+
1134
+ What makes this correct rather than merely fast: the projection is computed INSIDE the per-parent join, so a target row linked to two parents carries each parent's own junction row — the same per-link result the walker produces by cloning. The two paths are checked against each other on a live engine per dialect, so the JSON result is identical to the walker's, not merely similar.
1135
+
990
1136
  ## Doubly-declared
991
1137
 
992
1138
  `many` and `one` are unidirectional — you declare from the source side and the reverse is a separate `relations(...)` call. `manyToMany` is the same: declare BOTH directions independently if you need both:
@@ -1065,12 +1211,12 @@ const rows = await store.query(
1065
1211
  // rows: Array<User & { organizations: Org[] }>
1066
1212
  ```
1067
1213
 
1068
- The junction columns (role, joinedAt) are NOT in the resolved Org type. The framework eager-loads the TARGET side only. If you need junction columns, query the junction table directly.
1214
+ The junction columns (role, joinedAt) are NOT in the resolved Org type a plain `manyToMany` branch eager-loads the TARGET side only. Ask for them with `junction:` (above) to get them under `_junction`, or query the junction table directly when the membership row itself is the thing you want.
1069
1215
 
1070
1216
  ## Caveats
1071
1217
 
1072
1218
  - **Same target appearing twice on the source needs distinct relation names**. If users have `organizations` (membership-based) AND `ownedOrgs` (1:N via `orgs.ownerId`), declare them as separate relations with separate names. The framework keys by relation name.
1073
- - **`sourceKey` is the junction column pointing at THIS side**. Easy to confuse with the schema's `reference()` column name on the source table itself. The framework's error message at registration time helps: "no column 'userId' on org_memberships" if you mistype.
1219
+ - **`sourceKey` is the junction column pointing at THIS side**. Both keys name columns on the JUNCTION — easy to confuse with the `reference()` column on the source table itself. A key that isn't a column on the through-table fails the [boot validation](./index.md#the-keys-are-validated-at-boot) with the junction's real column list in the message, so the mistype can't reach a query.
1074
1220
  - **Cascade behaviour on the junction is separate from cascade on the parents**. `onDelete: 'cascade'` on `org_memberships.userId` removes memberships when the user is deleted, but doesn't touch `orgs`. Set it explicitly on each FK depending on lifecycle semantics.
1075
1221
  - **Junction extra columns aren't reactive through the m2m relation**. A write to `org_memberships.role` doesn't wake a subscription on `users.with({ organizations: true })` UNLESS the subscription's relevant-fields set includes `role` (which it doesn't by default — only the join keys are relevant for the m2m walk). If you want subscriptions to react to role changes, observe `org_memberships` directly.
1076
1222
 
@@ -1214,6 +1360,8 @@ FROM users;
1214
1360
 
1215
1361
  When the JSON-agg compiler can't express a particular spec — for example a relation that hasn't been registered, or a per-dialect edge case the emitter doesn't cover — the framework transparently falls back to a portable per-relation N+1 walker.
1216
1362
 
1363
+ Nothing in the eager spec routes here deliberately. [Junction columns](./many-to-many.md#junction-columns-in-the-result-junction) (`{ junction: [...] }` on a `manyToMany` branch) compile natively on every dialect, so asking for junction data does not reroute the query — let alone the rest of the `.with()` tree.
1364
+
1217
1365
  The walker:
1218
1366
 
1219
1367
  1. Runs the parent query without any eager loading.
@@ -423,6 +423,7 @@ the framework ships richer types for specific use cases:
423
423
  - **`timestamp` without `tz`.** Use `timestamp()` which is `timestamptz`.
424
424
  - **Storing JSON blobs as `text`.** Use `json<T>()` for type-safety + JSONB performance.
425
425
  - **`references('table')`.** The constructor is `reference(() => table)` — singular, thunk-arg.
426
+ - **Declaring a `timestamp()` column as `Schema.Number` in a descriptor's `output`.** That describes the *wire* type, not the domain type, so the schema has nothing left to convert and the handler ends up doing it by hand. Derive the row schema instead — [`rowSchema(table)`](/docs/data/queries#output-is-the-serializer-rowschema-table) for a whole row, or [`timestampMs` / `timestampMsOrNull`](/docs/data/queries#computed-outputs-timestampms) for one field of a hand-written struct.
426
427
 
427
428
 
428
429
 
@@ -1038,3 +1038,174 @@ Schedules and aggregates auto-coordinate via an advisory lock on SQL stores —
1038
1038
  - [ ] `VOLTRO_LOG_FORMAT=json`; `sentryPlugin()` + `SENTRY_DSN` for errors
1039
1039
  - [ ] `terminationGracePeriodSeconds` generous for graceful drain
1040
1040
  - [ ] `CACHE_BACKEND` / `KV_BACKEND` + a cross-replica change bus when running >1 replica
1041
+
1042
+
1043
+
1044
+ ---
1045
+
1046
+ <!-- source: en/deployment/seats-and-team.md -->
1047
+ ## Seats and team management
1048
+
1049
+ _How teams are invited, what a seat is, exactly which actions occupy one, and how machine credentials stay seat-free._
1050
+
1051
+ A Voltro seat is **one identified natural person**. Not a login, not a token, not a machine — a human. Everything on this page follows from that single definition.
1052
+
1053
+ This page is the published boundary: what occupies a seat, what does not, and how the product makes both visible before an invoice does.
1054
+
1055
+ ## Membership is not a seat
1056
+
1057
+ Two separate things, deliberately:
1058
+
1059
+ | | What it answers | Cost |
1060
+ |---|---|---|
1061
+ | **Membership** | May this person act in this org? | Free — invite as many as you like |
1062
+ | **Seat** | Does this person consume one of your purchased seats? | Counted |
1063
+
1064
+ A seat is occupied by **active work**. Watching is free: a manager following a deploy, a product owner reading logs, an accountant paying the invoice — all need a membership, none need a seat.
1065
+
1066
+ ## What occupies a seat
1067
+
1068
+ The list below is the complete one the server consults. It is an **allowlist**, which has a consequence worth stating: an endpoint that is not on it is free, so a newly shipped feature can never quietly start costing you money.
1069
+
1070
+ **Occupies a seat**
1071
+
1072
+ - Deploy an app · roll a deployment back
1073
+ - Create or delete a project · create an app · create an environment
1074
+ - Register a custom domain
1075
+ - Change environment variables
1076
+ - Submit or approve a migration plan
1077
+ - Create a service token · invite a teammate
1078
+
1079
+ **Free — no seat**
1080
+
1081
+ - Read logs, view metrics, follow deployment status
1082
+ - View usage and invoices
1083
+ - View the team
1084
+
1085
+ The same list is rendered inside the dashboard, on the team page, next to your live seat count. There is exactly one list — the server and the UI read the same file — so what you are told and what you are charged for cannot drift apart.
1086
+
1087
+ ### One person, one seat
1088
+
1089
+ Occupancy is keyed on `(organisation, person)` and enforced by a unique constraint in the database, not by application logic. In practice:
1090
+
1091
+ - Five machines, three rotated CLI tokens, two browser sessions → **one seat**.
1092
+ - A contractor working in five client organisations → **five seats**, one per client. Whoever works inside an org occupies a seat of that org; there are no cross-org seat pools.
1093
+ - Activity is recorded coarsely and debounced. Forty deploys in an afternoon are one write, not forty — the ledger records *that* you worked, not every request.
1094
+
1095
+ ## Inviting people
1096
+
1097
+ Invite by **personal work email** — one address per person.
1098
+
1099
+ 1. An owner or admin sends the invitation. A pending invitation is created and a **single-use, expiring link** is emailed.
1100
+ 2. The invitee opens the link. They can read who invited them, to which organisation, and in which role **without signing in** — the token is the authorisation for that preview, and it grants nothing else.
1101
+ 3. They sign in (or sign up) with the invited address and accept.
1102
+
1103
+ **The accepting account's email must equal the invited address.** This is where "personal accounts" stops being a clause and becomes a check: forwarding a link to a colleague, or into a shared inbox that another account reads, cannot produce a membership. Plus-tagged variants (`name+something@`) count as a different address — loosening that match is exactly how one invitation would start covering two people.
1104
+
1105
+ Two supporting behaviours:
1106
+
1107
+ - **Shared-looking addresses warn, but are not blocked.** `info@`, `team@`, `dev@` raise an inline warning. They are not refused, because `dev@` genuinely is one person at a two-person shop, and address patterns miss `buero@` and `kontakt@` anyway. The email match at acceptance is what actually enforces the rule.
1108
+ - **Resending rotates the token.** The old link stops working. That keeps revocation total and covers the usual reason for a resend — the first link landed somewhere the person no longer reads.
1109
+
1110
+ Invitations can be revoked at any time. Revocation is immediate: acceptance requires a *pending* invitation.
1111
+
1112
+ ## Service tokens — CI without a seat
1113
+
1114
+ CI needs a credential. If the only credential were user-bound, every team would paste a human's token into their pipeline — which is precisely the credential sharing the terms forbid. So machines get their own class:
1115
+
1116
+ > **Service token: org-scoped · no seat · individually revocable · automation scopes only**
1117
+
1118
+ Valid scopes are `deploy`, `build`, `register` and `inventory` — an automation-only set. **Interactive development is not among them and never will be**: requesting it is rejected outright. That boundary is what stops the seat model from being bypassed by routing every developer's work through one machine credential.
1119
+
1120
+ A token's value is shown **once**, at creation. Only its hash is stored, so there is no "show it again" and no support path that could reveal it. Revoking is a soft flip: the token stops working immediately, and the record of what it did survives.
1121
+
1122
+ ## Going over your seats
1123
+
1124
+ Nothing is blocked. Person 21 on a 20-seat plan starts working immediately; the extra seat appears on the next invoice as the contractual true-up.
1125
+
1126
+ Because nothing blocks, the warnings are the safeguard, not a nicety:
1127
+
1128
+ | State | What you see |
1129
+ |---|---|
1130
+ | Below 80 % | Your seat count, plainly |
1131
+ | From 80 % | An advance warning on the team page |
1132
+ | Every seat occupied | The next person to work will be an additional seat |
1133
+ | Over | How many seats beyond your purchase, and that they will be invoiced |
1134
+
1135
+ Suspension exists only for non-payment or abuse, through the contractual process. A developer is never locked out mid-sprint over a seat count.
1136
+
1137
+ ### Where your seat count comes from
1138
+
1139
+ Two numbers can define your limit, and the more specific one always wins:
1140
+
1141
+ | Situation | Your limit is |
1142
+ |---|---|
1143
+ | You have a paid subscription | Its **seat quantity** — what you bought |
1144
+ | No subscription | The seats **included** with your plan |
1145
+ | Free or Enterprise | Uncapped — Free is gated on projects × apps, Enterprise contractually |
1146
+
1147
+ The team page names which of the two applies, so "of 40 on your subscription" and "of 10 included with Team" are never confused. If you buy 40 seats on a plan that includes 10, your limit is 40 — the number you paid for governs.
1148
+
1149
+ A cancelled subscription stops granting its quantity and you return to the free tier's limits. A **failed payment does not**: while a card is being retried your team keeps working, because locking an organisation out over a bank decline is a support incident, not enforcement.
1150
+
1151
+ ## Self-hosted
1152
+
1153
+ Seats work the same way when you run Voltro yourself, and self-hosted development is **attributed to a person** — which is what makes the model measurable rather than aspirational.
1154
+
1155
+ > **Signing in.** Voltro Cloud accounts are authenticated by WorkOS — always, in every environment. There is no password, no fixed code, and no local fallback: if WorkOS cannot verify you, you are not signed in.
1156
+
1157
+ ### Getting a personal credential
1158
+
1159
+ Generate one from your team page ("Your CLI credential"), then store it:
1160
+
1161
+ ```bash
1162
+ voltro cloud login --token <token>
1163
+ ```
1164
+
1165
+ It is **org-scoped and personal**. Org-scoped because a contractor working in five client organisations occupies five seats, one per client — so generate one per organisation you work in. Personal because the control plane records who it belongs to, and that link is the whole basis of self-hosted attribution.
1166
+
1167
+ ### What is sent
1168
+
1169
+ `voltro dev` reports a coarse **daily heartbeat**:
1170
+
1171
+ > **organisation · person · project slug · CLI version · UTC day**
1172
+
1173
+ Never source, never schema, never data, never file names, never command arguments. That is the complete list, and it is asserted by a test that fails if a field is added — so growing it takes a deliberate decision, not a careless commit. What we do with it, on what legal basis, and for how long is set out in the [Privacy Policy](https://voltro.cloud/legal/privacy) under "Developer and usage data".
1174
+
1175
+ If you license Voltro for a team, note that you are responsible for informing the people concerned. Administrators see who occupies a seat and the date of last activity — never what anyone did.
1176
+
1177
+ Three properties you can rely on:
1178
+
1179
+ - **Debounced.** At most one report per day per project. Restarting your dev server forty times is one heartbeat.
1180
+ - **Offline-tolerant.** A failed send is remembered and flushed on a later run. Working on a train is a gap in a chart, not a licence problem.
1181
+ - **It never fails a command.** Every error path is swallowed. Telemetry that can break `voltro dev` eventually will.
1182
+
1183
+ Not logged in? Nothing is sent, nothing is warned about, and nothing is slower. That is the normal case for most local development.
1184
+
1185
+ ### Machine credentials are refused
1186
+
1187
+ `voltro dev` requires a **personal** credential. A service token belongs to no person, so it is rejected with an explanation rather than silently counted:
1188
+
1189
+ ```
1190
+ voltro dev needs a personal login — the stored credential belongs to no user.
1191
+ ```
1192
+
1193
+ Without that boundary, a team could route every developer's work through one shared machine token and occupy zero seats. Deploying with a service token stays fine — deploying is not developing.
1194
+
1195
+ ### From activity to an invoice
1196
+
1197
+ A nightly job closes the loop, and your team page shows both halves side by side:
1198
+
1199
+ | | What it is |
1200
+ |---|---|
1201
+ | **Live count** | Who occupies a seat right now — moves as people work |
1202
+ | **Billing period** | The rolled-up figure for the month, including the self-hosted share |
1203
+
1204
+ The rollup is idempotent, so a retried job cannot double-count. The two numbers differ between runs by design: one is what your team is doing, the other is what a bill would say, and showing only one of them is how a customer ends up arguing with a dashboard.
1205
+
1206
+ Air-gapped and enterprise deployments attest seats contractually instead; no telemetry path is required.
1207
+
1208
+ ## Related
1209
+
1210
+ - [Voltro Cloud](./voltro-cloud.md) — the control plane these features live in
1211
+ - [Self-hosting](./self-hosting.md) — running Voltro yourself
@@ -558,6 +558,8 @@ relativeTime(comment.postedAt, { now: renderedAt }) // measure against a fixed
558
558
 
559
559
  It picks the **largest unit that fits**, so a 90-minute delta reads "1 hour ago", not "90 minutes ago". Anything under a second renders through the `second` unit at 0 — "now" — which avoids the "0 seconds ago" flicker hand-rolled versions produce. `numeric: 'auto'` is the default, so English gets "yesterday" rather than "1 day ago".
560
560
 
561
+ **It is not a drop-in replacement for a hand-rolled helper.** Apps that wrote their own usually picked abbreviated, app-specific wording — "2 hr ago", "vor 2 Std." — whereas this hook emits `Intl.RelativeTimeFormat` output: "2 hours ago" / "vor 2 Stunden". Adopting it is a **visible copy change**, so treat it as a design decision rather than a find-and-replace. What you get in exchange is every locale for free and the unit-selection edge cases handled; what you give up is control over the exact phrasing.
562
+
561
563
  ### `useFormatNumber` and `useFormatCurrency`
562
564
 
563
565
  ```tsx
@@ -24,7 +24,7 @@ The framework ships some plugins; you write your own; the contract is small enou
24
24
  - [plugin-soft-delete](/docs/plugins/soft-delete) — `softDelete()` schema mixin (hide on delete, `hardDelete()` bypass)
25
25
  - [plugin-rbac](/docs/plugins/rbac) — roles + permissions + the `permission()` guard
26
26
  - [plugin-ratelimit](/docs/plugins/ratelimit) — per-endpoint / per-subject / per-tenant request limits
27
- - [plugin-billing](/docs/plugins/billing) — subscriptions, plans, entitlements + usage metering (Stripe + mock provider); seats, mid-cycle proration + dunning; money as integer minor units
27
+ - [plugin-billing](/docs/plugins/billing) — subscriptions, plans, entitlements + usage metering (Stripe + mock provider); seat-based billing on Stripe's own proration, retries, tax and checkout; money as integer minor units
28
28
  - [plugin-licensing](/docs/plugins/licensing) — offline-verified EdDSA license keys + cloud-issued entitlement snapshots that feed plugin-billing; plan entitlements + pricing decided server-side, never baked into a published version
29
29
  - [plugin-mail](/docs/plugins/mail) — transactional email (Resend / Postmark / SendGrid / SES / Mailgun / SMTP, templates, suppression, scheduling, batch, idempotency)
30
30
  - [plugin-storage](/docs/plugins/storage) — file storage: public (CDN-direct) + private (access policy + per-object grants), S3 / R2 / GCS / MinIO / filesystem
@@ -65,7 +65,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
65
65
  | `@voltro/plugin-soft-delete` | ✓ | `softDelete()` schema mixin — `deletedAt` / `deletedBy`; `delete` → UPDATE, `hardDelete()` bypass |
66
66
  | `@voltro/plugin-rbac` | ✓ | Roles compile to scopes + the `permission()` handler guard + typed `ScopeError` |
67
67
  | `@voltro/plugin-ratelimit` | ✓ | Per-endpoint / per-subject / per-tenant limits; sliding-window / fixed-window / token-bucket; memory / postgres / redis stores |
68
- | `@voltro/plugin-billing` | ✓ | Subscriptions, plans, entitlements + usage metering over a pluggable provider (Stripe + mock); seat-based billing + mid-cycle proration + dunning (failed-payment retries); `requireEntitlement()` guard + `enforce` interceptor; `/billing/webhook` via plugin-webhooks; money as integer minor units |
68
+ | `@voltro/plugin-billing` | ✓ | Subscriptions, plans, entitlements + usage metering over a pluggable provider (Stripe + mock); seat-based billing; proration, failed-payment retries, tax and the checkout seat stepper are Stripe's, via the official SDK; `requireEntitlement()` guard + `enforce` interceptor; `/billing/webhook` via plugin-webhooks; money as integer minor units |
69
69
  | `@voltro/plugin-mail` | ✓ | Transactional email — Resend / Postmark / SendGrid / SES / Mailgun / SMTP, *.email.tsx templates, per-tenant suppression, send-time scheduling, bulk/batch send, per-send idempotency, durable via workflows |
70
70
  | `@voltro/plugin-storage` | ✓ | File storage — public (CDN-direct) + private (access policy + per-object grants), S3 / R2 / GCS / MinIO / filesystem providers, presigned URLs, `listRefs` browse/search, HTTP Range (206) serving, dashboard browser |
71
71
  | `@voltro/plugin-postgis` | ✓ | Postgres-native `geography` / `geometry` columns + spatial predicates (`ST_DWithin`, `ST_Contains`, `ST_Intersects`); GiST indexes via `.expressionIndex(..., { kind: 'gist' })`. No `ST_Distance` projection yet. Postgres-only by design (fails loud elsewhere). [→ details](/docs/plugins/postgis) |
@@ -816,7 +816,7 @@ definePlugin({
816
816
  {
817
817
  id: 'overview',
818
818
  slot: 'page', // 'page' | 'widget' | 'nav'
819
- query: 'overview', // mounted at /plugins/audit/overview
819
+ route: 'overview', // mounted at /plugins/audit/overview
820
820
  label: 'Audit log',
821
821
  icon: 'shield', // optional lucide-react icon name
822
822
  bundleUrl: 'https://cdn.example.com/audit/v1/dashboard.mjs',
@@ -841,7 +841,7 @@ curl http://localhost:4000/_voltro/inspect/plugins/dashboard-mounts | jq
841
841
  ```
842
842
 
843
843
  Boot validates within-plugin id uniqueness AND that `slot: 'page'`
844
- mounts carry a `query`. Cross-plugin ids are namespaced as
844
+ mounts carry a `route`. Cross-plugin ids are namespaced as
845
845
  `<plugin-alias>:<mount.id>` (e.g. `audit:overview`).
846
846
 
847
847
  The actual dashboard-host runtime — the React shell that dynamic-imports
@@ -128,12 +128,44 @@ Returns:
128
128
 
129
129
  | Field | Meaning |
130
130
  |---|---|
131
- | `data` | Latest typed query output, or `undefined` before the first snapshot. |
131
+ | `data` | Latest typed query output; `undefined` while `loading` is `true`. |
132
+ | `loading` | `true` until the first snapshot arrives. Discriminates the result — see below. |
133
+ | `isEmpty` | The snapshot that arrived is empty. Always `false` while `loading`. |
132
134
  | `error` | Subscription setup error, if no snapshot could be delivered. |
133
135
  | `revision` | Server revision counter. |
134
136
  | `emittedAt` | Timestamp for the latest server delta. |
135
137
  | `pendingPatches` | Number of active optimistic patches applied to this cache entry. |
136
138
 
139
+ The result type `SubscriptionState<T>` is a **discriminated union on `loading`**:
140
+
141
+ ```ts
142
+ type SubscriptionState<T> =
143
+ | { loading: true; data: undefined; isEmpty: false }
144
+ | { loading: false; data: T; isEmpty: boolean }
145
+ // both members also carry revision, emittedAt, error and pendingPatches
146
+ ```
147
+
148
+ So `loading` is a type guard, not a flag beside `data` — narrow on it and `data`
149
+ is `T`, with no `?? []` and no `!`:
150
+
151
+ ```tsx
152
+ const { data, loading } = useSubscription<Team[]>('app', 'teams.list')
153
+ if (loading) return <Skeleton/>
154
+ return <TeamsTable teams={data}/> // data is Team[]
155
+ ```
156
+
157
+ Because it is a union, an `interface X extends SubscriptionState<...>` does not
158
+ compile — TypeScript cannot extend a union. Intersect instead:
159
+
160
+ ```ts
161
+ type TeamsState = SubscriptionState<ReadonlyArray<Team>> & { readonly canEdit: boolean }
162
+ ```
163
+
164
+ `loading` means **no data has arrived yet**, not "the subscription is still
165
+ warming up". A cold-start failure leaves `loading` true and sets `error`, so a
166
+ component branching on `loading` alone renders a skeleton forever — check
167
+ `error`.
168
+
137
169
  Use `{ skip }` to defer until inputs are ready:
138
170
 
139
171
  ```tsx
@@ -159,7 +191,7 @@ Returns:
159
191
 
160
192
  | Field | Meaning |
161
193
  |---|---|
162
- | `mutate(input)` | Calls the mutation and resolves the typed output. |
194
+ | `mutate(input, options?)` | Calls the mutation and resolves the typed output. `options` is `{ onSuccess, onError, notify }` — see [Mutations](/docs/data/mutations). |
163
195
  | `pending` | `true` while a call is in flight. |
164
196
  | `error` | Last failure, or `undefined`. |
165
197
  | `data` | Last successful result, or `undefined`. |
@@ -189,6 +221,28 @@ await invite.run({ email })
189
221
 
190
222
  Returns `run`, `pending`, `error`, and `lastResult`.
191
223
 
224
+ `run` takes the same options bag as `useMutation`'s `mutate` —
225
+ `{ onSuccess, onError, notify }`:
226
+
227
+ ```tsx
228
+ const invite = useAction('app', 'invites.send')
229
+
230
+ await invite.run({ email }, {
231
+ onSuccess: (out) => toast.success(`sent ${out.id}`),
232
+ onError: (e) => toast.error(readError(e)),
233
+ })
234
+ ```
235
+
236
+ Same load-bearing semantic: supplying an error handler (`onError` **or**
237
+ `notify.error`) marks the failure **handled**, so `run` resolves with `undefined`
238
+ instead of rejecting — that is what removes the `try/catch`. With no options it
239
+ rejects exactly as before, so unhandled failures stay loud.
240
+
241
+ **The callback form is for single-shot writes.** A loop or a multi-step sequence
242
+ relies on the promise *throwing* to stop; once the failure is handled the promise
243
+ resolves and the loop keeps going. Sequenced writes want the bare `run(input)`
244
+ plus a real `try/catch` — see [Actions](/docs/data/actions).
245
+
192
246
  ## `useWorkflow(apiName, workflowName)`
193
247
 
194
248
  Starts and controls a discovered `*.workflow.tsx`. Starting returns a `WorkflowRunHandle` immediately; it does not wait for the workflow's success payload.
@@ -1126,10 +1126,21 @@ The thread tables (`agent_threads`, `agent_messages`) are auto-provided and auto
1126
1126
 
1127
1127
  ```tsx
1128
1128
  // mint a thread id, subscribe to the live feed, then send a turn
1129
+ type ThreadMessage = {
1130
+ readonly id: string
1131
+ readonly role: string
1132
+ readonly content: string
1133
+ readonly streaming: boolean
1134
+ }
1135
+
1129
1136
  const threadId = `thread_${crypto.randomUUID().replace(/-/g, '')}`
1130
- const { data: messages } = useSubscription('app', ['support.messages', { threadId }], { threadId })
1137
+ const { data: messages = [] } = useSubscription<ReadonlyArray<ThreadMessage>>(
1138
+ 'app',
1139
+ 'support.messages',
1140
+ { threadId },
1141
+ )
1131
1142
  const send = useAction('app', 'support.send')
1132
- await send.run({ threadId, order: messages?.length ?? 0, prompt: 'How do I reset my password?' })
1143
+ await send.run({ threadId, order: messages.length, prompt: 'How do I reset my password?' })
1133
1144
  ```
1134
1145
 
1135
1146
  As `support.send` writes token deltas to the streaming row, the `support.messages` subscription re-fires and the client sees each chunk — the live typewriter bubble is just a row whose `streaming` flag is `true`. No streaming RPC, no manual ws handling.