@voltro/cli 0.57.0 → 0.58.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 +201 -0
- package/dist/apiBuild-CDE4NXNi.js +2 -0
- package/dist/{apiBuild-DDr2aNFd.js → apiBuild-CkpsCqIu.js} +2 -2
- package/dist/bin.js +1 -1
- package/dist/build-51qBWfYY.js +981 -0
- package/dist/{checkCommand-BISqx1OJ.js → checkCommand-C1EbNmwl.js} +1 -1
- package/dist/{checkCommand-DUtMWjcR.js → checkCommand-C30J2F18.js} +3 -3
- package/dist/{codegenCommand-DnVuDxwT.js → codegenCommand-kS3Twuc9.js} +7 -7
- package/dist/{codemodRunner-BlQPfjzA.js → codemodRunner-BKjCwGiN.js} +1127 -895
- package/dist/{commands-CRbxgxv0.js → commands-DowWkYtd.js} +21 -21
- package/dist/{dashboardCommand-Bf_-Ne3P.js → dashboardCommand-gG1y4PFG.js} +5 -5
- package/dist/{dataCommand-BoBJJ-Gb.js → dataCommand-hNOGKz4K.js} +6 -6
- package/dist/{dev-DOEJXicj.js → dev-Ce2gEziv.js} +2422 -2419
- package/dist/dev-Dff_pdik.js +3 -0
- package/dist/{doctorCommand-BrWu67JZ.js → doctorCommand-5ULMpL10.js} +505 -284
- package/dist/doctorCommand-B8H2MYCE.js +2 -0
- package/dist/{dormancyCommand-xn2y-pJm.js → dormancyCommand-DYgI0Kf-.js} +3 -3
- package/dist/{embeddingsCommand-Cn5MbRDM.js → embeddingsCommand-yJivQSrI.js} +2 -2
- package/dist/{envCommand-UJmJIbs9.js → envCommand-ORJUf8r_.js} +8 -8
- package/dist/{evolveCommand-Db30twUy.js → evolveCommand-DTawtTxf.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/{infoCommand-BjVXpMlP.js → infoCommand-BFksHunl.js} +1 -1
- package/dist/inspect-CZvsmp8t.js +2 -0
- package/dist/{inspect-DZnan87F.js → inspect-PcC5J-Dl.js} +6 -7
- package/dist/manifestBuild-AUh31bFU.js +2 -0
- package/dist/{manifestBuild-DnbFKF6w.js → manifestBuild-BwiS4iYm.js} +1 -1
- package/dist/{probeCommand-C5fuN6Z2.js → probeCommand-BUYzc0CT.js} +1 -1
- package/dist/{runtimeTrace-BQL_lfz6.js → runtimeTrace-BUk5zMXY.js} +1 -1
- package/dist/{sdkgen-CMUPrDjH.js → sdkgen-CAhmmbjx.js} +1 -1
- package/dist/serveCommand-BUXUtSF5.js +2 -0
- package/dist/{serveCommand-B_w-Mpb_.js → serveCommand-DhtC7WBq.js} +726 -725
- package/dist/serveEntry.js +1 -1
- package/dist/{start-s25GAIgn.js → start-C75w6ftA.js} +1 -1
- package/dist/{start-YaUehtDV.js → start-DNtIlBcn.js} +641 -609
- package/dist/startEntry.js +1 -1
- package/dist/{updateCommand-C9n_Z_oG.js → updateCommand-CtuD5r1t.js} +1 -1
- package/dist/updateCommand-eVKVHj3N.js +2 -0
- package/dist/webDev-Bzb8ZFus.js +2 -0
- package/dist/{webDev-BgdkyjP6.js → webDev-CBN9KkyQ.js} +154 -146
- package/dist/{webhooksCommand-CYXTNvXq.js → webhooksCommand-BPJQOGTe.js} +1 -1
- package/package.json +61 -19
- package/templates/AGENTS.md +1 -1
- package/templates/agent-docs/_index.md +1 -1
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/ai.md +174 -0
- package/templates/agent-docs/authentication.md +34 -0
- package/templates/agent-docs/data.md +30 -0
- package/templates/agent-docs/database/transactions.md +48 -0
- package/templates/agent-docs/observability.md +13 -2
- package/templates/agent-docs/plugins/auth-supabase.md +21 -0
- package/templates/agent-docs/plugins/sentry.md +91 -8
- package/templates/agent-docs/schema-driven-ui.md +29 -0
- package/templates/agent-docs/whats-new.md +112 -105
- package/templates/apps/api-ai/package.json +6 -6
- 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/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +9 -9
- package/templates/apps/api-collab/package.json +8 -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/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-row-history/package.json +8 -8
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +7 -7
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +7 -7
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/package.json +7 -7
- package/templates/apps/frontend-blank/package.json +6 -6
- package/templates/apps/frontend-cms/package.json +8 -8
- package/templates/apps/frontend-collab/package.json +9 -9
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +6 -6
- package/templates/apps/frontend-docs/package.json +8 -8
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/package.json +7 -7
- package/templates/apps/frontend-saas/package.json +7 -7
- package/templates/apps/frontend-spa/package.json +6 -6
- package/templates/apps/frontend-ssr/package.json +6 -6
- package/templates/apps/frontend-ssr-api/package.json +7 -7
- package/templates/apps/frontend-static-blog/package.json +8 -8
- package/templates/apps/frontend-status/package.json +7 -7
- package/templates/apps/mobile-app/package.json +4 -4
- package/dist/apiBuild-B83Cb2Rv.js +0 -2
- package/dist/build-QKP6Bm0J.js +0 -872
- package/dist/dev-Bl9HqtV7.js +0 -3
- package/dist/doctorCommand-DrQv9SL3.js +0 -2
- package/dist/inspect-B0hL41s0.js +0 -2
- package/dist/manifestBuild-UXrnUcXP.js +0 -2
- package/dist/serveCommand-Dtb48ffg.js +0 -2
- package/dist/updateCommand-DsXEAHbd.js +0 -2
- package/dist/webDev-B6ZMX42w.js +0 -2
|
@@ -222,7 +222,7 @@ createVerifier({ secret: [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET
|
|
|
222
222
|
...t === void 0 ? {} : { payload: t }
|
|
223
223
|
};
|
|
224
224
|
}, S = u({ scope: "voltro:webhooks" }), C = ["--out", "--name"], w = async (e) => {
|
|
225
|
-
let { walk: t, loadDiscovered: n } = await import("./dev-
|
|
225
|
+
let { walk: t, loadDiscovered: n } = await import("./dev-Dff_pdik.js"), { outgoingFromEvents: r } = await import("./webhookDiscovery-il9ti-HE.js");
|
|
226
226
|
return r((await n(await t(e), { root: e })).events.map((e) => ({
|
|
227
227
|
file: e.file,
|
|
228
228
|
descriptor: e.descriptor
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.0",
|
|
4
4
|
"description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -802,6 +802,48 @@
|
|
|
802
802
|
"title": "`voltro serve` on a WEB app now refuses — use `voltro start`",
|
|
803
803
|
"kind": "manual"
|
|
804
804
|
},
|
|
805
|
+
{
|
|
806
|
+
"version": "0.58.0",
|
|
807
|
+
"id": "0.58.0/01_system_writes_stamp_no_actor",
|
|
808
|
+
"title": "A `runAsSystem` write stamps no actor id instead of \"system\"",
|
|
809
|
+
"kind": "manual"
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
"version": "0.58.0",
|
|
813
|
+
"id": "0.58.0/02_workflow_caller_context_carries_a_scope",
|
|
814
|
+
"title": "ResolvedWorkflowCallerContext carries the run's resolved row-filter scope",
|
|
815
|
+
"kind": "manual"
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
"version": "0.58.0",
|
|
819
|
+
"id": "0.58.0/03_fleet_view_reports_departed_replicas",
|
|
820
|
+
"title": "A fleet view separates DEPARTED replicas from stale ones",
|
|
821
|
+
"kind": "manual"
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
"version": "0.58.0",
|
|
825
|
+
"id": "0.58.0/04_rpc_errors_reach_reporters",
|
|
826
|
+
"title": "rpc failures are published on the client error bus",
|
|
827
|
+
"kind": "manual"
|
|
828
|
+
},
|
|
829
|
+
{
|
|
830
|
+
"version": "0.58.0",
|
|
831
|
+
"id": "0.58.0/05_sentry_traces_on_by_default",
|
|
832
|
+
"title": "Sentry performance traces are on by default",
|
|
833
|
+
"kind": "manual"
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
"version": "0.58.0",
|
|
837
|
+
"id": "0.58.0/06_jwt_secret_may_be_a_function",
|
|
838
|
+
"title": "jwtSecret may be a function, so a build needs no production credential",
|
|
839
|
+
"kind": "manual"
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
"version": "0.58.0",
|
|
843
|
+
"id": "0.58.0/07_rerank_takes_a_provider_config",
|
|
844
|
+
"title": "rerank reaches the gateway through a ProviderConfig",
|
|
845
|
+
"kind": "manual"
|
|
846
|
+
},
|
|
805
847
|
{
|
|
806
848
|
"version": "0.6.0",
|
|
807
849
|
"id": "0.6.0/01_no-dev-session-secret",
|
|
@@ -884,24 +926,24 @@
|
|
|
884
926
|
"@effect/platform-node": "^0.108.0",
|
|
885
927
|
"@effect/sql": "^0.52.0",
|
|
886
928
|
"@effect/workflow": "^0.19.0",
|
|
887
|
-
"@voltro/ai": "0.
|
|
888
|
-
"@voltro/cache": "0.
|
|
889
|
-
"@voltro/client": "0.
|
|
890
|
-
"@voltro/content": "0.
|
|
891
|
-
"@voltro/data-transfer": "0.
|
|
892
|
-
"@voltro/database": "0.
|
|
893
|
-
"@voltro/env": "0.
|
|
894
|
-
"@voltro/kv": "0.
|
|
895
|
-
"@voltro/logger": "0.
|
|
896
|
-
"@voltro/plugin-auth": "0.
|
|
897
|
-
"@voltro/plugin-broadcast": "0.
|
|
898
|
-
"@voltro/plugin-mail": "0.
|
|
899
|
-
"@voltro/plugin-storage": "0.
|
|
900
|
-
"@voltro/plugin-webhooks": "0.
|
|
901
|
-
"@voltro/protocol": "0.
|
|
902
|
-
"@voltro/runtime": "0.
|
|
903
|
-
"@voltro/serverless": "0.
|
|
904
|
-
"@voltro/workflow": "0.
|
|
929
|
+
"@voltro/ai": "0.58.0",
|
|
930
|
+
"@voltro/cache": "0.58.0",
|
|
931
|
+
"@voltro/client": "0.58.0",
|
|
932
|
+
"@voltro/content": "0.58.0",
|
|
933
|
+
"@voltro/data-transfer": "0.58.0",
|
|
934
|
+
"@voltro/database": "0.58.0",
|
|
935
|
+
"@voltro/env": "0.58.0",
|
|
936
|
+
"@voltro/kv": "0.58.0",
|
|
937
|
+
"@voltro/logger": "0.58.0",
|
|
938
|
+
"@voltro/plugin-auth": "0.58.0",
|
|
939
|
+
"@voltro/plugin-broadcast": "0.58.0",
|
|
940
|
+
"@voltro/plugin-mail": "0.58.0",
|
|
941
|
+
"@voltro/plugin-storage": "0.58.0",
|
|
942
|
+
"@voltro/plugin-webhooks": "0.58.0",
|
|
943
|
+
"@voltro/protocol": "0.58.0",
|
|
944
|
+
"@voltro/runtime": "0.58.0",
|
|
945
|
+
"@voltro/serverless": "0.58.0",
|
|
946
|
+
"@voltro/workflow": "0.58.0",
|
|
905
947
|
"chokidar": "^5.0.0",
|
|
906
948
|
"ioredis": "^5.11.1",
|
|
907
949
|
"tinyglobby": "^0.2.17",
|
package/templates/AGENTS.md
CHANGED
|
@@ -749,7 +749,7 @@ each plugin's own README.
|
|
|
749
749
|
|
|
750
750
|
| Topic | Open | Summary |
|
|
751
751
|
|---|---|---|
|
|
752
|
-
| **What's new in 0.
|
|
752
|
+
| **What's new in 0.58.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
|
|
753
753
|
| AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
|
|
754
754
|
| Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
|
|
755
755
|
| Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
|
|
@@ -9,7 +9,7 @@ each plugin's own README.
|
|
|
9
9
|
|
|
10
10
|
| Topic | Open | Summary |
|
|
11
11
|
|---|---|---|
|
|
12
|
-
| **What's new in 0.
|
|
12
|
+
| **What's new in 0.58.0** | `node_modules/@voltro/cli/templates/agent-docs/whats-new.md` | Everything that changed in this version. Read it before hand-rolling something the framework may now ship. |
|
|
13
13
|
| AI | `node_modules/@voltro/cli/templates/agent-docs/ai.md` | How Voltro treats AI — agents, tools, streaming, RAG — all primitives over the same WebSocket as the rest of the framework. |
|
|
14
14
|
| Authentication | `node_modules/@voltro/cli/templates/agent-docs/authentication.md` | How @voltro/plugin-auth wires password + session-cookie auth across api + web, plus the pluggable identity-strategy protocol. |
|
|
15
15
|
| Caching | `node_modules/@voltro/cli/templates/agent-docs/caching.md` | Voltro's caching layer (@voltro/cache) — an always-on memory default, swappable Redis-compatible backends, a low-level wrap primitive, and automatic query-result invalidation. |
|
|
@@ -172,6 +172,38 @@ const models = await getAvailableModels({ modality: 'language' }) // filter op
|
|
|
172
172
|
|
|
173
173
|
Pricing is normalised to **USD per 1,000,000 tokens**, the same shape as the cost toolkit's `ModelPrice` — so a catalog entry can feed the [cost ledger](/docs/ai/cost-tracking) directly (`estimateCostUsd(usage, { model, price })`). `getAvailableModels` is `async` (a plain Promise, not an Effect) and `gatewayProvider` is injectable for tests, so a picker query can call it without a live gateway in CI.
|
|
174
174
|
|
|
175
|
+
### The full catalog — `readGatewayCatalog`
|
|
176
|
+
|
|
177
|
+
`getAvailableModels` asks the AI SDK's gateway provider, whose entry type carries six fields. The HTTP response carries **twenty-one**. `readGatewayCatalog()` reads the response:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { readGatewayCatalog, canExecute } from '@voltro/ai'
|
|
181
|
+
|
|
182
|
+
const models = await readGatewayCatalog({ modality: 'language' })
|
|
183
|
+
const usable = models.filter(canExecute).filter((m) => m.capabilities?.vision)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Each entry adds `capabilities`, `modalities`, `contextWindow`, `maxOutputTokens`, `releasedAt`, `knowledgeCutoff` and `dataPolicy`.
|
|
187
|
+
|
|
188
|
+
`capabilities` is **unioned from three fields that each half-say it** — `tags`, `supported_parameters` and `modalities` — because they overlap and regularly disagree: a model tagged `reasoning` does not always carry the parameter, and the other way round. Taking either alone produces a false negative, and a false "cannot" removes a model from a picker with no way for anyone to find out why. That union is framework knowledge; without it every app writes the same forty lines, differently.
|
|
189
|
+
|
|
190
|
+
`dataPolicy` carries `zeroRetention` and `noTraining` (`'all' | 'some' | 'none'`). They decide whether a model may see customer text, and an unreadable value reads as `'none'` — the question is "may this", and an unparseable answer is not a yes.
|
|
191
|
+
|
|
192
|
+
`capabilities` and `modalities` are **optional**, and that is deliberate: they are populated on every HTTP read, and absent only when an entry came from the injected `gatewayProvider` a test uses. Defaulting them to all-false would turn "not known" into the claim "this model has no vision".
|
|
193
|
+
|
|
194
|
+
### What the runtime can actually run — `executableModalities`
|
|
195
|
+
|
|
196
|
+
The catalog names eight modalities; a build can execute the ones it has primitives for. Offering a model it cannot run produces a control that writes into nothing, so ask rather than keep a list:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
import { executableModalities, canExecute } from '@voltro/ai'
|
|
200
|
+
|
|
201
|
+
executableModalities() // Set { language, embedding, image, video, speech, reranking, transcription }
|
|
202
|
+
canExecute(model) // false for `realtime` today
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A hand-maintained list of runnable kinds goes stale the moment a primitive ships, and it goes stale silently — the app keeps hiding a modality that now works. This set is tied to the exports that justify each entry, so shipping a primitive without adding it turns a test red.
|
|
206
|
+
|
|
175
207
|
## Per-config key + base URL
|
|
176
208
|
|
|
177
209
|
Every `ProviderConfig` accepts an optional `apiKey` and `baseURL`. When EITHER is set, the framework builds a **dedicated provider instance** from it (`createOpenAI({ apiKey })` / `createAnthropic(...)` / `createGateway(...)`) instead of the env-default singleton. Omit both → it falls back to the standard env var. The mock provider ignores both.
|
|
@@ -1623,6 +1655,19 @@ const top5 = reranked.map((r) => r.document)
|
|
|
1623
1655
|
|
|
1624
1656
|
Re-rankers are slower than vector search but much more accurate. Use the cheap vector search to narrow to ~20 candidates, then the rerank model to pick the top 5. Total latency: ~150-300ms vs. 50ms for vector-only. The default `provider: 'mock'` scores by lexical overlap — deterministic and key-free for tests; the `cohere` / `voyage` providers resolve their SDK lazily (install the provider package to use them).
|
|
1625
1657
|
|
|
1658
|
+
### Through the gateway
|
|
1659
|
+
|
|
1660
|
+
`provider` also takes a `ProviderConfig`, the shape every other primitive here takes — so one `AI_GATEWAY_API_KEY` reaches every reranking model in the catalog instead of each vendor's own key:
|
|
1661
|
+
|
|
1662
|
+
```ts
|
|
1663
|
+
const reranked = yield* rerank({
|
|
1664
|
+
query, documents: candidates, getText: (d) => d.body, topN: 5,
|
|
1665
|
+
provider: { name: 'gateway', model: 'cohere/rerank-v4-pro' },
|
|
1666
|
+
})
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
The string shorthands (`'cohere'`, `'voyage'`) stay for the direct-key route. They are deliberately not folded into `ProviderConfig`: that union is shared with `generateText` and friends, and adding `cohere` there would let you name a provider that has no language model — a config error the type currently catches.
|
|
1670
|
+
|
|
1626
1671
|
## Citing sources
|
|
1627
1672
|
|
|
1628
1673
|
The model needs source info in its context to cite:
|
|
@@ -2468,3 +2513,132 @@ infrastructure. It does **not** execute the literal generated TypeScript against
|
|
|
2468
2513
|
database — the **deployed** app does that, running the real artifacts on a real
|
|
2469
2514
|
store. So the preview is a true feel for how the app behaves, not a bit-for-bit
|
|
2470
2515
|
run of the code.
|
|
2516
|
+
|
|
2517
|
+
|
|
2518
|
+
|
|
2519
|
+
---
|
|
2520
|
+
|
|
2521
|
+
<!-- source: en/ai/media.md -->
|
|
2522
|
+
## Media modalities
|
|
2523
|
+
|
|
2524
|
+
_Speech, transcription, images and video — one provider config, one error type, one cost ledger. Plus the seam for a speech provider the gateway does not carry._
|
|
2525
|
+
|
|
2526
|
+
The gateway catalog names eight modalities. Four of them are media, and each has a primitive with the same shape as the rest of `@voltro/ai`: a `ProviderConfig`, an `AiError` failure channel, and usage that reaches the same [cost ledger](/docs/ai/cost-tracking).
|
|
2527
|
+
|
|
2528
|
+
| Modality | Primitive |
|
|
2529
|
+
|---|---|
|
|
2530
|
+
| `image` | `generateImage` |
|
|
2531
|
+
| `video` | `generateVideo` |
|
|
2532
|
+
| `speech` | `generateSpeech` |
|
|
2533
|
+
| `transcription` | `transcribe` |
|
|
2534
|
+
|
|
2535
|
+
## `transcribe` — audio to text
|
|
2536
|
+
|
|
2537
|
+
```ts
|
|
2538
|
+
import { transcribe } from '@voltro/ai'
|
|
2539
|
+
|
|
2540
|
+
const { text, segments } = yield* transcribe({
|
|
2541
|
+
source: { storageRef: recording.ref },
|
|
2542
|
+
resolveRef: (ref) => ctx.storage.read(ref),
|
|
2543
|
+
timestamps: 'segment',
|
|
2544
|
+
language: 'de',
|
|
2545
|
+
})
|
|
2546
|
+
```
|
|
2547
|
+
|
|
2548
|
+
### The source is a reference, not bytes
|
|
2549
|
+
|
|
2550
|
+
Audio is large and usually already somewhere. Pushing a ninety-minute recording through an rpc frame to hand it back to a provider is the wrong direction, so `source` takes three shapes and they are genuinely different:
|
|
2551
|
+
|
|
2552
|
+
- **`{ storageRef }`** — the case to reach for. The app resolves it, because the app's storage is what knows about tenancy, signing and lifetime. The resolver is **injected** (`resolveRef`) rather than imported, so `@voltro/ai` never learns about buckets.
|
|
2553
|
+
- **`{ url }`** — fetched by `transcribe`. Convenient, but the bytes do pass through the process: the SDK takes bytes or base64 and never a location.
|
|
2554
|
+
- **`{ bytes, mediaType }`** — already in hand. The one that costs memory by construction.
|
|
2555
|
+
|
|
2556
|
+
### Timestamps and speakers
|
|
2557
|
+
|
|
2558
|
+
`timestamps: 'none' | 'segment' | 'word'` is a request, not a default — providers charge for the detail. `diarize: true` asks for speaker separation; a provider that cannot do it returns segments without `speaker` rather than failing.
|
|
2559
|
+
|
|
2560
|
+
`segments` is **absent** when the provider returned none, never `[]`. An empty array is a claim ("this audio has no speech"); absent is the truth ("the provider did not say").
|
|
2561
|
+
|
|
2562
|
+
## `generateSpeech` — text to audio
|
|
2563
|
+
|
|
2564
|
+
```ts
|
|
2565
|
+
const { audio } = yield* generateSpeech({
|
|
2566
|
+
text: 'Willkommen.',
|
|
2567
|
+
provider: { name: 'gateway', model: 'openai/tts-1-hd' },
|
|
2568
|
+
voice: 'alloy',
|
|
2569
|
+
outputFormat: 'mp3',
|
|
2570
|
+
})
|
|
2571
|
+
```
|
|
2572
|
+
|
|
2573
|
+
### A provider the gateway does not carry
|
|
2574
|
+
|
|
2575
|
+
The gateway serves nine speech models. A real voice catalog runs to hundreds behind vendors it does not carry — Cartesia, ElevenLabs, Google Cloud TTS — and an app with its own adapter for those had nowhere to plug it in, so it called its own synthesizer and skipped the primitive entirely, losing the shared error type and cost path with it.
|
|
2576
|
+
|
|
2577
|
+
Register the adapter instead:
|
|
2578
|
+
|
|
2579
|
+
```ts
|
|
2580
|
+
import { registerSpeechProvider, generateSpeech } from '@voltro/ai'
|
|
2581
|
+
|
|
2582
|
+
registerSpeechProvider('elevenlabs', {
|
|
2583
|
+
listVoices: () => …,
|
|
2584
|
+
synthesize: (request) => …,
|
|
2585
|
+
})
|
|
2586
|
+
|
|
2587
|
+
yield* generateSpeech({ text, provider: { name: 'elevenlabs', model: 'eleven_turbo_v3' }, voice })
|
|
2588
|
+
```
|
|
2589
|
+
|
|
2590
|
+
This ships **no** adapters, on purpose: bundling an ElevenLabs client would put the framework in the business of tracking somebody else's REST API, and the app already has the adapter. What the app deletes is its dispatch, not its adapters.
|
|
2591
|
+
|
|
2592
|
+
A registered provider receives every option the primitive already had — `voice`, `outputFormat`, `speed`, `language`, `instructions`, `providerOptions` — or the seam would be a narrower primitive wearing the same name. Built-in names (`mock`, `openai`, `gateway`, `anthropic`) cannot be shadowed: silently overriding one would make the same config mean different things depending on which module loaded first.
|
|
2593
|
+
|
|
2594
|
+
## As a durable step — `transcribeStep`
|
|
2595
|
+
|
|
2596
|
+
A ninety-minute recording is not a request. `aiStep` and `aiObjectStep` have had
|
|
2597
|
+
`offload: true` since they shipped, and transcription — the modality that runs
|
|
2598
|
+
longest — is where it matters most:
|
|
2599
|
+
|
|
2600
|
+
```ts
|
|
2601
|
+
const { text, language } = yield* transcribeStep({
|
|
2602
|
+
name: 'transcribe-recording',
|
|
2603
|
+
source: { storageRef: recording.ref },
|
|
2604
|
+
resolveRef: (ref) => ctx.storage.read(ref),
|
|
2605
|
+
offload: true,
|
|
2606
|
+
})
|
|
2607
|
+
```
|
|
2608
|
+
|
|
2609
|
+
Offloaded, the run parks on a durable signal and frees its worker; the dispatcher
|
|
2610
|
+
performs the call and resumes it. The queue row carries the **source**, never the
|
|
2611
|
+
bytes — putting the audio in the row would reintroduce exactly the cost
|
|
2612
|
+
offloading exists to remove. A ref source therefore needs `resolveTranscriptionRef`
|
|
2613
|
+
wired on the dispatcher too, since that is the process doing the read.
|
|
2614
|
+
|
|
2615
|
+
The journal keeps `text`, `language` and `durationSec` and **not** `segments`: a
|
|
2616
|
+
journal is read back on every replay, and segments for a long recording would
|
|
2617
|
+
make each replay pay for the transcript again. The segments belong wherever the
|
|
2618
|
+
app puts the transcript.
|
|
2619
|
+
|
|
2620
|
+
## On the client — `useTranscription`
|
|
2621
|
+
|
|
2622
|
+
The entrance is a file, so the two halves are one hook:
|
|
2623
|
+
|
|
2624
|
+
```tsx
|
|
2625
|
+
const t = useTranscription('app', { action: 'media.transcribe' })
|
|
2626
|
+
|
|
2627
|
+
await t.transcribe(file)
|
|
2628
|
+
t.phase // 'idle' | 'uploading' | 'transcribing' | 'done' | 'error'
|
|
2629
|
+
t.progress // 0..1 while uploading, undefined once the model is running
|
|
2630
|
+
t.result // { text, language, durationSec, segments? }
|
|
2631
|
+
```
|
|
2632
|
+
|
|
2633
|
+
`progress` is scoped to the phase on purpose. A single number across both reaches
|
|
2634
|
+
`1` when the **upload** finishes, while the model has not started — a bar that
|
|
2635
|
+
says finished is worse than no bar, so the transcribing phase reports `undefined`
|
|
2636
|
+
and an indeterminate spinner is the honest control.
|
|
2637
|
+
|
|
2638
|
+
The hook opens no transport: the upload is `useUpload`'s and the transcription is
|
|
2639
|
+
an action **you** declare, guard and point at a model. It owns the reduction,
|
|
2640
|
+
which is the part every app was rewriting.
|
|
2641
|
+
|
|
2642
|
+
## What is not here yet
|
|
2643
|
+
|
|
2644
|
+
`realtime` — the catalog's six duplex speech-to-speech models — has no primitive. The reason is not the model surface, which the gateway exposes; it is that rpc has no client→server stream. Every streaming kind in Voltro (`subscription`, `stream`, `event`) is server→client, so relaying a duplex session over the existing authenticated connection needs a transport capability that does not exist yet. `canExecute({ modality: 'realtime' })` answers `false`, which is what keeps it out of a picker until it does.
|
|
@@ -2199,6 +2199,17 @@ A subscription is a long-lived grant. Its guards — scope and relationship alik
|
|
|
2199
2199
|
— are re-evaluated before each delivery, so revoking a relation mid-session ends
|
|
2200
2200
|
the stream with the typed error instead of continuing to push rows.
|
|
2201
2201
|
|
|
2202
|
+
The same holds for a `*.stream.ts`: its guards are checked when the stream is
|
|
2203
|
+
opened — **before the executor runs** — and again before each element. The
|
|
2204
|
+
subscribe-time check comes first for a reason that is not symmetry: a stream's
|
|
2205
|
+
executor is where the work happens, so a gate placed after it refuses the result
|
|
2206
|
+
of something that has already been done and paid for. A denial ends the stream
|
|
2207
|
+
with the typed error rather than dropping elements, because a skipped element is
|
|
2208
|
+
indistinguishable from "nothing to send".
|
|
2209
|
+
|
|
2210
|
+
Every primitive that accepts `guards:` enforces them: queries, mutations,
|
|
2211
|
+
actions, events and streams.
|
|
2212
|
+
|
|
2202
2213
|
## Every procedure decides — `guards:` or `openAccess:`
|
|
2203
2214
|
|
|
2204
2215
|
A procedure that declares neither is **refused at boot**. `guards:` used to
|
|
@@ -2573,6 +2584,29 @@ wrapStoreWithMixinBehaviour(store, { subject, schemaRegistry, rowFilter: NO_ROW_
|
|
|
2573
2584
|
do this; a system subject bypasses row filters by design, and it is now written
|
|
2574
2585
|
down rather than inferred from an absence.
|
|
2575
2586
|
|
|
2587
|
+
You do not write it in your own schedules or subscribers. They run as a `system`
|
|
2588
|
+
subject and are exempt by that fact — not by appearing on a list. The distinction
|
|
2589
|
+
is worth stating, because the list was tried first: the resolution was taken
|
|
2590
|
+
through "all four request-context arms", and the arm that is not a request fell
|
|
2591
|
+
out of a list of request arms without anybody forgetting it. No entry added to a
|
|
2592
|
+
list closes that; the subject type is what every non-request path has in common.
|
|
2593
|
+
|
|
2594
|
+
A workflow splits along exactly that line, which is why it is the useful example.
|
|
2595
|
+
A BOOTSTRAP run — one with no recorded caller — is the system and bypasses. A run
|
|
2596
|
+
started BY A USER is not, and it reads through THAT user's filter, resolved when
|
|
2597
|
+
the step executes rather than restored from the row that started it. A workflow
|
|
2598
|
+
resumed three days later must not act on what its caller could see when they
|
|
2599
|
+
started it, for the same reason it must not act on the permissions they held
|
|
2600
|
+
then. If the scope cannot be resolved, the attempt fails rather than quietly
|
|
2601
|
+
reading fewer rows.
|
|
2602
|
+
|
|
2603
|
+
`ctx.storeForTenant(id)` is the one derived view worth naming separately. Called
|
|
2604
|
+
from a schedule it runs as a `serviceAccount` subject rather than the system one
|
|
2605
|
+
— deliberately, so a subject whose null tenant means *every* tenant cannot widen
|
|
2606
|
+
back out of the tenant it was just confined to. The row-filter bypass travels
|
|
2607
|
+
across that narrowing; the tenant confinement does not. A per-tenant sweep
|
|
2608
|
+
therefore reads exactly one tenant's rows, unfiltered.
|
|
2609
|
+
|
|
2576
2610
|
## What does *not* bypass it
|
|
2577
2611
|
|
|
2578
2612
|
| | Bypasses the row filter? |
|
|
@@ -5458,6 +5458,36 @@ error: Schema.Union(ConstraintViolation, MyDomainError),
|
|
|
5458
5458
|
|
|
5459
5459
|
If you do not, it still reaches the client — collapsed to `InternalError` like any undeclared error, but carrying its own sentence (`ConstraintViolation: foreign key tasks_laneId_fkey on tasks: the referenced row does not exist`) rather than the opaque `Failed to execute statement` a raw `SqlError` produces.
|
|
5460
5460
|
|
|
5461
|
+
### When the FRAMEWORK refuses the write
|
|
5462
|
+
|
|
5463
|
+
A few refusals are ours rather than the database's. The MySQL-family upsert has
|
|
5464
|
+
one: `ON DUPLICATE KEY UPDATE` fires on whichever unique key the incoming row
|
|
5465
|
+
violates, which may not be the one your `conflictColumns` named, so the store
|
|
5466
|
+
checks afterwards and rolls back when the statement reached a row those columns
|
|
5467
|
+
could not have selected. The database was perfectly happy — we are the ones
|
|
5468
|
+
saying no.
|
|
5469
|
+
|
|
5470
|
+
These arrive as `ConstraintViolation` too, on the same failure channel and with
|
|
5471
|
+
the same `kind`, so nothing downstream needs a second branch. They were plain
|
|
5472
|
+
throws until 0.58.0, which made them **defects**: the client received a `Defect`
|
|
5473
|
+
wrapping a stringified `InternalError`, with no tag to match on, for a condition
|
|
5474
|
+
an app can genuinely branch on.
|
|
5475
|
+
|
|
5476
|
+
They carry one extra field:
|
|
5477
|
+
|
|
5478
|
+
```ts
|
|
5479
|
+
{ _tag: 'ConstraintViolation',
|
|
5480
|
+
kind: 'unique', table: 'projectHours', operation: 'upsert',
|
|
5481
|
+
detail: 'MysqlStore.upsert: the row written to …' }
|
|
5482
|
+
```
|
|
5483
|
+
|
|
5484
|
+
`detail` is a sentence the framework authored, and it replaces the generated one
|
|
5485
|
+
in `message`. It is populated only for these refusals — never from a driver
|
|
5486
|
+
message, and never with a row value. That is the same rule the generated sentence
|
|
5487
|
+
follows: on several dialects the driver's own text contains row data, so it is
|
|
5488
|
+
not forwarded. A sentence we wrote carries exactly what we put in it.
|
|
5489
|
+
|
|
5490
|
+
|
|
5461
5491
|
## Loader errors
|
|
5462
5492
|
|
|
5463
5493
|
Loaders run in the web app — they receive `{ params, query, headers, signal }`, NOT a server `ctx` with `.store`. A loader reaches the backend through `query(...)` (the `POST /rpc` path, server-side only). It short-circuits with the branded control-flow signals `NotFoundError` / `RedirectError`:
|
|
@@ -263,6 +263,54 @@ await ctx.store.upsert('orgSlugs', {
|
|
|
263
263
|
Requires a composite UNIQUE constraint on the table — declare it via
|
|
264
264
|
`.unique([cols])` in the schema (see [Indexes](/docs/database/indexes#composite-unique-constraints)).
|
|
265
265
|
|
|
266
|
+
### The row you get back is the row that was there
|
|
267
|
+
|
|
268
|
+
When an upsert on a non-`id` key MATCHES, it updates the existing row — and that
|
|
269
|
+
row keeps its own `id`. The `id` you passed is the one that would have been used
|
|
270
|
+
had it inserted, so a fresh id on every call is the normal shape:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
await ctx.store.upsert('projectHours', {
|
|
274
|
+
teamId: 't-1', year: 2026, hours: 111,
|
|
275
|
+
}, {
|
|
276
|
+
conflictColumns: ['teamId', 'year'],
|
|
277
|
+
update: ['hours'],
|
|
278
|
+
})
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The returned row carries the id of whichever row now holds those values. Compare
|
|
282
|
+
values, not ids, if you need to know whether you inserted or updated.
|
|
283
|
+
|
|
284
|
+
On MySQL and MariaDB this costs one extra guarantee, because the dialect cannot
|
|
285
|
+
express the question. Postgres names its target — `ON CONFLICT (teamId, year)`
|
|
286
|
+
fires on that index and nothing else — while `ON DUPLICATE KEY UPDATE` fires on
|
|
287
|
+
whichever unique key the incoming row violates, which may not be the one you
|
|
288
|
+
named. The store therefore checks afterwards that the row it reached carries your
|
|
289
|
+
conflict values, and refuses the write if it does not: that row would have
|
|
290
|
+
absorbed your values while your row was never written. The refusal names the
|
|
291
|
+
differing column; the values go to the server log rather than into a sentence
|
|
292
|
+
that may reach a user.
|
|
293
|
+
|
|
294
|
+
A conflict column the database GENERATES cannot be checked this way — its value
|
|
295
|
+
is never in the row you sent. The remaining columns are still compared; if a
|
|
296
|
+
conflict key consists ENTIRELY of generated columns there is nothing to compare,
|
|
297
|
+
and the upsert refuses rather than guessing.
|
|
298
|
+
|
|
299
|
+
### Generated columns in a conflict key
|
|
300
|
+
|
|
301
|
+
A partial-unique index on MySQL/MariaDB is usually a STORED generated column plus
|
|
302
|
+
NULL-distinct semantics — the key constrains only the rows the expression marks.
|
|
303
|
+
An upsert can conflict on such a key, but only on the path where the DATABASE
|
|
304
|
+
evaluates the expression: the single-statement form, reached on MariaDB when the
|
|
305
|
+
row is a complete INSERT row and `update` is a column list.
|
|
306
|
+
|
|
307
|
+
The other path looks the row up by value first — it has to, because a function
|
|
308
|
+
`update` needs the existing row to compute its patch — and the value of a
|
|
309
|
+
generated column is not in the row you passed. There is no substitute for it, so
|
|
310
|
+
that combination refuses and names the column rather than guessing at one.
|
|
311
|
+
Postgres has no such split: `ON CONFLICT` names its index and the server
|
|
312
|
+
evaluates the column.
|
|
313
|
+
|
|
266
314
|
## `insertIgnore` — keep existing on conflict
|
|
267
315
|
|
|
268
316
|
For idempotent write patterns where you want to ensure a row exists
|
|
@@ -513,7 +513,7 @@ others. The answer is `fleet`-scoped and says what it is missing:
|
|
|
513
513
|
}
|
|
514
514
|
```
|
|
515
515
|
|
|
516
|
-
|
|
516
|
+
Four properties are deliberate:
|
|
517
517
|
|
|
518
518
|
- **A replica that has written nothing is `missing`, not absent.** Dropping it
|
|
519
519
|
would make a partial answer look complete — the same unlabelled sample, one
|
|
@@ -521,9 +521,20 @@ Three properties are deliberate:
|
|
|
521
521
|
- **A stale row is reported with its `ageMs`, not filtered out.** Removing it
|
|
522
522
|
hides that the answer is partial; keeping it unmarked presents fiction as
|
|
523
523
|
current.
|
|
524
|
+
- **A row from a replica that is GONE is `departed`, not stale.** These are
|
|
525
|
+
different facts with different next steps: a stale row belongs to a replica
|
|
526
|
+
membership still lists, so it is running and has stopped refreshing — a fault,
|
|
527
|
+
and a reason for `complete: false`. A departed row belongs to a process that
|
|
528
|
+
exited; that is what a scale-down looks like, and it is not a reason for
|
|
529
|
+
anything. Conflating them made `complete` unreachable after any replica had
|
|
530
|
+
ever exited. They are still listed, with their age, because a reader who
|
|
531
|
+
cannot see them cannot tell a fleet that scaled down from a table being
|
|
532
|
+
written by something nobody is tracking.
|
|
524
533
|
- **Mixed versions are named** in `completeness.versions` when the responders
|
|
525
534
|
disagree. A rolling deploy spans two shapes, and averaging them silently is
|
|
526
|
-
wrong in a way nothing downstream can detect.
|
|
535
|
+
wrong in a way nothing downstream can detect. Departed replicas are excluded
|
|
536
|
+
from the tally — a process that has been dead for hours reporting a "version
|
|
537
|
+
split" is a line somebody acts on.
|
|
527
538
|
|
|
528
539
|
**If there is no shared store, the request is refused with `501`** and a reason
|
|
529
540
|
— never answered with this replica's own numbers. Handing back a sample to
|
|
@@ -87,6 +87,27 @@ const resolve = composeAuthStrategies([
|
|
|
87
87
|
| `defaultTenantId` | (none) | Fallback for single-tenant deployments. |
|
|
88
88
|
| `scopesFromClaims` | (none) | Map claims (Supabase `role`, or an `app_metadata` permissions array) → `Subject.scopes`. |
|
|
89
89
|
|
|
90
|
+
### `jwtSecret` may be a function — and should be, if you build an image
|
|
91
|
+
|
|
92
|
+
`voltro build` imports `app.config.ts`, so anything the config CONSTRUCTS runs at
|
|
93
|
+
build time. `jwtSecret: process.env.SUPABASE_JWT_SECRET` therefore makes an image
|
|
94
|
+
build need a production credential for a bundle that will not contain one, and a
|
|
95
|
+
placeholder is not an option — the framework ships no secret values, and an
|
|
96
|
+
`ARG SUPABASE_JWT_SECRET` in a Dockerfile is exactly that.
|
|
97
|
+
|
|
98
|
+
Pass a function instead:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
supabaseStrategy({ jwtSecret: () => process.env.SUPABASE_JWT_SECRET })
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The build never calls it. The BOOT does, once, through the strategy pass both
|
|
105
|
+
`voltro dev` and `voltro serve` share — so an unset variable still refuses to
|
|
106
|
+
start, with the strategy's id in the message. Deferring the read does not defer
|
|
107
|
+
the failure to the first request: an auth strategy that cannot verify a token
|
|
108
|
+
accepts nobody, and a process that started anyway serves a login that can never
|
|
109
|
+
succeed.
|
|
110
|
+
|
|
90
111
|
Algorithms follow the mode — JWKS pins `['RS256', 'ES256']`, `jwtSecret` pins `['HS256']`; the two are mutually exclusive, so there is no alg-confusion downgrade surface (an RS256 setup can't be tricked into accepting an HS256-forged token). `none` is never accepted.
|
|
91
112
|
|
|
92
113
|
## Environment variables
|