@voltro/cli 0.4.0 → 0.6.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 (84) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/voltro.mjs +54 -2
  3. package/dist/apiBuild-Bk4JBt-i.js +2 -0
  4. package/dist/{apiBuild-CvtQeBMs.js → apiBuild-BxY44VGj.js} +20 -20
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-DhyBIs1O.js → commands-My_YlUJV.js} +2823 -2265
  7. package/dist/{dev-CQxbrpDz.js → dev-Bjq-nIvH.js} +2650 -2230
  8. package/dist/dev-D6b74iXI.js +2 -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/serveCommand-C-XQ89NJ.js +1110 -0
  14. package/dist/serveEntry.js +2 -2
  15. package/package.json +22 -17
  16. package/templates/AGENTS.core.md +119 -0
  17. package/templates/AGENTS.md +119 -0
  18. package/templates/agent-docs/_manifest.json +3 -3
  19. package/templates/agent-docs/ai.md +41 -5
  20. package/templates/agent-docs/authentication.md +238 -0
  21. package/templates/agent-docs/cli.md +142 -0
  22. package/templates/agent-docs/configuration.md +38 -0
  23. package/templates/agent-docs/data.md +718 -42
  24. package/templates/agent-docs/database/advancedqueries.md +21 -0
  25. package/templates/agent-docs/database/querying.md +166 -8
  26. package/templates/agent-docs/database/schema.md +6 -2
  27. package/templates/agent-docs/deployment.md +171 -0
  28. package/templates/agent-docs/internationalization.md +2 -0
  29. package/templates/agent-docs/plugins.md +4 -4
  30. package/templates/agent-docs/reference.md +90 -2
  31. package/templates/agent-docs/templates/apibackends.md +13 -2
  32. package/templates/agent-docs/testing.md +157 -3
  33. package/templates/agent-docs/workflows.md +11 -8
  34. package/templates/apps/api-ai/package.json +7 -7
  35. package/templates/apps/api-auth/app.config.ts +20 -13
  36. package/templates/apps/api-auth/package.json +8 -8
  37. package/templates/apps/api-backend/package.json +7 -7
  38. package/templates/apps/api-backend-deactivation/package.json +7 -7
  39. package/templates/apps/api-backend-mail/package.json +8 -8
  40. package/templates/apps/api-backend-mariadb/.env.example +10 -4
  41. package/templates/apps/api-backend-mariadb/package.json +9 -9
  42. package/templates/apps/api-backend-storage/package.json +8 -8
  43. package/templates/apps/api-data-advanced/app.config.ts +21 -8
  44. package/templates/apps/api-data-advanced/package.json +8 -8
  45. package/templates/apps/api-durable/package.json +8 -8
  46. package/templates/apps/api-feature-flags/package.json +9 -9
  47. package/templates/apps/api-governance/app.config.ts +15 -2
  48. package/templates/apps/api-governance/package.json +8 -8
  49. package/templates/apps/api-kv/package.json +8 -8
  50. package/templates/apps/api-moderation/package.json +8 -8
  51. package/templates/apps/api-observability/package.json +8 -8
  52. package/templates/apps/api-ratelimit/package.json +8 -8
  53. package/templates/apps/api-rbac/package.json +8 -8
  54. package/templates/apps/api-rest/package.json +7 -7
  55. package/templates/apps/api-saas/package.json +11 -11
  56. package/templates/apps/api-search/package.json +8 -8
  57. package/templates/apps/api-versioning/package.json +8 -8
  58. package/templates/apps/api-webhooks/app.config.ts +20 -1
  59. package/templates/apps/api-webhooks/package.json +8 -8
  60. package/templates/apps/changelog/package.json +6 -6
  61. package/templates/apps/edge-functions/package.json +2 -2
  62. package/templates/apps/frontend-admin/package.json +8 -8
  63. package/templates/apps/frontend-app/package.json +8 -8
  64. package/templates/apps/frontend-blank/package.json +7 -7
  65. package/templates/apps/frontend-contact/package.json +7 -7
  66. package/templates/apps/frontend-dashboard/package.json +7 -7
  67. package/templates/apps/frontend-docs/package.json +7 -7
  68. package/templates/apps/frontend-i18n/package.json +6 -6
  69. package/templates/apps/frontend-landing/package.json +7 -7
  70. package/templates/apps/frontend-spa/package.json +7 -7
  71. package/templates/apps/frontend-ssr/package.json +7 -7
  72. package/templates/apps/frontend-ssr-api/package.json +8 -8
  73. package/templates/apps/frontend-static-blog/package.json +6 -6
  74. package/templates/baselines/bare/.env.example +10 -3
  75. package/templates/baselines/compose/.env.example +10 -2
  76. package/templates/baselines/compose-mariadb/.env.example +10 -2
  77. package/templates/baselines/helm/.env.example +10 -3
  78. package/dist/apiBuild-DQBNqNZ8.js +0 -2
  79. package/dist/dev-DYjGqPGD.js +0 -2
  80. package/dist/serveCommand-BZzUJIyo.js +0 -1077
  81. package/templates/apps/api-auth/.env +0 -17
  82. package/templates/apps/api-data-advanced/.env +0 -17
  83. package/templates/apps/api-governance/.env +0 -4
  84. package/templates/apps/api-webhooks/.env +0 -6
