@voltro/cli 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/bin/voltro.mjs +63 -2
  3. package/dist/apiBuild-CSM74oEZ.js +2 -0
  4. package/dist/{apiBuild-CZugTK46.js → apiBuild-deIjJKJm.js} +2 -1
  5. package/dist/bin.js +2 -2
  6. package/dist/{commands-CJheDVOr.js → commands-BHdc71DG.js} +2671 -2960
  7. package/dist/dev-BYfrYwrT.js +2 -0
  8. package/dist/dev-Bqyq62Ak.js +6492 -0
  9. package/dist/devActivity-1WtIVyHc.js +153 -0
  10. package/dist/devActivity.d.ts +46 -0
  11. package/dist/devActivity.js +2 -0
  12. package/dist/index.js +1 -1
  13. package/dist/inspectMetrics-BLUBAWLb.js +3711 -0
  14. package/dist/serveCommand-Dn6--0Bv.js +1111 -0
  15. package/dist/serveEntry.js +3 -2
  16. package/dist/start-CWZW_4Et.js +941 -0
  17. package/dist/startEntry.d.ts +5 -0
  18. package/dist/startEntry.js +3 -0
  19. package/package.json +22 -17
  20. package/templates/AGENTS.core.md +36 -1
  21. package/templates/AGENTS.md +36 -1
  22. package/templates/agent-docs/_manifest.json +3 -3
  23. package/templates/agent-docs/ai.md +35 -3
  24. package/templates/agent-docs/authentication.md +264 -0
  25. package/templates/agent-docs/cli.md +126 -3
  26. package/templates/agent-docs/configuration.md +38 -0
  27. package/templates/agent-docs/data.md +616 -40
  28. package/templates/agent-docs/database/querying.md +151 -3
  29. package/templates/agent-docs/database/schema.md +1 -0
  30. package/templates/agent-docs/deployment.md +171 -0
  31. package/templates/agent-docs/internationalization.md +2 -0
  32. package/templates/agent-docs/plugins.md +4 -4
  33. package/templates/agent-docs/reference.md +56 -2
  34. package/templates/agent-docs/templates/apibackends.md +13 -2
  35. package/templates/agent-docs/testing.md +215 -3
  36. package/templates/apps/api-ai/package.json +7 -7
  37. package/templates/apps/api-auth/app.config.ts +20 -13
  38. package/templates/apps/api-auth/package.json +8 -8
  39. package/templates/apps/api-backend/package.json +7 -7
  40. package/templates/apps/api-backend-deactivation/package.json +7 -7
  41. package/templates/apps/api-backend-mail/package.json +8 -8
  42. package/templates/apps/api-backend-mariadb/.env.example +10 -4
  43. package/templates/apps/api-backend-mariadb/package.json +9 -9
  44. package/templates/apps/api-backend-storage/package.json +8 -8
  45. package/templates/apps/api-data-advanced/app.config.ts +21 -8
  46. package/templates/apps/api-data-advanced/package.json +8 -8
  47. package/templates/apps/api-durable/package.json +8 -8
  48. package/templates/apps/api-feature-flags/package.json +9 -9
  49. package/templates/apps/api-governance/app.config.ts +15 -2
  50. package/templates/apps/api-governance/package.json +8 -8
  51. package/templates/apps/api-kv/package.json +8 -8
  52. package/templates/apps/api-moderation/package.json +8 -8
  53. package/templates/apps/api-observability/package.json +8 -8
  54. package/templates/apps/api-ratelimit/package.json +8 -8
  55. package/templates/apps/api-rbac/package.json +8 -8
  56. package/templates/apps/api-rest/package.json +7 -7
  57. package/templates/apps/api-saas/package.json +11 -11
  58. package/templates/apps/api-search/package.json +8 -8
  59. package/templates/apps/api-versioning/package.json +8 -8
  60. package/templates/apps/api-webhooks/app.config.ts +20 -1
  61. package/templates/apps/api-webhooks/package.json +8 -8
  62. package/templates/apps/changelog/package.json +6 -6
  63. package/templates/apps/edge-functions/package.json +2 -2
  64. package/templates/apps/frontend-admin/package.json +8 -8
  65. package/templates/apps/frontend-app/package.json +8 -8
  66. package/templates/apps/frontend-blank/package.json +7 -7
  67. package/templates/apps/frontend-contact/package.json +7 -7
  68. package/templates/apps/frontend-dashboard/package.json +7 -7
  69. package/templates/apps/frontend-docs/package.json +7 -7
  70. package/templates/apps/frontend-i18n/package.json +6 -6
  71. package/templates/apps/frontend-landing/package.json +7 -7
  72. package/templates/apps/frontend-spa/package.json +7 -7
  73. package/templates/apps/frontend-ssr/package.json +7 -7
  74. package/templates/apps/frontend-ssr-api/package.json +8 -8
  75. package/templates/apps/frontend-static-blog/package.json +6 -6
  76. package/templates/baselines/bare/.env.example +10 -3
  77. package/templates/baselines/compose/.env.example +10 -2
  78. package/templates/baselines/compose-mariadb/.env.example +10 -2
  79. package/templates/baselines/helm/.env.example +10 -3
  80. package/dist/apiBuild-D-iBYKa3.js +0 -2
  81. package/dist/dev-B71ICpXJ.js +0 -9896
  82. package/dist/dev-DNUetyG2.js +0 -2
  83. package/dist/serveCommand-C0WFA396.js +0 -1084
  84. package/templates/apps/api-auth/.env +0 -17
  85. package/templates/apps/api-data-advanced/.env +0 -17
  86. package/templates/apps/api-governance/.env +0 -4
  87. package/templates/apps/api-webhooks/.env +0 -6
@@ -193,9 +193,9 @@ Save both files and `notes.list` becomes a streaming query in the typed client.
193
193
  import { useSubscription } from '@voltro/client'
194
194
 
