@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.
- package/CHANGELOG.md +68 -0
- package/bin/voltro.mjs +54 -2
- package/dist/apiBuild-Bk4JBt-i.js +2 -0
- package/dist/{apiBuild-CvtQeBMs.js → apiBuild-BxY44VGj.js} +20 -20
- package/dist/bin.js +2 -2
- package/dist/{commands-DhyBIs1O.js → commands-My_YlUJV.js} +2823 -2265
- package/dist/{dev-CQxbrpDz.js → dev-Bjq-nIvH.js} +2650 -2230
- package/dist/dev-D6b74iXI.js +2 -0
- package/dist/devActivity-1WtIVyHc.js +153 -0
- package/dist/devActivity.d.ts +46 -0
- package/dist/devActivity.js +2 -0
- package/dist/index.js +1 -1
- package/dist/serveCommand-C-XQ89NJ.js +1110 -0
- package/dist/serveEntry.js +2 -2
- package/package.json +22 -17
- package/templates/AGENTS.core.md +119 -0
- package/templates/AGENTS.md +119 -0
- package/templates/agent-docs/_manifest.json +3 -3
- package/templates/agent-docs/ai.md +41 -5
- package/templates/agent-docs/authentication.md +238 -0
- package/templates/agent-docs/cli.md +142 -0
- package/templates/agent-docs/configuration.md +38 -0
- package/templates/agent-docs/data.md +718 -42
- package/templates/agent-docs/database/advancedqueries.md +21 -0
- package/templates/agent-docs/database/querying.md +166 -8
- package/templates/agent-docs/database/schema.md +6 -2
- package/templates/agent-docs/deployment.md +171 -0
- package/templates/agent-docs/internationalization.md +2 -0
- package/templates/agent-docs/plugins.md +4 -4
- package/templates/agent-docs/reference.md +90 -2
- package/templates/agent-docs/templates/apibackends.md +13 -2
- package/templates/agent-docs/testing.md +157 -3
- package/templates/agent-docs/workflows.md +11 -8
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/app.config.ts +20 -13
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/.env.example +10 -4
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-data-advanced/app.config.ts +21 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/app.config.ts +15 -2
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/app.config.ts +20 -1
- package/templates/apps/api-webhooks/package.json +8 -8
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/baselines/bare/.env.example +10 -3
- package/templates/baselines/compose/.env.example +10 -2
- package/templates/baselines/compose-mariadb/.env.example +10 -2
- package/templates/baselines/helm/.env.example +10 -3
- package/dist/apiBuild-DQBNqNZ8.js +0 -2
- package/dist/dev-DYjGqPGD.js +0 -2
- package/dist/serveCommand-BZzUJIyo.js +0 -1077
- package/templates/apps/api-auth/.env +0 -17
- package/templates/apps/api-data-advanced/.env +0 -17
- package/templates/apps/api-governance/.env +0 -4
- package/templates/apps/api-webhooks/.env +0 -6
|
@@ -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 (
|
|
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,108 @@ 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
|
+
### Keeping a column off the wire — `omit`
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { rowSchema } from '@voltro/database'
|
|
386
|
+
import { users } from '../database/schema'
|
|
387
|
+
|
|
388
|
+
rowSchema(users, { omit: ['passwordHash'] })
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`omit` is a **convenience, not a security boundary**. The column is simply absent
|
|
392
|
+
from *this* schema; a handler that returns the same row under a different schema
|
|
393
|
+
still sends it. For a real boundary, see
|
|
394
|
+
[`.encrypted()` and column sensitivity](/docs/database/sensitivity).
|
|
395
|
+
|
|
396
|
+
### One column at a time — `columnSchema`
|
|
397
|
+
|
|
398
|
+
`columnSchema` maps a single column, and it takes a column **definition** — what
|
|
399
|
+
`table.fields` holds — not the builder that `text()` or `timestamp()` returns
|
|
400
|
+
(a builder's definition is private, so it cannot be read from outside):
|
|
401
|
+
|
|
402
|
+
```ts no-check
|
|
403
|
+
import { columnSchema } from '@voltro/database'
|
|
404
|
+
|
|
405
|
+
columnSchema(notes.fields.createdAt) // ✅ a definition, from table.fields
|
|
406
|
+
columnSchema(timestamp()) // ❌ a builder — not readable
|
|
407
|
+
```
|
|
408
|
+
|
|
307
409
|
## What gets sent on the wire
|
|
308
410
|
|
|
309
411
|
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 +429,34 @@ a flash of empty-state before the first snapshot:
|
|
|
327
429
|
| `isEmpty` | snapshot arrived, zero rows (or a null value) | empty state |
|
|
328
430
|
| neither | rows present | the list |
|
|
329
431
|
|
|
432
|
+
### `loading` narrows `data`
|
|
433
|
+
|
|
434
|
+
`SubscriptionState<T>` is a **discriminated union on `loading`**, so `loading` is
|
|
435
|
+
not a flag sitting beside `data` — it is a type guard for it:
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
type SubscriptionState<T> =
|
|
439
|
+
| { loading: true; data: undefined; isEmpty: false }
|
|
440
|
+
| { loading: false; data: T; isEmpty: boolean }
|
|
441
|
+
// both members also carry revision, emittedAt, error and pendingPatches
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Once you have returned for `loading`, `data` is `T`. No `?? []`, no `!`:
|
|
445
|
+
|
|
330
446
|
```tsx
|
|
331
447
|
const { data, loading, isEmpty } = useSubscription<Note[]>('app', 'notes.list', {})
|
|
332
448
|
if (loading) return <TableSkeleton/>
|
|
333
449
|
if (isEmpty) return <EmptyNotes/>
|
|
334
|
-
return <NotesTable notes={data
|
|
450
|
+
return <NotesTable notes={data}/>
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
A derived constant narrows just as well, as long as `loading` is part of it:
|
|
454
|
+
|
|
455
|
+
```tsx
|
|
456
|
+
const { data, loading } = useSubscription<Note[]>('app', 'notes.list', {})
|
|
457
|
+
const isLoading = !currentUser || loading
|
|
458
|
+
if (isLoading) return <TableSkeleton/>
|
|
459
|
+
return <NotesTable notes={data}/> // data is Note[]
|
|
335
460
|
```
|
|
336
461
|
|
|
337
462
|
`fallback` fills `data` while loading so a page can render its real (empty) shell
|
|
@@ -342,11 +467,18 @@ const { data, loading } = useSubscription('app', 'notes.list', {}, { fallback: [
|
|
|
342
467
|
// data is [] before the first snapshot; loading is still true
|
|
343
468
|
```
|
|
344
469
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
470
|
+
That call returns `SubscriptionStateWithFallback<T>` instead of the union: `data`
|
|
471
|
+
is always present (the fallback stands in until the first snapshot) and `loading`
|
|
472
|
+
is a plain boolean reporting the true state. There is nothing to narrow.
|
|
473
|
+
|
|
474
|
+
**Errors.** `loading` means **no data has arrived yet** — it is not a claim that
|
|
475
|
+
the subscription is healthy. A **cold-start** failure (nothing ever arrived)
|
|
476
|
+
leaves `loading` true *and* sets `error`, so a component that branches on
|
|
477
|
+
`loading` alone renders a skeleton forever; check `error` to break out of it. A
|
|
478
|
+
failure AFTER data arrived deliberately does NOT replace good data with an error
|
|
479
|
+
banner (a transient websocket hiccup would blank a working screen); those reach
|
|
480
|
+
the api's error bus instead — subscribe with `useOnRpcError` for
|
|
481
|
+
connection-level UX.
|
|
350
482
|
|
|
351
483
|
|
|
352
484
|
|
|
@@ -408,12 +540,14 @@ export default async (
|
|
|
408
540
|
tenantId: ctx.request.subject.tenantId,
|
|
409
541
|
})
|
|
410
542
|
|
|
411
|
-
return { id: inserted['id']
|
|
543
|
+
return { id: String(inserted['id']) }
|
|
412
544
|
}
|
|
413
545
|
```
|
|
414
546
|
|
|
415
547
|
The descriptor is the wire contract. The `.mutation.server.ts` file is the server-only implementation.
|
|
416
548
|
|
|
549
|
+
`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).
|
|
550
|
+
|
|
417
551
|
## What The Runtime Does
|
|
418
552
|
|
|
419
553
|
1. Decode `input` with the descriptor schema.
|
|
@@ -494,6 +628,26 @@ import { setMutationNotifier } from '@voltro/client'
|
|
|
494
628
|
setMutationNotifier({ success: (m) => toast.success(m), error: (m) => toast.error(m) })
|
|
495
629
|
```
|
|
496
630
|
|
|
631
|
+
**The callback form is for SINGLE-SHOT writes.** A loop or a multi-step sequence
|
|
632
|
+
relies on the promise *throwing* to stop. Once the failure is handled the promise
|
|
633
|
+
resolves, so the loop cheerfully continues past the row that failed:
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
// WRONG — onError handles the failure, so the loop never stops
|
|
637
|
+
for (const row of rows) {
|
|
638
|
+
await create.mutate(row, { onError: (e) => toast.error(messageFor(e)) })
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// RIGHT — bare mutate rejects, so the sequence aborts where it broke
|
|
642
|
+
try {
|
|
643
|
+
for (const row of rows) await create.mutate(row)
|
|
644
|
+
} catch (e) {
|
|
645
|
+
toast.error(messageFor(e))
|
|
646
|
+
}
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
The same applies to `run` on [actions](/docs/data/actions).
|
|
650
|
+
|
|
497
651
|
|
|
498
652
|
## Auto-Optimistic
|
|
499
653
|
|
|
@@ -734,6 +888,44 @@ const onClick = async () => {
|
|
|
734
888
|
|
|
735
889
|
`useAction` returns `run`, `pending`, `error`, and `lastResult`.
|
|
736
890
|
|
|
891
|
+
### Handling the result — `onSuccess` / `onError` / `notify`
|
|
892
|
+
|
|
893
|
+
`run` takes the same options bag as a [mutation](/docs/data/mutations)'s `mutate`,
|
|
894
|
+
so a one-shot action does not need a `try/catch/finally` + toast wrapper —
|
|
895
|
+
`pending` already replaces the `finally`:
|
|
896
|
+
|
|
897
|
+
```ts
|
|
898
|
+
const invite = useAction('app', 'invites.send')
|
|
899
|
+
|
|
900
|
+
await invite.run({ email }, {
|
|
901
|
+
onSuccess: (out) => toast.success(`sent ${out.id}`),
|
|
902
|
+
onError: (e) => toast.error(readError(e)),
|
|
903
|
+
})
|
|
904
|
+
```
|
|
905
|
+
|
|
906
|
+
**The load-bearing rule:** supplying an error handler (`onError` **or**
|
|
907
|
+
`notify.error`) marks the failure **handled** — `run` then resolves with
|
|
908
|
+
`undefined` instead of rejecting, which is what removes the `try/catch`. With no
|
|
909
|
+
options it rejects exactly as before, so an unhandled failure stays loud.
|
|
910
|
+
|
|
911
|
+
**The callback form is for SINGLE-SHOT calls.** A loop or a multi-step sequence
|
|
912
|
+
relies on the promise *throwing* to stop; once the failure is handled the promise
|
|
913
|
+
resolves and the sequence runs on past the step that broke:
|
|
914
|
+
|
|
915
|
+
```ts
|
|
916
|
+
// WRONG — onError handles the failure, so the loop never stops
|
|
917
|
+
for (const email of emails) {
|
|
918
|
+
await invite.run({ email }, { onError: (e) => toast.error(readError(e)) })
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// RIGHT — bare run rejects, so the sequence aborts where it broke
|
|
922
|
+
try {
|
|
923
|
+
for (const email of emails) await invite.run({ email })
|
|
924
|
+
} catch (e) {
|
|
925
|
+
toast.error(readError(e))
|
|
926
|
+
}
|
|
927
|
+
```
|
|
928
|
+
|
|
737
929
|
## Action vs Mutation vs Stream
|
|
738
930
|
|
|
739
931
|
| Need | Use |
|
|
@@ -1210,9 +1402,9 @@ export default defineAggregate({
|
|
|
1210
1402
|
.descriptor,
|
|
1211
1403
|
)
|
|
1212
1404
|
return players.map((p, i) => ({
|
|
1213
|
-
playerId: p.id
|
|
1405
|
+
playerId: p.id,
|
|
1214
1406
|
rank: i + 1,
|
|
1215
|
-
kd: p.kdRatio
|
|
1407
|
+
kd: p.kdRatio,
|
|
1216
1408
|
}))
|
|
1217
1409
|
},
|
|
1218
1410
|
})
|
|
@@ -1510,7 +1702,7 @@ _Per-table post-commit reactivity via file convention. Default-exported defineSu
|
|
|
1510
1702
|
|
|
1511
1703
|
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.
|
|
1512
1704
|
|
|
1513
|
-
Subscribers are deliberately **best-effort** + **non-durable**. For crash-safe async work,
|
|
1705
|
+
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.
|
|
1514
1706
|
|
|
1515
1707
|
## File shape
|
|
1516
1708
|
|
|
@@ -1558,22 +1750,24 @@ Subscribers are **non-durable** by design:
|
|
|
1558
1750
|
- **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.
|
|
1559
1751
|
- **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.
|
|
1560
1752
|
|
|
1561
|
-
If you need any of those properties (transactional, durable, retried),
|
|
1753
|
+
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:
|
|
1562
1754
|
|
|
1563
|
-
```
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1755
|
+
```tsx
|
|
1756
|
+
// reactions/fulfillOrder.reaction.tsx
|
|
1757
|
+
import { defineReaction } from '@voltro/runtime'
|
|
1758
|
+
|
|
1759
|
+
export default defineReaction({
|
|
1760
|
+
name: 'fulfillOrder',
|
|
1761
|
+
watch: { table: 'orders', on: 'insert' },
|
|
1762
|
+
act: { kind: 'workflow', workflow: 'orders.fulfill' }, // the changed row is the payload
|
|
1763
|
+
guards: {
|
|
1764
|
+
// REQUIRED — a stable idempotency key, so the same change acts exactly once.
|
|
1765
|
+
dedupeKey: (event) => String((event.new as { id?: string })?.id ?? ''),
|
|
1572
1766
|
},
|
|
1573
1767
|
})
|
|
1574
1768
|
```
|
|
1575
1769
|
|
|
1576
|
-
The workflow body owns durability.
|
|
1770
|
+
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.
|
|
1577
1771
|
|
|
1578
1772
|
## Why this and not a regular subscription?
|
|
1579
1773
|
|
|
@@ -1583,7 +1777,7 @@ A `useSubscription('app', 'todos.list')` is the right tool when the **client** w
|
|
|
1583
1777
|
|---|---|
|
|
1584
1778
|
| The browser, to show fresh data | `useSubscription` in a hook |
|
|
1585
1779
|
| A server-side action, every commit | `*.subscribe.ts` (this) |
|
|
1586
|
-
| A server-side action, eventually-consistent + durable | `*.
|
|
1780
|
+
| A server-side action, eventually-consistent + durable | [`*.reaction.tsx`](/docs/data/reactions) with `act: { kind: 'workflow' }` |
|
|
1587
1781
|
| A server-side action, in the same transaction | Do the work inside the mutation handler |
|
|
1588
1782
|
|
|
1589
1783
|
## Decision tree — picking the right seam
|
|
@@ -1594,7 +1788,7 @@ A `useSubscription('app', 'todos.list')` is the right tool when the **client** w
|
|
|
1594
1788
|
| Reject the insert | [`table().validate(Schema)`](/docs/database/columns#table-level-validation) |
|
|
1595
1789
|
| Derive a value at write time | [`column.computed(row => ...)`](/docs/database/columns#computed-columns) / [`column.default(() => ...)`](/docs/database/columns#default-with-callback) |
|
|
1596
1790
|
| **Best-effort post-commit reactivity per row** | **`*.subscribe.ts`** (this) |
|
|
1597
|
-
| Crash-safe post-commit reactivity |
|
|
1791
|
+
| Crash-safe post-commit reactivity | [`*.reaction.tsx`](/docs/data/reactions) acting on a workflow |
|
|
1598
1792
|
| Periodically-refreshed pre-computed query | [`*.aggregate.ts`](/docs/data/aggregates) |
|
|
1599
1793
|
| Event ingestion + analytical aggregates | [Analytics sink](/docs/plugins/analytics) |
|
|
1600
1794
|
|
|
@@ -1636,23 +1830,23 @@ export default defineSubscriber({
|
|
|
1636
1830
|
|
|
1637
1831
|
### Soft-delete cleanup workflow
|
|
1638
1832
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
},
|
|
1833
|
+
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:
|
|
1834
|
+
|
|
1835
|
+
```tsx
|
|
1836
|
+
// reactions/cleanupDeletedDocuments.reaction.tsx
|
|
1837
|
+
import { defineReaction } from '@voltro/runtime'
|
|
1838
|
+
|
|
1839
|
+
export default defineReaction({
|
|
1840
|
+
name: 'cleanupDeletedDocuments',
|
|
1841
|
+
watch: { table: 'documents', on: 'update' },
|
|
1842
|
+
// Fire ONLY when deletedAt was just set — not on every other update.
|
|
1843
|
+
when: (event) => event.old?.['deletedAt'] === null && event.new?.['deletedAt'] != null,
|
|
1844
|
+
act: { kind: 'workflow', workflow: 'documents.cleanupDeleted' },
|
|
1845
|
+
guards: { dedupeKey: (event) => String((event.new as { id?: string })?.id ?? '') },
|
|
1652
1846
|
})
|
|
1653
1847
|
```
|
|
1654
1848
|
|
|
1655
|
-
The workflow does the heavy lift (scrub child rows, notify owners, archive to cold storage); the
|
|
1849
|
+
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.
|
|
1656
1850
|
|
|
1657
1851
|
## Failure semantics in detail
|
|
1658
1852
|
|
|
@@ -1661,11 +1855,11 @@ The framework logs subscriber failures via the structured logger — scope `subs
|
|
|
1661
1855
|
There's no retry. If a transient failure should be retried, do one of:
|
|
1662
1856
|
|
|
1663
1857
|
1. Wrap the call in your own retry policy inside the handler (`Effect.retry`, `pRetry`, etc.).
|
|
1664
|
-
2. Move the work
|
|
1858
|
+
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.
|
|
1665
1859
|
|
|
1666
1860
|
## By design
|
|
1667
1861
|
|
|
1668
|
-
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.
|
|
1862
|
+
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.
|
|
1669
1863
|
|
|
1670
1864
|
- **One subscriber per file.** Multiple defaults exported don't compose; pick the most natural file boundary (one cohesive concern per file).
|
|
1671
1865
|
- **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.
|
|
@@ -1686,7 +1880,7 @@ The subscriber sees the post-commit row, which has been auto-stamped by audit/te
|
|
|
1686
1880
|
## See also
|
|
1687
1881
|
|
|
1688
1882
|
- [Subscriptions](/docs/data/subscriptions) — the CLIENT-side `useSubscription` hook for live query data (different concept, similar name).
|
|
1689
|
-
- [Streams](/docs/data/streams) — transient element feeds; for crash-safe post-commit work,
|
|
1883
|
+
- [Streams](/docs/data/streams) — transient element feeds; for crash-safe post-commit work, use a [reaction](/docs/data/reactions) that acts on a workflow.
|
|
1690
1884
|
|
|
1691
1885
|
|
|
1692
1886
|
|
|
@@ -1772,6 +1966,241 @@ into the agent's prompt.
|
|
|
1772
1966
|
|
|
1773
1967
|
|
|
1774
1968
|
|
|
1969
|
+
---
|
|
1970
|
+
|
|
1971
|
+
<!-- source: en/data/outbox.md -->
|
|
1972
|
+
## Transactional outbox
|
|
1973
|
+
|
|
1974
|
+
_ctx.outbox.enqueue — a reliable external side effect from a mutation, committed in the same transaction as the write that caused it._
|
|
1975
|
+
|
|
1976
|
+
A mutation must not do external I/O. It runs in a transaction, and an HTTP call
|
|
1977
|
+
cannot be rolled back — if the request succeeds and the transaction then fails,
|
|
1978
|
+
you have charged a card for an order that does not exist.
|
|
1979
|
+
|
|
1980
|
+
So "write this row **and** sync it to Jira" has no correct one-step form. The
|
|
1981
|
+
usual workaround is to build one: a deliveries table written inside the
|
|
1982
|
+
transaction, a cron that drains it, and a worker with backoff and a dead-letter.
|
|
1983
|
+
That is a real subsystem, and every integration app rebuilds it.
|
|
1984
|
+
|
|
1985
|
+
`ctx.outbox` is that subsystem, as a one-liner.
|
|
1986
|
+
|
|
1987
|
+
```ts
|
|
1988
|
+
// apps/api/mutations/ticket.create.server.ts
|
|
1989
|
+
export default async (input: { title: string }, ctx: AppContext) => {
|
|
1990
|
+
const ticket = await ctx.store.insert('tickets', { title: input.title })
|
|
1991
|
+
|
|
1992
|
+
await ctx.outbox.enqueue('jira.sync', { ticketId: ticket.id }, {
|
|
1993
|
+
idempotencyKey: `jira.sync:${ticket.id}`,
|
|
1994
|
+
})
|
|
1995
|
+
|
|
1996
|
+
return { id: ticket.id }
|
|
1997
|
+
}
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
## Why this is correct, not just convenient
|
|
2001
|
+
|
|
2002
|
+
`enqueue` writes through `ctx.store` — and inside a mutation, `ctx.store` **is
|
|
2003
|
+
the transactional view**. The outbox row commits in the same transaction as the
|
|
2004
|
+
domain write, or neither does.
|
|
2005
|
+
|
|
2006
|
+
That is the whole guarantee. There is no window in which the ticket exists and
|
|
2007
|
+
the intent to sync it was lost, because losing the intent means the ticket was
|
|
2008
|
+
rolled back too.
|
|
2009
|
+
|
|
2010
|
+
This is what separates it from reacting to a change *after* commit. A
|
|
2011
|
+
post-commit tap — including `@voltro/plugin-cdc-out`, which says so plainly —
|
|
2012
|
+
is at-least-once **from enqueue**: a crash between the commit and the tap loses
|
|
2013
|
+
the event. Here, enqueue cannot be lost.
|
|
2014
|
+
|
|
2015
|
+
Delivery *after* commit is still at-least-once. That is the strongest guarantee
|
|
2016
|
+
available without a distributed transaction into the target system, so:
|
|
2017
|
+
|
|
2018
|
+
**Handlers must be idempotent.** A process that dies between "the remote
|
|
2019
|
+
accepted it" and "we recorded that" will retry.
|
|
2020
|
+
|
|
2021
|
+
## Declaring the handler
|
|
2022
|
+
|
|
2023
|
+
One `*.outbox.ts` file per effect:
|
|
2024
|
+
|
|
2025
|
+
```ts
|
|
2026
|
+
// apps/api/outbox/jira.sync.outbox.ts
|
|
2027
|
+
import { defineOutboxHandler } from '@voltro/runtime'
|
|
2028
|
+
|
|
2029
|
+
export default defineOutboxHandler({
|
|
2030
|
+
effect: 'jira.sync',
|
|
2031
|
+
maxAttempts: 5,
|
|
2032
|
+
handler: async ({ payload, attempt, subjectId, traceId }) => {
|
|
2033
|
+
await jira.syncIssue(payload['ticketId'] as string)
|
|
2034
|
+
},
|
|
2035
|
+
})
|
|
2036
|
+
```
|
|
2037
|
+
|
|
2038
|
+
The handler runs **after** the enqueuing transaction committed, outside it, and
|
|
2039
|
+
may do external I/O — that is the point. It receives the payload, the attempt
|
|
2040
|
+
number, and the subject / tenant / trace of whoever enqueued it.
|
|
2041
|
+
|
|
2042
|
+
Two handlers claiming the same `effect` is refused at boot with both filenames,
|
|
2043
|
+
rather than letting whichever loaded last silently win.
|
|
2044
|
+
|
|
2045
|
+
## Retries, backoff, dead-letter
|
|
2046
|
+
|
|
2047
|
+
| | |
|
|
2048
|
+
|---|---|
|
|
2049
|
+
| Retry schedule | exponential — 1s, 2s, 4s … capped at 5 minutes |
|
|
2050
|
+
| Default attempts | 8 (`maxAttempts` on the handler, or per-enqueue) |
|
|
2051
|
+
| Exhausted | row moves to `dead`, logged at ERROR, stays in the table |
|
|
2052
|
+
| Unknown effect | left **pending**, never discarded |
|
|
2053
|
+
|
|
2054
|
+
That last row matters. The usual cause of an unknown effect is a deploy where
|
|
2055
|
+
the enqueuing code shipped ahead of its handler. Dead-lettering those would turn
|
|
2056
|
+
a rollout ordering detail into permanent loss of a side effect the app believes
|
|
2057
|
+
happened, so they wait instead.
|
|
2058
|
+
|
|
2059
|
+
A dead-lettered row is not deleted — it is queryable in `_voltro_outbox` with
|
|
2060
|
+
its `lastError`, because a dead letter is a side effect your app thinks occurred
|
|
2061
|
+
and which never will.
|
|
2062
|
+
|
|
2063
|
+
## Delivery, and why there is both a nudge and a poll
|
|
2064
|
+
|
|
2065
|
+
When the transaction commits, the worker is nudged and the effect usually goes
|
|
2066
|
+
out in milliseconds. A poll also runs every 5 seconds.
|
|
2067
|
+
|
|
2068
|
+
The nudge is an optimisation. The **poll is the contract**: it picks up rows
|
|
2069
|
+
whose nudge was lost because the process died between commit and delivery, rows
|
|
2070
|
+
enqueued by another replica, and rows waiting out a backoff. Without it the
|
|
2071
|
+
guarantee degrades to "delivered unless something went wrong" — which is the
|
|
2072
|
+
exact case a durable outbox exists for.
|
|
2073
|
+
|
|
2074
|
+
## Options
|
|
2075
|
+
|
|
2076
|
+
```ts
|
|
2077
|
+
await ctx.outbox.enqueue('mail.welcome', { userId }, {
|
|
2078
|
+
idempotencyKey: `welcome:${userId}`, // drop if an undelivered row has this key
|
|
2079
|
+
maxAttempts: 3, // override the handler's default
|
|
2080
|
+
delayMs: 60_000, // don't attempt before then
|
|
2081
|
+
})
|
|
2082
|
+
```
|
|
2083
|
+
|
|
2084
|
+
`idempotencyKey` dedupes against rows that have not yet succeeded, so a retried
|
|
2085
|
+
mutation does not produce a second side effect. A **delivered** key is
|
|
2086
|
+
deliberately not a blocker — reusing a key later means "do it again", and
|
|
2087
|
+
treating it as permanently consumed would silently swallow a legitimate request.
|
|
2088
|
+
|
|
2089
|
+
`enqueue` returns the outbox row id, which is also the delivery id: persist it
|
|
2090
|
+
alongside your row and a client can watch the effect's progress.
|
|
2091
|
+
|
|
2092
|
+
## Delivery history
|
|
2093
|
+
|
|
2094
|
+
The queue answers "is this still owed". It cannot answer "what did the remote
|
|
2095
|
+
say on attempt 3", "how long did it take", or "who resent it" — which is what a
|
|
2096
|
+
delivery-history screen renders. So every attempt appends a row to
|
|
2097
|
+
`_voltro_outbox_attempts`:
|
|
2098
|
+
|
|
2099
|
+
| column | |
|
|
2100
|
+
|---|---|
|
|
2101
|
+
| `outboxId` | the entry this attempt belongs to |
|
|
2102
|
+
| `effect` | denormalised — the history stays readable after the entry is purged |
|
|
2103
|
+
| `attempt` | 1-indexed, monotonic across the entry's whole life |
|
|
2104
|
+
| `trigger` | `automatic` (the drain) or `manual` (a resend) |
|
|
2105
|
+
| `triggeredBy` / `reason` | who asked for a manual resend, and why |
|
|
2106
|
+
| `outcome` | `delivered` · `failed` · `dead` (`dead` = the attempt that exhausted the budget) |
|
|
2107
|
+
| `startedAt` / `finishedAt` / `durationMs` | timing |
|
|
2108
|
+
| `error` | failure message (clipped) |
|
|
2109
|
+
| `response` | whatever the handler **returned**, as JSON |
|
|
2110
|
+
| `subjectId` / `tenantId` / `traceId` | carried from the entry |
|
|
2111
|
+
|
|
2112
|
+
`response` is how per-attempt transport detail gets recorded without the
|
|
2113
|
+
framework pretending to model HTTP — return `{ status, body }` from the handler
|
|
2114
|
+
and the history has it:
|
|
2115
|
+
|
|
2116
|
+
```ts
|
|
2117
|
+
export default defineOutboxHandler({
|
|
2118
|
+
effect: 'jira.sync',
|
|
2119
|
+
handler: async ({ payload }) => {
|
|
2120
|
+
const res = await fetch(url, { method: 'POST', body: JSON.stringify(payload) })
|
|
2121
|
+
if (!res.ok) throw new Error(`jira ${res.status}`)
|
|
2122
|
+
return { status: res.status, body: (await res.text()).slice(0, 500) }
|
|
2123
|
+
},
|
|
2124
|
+
})
|
|
2125
|
+
```
|
|
2126
|
+
|
|
2127
|
+
It is a framework table like any other, so reading it is a normal store read —
|
|
2128
|
+
there is no separate history API to learn:
|
|
2129
|
+
|
|
2130
|
+
```ts
|
|
2131
|
+
const history = await ctx.store.query({
|
|
2132
|
+
table: '_voltro_outbox_attempts',
|
|
2133
|
+
predicate: eq('outboxId', deliveryId),
|
|
2134
|
+
order: [{ column: 'attempt', direction: 'desc' }],
|
|
2135
|
+
take: 20,
|
|
2136
|
+
})
|
|
2137
|
+
```
|
|
2138
|
+
|
|
2139
|
+
Attempts are recorded best-effort: if the log write fails, the delivery still
|
|
2140
|
+
succeeds. History must never be able to break the thing it observes.
|
|
2141
|
+
|
|
2142
|
+
## Resending on demand
|
|
2143
|
+
|
|
2144
|
+
```ts
|
|
2145
|
+
await ctx.outbox.resend(deliveryId, { reason: 'customer never received it' })
|
|
2146
|
+
```
|
|
2147
|
+
|
|
2148
|
+
Re-arms one entry for delivery now — including one that already reached `dead`.
|
|
2149
|
+
This is legitimate precisely because delivery is already at-least-once and
|
|
2150
|
+
handlers are therefore already required to be idempotent: a resend is the same
|
|
2151
|
+
hazard the contract obliges them to absorb, not a new one.
|
|
2152
|
+
|
|
2153
|
+
What it must never be is invisible. The resulting attempt is recorded with
|
|
2154
|
+
`trigger: 'manual'` plus the requesting subject and reason, so an operator
|
|
2155
|
+
redelivery can never be mistaken for a backoff retry when someone later asks why
|
|
2156
|
+
the remote saw the effect twice. The entry also carries a `resendCount`.
|
|
2157
|
+
|
|
2158
|
+
Three behaviours worth knowing:
|
|
2159
|
+
|
|
2160
|
+
- It **re-arms the existing entry** rather than enqueuing a copy. A copy would
|
|
2161
|
+
carry the same `idempotencyKey`, duplicate the payload, and split one entry's
|
|
2162
|
+
history across two ids.
|
|
2163
|
+
- It grants a **small fresh budget** (`attempts`, default 1). A dead entry has
|
|
2164
|
+
already spent its allowance, so without this it would re-die untried — and
|
|
2165
|
+
"try again now" is what the button means, not "restart the whole schedule".
|
|
2166
|
+
- It **refuses an entry whose attempt is in flight**, where re-arming would race
|
|
2167
|
+
the running attempt into a double delivery.
|
|
2168
|
+
|
|
2169
|
+
## Retention
|
|
2170
|
+
|
|
2171
|
+
Two bounds, because an unbounded attempt log is a slow-motion outage:
|
|
2172
|
+
|
|
2173
|
+
- A **per-entry cap** (50 attempts) trims the oldest as new ones arrive. The
|
|
2174
|
+
automatic path can't reach it — `maxAttempts` defaults to 8 — so this bounds
|
|
2175
|
+
the one case that is genuinely unbounded: an entry resent by hand for years.
|
|
2176
|
+
It works on every dialect.
|
|
2177
|
+
- A **time bound** on the boot sweep purges attempts older than 30 days
|
|
2178
|
+
(`VOLTRO_OUTBOX_ATTEMPTS_TTL_HOURS`), plus **delivered** queue rows
|
|
2179
|
+
(`VOLTRO_OUTBOX_TTL_HOURS`). `dead` and `pending` entries are never aged out —
|
|
2180
|
+
a dead letter is an unresolved incident and a pending one is still owed.
|
|
2181
|
+
|
|
2182
|
+
The time sweep runs on postgres; on other dialects the per-entry cap is the
|
|
2183
|
+
bound.
|
|
2184
|
+
|
|
2185
|
+
## When NOT to use it
|
|
2186
|
+
|
|
2187
|
+
- **Work that must be observable step-by-step, or that suspends** → a
|
|
2188
|
+
[workflow](/docs/workflows/overview). The outbox delivers one effect; it is
|
|
2189
|
+
not a durable multi-step process.
|
|
2190
|
+
- **Reacting to any change on a table, not to one mutation's intent** →
|
|
2191
|
+
[`defineSubscriber`](/docs/data/subscribers) or
|
|
2192
|
+
[`defineReaction`](/docs/data/reactions).
|
|
2193
|
+
- **Mirroring a table outward continuously** → `@voltro/plugin-cdc-out`, which
|
|
2194
|
+
is built for reverse-ETL with per-pipe ordering.
|
|
2195
|
+
|
|
2196
|
+
## See also
|
|
2197
|
+
|
|
2198
|
+
- [Mutations](/docs/data/mutations) — the transaction boundary this rides
|
|
2199
|
+
- [Subscribers](/docs/data/subscribers) — post-commit reactions to table changes
|
|
2200
|
+
- [Workflows](/docs/workflows/overview) — durable multi-step work
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
|
|
1775
2204
|
---
|
|
1776
2205
|
|
|
1777
2206
|
<!-- source: en/data/wire-protocol.md -->
|
|
@@ -2360,3 +2789,250 @@ import { ContentForm } from '@voltro/cms/web'
|
|
|
2360
2789
|
<ContentForm contentType={blogPost} value={row} onChange={setRow}
|
|
2361
2790
|
widgets={{ richText: MyTipTapWidget }} />
|
|
2362
2791
|
```
|
|
2792
|
+
|
|
2793
|
+
|
|
2794
|
+
|
|
2795
|
+
---
|
|
2796
|
+
|
|
2797
|
+
<!-- source: en/data/connections.md -->
|
|
2798
|
+
## Connections (credentials vault)
|
|
2799
|
+
|
|
2800
|
+
_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._
|
|
2801
|
+
|
|
2802
|
+
An app that acts on a third party **on behalf of a user** — Jira with that
|
|
2803
|
+
user's token, Slack with that user's OAuth grant — needs five things, none of
|
|
2804
|
+
them app-specific and all of them easy to get subtly wrong:
|
|
2805
|
+
|
|
2806
|
+
1. a per-user token table,
|
|
2807
|
+
2. encryption, so the tokens are not sitting in the database in the clear,
|
|
2808
|
+
3. an OAuth authorize/callback pair with anti-CSRF state,
|
|
2809
|
+
4. an expiry check with a refresh, without stampeding on the refresh token,
|
|
2810
|
+
5. a resolver the request path can ask for "the credential for **this** user".
|
|
2811
|
+
|
|
2812
|
+
`defineConnection` is that whole stack from one declaration.
|
|
2813
|
+
|
|
2814
|
+
## Declare a connection
|
|
2815
|
+
|
|
2816
|
+
One `*.connection.ts` file per connection, default-exported. The file is
|
|
2817
|
+
**server-only** — it holds a client secret, and it is never part of the browser
|
|
2818
|
+
bundle.
|
|
2819
|
+
|
|
2820
|
+
```ts
|
|
2821
|
+
// api/connections/jira.connection.ts
|
|
2822
|
+
import { defineConnection } from '@voltro/runtime'
|
|
2823
|
+
import { serverEnv } from '../env/server'
|
|
2824
|
+
|
|
2825
|
+
export default defineConnection({
|
|
2826
|
+
id: 'jira',
|
|
2827
|
+
kind: 'oauth2',
|
|
2828
|
+
label: 'Jira',
|
|
2829
|
+
authorizeUrl: 'https://auth.atlassian.com/authorize',
|
|
2830
|
+
tokenUrl: 'https://auth.atlassian.com/oauth/token',
|
|
2831
|
+
clientId: serverEnv.JIRA_CLIENT_ID,
|
|
2832
|
+
clientSecret: serverEnv.JIRA_CLIENT_SECRET,
|
|
2833
|
+
scopes: ['read:jira-work', 'offline_access'],
|
|
2834
|
+
authorizeParams: { audience: 'api.atlassian.com', prompt: 'consent' },
|
|
2835
|
+
identify: async (tokens) => {
|
|
2836
|
+
const res = await fetch('https://api.atlassian.com/me', {
|
|
2837
|
+
headers: { authorization: `Bearer ${tokens.accessToken}` },
|
|
2838
|
+
})
|
|
2839
|
+
const me = await res.json() as { account_id: string, email: string }
|
|
2840
|
+
return { accountId: me.account_id, accountLabel: me.email }
|
|
2841
|
+
},
|
|
2842
|
+
})
|
|
2843
|
+
```
|
|
2844
|
+
|
|
2845
|
+
For a provider with no OAuth app — or where a personal access token is simply
|
|
2846
|
+
the supported path — use `kind: 'pat'`:
|
|
2847
|
+
|
|
2848
|
+
```ts
|
|
2849
|
+
// api/connections/github.connection.ts
|
|
2850
|
+
import { defineConnection } from '@voltro/runtime'
|
|
2851
|
+
|
|
2852
|
+
export default defineConnection({
|
|
2853
|
+
id: 'github',
|
|
2854
|
+
kind: 'pat',
|
|
2855
|
+
label: 'GitHub',
|
|
2856
|
+
instructionsUrl: 'https://github.com/settings/tokens',
|
|
2857
|
+
validate: async (token) => {
|
|
2858
|
+
const res = await fetch('https://api.github.com/user', {
|
|
2859
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2860
|
+
})
|
|
2861
|
+
if (!res.ok) throw new Error('GitHub rejected that token')
|
|
2862
|
+
const me = await res.json() as { login: string }
|
|
2863
|
+
return { accountLabel: me.login }
|
|
2864
|
+
},
|
|
2865
|
+
})
|
|
2866
|
+
```
|
|
2867
|
+
|
|
2868
|
+
`validate` is worth writing. Without it, a mistyped token is accepted happily
|
|
2869
|
+
and fails hours later inside a background job; with it, the user finds out while
|
|
2870
|
+
they still have the token on their clipboard.
|
|
2871
|
+
|
|
2872
|
+
## Use the credential
|
|
2873
|
+
|
|
2874
|
+
`ctx.connections.get(id)` returns the **calling subject's** credential,
|
|
2875
|
+
refreshed if it was near expiry. There is no parameter for "some other
|
|
2876
|
+
subject" — the facade is bound to the request.
|
|
2877
|
+
|
|
2878
|
+
```ts
|
|
2879
|
+
// api/actions/syncIssues.action.server.ts
|
|
2880
|
+
export default async function syncIssues(input: { projectKey: string }, ctx) {
|
|
2881
|
+
const jira = await ctx.connections.get('jira')
|
|
2882
|
+
|
|
2883
|
+
const res = await fetch(`https://api.atlassian.com/ex/jira/search?jql=project=${input.projectKey}`, {
|
|
2884
|
+
headers: { authorization: `Bearer ${jira.accessToken}` },
|
|
2885
|
+
})
|
|
2886
|
+
return { count: (await res.json()).total }
|
|
2887
|
+
}
|
|
2888
|
+
```
|
|
2889
|
+
|
|
2890
|
+
Use `ctx.connections.tryGet(id)` for the "use it if we have it" branch — it
|
|
2891
|
+
returns `null` when nothing is connected. A failing *refresh* still throws:
|
|
2892
|
+
silently degrading a connected-but-broken account to "not connected" would hide
|
|
2893
|
+
a revoked grant behind a feature that quietly does nothing.
|
|
2894
|
+
|
|
2895
|
+
`ctx.connections` is **absent** when the app declares no `*.connection.ts`, so
|
|
2896
|
+
reaching for it in an app with no connections is a type error rather than a
|
|
2897
|
+
runtime surprise.
|
|
2898
|
+
|
|
2899
|
+
## The connect UI
|
|
2900
|
+
|
|
2901
|
+
`useConnection` gives you one connection's live state plus its operations.
|
|
2902
|
+
Everything reactive comes from a single subscription, so a completed OAuth
|
|
2903
|
+
callback updates an open settings page with no polling.
|
|
2904
|
+
|
|
2905
|
+
```tsx
|
|
2906
|
+
import { useConnection } from '@voltro/client'
|
|
2907
|
+
import { ConnectAccount } from '@voltro/ui'
|
|
2908
|
+
|
|
2909
|
+
export const IntegrationSettings = () => {
|
|
2910
|
+
const jira = useConnection('app', 'jira')
|
|
2911
|
+
const github = useConnection('app', 'github')
|
|
2912
|
+
|
|
2913
|
+
return (
|
|
2914
|
+
<>
|
|
2915
|
+
<ConnectAccount connection={jira} loading={jira.loading} />
|
|
2916
|
+
<ConnectAccount connection={github} instructionsUrl="https://github.com/settings/tokens" />
|
|
2917
|
+
</>
|
|
2918
|
+
)
|
|
2919
|
+
}
|
|
2920
|
+
```
|
|
2921
|
+
|
|
2922
|
+
`useConnections('app')` returns the whole list if you would rather render the
|
|
2923
|
+
page yourself:
|
|
2924
|
+
|
|
2925
|
+
```tsx
|
|
2926
|
+
const { connections, get, loading } = useConnections('app')
|
|
2927
|
+
// each entry: { connectionId, kind, label, status, accountLabel, scopes, expiresAt, lastError }
|
|
2928
|
+
```
|
|
2929
|
+
|
|
2930
|
+
`connect()` opens the provider's consent screen in a popup by default (which
|
|
2931
|
+
keeps the current page — and any unsaved form state — mounted) and falls back to
|
|
2932
|
+
a full navigation when the popup is blocked. Pass `{ mode: 'redirect' }` to
|
|
2933
|
+
navigate the tab instead.
|
|
2934
|
+
|
|
2935
|
+
**The token is never on the client.** The wire shape carries status, account and
|
|
2936
|
+
expiry — nothing else. If you find yourself wanting the token in the browser,
|
|
2937
|
+
the call that needs it belongs on the server.
|
|
2938
|
+
|
|
2939
|
+
## Status
|
|
2940
|
+
|
|
2941
|
+
| Status | Meaning |
|
|
2942
|
+
| --- | --- |
|
|
2943
|
+
| `disconnected` | No credential on file for this user. |
|
|
2944
|
+
| `connected` | A usable credential is on file. |
|
|
2945
|
+
| `expired` | The access token is past its deadline and there is no refresh token. Re-consent needed. |
|
|
2946
|
+
| `revoked` | The provider **refused** the refresh. The stored tokens were cleared; only reconnecting restores it. |
|
|
2947
|
+
| `error` | The last refresh failed transiently (5xx / network). Tokens retained; the next use retries. |
|
|
2948
|
+
|
|
2949
|
+
`expired`/`revoked`/`error` are deliberately not "connected" — that distinction
|
|
2950
|
+
is what makes the UI prompt rather than silently do nothing.
|
|
2951
|
+
|
|
2952
|
+
## Encryption is not optional
|
|
2953
|
+
|
|
2954
|
+
Tokens are written through the framework's field cipher — the same
|
|
2955
|
+
`FieldCipher` `.encrypted()` columns use, not a second mechanism with its own
|
|
2956
|
+
key management. **An app that declares a connection and has no cipher
|
|
2957
|
+
configured refuses to boot**, with a message naming the connections. There is no
|
|
2958
|
+
plaintext fallback and no warn-and-continue path.
|
|
2959
|
+
|
|
2960
|
+
```ts
|
|
2961
|
+
// app.config.ts
|
|
2962
|
+
import { governancePlugin } from '@voltro/plugin-governance'
|
|
2963
|
+
|
|
2964
|
+
export default defineApiConfig({
|
|
2965
|
+
plugins: [
|
|
2966
|
+
governancePlugin({ fieldEncryption: { secretKey: 'VOLTRO_FIELD_ENCRYPTION_KEY' } }),
|
|
2967
|
+
],
|
|
2968
|
+
})
|
|
2969
|
+
```
|
|
2970
|
+
|
|
2971
|
+
## Refresh
|
|
2972
|
+
|
|
2973
|
+
Resolving checks the deadline with 60s of skew and renews **ahead** of it, so no
|
|
2974
|
+
call site needs a retry-on-401 dance. Two layers keep concurrent refreshes from
|
|
2975
|
+
stampeding:
|
|
2976
|
+
|
|
2977
|
+
- an in-process single-flight, so many handlers on one replica share one
|
|
2978
|
+
refresh;
|
|
2979
|
+
- a compare-and-set lease on the row, so two **replicas** do not both spend the
|
|
2980
|
+
refresh token — which, with a provider that rotates refresh tokens, would
|
|
2981
|
+
invalidate the grant outright.
|
|
2982
|
+
|
|
2983
|
+
A provider that does not rotate simply omits `refresh_token` from its reply; the
|
|
2984
|
+
existing one is kept rather than overwritten with null.
|
|
2985
|
+
|
|
2986
|
+
## Plugins
|
|
2987
|
+
|
|
2988
|
+
A plugin never sees a request context, so the vault also publishes a
|
|
2989
|
+
process-level resolver. `@voltro/plugin-atlassian` consumes it directly:
|
|
2990
|
+
|
|
2991
|
+
```ts
|
|
2992
|
+
// app.config.ts
|
|
2993
|
+
import { atlassianPlugin } from '@voltro/plugin-atlassian'
|
|
2994
|
+
import { connectionCredentials } from '@voltro/plugin-atlassian/connection'
|
|
2995
|
+
|
|
2996
|
+
plugins: [
|
|
2997
|
+
atlassianPlugin({
|
|
2998
|
+
credentialsResolver: connectionCredentials({
|
|
2999
|
+
connectionId: 'jira',
|
|
3000
|
+
baseUrl: serverEnv.JIRA_BASE_URL,
|
|
3001
|
+
}),
|
|
3002
|
+
}),
|
|
3003
|
+
]
|
|
3004
|
+
```
|
|
3005
|
+
|
|
3006
|
+
The plugin's contract is unchanged — it still receives a
|
|
3007
|
+
`(subject) => Effect<AtlassianCredentials>` and still never reads your schema.
|
|
3008
|
+
What changes is who owns the token. A plugin with its own credential resolver
|
|
3009
|
+
keeps working exactly as before; this is an additional way to satisfy the same
|
|
3010
|
+
option, not a replacement.
|
|
3011
|
+
|
|
3012
|
+
## What the framework stores
|
|
3013
|
+
|
|
3014
|
+
Two tables, created only when the app declares a connection:
|
|
3015
|
+
|
|
3016
|
+
- `_voltro_connections` — one row per (connection, subject). Access and refresh
|
|
3017
|
+
tokens are ciphertext. Reactive, which is what makes the connect UI live.
|
|
3018
|
+
- `_voltro_connection_grants` — an in-flight OAuth handshake (single-use state,
|
|
3019
|
+
encrypted PKCE verifier, 10-minute TTL).
|
|
3020
|
+
|
|
3021
|
+
The callback endpoint is `GET /_voltro/connections/<id>/callback`, mounted
|
|
3022
|
+
automatically under both `voltro dev` and `voltro serve`. Register it as the
|
|
3023
|
+
redirect URI on your OAuth app, or set `redirectUri` on the declaration. The
|
|
3024
|
+
app's public origin comes from `VOLTRO_PUBLIC_URL`.
|
|
3025
|
+
|
|
3026
|
+
## Deliberate non-goals
|
|
3027
|
+
|
|
3028
|
+
- **Disconnect does not revoke at the provider.** Providers disagree on whether
|
|
3029
|
+
a revocation endpoint exists, what it takes, and whether it kills sibling
|
|
3030
|
+
sessions. `disconnect()` forgets our copy and says so, rather than pretending
|
|
3031
|
+
to a revocation it cannot guarantee. Revoke in the provider's own UI when that
|
|
3032
|
+
matters.
|
|
3033
|
+
- **No app-wide connection.** A credential belongs to a subject. An anonymous
|
|
3034
|
+
caller is refused rather than bucketed under a shared pseudo-subject, which
|
|
3035
|
+
would be a credential every visitor shares. For a service account, store it as
|
|
3036
|
+
a secret, not a connection.
|
|
3037
|
+
- **`redirectTo` is a same-origin path only.** An absolute URL is rejected —
|
|
3038
|
+
otherwise every app declaring a connection would ship an open redirector.
|