@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.
- package/CHANGELOG.md +68 -1
- package/bin/voltro.mjs +63 -2
- package/dist/apiBuild-CSM74oEZ.js +2 -0
- package/dist/{apiBuild-CZugTK46.js → apiBuild-deIjJKJm.js} +2 -1
- package/dist/bin.js +2 -2
- package/dist/{commands-CJheDVOr.js → commands-BHdc71DG.js} +2671 -2960
- package/dist/dev-BYfrYwrT.js +2 -0
- package/dist/dev-Bqyq62Ak.js +6492 -0
- package/dist/devActivity-1WtIVyHc.js +153 -0
- package/dist/devActivity.d.ts +46 -0
- package/dist/devActivity.js +2 -0
- package/dist/index.js +1 -1
- package/dist/inspectMetrics-BLUBAWLb.js +3711 -0
- package/dist/serveCommand-Dn6--0Bv.js +1111 -0
- package/dist/serveEntry.js +3 -2
- package/dist/start-CWZW_4Et.js +941 -0
- package/dist/startEntry.d.ts +5 -0
- package/dist/startEntry.js +3 -0
- package/package.json +22 -17
- package/templates/AGENTS.core.md +36 -1
- package/templates/AGENTS.md +36 -1
- package/templates/agent-docs/_manifest.json +3 -3
- package/templates/agent-docs/ai.md +35 -3
- package/templates/agent-docs/authentication.md +264 -0
- package/templates/agent-docs/cli.md +126 -3
- package/templates/agent-docs/configuration.md +38 -0
- package/templates/agent-docs/data.md +616 -40
- package/templates/agent-docs/database/querying.md +151 -3
- package/templates/agent-docs/database/schema.md +1 -0
- package/templates/agent-docs/deployment.md +171 -0
- package/templates/agent-docs/internationalization.md +2 -0
- package/templates/agent-docs/plugins.md +4 -4
- package/templates/agent-docs/reference.md +56 -2
- package/templates/agent-docs/templates/apibackends.md +13 -2
- package/templates/agent-docs/testing.md +215 -3
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/app.config.ts +20 -13
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/.env.example +10 -4
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/app.config.ts +21 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/app.config.ts +15 -2
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/app.config.ts +20 -1
- package/templates/apps/api-webhooks/package.json +8 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/baselines/bare/.env.example +10 -3
- package/templates/baselines/compose/.env.example +10 -2
- package/templates/baselines/compose-mariadb/.env.example +10 -2
- package/templates/baselines/helm/.env.example +10 -3
- package/dist/apiBuild-D-iBYKa3.js +0 -2
- package/dist/dev-B71ICpXJ.js +0 -9896
- package/dist/dev-DNUetyG2.js +0 -2
- package/dist/serveCommand-C0WFA396.js +0 -1084
- package/templates/apps/api-auth/.env +0 -17
- package/templates/apps/api-data-advanced/.env +0 -17
- package/templates/apps/api-governance/.env +0 -4
- 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
|
|
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
|
|
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**.
|
|
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);
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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.
|