195
195
  export default function Notes() {
196
- const { data, error } = useSubscription('app', 'notes.list', {})
196
+ const { data, loading, error } = useSubscription('app', 'notes.list', {})
197
197
  if (error) return <p>Error: {String(error)}</p>
198
- if (data === undefined) return <p>Loading...</p>
198
+ if (loading) return <p>Loading...</p>
199
199
  return (
200
200
  <ul>
201
201
  {data.map((note) => <li key={note.id}>{note.title}</li>)}
@@ -304,6 +304,152 @@ defineMutation({ name: 'notes.create', target: { table: 'notes', op: 'insert' },
304
304
 
305
305
  With that pairing, `useMutation('app', 'notes.create')` can stage an optimistic row in active `notes.list` caches without client-side cache plumbing.
306
306
 
307
+ ## `output` is the serializer — `rowSchema(table)`
308
+
309
+ A descriptor's `output` is not documentation of the shape. It **is** the
310
+ serializer: it is handed to the rpc as the success schema, so a handler's result
311
+ is *encoded through it* on the way out and decoded on the client. Any conversion
312
+ the schema describes, the framework performs — you never need a converter at the
313
+ tail of a handler.
314
+
315
+ That is worth stating plainly, because the symptom is usually read backwards.
316
+ A `timestamp()` column comes back from the store as a `Date`, and `Date` is not
317
+ JSON. Declaring that field as `Schema.Number` looks like the fix, but it
318
+ describes the **wire** type rather than the domain type — which leaves the schema
319
+ with nothing to convert, and pushes the conversion back into the handler:
320
+
321
+ ```ts no-check
322
+ // The shape that leads to hand-written converters everywhere
323
+ output: Schema.Array(Schema.Struct({ createdAt: Schema.Number })),
324
+ // …and then, at the tail of every executor:
325
+ return rows.map((row) => ({ ...row, createdAt: row.createdAt.getTime() }))
326
+ ```
327
+
328
+ Declaring `Schema.DateFromNumber` instead makes the conversion automatic — but
329
+ nobody wants to hand-map thirty columns to work that out per table.
330
+ `rowSchema(table)` derives the whole struct from the table definition:
331
+
332
+ ```ts
333
+ // apps/api/queries/notes.list.query.ts
334
+ import { defineQuery } from '@voltro/protocol'
335
+ import { rowSchema } from '@voltro/database'
336
+ import { Schema } from 'effect'
337
+ import { notes } from '../database/schema'
338
+
339
+ export const listNotes = defineQuery({
340
+ name: 'notes.list',
341
+ source: 'notes',
342
+ input: Schema.Struct({}),
343
+ output: Schema.Array(rowSchema(notes)),
344
+ })
345
+ ```
346
+
347
+ The executor returns rows straight from the store. No converters:
348
+
349
+ ```ts no-check
350
+ // apps/api/queries/notes.list.query.server.ts
351
+ export default () => database.notes.orderBy('createdAt', 'desc').limit(100)
352
+ ```
353
+
354
+ ### The Encoded / Type split
355
+
356
+ That split is the whole point — the handler works in domain types, the wire
357
+ carries something JSON can represent:
358
+
359
+ | Column | In the handler (Type) | On the wire (Encoded) |
360
+ |---|---|---|
361
+ | `timestamp()`, `date()` | `Date` | `number` (epoch ms) |
362
+ | `bigint()` | `bigint` | `string` (decimal) |
363
+ | `text()`, `enum()`, `id()`, `reference()` | `string` | `string` |
364
+ | `integer()`, `real()`, `decimal()` | `number` | `number` |
365
+ | `boolean()` | `boolean` | `boolean` |
366
+ | `json()`, `vector()`, `raw()` | `unknown` | `unknown` |
367
+
368
+ `bigint()` crosses as a **decimal string** on purpose: a `bigint` sent as a JSON
369
+ number rounds silently past 2^53, and a value that is quietly wrong is worse than
370
+ one that is rejected.
371
+
372
+ `json()` / `vector()` / `raw()` map to `Schema.Unknown` rather than a guess.
373
+ These are shapes the column declaration does not pin down, and a schema that
374
+ guesses **wrong** rejects valid rows at the wire boundary — reporting the failure
375
+ far from the column responsible. Loose beats wrong here; declare the precise
376
+ shape yourself when you want one.
377
+
378
+ A `.nullable()` column wraps its mapped type, so a null timestamp round-trips as
379
+ `null` rather than becoming epoch 0 (which would render as a plausible
380
+ `1970-01-01` instead of "never").
381
+
382
+ ### Computed outputs — `timestampMs`
383
+
384
+ `rowSchema(table)` only helps a handler that returns a **raw, full table row**.
385
+ Most handlers don't: they return a struct assembled by hand from several tables
386
+ — `{ id, name, slug, addedAt, jiraProjectKey }` — where there is no single table
387
+ to derive from. That is exactly where the hand-written `Date → epoch` converters
388
+ pile up.
389
+
390
+ For those, use the field-level exports directly in your own `Schema.Struct`:
391
+
392
+ ```ts
393
+ // apps/api/queries/projects.list.query.ts
394
+ import { defineQuery } from '@voltro/protocol'
395
+ import { timestampMs, timestampMsOrNull } from '@voltro/database'
396
+ import { Schema } from 'effect'
397
+
398
+ export const listProjects = defineQuery({
399
+ name: 'projects.list',
400
+ source: 'projects',
401
+ input: Schema.Struct({}),
402
+ output: Schema.Array(
403
+ Schema.Struct({
404
+ id: Schema.String,
405
+ name: Schema.String,
406
+ jiraProjectKey: Schema.String,
407
+ addedAt: timestampMs, // Date in the handler, epoch ms on the wire
408
+ archivedAt: timestampMsOrNull, // for a nullable timestamp column
409
+ seenAt: Schema.optional(timestampMs),
410
+ }),
411
+ ),
412
+ })
413
+ ```
414
+
415
+ Same semantics as a `timestamp()` column inside `rowSchema` — literally the same
416
+ schema, which `columnSchema` reads from, so the two can never drift into
417
+ different wire representations.
418
+
419
+ - `timestampMs` — `Date` in the handler, `number` (epoch ms) on the wire.
420
+ - `timestampMsOrNull` — for a `.nullable()` timestamp. Use this rather than
421
+ converting a null by hand: `new Date(null)` is `1970-01-01`, so "never
422
+ archived" renders as a plausible date instead of as nothing.
423
+ - An **optional** field is `Schema.optional(timestampMs)` — there is no third
424
+ export for it.
425
+
426
+ ### Keeping a column off the wire — `omit`
427
+
428
+ ```ts
429
+ import { rowSchema } from '@voltro/database'
430
+ import { users } from '../database/schema'
431
+
432
+ rowSchema(users, { omit: ['passwordHash'] })
433
+ ```
434
+
435
+ `omit` is a **convenience, not a security boundary**. The column is simply absent
436
+ from *this* schema; a handler that returns the same row under a different schema
437
+ still sends it. For a real boundary, see
438
+ [`.encrypted()` and column sensitivity](/docs/database/sensitivity).
439
+
440
+ ### One column at a time — `columnSchema`
441
+
442
+ `columnSchema` maps a single column, and it takes a column **definition** — what
443
+ `table.fields` holds — not the builder that `text()` or `timestamp()` returns
444
+ (a builder's definition is private, so it cannot be read from outside):
445
+
446
+ ```ts no-check
447
+ import { columnSchema } from '@voltro/database'
448
+
449
+ columnSchema(notes.fields.createdAt) // ✅ a definition, from table.fields
450
+ columnSchema(timestamp()) // ❌ a builder — not readable
451
+ ```
452
+
307
453
  ## What gets sent on the wire
308
454
 
309
455
  Queries are streaming RPCs whose elements are **subscription events**: an initial `snapshot` followed by `delta`s. See [Wire protocol](/docs/data/wire-protocol#subscription-events-snapshot-delta) for the envelope shape. For plain element streams, use [Streams](/docs/data/streams).
@@ -327,11 +473,34 @@ a flash of empty-state before the first snapshot:
327
473
  | `isEmpty` | snapshot arrived, zero rows (or a null value) | empty state |
328
474
  | neither | rows present | the list |
329
475
 
476
+ ### `loading` narrows `data`
477
+
478
+ `SubscriptionState<T>` is a **discriminated union on `loading`**, so `loading` is
479
+ not a flag sitting beside `data` — it is a type guard for it:
480
+
481
+ ```ts
482
+ type SubscriptionState<T> =
483
+ | { loading: true; data: undefined; isEmpty: false }
484
+ | { loading: false; data: T; isEmpty: boolean }
485
+ // both members also carry revision, emittedAt, error and pendingPatches
486
+ ```
487
+
488
+ Once you have returned for `loading`, `data` is `T`. No `?? []`, no `!`:
489
+
330
490
  ```tsx
331
491
  const { data, loading, isEmpty } = useSubscription<Note[]>('app', 'notes.list', {})
332
492
  if (loading) return <TableSkeleton/>
333
493
  if (isEmpty) return <EmptyNotes/>
334
- return <NotesTable notes={data!}/>
494
+ return <NotesTable notes={data}/>
495
+ ```
496
+
497
+ A derived constant narrows just as well, as long as `loading` is part of it:
498
+
499
+ ```tsx
500
+ const { data, loading } = useSubscription<Note[]>('app', 'notes.list', {})
501
+ const isLoading = !currentUser || loading
502
+ if (isLoading) return <TableSkeleton/>
503
+ return <NotesTable notes={data}/> // data is Note[]
335
504
  ```
336
505
 
337
506
  `fallback` fills `data` while loading so a page can render its real (empty) shell
@@ -342,11 +511,18 @@ const { data, loading } = useSubscription('app', 'notes.list', {}, { fallback: [
342
511
  // data is [] before the first snapshot; loading is still true
343
512
  ```
344
513
 
345
- **Errors.** `error` carries a **cold-start** stream failure (nothing ever
346
- arrived) check it to avoid an infinite skeleton. A failure AFTER data arrived
347
- deliberately does NOT replace good data with an error banner (a transient
348
- websocket hiccup would blank a working screen); those reach the api's error bus
349
- insteadsubscribe with `useOnRpcError` for connection-level UX.
514
+ That call returns `SubscriptionStateWithFallback<T>` instead of the union: `data`
515
+ is always present (the fallback stands in until the first snapshot) and `loading`
516
+ is a plain boolean reporting the true state. There is nothing to narrow.
517
+
518
+ **Errors.** `loading` means **no data has arrived yet** it is not a claim that
519
+ the subscription is healthy. A **cold-start** failure (nothing ever arrived)
520
+ leaves `loading` true *and* sets `error`, so a component that branches on
521
+ `loading` alone renders a skeleton forever; check `error` to break out of it. A
522
+ failure AFTER data arrived deliberately does NOT replace good data with an error
523
+ banner (a transient websocket hiccup would blank a working screen); those reach
524
+ the api's error bus instead — subscribe with `useOnRpcError` for
525
+ connection-level UX.
350
526
 
351
527
 
352
528
 
@@ -414,7 +590,7 @@ export default async (
414
590
 
415
591
  The descriptor is the wire contract. The `.mutation.server.ts` file is the server-only implementation.
416
592
 
417
- `insert` returns the post-image as an untyped `Row` (`Readonly<Record<string, unknown>>`), so narrow the field you need (`String(...)`) instead of asserting it with `as string` — an assertion silences the compiler without checking anything. To read a row back, use the fluent terminals: `ctx.store.select('notes').where('id', id).one()` fails with the typed `NoRowFound` when the row is missing (or when more than one matches), so you never need a hand-written not-found branch; `.first()` / `.maybeOne()` return `null` instead.
593
+ `insert` returns the post-image as an untyped `Row` (`Readonly<Record<string, unknown>>`), so narrow the field you need (`String(...)`) instead of asserting it with `as string` — an assertion silences the compiler without checking anything. To read a row back, use the fluent terminals: `ctx.store.select('notes').where('id', id).one()` fails with the typed `NoRowFound` when the row is missing (or when more than one matches), so you never need a hand-written not-found branch; `.first()` / `.maybeOne()` return `null` instead. That fluent builder is string-keyed and yields an untyped `Row`; for a TYPED single row, pass the `database.<table>` builder to the store's own terminals — `await ctx.store.one(database.notes.where(eq('id', id)))` returns the row type with no cast. See [single-row terminals](/docs/database/query-builder#single-row-terminals-on-the-typed-builder).
418
594
 
419
595
  ## What The Runtime Does
420
596
 
@@ -496,6 +672,26 @@ import { setMutationNotifier } from '@voltro/client'
496
672
  setMutationNotifier({ success: (m) => toast.success(m), error: (m) => toast.error(m) })
497
673
  ```
498
674
 
675
+ **The callback form is for SINGLE-SHOT writes.** A loop or a multi-step sequence
676
+ relies on the promise *throwing* to stop. Once the failure is handled the promise
677
+ resolves, so the loop cheerfully continues past the row that failed:
678
+
679
+ ```ts
680
+ // WRONG — onError handles the failure, so the loop never stops
681
+ for (const row of rows) {
682
+ await create.mutate(row, { onError: (e) => toast.error(messageFor(e)) })
683
+ }
684
+
685
+ // RIGHT — bare mutate rejects, so the sequence aborts where it broke
686
+ try {
687
+ for (const row of rows) await create.mutate(row)
688
+ } catch (e) {
689
+ toast.error(messageFor(e))
690
+ }
691
+ ```
692
+
693
+ The same applies to `run` on [actions](/docs/data/actions).
694
+
499
695
 
500
696
  ## Auto-Optimistic
501
697
 
@@ -736,6 +932,44 @@ const onClick = async () => {
736
932
 
737
933
  `useAction` returns `run`, `pending`, `error`, and `lastResult`.
738
934
 
935
+ ### Handling the result — `onSuccess` / `onError` / `notify`
936
+
937
+ `run` takes the same options bag as a [mutation](/docs/data/mutations)'s `mutate`,
938
+ so a one-shot action does not need a `try/catch/finally` + toast wrapper —
939
+ `pending` already replaces the `finally`:
940
+
941
+ ```ts
942
+ const invite = useAction('app', 'invites.send')
943
+
944
+ await invite.run({ email }, {
945
+ onSuccess: (out) => toast.success(`sent ${out.id}`),
946
+ onError: (e) => toast.error(readError(e)),
947
+ })
948
+ ```
949
+
950
+ **The load-bearing rule:** supplying an error handler (`onError` **or**
951
+ `notify.error`) marks the failure **handled** — `run` then resolves with
952
+ `undefined` instead of rejecting, which is what removes the `try/catch`. With no
953
+ options it rejects exactly as before, so an unhandled failure stays loud.
954
+
955
+ **The callback form is for SINGLE-SHOT calls.** A loop or a multi-step sequence
956
+ relies on the promise *throwing* to stop; once the failure is handled the promise
957
+ resolves and the sequence runs on past the step that broke:
958
+
959
+ ```ts
960
+ // WRONG — onError handles the failure, so the loop never stops
961
+ for (const email of emails) {
962
+ await invite.run({ email }, { onError: (e) => toast.error(readError(e)) })
963
+ }
964
+
965
+ // RIGHT — bare run rejects, so the sequence aborts where it broke
966
+ try {
967
+ for (const email of emails) await invite.run({ email })
968
+ } catch (e) {
969
+ toast.error(readError(e))
970
+ }
971
+ ```
972
+
739
973
  ## Action vs Mutation vs Stream
740
974
 
741
975
  | Need | Use |
@@ -1512,7 +1746,7 @@ _Per-table post-commit reactivity via file convention. Default-exported defineSu
1512
1746
 
1513
1747
  Use a `*.subscribe.ts` file when you want code to **run after every commit** to a specific table — refresh a search index, emit an external notification, invalidate a cache, push to a worker queue. The file convention is parallel to `*.startup.ts` / `*.cron.tsx` / `*.webhook.tsx`: drop a file matching the suffix anywhere under `apps/<api>/`, default-export a `defineSubscriber({...})`, the framework discovers + binds it at boot.
1514
1748
 
1515
- Subscribers are deliberately **best-effort** + **non-durable**. For crash-safe async work, have the subscriber invoke a workflow.
1749
+ Subscribers are deliberately **best-effort** + **non-durable**. For crash-safe async work — "a row changed, now run a workflow" — reach for a [reaction](/docs/data/reactions) instead.
1516
1750
 
1517
1751
  ## File shape
1518
1752
 
@@ -1560,22 +1794,24 @@ Subscribers are **non-durable** by design:
1560
1794
  - **Async handlers are NOT awaited by the dispatcher.** Fire-and-forget — a slow handler can't back-pressure the change stream. Errors propagate to the structured log via `.catch()`, but the change-emission path returns immediately.
1561
1795
  - **No retry, no resume.** If the process crashes mid-handler, the work is gone. Same if the network call inside the handler fails — there's no built-in retry policy.
1562
1796
 
1563
- If you need any of those properties (transactional, durable, retried), invoke a workflow from inside the subscriber:
1797
+ If you need any of those properties (transactional, durable, retried), don't hand-roll the kickoff in the handler — a subscriber's `ctx` carries exactly two members (`ctx.log` and `ctx.id`), so there is no framework handle to start a workflow with. "A row changed → start a workflow" has its own primitive: a [reaction](/docs/data/reactions). A `*.reaction.tsx` watches the same post-commit change stream and its `act` starts the workflow for you, behind mandatory guards:
1564
1798
 
1565
- ```ts
1566
- export default defineSubscriber({
1567
- table: 'orders',
1568
- on: 'insert',
1569
- handler: async (event, ctx) => {
1570
- // Best-effort kickoff. The workflow itself is crash-safe + retried.
1571
- await ctx.app.workflowClient.send('orders.fulfill', {
1572
- orderId: event.new!.id as string,
1573
- })
1799
+ ```tsx
1800
+ // reactions/fulfillOrder.reaction.tsx
1801
+ import { defineReaction } from '@voltro/runtime'
1802
+
1803
+ export default defineReaction({
1804
+ name: 'fulfillOrder',
1805
+ watch: { table: 'orders', on: 'insert' },
1806
+ act: { kind: 'workflow', workflow: 'orders.fulfill' }, // the changed row is the payload
1807
+ guards: {
1808
+ // REQUIRED — a stable idempotency key, so the same change acts exactly once.
1809
+ dedupeKey: (event) => String((event.new as { id?: string })?.id ?? ''),
1574
1810
  },
1575
1811
  })
1576
1812
  ```
1577
1813
 
1578
- The workflow body owns durability. The subscriber's job is just "translate row-change into workflow invocation".
1814
+ The workflow body owns durability; the reaction owns the trigger and the guards. `dedupeKey` is not optional — an ungated reaction whose act writes back into the watched table self-triggers forever, so `defineReaction` throws at boot when it's missing. See [Reactions](/docs/data/reactions) for the `when` predicate, `rateLimit`, `costBudgetUsd`, and the `act: { kind: 'agent' }` target.
1579
1815
 
1580
1816
  ## Why this and not a regular subscription?
1581
1817
 
@@ -1585,7 +1821,7 @@ A `useSubscription('app', 'todos.list')` is the right tool when the **client** w
1585
1821
  |---|---|
1586
1822
  | The browser, to show fresh data | `useSubscription` in a hook |
1587
1823
  | A server-side action, every commit | `*.subscribe.ts` (this) |
1588
- | A server-side action, eventually-consistent + durable | `*.subscribe.ts` that kicks off a workflow |
1824
+ | A server-side action, eventually-consistent + durable | [`*.reaction.tsx`](/docs/data/reactions) with `act: { kind: 'workflow' }` |
1589
1825
  | A server-side action, in the same transaction | Do the work inside the mutation handler |
1590
1826
 
1591
1827
  ## Decision tree — picking the right seam
@@ -1596,7 +1832,7 @@ A `useSubscription('app', 'todos.list')` is the right tool when the **client** w
1596
1832
  | Reject the insert | [`table().validate(Schema)`](/docs/database/columns#table-level-validation) |
1597
1833
  | Derive a value at write time | [`column.computed(row => ...)`](/docs/database/columns#computed-columns) / [`column.default(() => ...)`](/docs/database/columns#default-with-callback) |
1598
1834
  | **Best-effort post-commit reactivity per row** | **`*.subscribe.ts`** (this) |
1599
- | Crash-safe post-commit reactivity | workflow invoked from `*.subscribe.ts` |
1835
+ | Crash-safe post-commit reactivity | [`*.reaction.tsx`](/docs/data/reactions) acting on a workflow |
1600
1836
  | Periodically-refreshed pre-computed query | [`*.aggregate.ts`](/docs/data/aggregates) |
1601
1837
  | Event ingestion + analytical aggregates | [Analytics sink](/docs/plugins/analytics) |
1602
1838
 
@@ -1638,23 +1874,23 @@ export default defineSubscriber({
1638
1874
 
1639
1875
  ### Soft-delete cleanup workflow
1640
1876
 
1641
- ```ts
1642
- export default defineSubscriber({
1643
- table: 'documents',
1644
- on: 'update',
1645
- handler: async (event, ctx) => {
1646
- // Trigger a long-running cleanup ONLY when deletedAt was just set
1647
- if (!event.new || !event.old) return
1648
- if (event.old.deletedAt === null && event.new.deletedAt !== null) {
1649
- await ctx.app.workflowClient.send('documents.cleanupDeleted', {
1650
- documentId: event.new.id as string,
1651
- })
1652
- }
1653
- },
1877
+ Starting a workflow off a state transition is a [reaction](/docs/data/reactions), not a subscriber — `when` expresses the transition, `dedupeKey` keeps it from acting twice:
1878
+
1879
+ ```tsx
1880
+ // reactions/cleanupDeletedDocuments.reaction.tsx
1881
+ import { defineReaction } from '@voltro/runtime'
1882
+
1883
+ export default defineReaction({
1884
+ name: 'cleanupDeletedDocuments',
1885
+ watch: { table: 'documents', on: 'update' },
1886
+ // Fire ONLY when deletedAt was just set — not on every other update.
1887
+ when: (event) => event.old?.['deletedAt'] === null && event.new?.['deletedAt'] != null,
1888
+ act: { kind: 'workflow', workflow: 'documents.cleanupDeleted' },
1889
+ guards: { dedupeKey: (event) => String((event.new as { id?: string })?.id ?? '') },
1654
1890
  })
1655
1891
  ```
1656
1892
 
1657
- The workflow does the heavy lift (scrub child rows, notify owners, archive to cold storage); the subscriber's job is just to detect the state transition.
1893
+ The workflow does the heavy lift (scrub child rows, notify owners, archive to cold storage); the reaction's job is just to detect the state transition and hand the row over.
1658
1894
 
1659
1895
  ## Failure semantics in detail
1660
1896
 
@@ -1663,11 +1899,11 @@ The framework logs subscriber failures via the structured logger — scope `subs
1663
1899
  There's no retry. If a transient failure should be retried, do one of:
1664
1900
 
1665
1901
  1. Wrap the call in your own retry policy inside the handler (`Effect.retry`, `pRetry`, etc.).
1666
- 2. Move the work to a workflow that the subscriber just kicks off — workflows have first-class retry semantics.
1902
+ 2. Move the work into a [reaction](/docs/data/reactions) whose `act` starts a workflow — workflows have first-class retry semantics, and the reaction's `dedupeKey` makes the trigger idempotent.
1667
1903
 
1668
1904
  ## By design
1669
1905
 
1670
- The subscriber is a deliberately thin post-commit hook — the sharp edges below are choices, not gaps. When you outgrow them, the escape hatch is a workflow (durable, retried) or handler-side logic.
1906
+ The subscriber is a deliberately thin post-commit hook — the sharp edges below are choices, not gaps. When you outgrow them, the escape hatch is a [reaction](/docs/data/reactions) acting on a workflow (durable, retried) or handler-side logic.
1671
1907
 
1672
1908
  - **One subscriber per file.** Multiple defaults exported don't compose; pick the most natural file boundary (one cohesive concern per file).
1673
1909
  - **Filter on `on` + table; per-row predicates live in the handler.** A predicate like "fire only when the user's plan changed" is a one-line guard at the top of the handler — kept there rather than in a framework pre-filter so the matching rule sits next to the code that reacts to it.
@@ -1688,7 +1924,7 @@ The subscriber sees the post-commit row, which has been auto-stamped by audit/te
1688
1924
  ## See also
1689
1925
 
1690
1926
  - [Subscriptions](/docs/data/subscriptions) — the CLIENT-side `useSubscription` hook for live query data (different concept, similar name).
1691
- - [Streams](/docs/data/streams) — transient element feeds; for crash-safe post-commit work, invoke a workflow from a subscriber.
1927
+ - [Streams](/docs/data/streams) — transient element feeds; for crash-safe post-commit work, use a [reaction](/docs/data/reactions) that acts on a workflow.
1692
1928
 
1693
1929
 
1694
1930
 
@@ -1897,6 +2133,99 @@ treating it as permanently consumed would silently swallow a legitimate request.
1897
2133
  `enqueue` returns the outbox row id, which is also the delivery id: persist it
1898
2134
  alongside your row and a client can watch the effect's progress.
1899
2135
 
2136
+ ## Delivery history
2137
+
2138
+ The queue answers "is this still owed". It cannot answer "what did the remote
2139
+ say on attempt 3", "how long did it take", or "who resent it" — which is what a
2140
+ delivery-history screen renders. So every attempt appends a row to
2141
+ `_voltro_outbox_attempts`:
2142
+
2143
+ | column | |
2144
+ |---|---|
2145
+ | `outboxId` | the entry this attempt belongs to |
2146
+ | `effect` | denormalised — the history stays readable after the entry is purged |
2147
+ | `attempt` | 1-indexed, monotonic across the entry's whole life |
2148
+ | `trigger` | `automatic` (the drain) or `manual` (a resend) |
2149
+ | `triggeredBy` / `reason` | who asked for a manual resend, and why |
2150
+ | `outcome` | `delivered` · `failed` · `dead` (`dead` = the attempt that exhausted the budget) |
2151
+ | `startedAt` / `finishedAt` / `durationMs` | timing |
2152
+ | `error` | failure message (clipped) |
2153
+ | `response` | whatever the handler **returned**, as JSON |
2154
+ | `subjectId` / `tenantId` / `traceId` | carried from the entry |
2155
+
2156
+ `response` is how per-attempt transport detail gets recorded without the
2157
+ framework pretending to model HTTP — return `{ status, body }` from the handler
2158
+ and the history has it:
2159
+
2160
+ ```ts
2161
+ export default defineOutboxHandler({
2162
+ effect: 'jira.sync',
2163
+ handler: async ({ payload }) => {
2164
+ const res = await fetch(url, { method: 'POST', body: JSON.stringify(payload) })
2165
+ if (!res.ok) throw new Error(`jira ${res.status}`)
2166
+ return { status: res.status, body: (await res.text()).slice(0, 500) }
2167
+ },
2168
+ })
2169
+ ```
2170
+
2171
+ It is a framework table like any other, so reading it is a normal store read —
2172
+ there is no separate history API to learn:
2173
+
2174
+ ```ts
2175
+ const history = await ctx.store.query({
2176
+ table: '_voltro_outbox_attempts',
2177
+ predicate: eq('outboxId', deliveryId),
2178
+ order: [{ column: 'attempt', direction: 'desc' }],
2179
+ take: 20,
2180
+ })
2181
+ ```
2182
+
2183
+ Attempts are recorded best-effort: if the log write fails, the delivery still
2184
+ succeeds. History must never be able to break the thing it observes.
2185
+
2186
+ ## Resending on demand
2187
+
2188
+ ```ts
2189
+ await ctx.outbox.resend(deliveryId, { reason: 'customer never received it' })
2190
+ ```
2191
+
2192
+ Re-arms one entry for delivery now — including one that already reached `dead`.
2193
+ This is legitimate precisely because delivery is already at-least-once and
2194
+ handlers are therefore already required to be idempotent: a resend is the same
2195
+ hazard the contract obliges them to absorb, not a new one.
2196
+
2197
+ What it must never be is invisible. The resulting attempt is recorded with
2198
+ `trigger: 'manual'` plus the requesting subject and reason, so an operator
2199
+ redelivery can never be mistaken for a backoff retry when someone later asks why
2200
+ the remote saw the effect twice. The entry also carries a `resendCount`.
2201
+
2202
+ Three behaviours worth knowing:
2203
+
2204
+ - It **re-arms the existing entry** rather than enqueuing a copy. A copy would
2205
+ carry the same `idempotencyKey`, duplicate the payload, and split one entry's
2206
+ history across two ids.
2207
+ - It grants a **small fresh budget** (`attempts`, default 1). A dead entry has
2208
+ already spent its allowance, so without this it would re-die untried — and
2209
+ "try again now" is what the button means, not "restart the whole schedule".
2210
+ - It **refuses an entry whose attempt is in flight**, where re-arming would race
2211
+ the running attempt into a double delivery.
2212
+
2213
+ ## Retention
2214
+
2215
+ Two bounds, because an unbounded attempt log is a slow-motion outage:
2216
+
2217
+ - A **per-entry cap** (50 attempts) trims the oldest as new ones arrive. The
2218
+ automatic path can't reach it — `maxAttempts` defaults to 8 — so this bounds
2219
+ the one case that is genuinely unbounded: an entry resent by hand for years.
2220
+ It works on every dialect.
2221
+ - A **time bound** on the boot sweep purges attempts older than 30 days
2222
+ (`VOLTRO_OUTBOX_ATTEMPTS_TTL_HOURS`), plus **delivered** queue rows
2223
+ (`VOLTRO_OUTBOX_TTL_HOURS`). `dead` and `pending` entries are never aged out —
2224
+ a dead letter is an unresolved incident and a pending one is still owed.
2225
+
2226
+ The time sweep runs on postgres; on other dialects the per-entry cap is the
2227
+ bound.
2228
+
1900
2229
  ## When NOT to use it
1901
2230
 
1902
2231
  - **Work that must be observable step-by-step, or that suspends** → a
@@ -2504,3 +2833,250 @@ import { ContentForm } from '@voltro/cms/web'
2504
2833
  <ContentForm contentType={blogPost} value={row} onChange={setRow}
2505
2834
  widgets={{ richText: MyTipTapWidget }} />
2506
2835
  ```
2836
+
2837
+
2838
+
2839
+ ---
2840
+
2841
+ <!-- source: en/data/connections.md -->
2842
+ ## Connections (credentials vault)
2843
+
2844
+ _defineConnection — a per-user, encrypted store for third-party credentials, with the OAuth handshake, refresh-before-use, a resolver for handlers and plugins, and a connect UI, from one declaration._
2845
+
2846
+ An app that acts on a third party **on behalf of a user** — Jira with that
2847
+ user's token, Slack with that user's OAuth grant — needs five things, none of
2848
+ them app-specific and all of them easy to get subtly wrong:
2849
+
2850
+ 1. a per-user token table,
2851
+ 2. encryption, so the tokens are not sitting in the database in the clear,
2852
+ 3. an OAuth authorize/callback pair with anti-CSRF state,
2853
+ 4. an expiry check with a refresh, without stampeding on the refresh token,
2854
+ 5. a resolver the request path can ask for "the credential for **this** user".
2855
+
2856
+ `defineConnection` is that whole stack from one declaration.
2857
+
2858
+ ## Declare a connection
2859
+
2860
+ One `*.connection.ts` file per connection, default-exported. The file is
2861
+ **server-only** — it holds a client secret, and it is never part of the browser
2862
+ bundle.
2863
+
2864
+ ```ts
2865
+ // api/connections/jira.connection.ts
2866
+ import { defineConnection } from '@voltro/runtime'
2867
+ import { serverEnv } from '../env/server'
2868
+
2869
+ export default defineConnection({
2870
+ id: 'jira',
2871
+ kind: 'oauth2',
2872
+ label: 'Jira',
2873
+ authorizeUrl: 'https://auth.atlassian.com/authorize',
2874
+ tokenUrl: 'https://auth.atlassian.com/oauth/token',
2875
+ clientId: serverEnv.JIRA_CLIENT_ID,
2876
+ clientSecret: serverEnv.JIRA_CLIENT_SECRET,
2877
+ scopes: ['read:jira-work', 'offline_access'],
2878
+ authorizeParams: { audience: 'api.atlassian.com', prompt: 'consent' },
2879
+ identify: async (tokens) => {
2880
+ const res = await fetch('https://api.atlassian.com/me', {
2881
+ headers: { authorization: `Bearer ${tokens.accessToken}` },
2882
+ })
2883
+ const me = await res.json() as { account_id: string, email: string }
2884
+ return { accountId: me.account_id, accountLabel: me.email }
2885
+ },
2886
+ })
2887
+ ```
2888
+
2889
+ For a provider with no OAuth app — or where a personal access token is simply
2890
+ the supported path — use `kind: 'pat'`:
2891
+
2892
+ ```ts
2893
+ // api/connections/github.connection.ts
2894
+ import { defineConnection } from '@voltro/runtime'
2895
+
2896
+ export default defineConnection({
2897
+ id: 'github',
2898
+ kind: 'pat',
2899
+ label: 'GitHub',
2900
+ instructionsUrl: 'https://github.com/settings/tokens',
2901
+ validate: async (token) => {
2902
+ const res = await fetch('https://api.github.com/user', {
2903
+ headers: { authorization: `Bearer ${token}` },
2904
+ })
2905
+ if (!res.ok) throw new Error('GitHub rejected that token')
2906
+ const me = await res.json() as { login: string }
2907
+ return { accountLabel: me.login }
2908
+ },
2909
+ })
2910
+ ```
2911
+
2912
+ `validate` is worth writing. Without it, a mistyped token is accepted happily
2913
+ and fails hours later inside a background job; with it, the user finds out while
2914
+ they still have the token on their clipboard.
2915
+
2916
+ ## Use the credential
2917
+
2918
+ `ctx.connections.get(id)` returns the **calling subject's** credential,
2919
+ refreshed if it was near expiry. There is no parameter for "some other
2920
+ subject" — the facade is bound to the request.
2921
+
2922
+ ```ts
2923
+ // api/actions/syncIssues.action.server.ts
2924
+ export default async function syncIssues(input: { projectKey: string }, ctx) {
2925
+ const jira = await ctx.connections.get('jira')
2926
+
2927
+ const res = await fetch(`https://api.atlassian.com/ex/jira/search?jql=project=${input.projectKey}`, {
2928
+ headers: { authorization: `Bearer ${jira.accessToken}` },
2929
+ })
2930
+ return { count: (await res.json()).total }
2931
+ }
2932
+ ```
2933
+
2934
+ Use `ctx.connections.tryGet(id)` for the "use it if we have it" branch — it
2935
+ returns `null` when nothing is connected. A failing *refresh* still throws:
2936
+ silently degrading a connected-but-broken account to "not connected" would hide
2937
+ a revoked grant behind a feature that quietly does nothing.
2938
+
2939
+ `ctx.connections` is **absent** when the app declares no `*.connection.ts`, so
2940
+ reaching for it in an app with no connections is a type error rather than a
2941
+ runtime surprise.
2942
+
2943
+ ## The connect UI
2944
+
2945
+ `useConnection` gives you one connection's live state plus its operations.
2946
+ Everything reactive comes from a single subscription, so a completed OAuth
2947
+ callback updates an open settings page with no polling.
2948
+
2949
+ ```tsx
2950
+ import { useConnection } from '@voltro/client'
2951
+ import { ConnectAccount } from '@voltro/ui'
2952
+
2953
+ export const IntegrationSettings = () => {
2954
+ const jira = useConnection('app', 'jira')
2955
+ const github = useConnection('app', 'github')
2956
+
2957
+ return (
2958
+ <>
2959
+ <ConnectAccount connection={jira} loading={jira.loading} />
2960
+ <ConnectAccount connection={github} instructionsUrl="https://github.com/settings/tokens" />
2961
+ </>
2962
+ )
2963
+ }
2964
+ ```
2965
+
2966
+ `useConnections('app')` returns the whole list if you would rather render the
2967
+ page yourself:
2968
+
2969
+ ```tsx
2970
+ const { connections, get, loading } = useConnections('app')
2971
+ // each entry: { connectionId, kind, label, status, accountLabel, scopes, expiresAt, lastError }
2972
+ ```
2973
+
2974
+ `connect()` opens the provider's consent screen in a popup by default (which
2975
+ keeps the current page — and any unsaved form state — mounted) and falls back to
2976
+ a full navigation when the popup is blocked. Pass `{ mode: 'redirect' }` to
2977
+ navigate the tab instead.
2978
+
2979
+ **The token is never on the client.** The wire shape carries status, account and
2980
+ expiry — nothing else. If you find yourself wanting the token in the browser,
2981
+ the call that needs it belongs on the server.
2982
+
2983
+ ## Status
2984
+
2985
+ | Status | Meaning |
2986
+ | --- | --- |
2987
+ | `disconnected` | No credential on file for this user. |
2988
+ | `connected` | A usable credential is on file. |
2989
+ | `expired` | The access token is past its deadline and there is no refresh token. Re-consent needed. |
2990
+ | `revoked` | The provider **refused** the refresh. The stored tokens were cleared; only reconnecting restores it. |
2991
+ | `error` | The last refresh failed transiently (5xx / network). Tokens retained; the next use retries. |
2992
+
2993
+ `expired`/`revoked`/`error` are deliberately not "connected" — that distinction
2994
+ is what makes the UI prompt rather than silently do nothing.
2995
+
2996
+ ## Encryption is not optional
2997
+
2998
+ Tokens are written through the framework's field cipher — the same
2999
+ `FieldCipher` `.encrypted()` columns use, not a second mechanism with its own
3000
+ key management. **An app that declares a connection and has no cipher
3001
+ configured refuses to boot**, with a message naming the connections. There is no
3002
+ plaintext fallback and no warn-and-continue path.
3003
+
3004
+ ```ts
3005
+ // app.config.ts
3006
+ import { governancePlugin } from '@voltro/plugin-governance'
3007
+
3008
+ export default defineApiConfig({
3009
+ plugins: [
3010
+ governancePlugin({ fieldEncryption: { secretKey: 'VOLTRO_FIELD_ENCRYPTION_KEY' } }),
3011
+ ],
3012
+ })
3013
+ ```
3014
+
3015
+ ## Refresh
3016
+
3017
+ Resolving checks the deadline with 60s of skew and renews **ahead** of it, so no
3018
+ call site needs a retry-on-401 dance. Two layers keep concurrent refreshes from
3019
+ stampeding:
3020
+
3021
+ - an in-process single-flight, so many handlers on one replica share one
3022
+ refresh;
3023
+ - a compare-and-set lease on the row, so two **replicas** do not both spend the
3024
+ refresh token — which, with a provider that rotates refresh tokens, would
3025
+ invalidate the grant outright.
3026
+
3027
+ A provider that does not rotate simply omits `refresh_token` from its reply; the
3028
+ existing one is kept rather than overwritten with null.
3029
+
3030
+ ## Plugins
3031
+
3032
+ A plugin never sees a request context, so the vault also publishes a
3033
+ process-level resolver. `@voltro/plugin-atlassian` consumes it directly:
3034
+
3035
+ ```ts
3036
+ // app.config.ts
3037
+ import { atlassianPlugin } from '@voltro/plugin-atlassian'
3038
+ import { connectionCredentials } from '@voltro/plugin-atlassian/connection'
3039
+
3040
+ plugins: [
3041
+ atlassianPlugin({
3042
+ credentialsResolver: connectionCredentials({
3043
+ connectionId: 'jira',
3044
+ baseUrl: serverEnv.JIRA_BASE_URL,
3045
+ }),
3046
+ }),
3047
+ ]
3048
+ ```
3049
+
3050
+ The plugin's contract is unchanged — it still receives a
3051
+ `(subject) => Effect<AtlassianCredentials>` and still never reads your schema.
3052
+ What changes is who owns the token. A plugin with its own credential resolver
3053
+ keeps working exactly as before; this is an additional way to satisfy the same
3054
+ option, not a replacement.
3055
+
3056
+ ## What the framework stores
3057
+
3058
+ Two tables, created only when the app declares a connection:
3059
+
3060
+ - `_voltro_connections` — one row per (connection, subject). Access and refresh
3061
+ tokens are ciphertext. Reactive, which is what makes the connect UI live.
3062
+ - `_voltro_connection_grants` — an in-flight OAuth handshake (single-use state,
3063
+ encrypted PKCE verifier, 10-minute TTL).
3064
+
3065
+ The callback endpoint is `GET /_voltro/connections/<id>/callback`, mounted
3066
+ automatically under both `voltro dev` and `voltro serve`. Register it as the
3067
+ redirect URI on your OAuth app, or set `redirectUri` on the declaration. The
3068
+ app's public origin comes from `VOLTRO_PUBLIC_URL`.
3069
+
3070
+ ## Deliberate non-goals
3071
+
3072
+ - **Disconnect does not revoke at the provider.** Providers disagree on whether
3073
+ a revocation endpoint exists, what it takes, and whether it kills sibling
3074
+ sessions. `disconnect()` forgets our copy and says so, rather than pretending
3075
+ to a revocation it cannot guarantee. Revoke in the provider's own UI when that
3076
+ matters.
3077
+ - **No app-wide connection.** A credential belongs to a subject. An anonymous
3078
+ caller is refused rather than bucketed under a shared pseudo-subject, which
3079
+ would be a credential every visitor shares. For a service account, store it as
3080
+ a secret, not a connection.
3081
+ - **`redirectTo` is a same-origin path only.** An absolute URL is rejected —
3082
+ otherwise every app declaring a connection would ship an open redirector.