@@ -803,10 +803,31 @@ eager-load with `.with({...})` to get the per-field pre-filter.
803
803
  inject a default depth limit.
804
804
  - **No reactivity**: see above.
805
805
 
806
+ ## When NOT to use
807
+
808
+ - **Single-level parent / child** — use a regular
809
+ [self-join](/docs/database/self-joins). A recursive CTE is overkill
810
+ at depth 1.
811
+ - **Performance-critical hot path with a large result set** —
812
+ recursive queries can explode on wide trees. Profile with
813
+ `EXPLAIN ANALYZE` against realistic data. If the iteration count
814
+ runs into the thousands, consider materializing the computed
815
+ hierarchy into a separate table instead.
816
+ - **Arbitrary graph algorithms** (shortest path, connected
817
+ components) — a recursive CTE can be bent into these but it gets
818
+ ugly fast. A graph database (Neo4j, the AGE extension) is the
819
+ better fit.
820
+
806
821
  ## See also
807
822
 
808
823
  - [Plain CTEs](/docs/database/query-builder#ctes) — `withCte()` for
809
824
  non-recursive named sub-queries
825
+ - [Self-joins](/docs/database/self-joins) — for single-level
826
+ parent/child queries
827
+ - [Sub-queries](/docs/database/sub-queries) — for non-recursive
828
+ "rows where a column matches another query" patterns
829
+ - [Set operations](/docs/database/set-operations) — `union` /
830
+ `unionAll`, the mechanism a recursive CTE is built on
810
831
  - [Joins](/docs/database/joins) — relation-based traversal when the
811
832
  graph depth is fixed (e.g. parent + immediate children)
812
833
  - [Aggregations](/docs/database/query-builder#aggregations) —
@@ -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):
@@ -130,19 +169,29 @@ Each call adds ONE column + direction; chain for multi-column ordering:
130
169
  .limit(20).offset(40)
131
170
  ```
132
171
 
133
- For cursor pagination that avoids OFFSET's O(n) scan, use the standalone `paginateById` helper over `ctx.store.query(...)`:
172
+ For cursor pagination that avoids OFFSET's O(n) scan, use `paginateBy` over `ctx.store.query(...)`:
134
173
 
135
174
  ```ts
136
- import { paginateById } from '@voltro/database'
175
+ import { paginateBy } from '@voltro/database'
137
176
 
138
177
  const rows = await ctx.store.query(
139
- paginateById(database.notes.orderBy('createdAt', 'desc').descriptor, req.cursor, 20),
178
+ paginateBy(database.notes.descriptor, 'createdAt', req.cursor, 20, 'desc'),
140
179
  )
141
- const nextCursor = rows.at(-1)?.id ?? null
180
+ const nextCursor = rows.at(-1)?.createdAt ?? null
142
181
  return { rows, nextCursor }
143
182
  ```
144
183
 
145
- `paginateById(descriptor, cursor, limit)` adds a `WHERE id > cursor ORDER BY id LIMIT n` clause and preserves any existing predicate. Works for any sortable id scheme (TypeID, ULID, Snowflake, Numeric).
184
+ `paginateBy(descriptor, column, cursor, limit, direction?)` adds the keyset predicate, sets the ORDER BY, and preserves any existing `where`.
185
+
186
+ The `direction` argument controls **both** the comparison and the sort — a `desc` feed pages with `<`, not `>`. That pairing is the classic keyset bug: an ascending comparison under a descending sort returns the same first page forever.
187
+
188
+ The cursor column must be unique, or monotonic enough that ties don't straddle a page boundary. For a timestamp with collisions, order by a tie-breaker and paginate on that:
189
+
190
+ ```ts
191
+ paginateBy(database.notes.orderBy('createdAt', 'desc').descriptor, 'id', req.cursor, 20)
192
+ ```
193
+
194
+ `paginateById(descriptor, cursor, limit)` is the `id`-column shorthand — literally `paginateBy(descriptor, 'id', cursor, limit)`. It works for any sortable id scheme (TypeID, ULID, Snowflake, Numeric). Note it sets the order to `id asc`, so passing a descriptor that already carries `.orderBy('createdAt', 'desc')` does **not** page by `createdAt` — use `paginateBy` when the sort column is the thing you want to page on.
146
195
 
147
196
  ## Aggregates
148
197
 
@@ -435,6 +484,72 @@ export const usersRelations = relations(users, ({ one, many, manyToMany }) => ({
435
484
 
436
485
  The framework auto-registers every `relations(...)` call at boot — you don't write a manual barrel.
437
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
+
438
553
  ## Quick start — all three cardinalities
439
554
 
440
555
  ```typescript
@@ -685,7 +800,7 @@ A subscription opened with `.with({ profile: true })` registers against BOTH `us
685
800
 
686
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.
687
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.
688
- - **`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.
689
804
 
690
805
  ## Where it lives
691
806
 
@@ -977,6 +1092,47 @@ database.users.with({
977
1092
 
978
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).
979
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
+
980
1136
  ## Doubly-declared
981
1137
 
982
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:
@@ -1055,12 +1211,12 @@ const rows = await store.query(
1055
1211
  // rows: Array<User & { organizations: Org[] }>
1056
1212
  ```
1057
1213
 
1058
- 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.
1059
1215
 
1060
1216
  ## Caveats
1061
1217
 
1062
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.
1063
- - **`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.
1064
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.
1065
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.
1066
1222
 
@@ -1204,6 +1360,8 @@ FROM users;
1204
1360
 
1205
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.
1206
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
+
1207
1365
  The walker:
1208
1366
 
1209
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).
426
427
 
427
428
 
428
429
 
@@ -716,8 +717,11 @@ const row = await ctx.store.insert('todos', { title, done: false })
716
717
  // Test — deterministic id.
717
718
  await ctx.store.insert('todos', { id: 'todo_pinned_for_test', title, done: false })
718
719
 
719
- // Signup self-stamping — pass createdBy: userId in the same insert.
720
- await ctx.store.insert('users', { id: userId, createdBy: userId, email })
720
+ // Signup self-stamping — the actor IS the row. Generate the id up front
721
+ // so createdBy can point at the row being created, in the same insert.
722
+ import { typeid } from 'typeid-js'
723
+ const userId = typeid('user').toString()
724
+ await ctx.store.insert('users', { id: userId, email, createdBy: userId })
721
725
  ```
722
726
 
723
727
  ## Branded TypeScript types
@@ -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.
@@ -811,8 +865,42 @@ await ctx.store.delete('notes', noteId)
811
865
  const rows = await ctx.store.query(database.notes.descriptor)
812
866
  ```
813
867
 
868
+ `ctx.store.query` returns the row type the builder already knew — `database.notes`
869
+ resolves to that table's row, so `rows[0].title` is a `string` with no cast. A
870
+ hand-built descriptor still resolves to the untyped `Row`.
871
+
814
872
  Mutations receive a transactional store view. Actions and streams receive a normal store view; writes from them are not automatically rolled back as one unit.
815
873
 
874
+ ## `ctx.load` / `ctx.loadMany` — request-scoped batching
875
+
876
+ `relations()` + `.with()` is the right answer whenever the shape of the related
877
+ data is known statically: it compiles to ONE query. Reach for it first.
878
+
879
+ This is for the case it cannot express — assembly whose shape depends on the
880
+ DATA. A breadth-first walk over a tree is the canonical example: each level's
881
+ ids come from the level above, so no declarative relation spec covers it, and
882
+ the natural code is one query per node.
883
+
884
+ ```ts
885
+ let level = [await ctx.load('nodes', rootId)]
886
+ while (level.length > 0) {
887
+ const childIds = level.flatMap((n) => n?.childIds ?? [])
888
+ if (childIds.length === 0) break
889
+ level = [...await ctx.loadMany('nodes', childIds)]
890
+ }
891
+ ```
892
+
893
+ Every `load` issued in the same tick for the same table is coalesced into one
894
+ `WHERE id IN (...)`, so that walk costs one query per LEVEL, not per node.
895
+ Repeated ids — a diamond where two parents share a child — are fetched once.
896
+ A missing row is `null` rather than a throw, because a dangling edge in a graph
897
+ walk is usually data; use `.one()` when absence is an error.
898
+
899
+ The cache lives exactly as long as the request. That is a correctness
900
+ requirement, not a tuning choice: a longer-lived cache would serve one
901
+ subject's rows to another (a data-isolation bug on a tenant-scoped store) and
902
+ would go stale across a mutation in the same request.
903
+
816
904
  ## `ctx.cache`
817
905
 
818
906
  Async cache facade for request handlers: