@voltro/cli 0.11.2 → 0.11.4
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 +228 -0
- package/THIRD-PARTY-NOTICES.md +4 -4
- package/dist/apiBuild-D9MtlD02.js +2 -0
- package/dist/{apiBuild-BVIiATXr.js → apiBuild-DHHuiwXF.js} +4 -4
- package/dist/bin.js +13 -6
- package/dist/{commands-C0ytPFfl.js → commands-BwbMqR-1.js} +1982 -1854
- package/dist/dev-BsBsIv93.js +2 -0
- package/dist/{dev-zJGsTTNb.js → dev-Cvojvb_J.js} +1696 -1654
- package/dist/{devActivity-1WtIVyHc.js → devActivity-BhIu6ncs.js} +7 -1
- package/dist/devActivity.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-BuBwg1yW.js → inspectMetrics-D4EJ8LTi.js} +339 -327
- package/dist/{serveCommand-Dd3oLzbo.js → serveCommand-ARqSs73o.js} +397 -350
- package/dist/serveEntry.js +3 -3
- package/dist/{start-ChN6PO-c.js → start-DyJE6w1R.js} +281 -273
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/agent-docs/_manifest.json +2 -2
- package/templates/agent-docs/authentication.md +22 -0
- package/templates/agent-docs/cli.md +86 -2
- package/templates/agent-docs/data.md +171 -0
- package/templates/agent-docs/database/misc.md +32 -0
- package/templates/agent-docs/database/querying.md +29 -0
- package/templates/agent-docs/internationalization.md +8 -1
- package/templates/agent-docs/reference.md +35 -0
- package/templates/agent-docs/routing.md +17 -0
- package/templates/agent-docs/workflows.md +31 -0
- package/templates/apps/api-ai/package.json +7 -7
- 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-storage/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-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/README.md +3 -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/dist/apiBuild-C_KY5oY_.js +0 -2
- package/dist/dev-DH13Ysgs.js +0 -2
package/dist/startEntry.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t } from "./start-
|
|
1
|
+
import { xt as e } from "./inspectMetrics-D4EJ8LTi.js";
|
|
2
|
+
import { t } from "./start-DyJE6w1R.js";
|
|
3
3
|
export { e as loadDotEnv, t as runStartCommand };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "The `voltro` CLI — dev server, codegen, migrations, project scaffolding, agent-docs seeding, and production serve.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -62,22 +62,22 @@
|
|
|
62
62
|
"@effect/platform-node": "^0.107.0",
|
|
63
63
|
"@effect/sql": "^0.51.1",
|
|
64
64
|
"@effect/workflow": "^0.18.2",
|
|
65
|
-
"@voltro/ai": "0.11.
|
|
66
|
-
"@voltro/cache": "0.11.
|
|
67
|
-
"@voltro/data-transfer": "0.11.
|
|
68
|
-
"@voltro/database": "0.11.
|
|
69
|
-
"@voltro/env": "0.11.
|
|
70
|
-
"@voltro/kv": "0.11.
|
|
71
|
-
"@voltro/logger": "0.11.
|
|
72
|
-
"@voltro/plugin-auth": "0.11.
|
|
73
|
-
"@voltro/plugin-broadcast": "0.11.
|
|
74
|
-
"@voltro/plugin-mail": "0.11.
|
|
75
|
-
"@voltro/plugin-storage": "0.11.
|
|
76
|
-
"@voltro/plugin-webhooks": "0.11.
|
|
77
|
-
"@voltro/protocol": "0.11.
|
|
78
|
-
"@voltro/runtime": "0.11.
|
|
79
|
-
"@voltro/serverless": "0.11.
|
|
80
|
-
"@voltro/workflow": "0.11.
|
|
65
|
+
"@voltro/ai": "0.11.4",
|
|
66
|
+
"@voltro/cache": "0.11.4",
|
|
67
|
+
"@voltro/data-transfer": "0.11.4",
|
|
68
|
+
"@voltro/database": "0.11.4",
|
|
69
|
+
"@voltro/env": "0.11.4",
|
|
70
|
+
"@voltro/kv": "0.11.4",
|
|
71
|
+
"@voltro/logger": "0.11.4",
|
|
72
|
+
"@voltro/plugin-auth": "0.11.4",
|
|
73
|
+
"@voltro/plugin-broadcast": "0.11.4",
|
|
74
|
+
"@voltro/plugin-mail": "0.11.4",
|
|
75
|
+
"@voltro/plugin-storage": "0.11.4",
|
|
76
|
+
"@voltro/plugin-webhooks": "0.11.4",
|
|
77
|
+
"@voltro/protocol": "0.11.4",
|
|
78
|
+
"@voltro/runtime": "0.11.4",
|
|
79
|
+
"@voltro/serverless": "0.11.4",
|
|
80
|
+
"@voltro/workflow": "0.11.4",
|
|
81
81
|
"chokidar": "^5.0.0",
|
|
82
82
|
"ioredis": "^5.11.1",
|
|
83
83
|
"tinyglobby": "^0.2.17",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"group": null,
|
|
36
36
|
"description": "The voltro CLI — every command, grouped by purpose, with the flags that actually matter.",
|
|
37
37
|
"path": "agent-docs/cli.md",
|
|
38
|
-
"files":
|
|
38
|
+
"files": 10
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
"id": "configuration",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"group": null,
|
|
54
54
|
"description": "How Voltro's reactive data layer works — queries, mutations, actions, streams, all over one WebSocket with typed errors and tracked dependencies.",
|
|
55
55
|
"path": "agent-docs/data.md",
|
|
56
|
-
"files":
|
|
56
|
+
"files": 16
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"id": "database/advancedqueries",
|
|
@@ -723,6 +723,28 @@ The strategy above only *verifies* — your browser still has to *send* the toke
|
|
|
723
723
|
|
|
724
724
|
This is **required when the api is a separate origin** (the common case — `api.example.com` vs your web origin): there is no shared cookie and no upgrade header, so without `headers` every subscription connects **anonymous** (the shell renders, but user-/tenant-scoped data stays empty). A static object works for non-rotating tokens; never hardcode a secret literal — it ships to the browser.
|
|
725
725
|
|
|
726
|
+
### Declaratively — `apis.<name>.authHeaders` in `app.config.ts`
|
|
727
|
+
|
|
728
|
+
Rather than hand-writing a `mount()` entry just to inject the thunk, declare it on the api in `app.config.ts`. The framework owns the client mount, the SSR-null case (the resolver runs browser-only — it never fires on the server), and the re-resolve on every reconnect; you supply only the token function:
|
|
729
|
+
|
|
730
|
+
```ts
|
|
731
|
+
// app.config.ts
|
|
732
|
+
export default {
|
|
733
|
+
type: 'web' as const,
|
|
734
|
+
apis: {
|
|
735
|
+
api: {
|
|
736
|
+
package: '@app/api',
|
|
737
|
+
// Resolved fresh on every (re)connect — a rotating token is pulled anew:
|
|
738
|
+
authHeaders: async () => ({
|
|
739
|
+
authorization: `Bearer ${(await supabase.auth.getSession()).data.session?.access_token ?? ''}`,
|
|
740
|
+
}),
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
}
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
`authHeaders` is a **function**, so it is imported from `app.config.ts` into the client bundle rather than serialized — the file must stay browser-safe (no `node:*` / server-only value imports; the env schema and other pure config are fine). It supersedes a static `headers` on the same api. This is the preferred form; reach for a hand-written `mount()` only when you need to wrap the tree in your own provider as well.
|
|
747
|
+
|
|
726
748
|
## WorkOS
|
|
727
749
|
|
|
728
750
|
```ts
|
|
@@ -21,9 +21,9 @@ The dispatcher routes `voltro <command> [args]` to the matching subcommand and p
|
|
|
21
21
|
|---|---|
|
|
22
22
|
| [Scaffolding](/docs/cli/scaffolding) | `create-project`, `add-app`, `list-templates` |
|
|
23
23
|
| Packages | `package` (`create` / `publishable` / `private` / `status`), `create-package` |
|
|
24
|
-
| [Dev](/docs/cli/dev) | `dev`, `codegen`, `agents-md` |
|
|
24
|
+
| [Dev](/docs/cli/dev) | `dev`, `codegen`, `agents-md`, [`env`](/docs/cli/env) (`check` / `sync` / `types` / `turbo`), [`generate`](/docs/cli/scaffolding) (AI app-builder), `dashboard` (serve the DevTools dashboard standalone; `--port`, `VOLTRO_DASHBOARD_APPS`) |
|
|
25
25
|
| [Build & run](/docs/cli/build-and-start) | `build`, `start`, `serve` |
|
|
26
|
-
| Deploy | `deploy` (`plan` — auto-detect the target tier per app + function), [`serverless`](/docs/deployment/serverless-functions) (`list` / `dev` / `serve` / `build` / `deploy`), [`static`](/docs/deployment/static-sites) (`hosts` / `deploy`) |
|
|
26
|
+
| Deploy | `deploy` (`plan` — auto-detect the target tier per app + function), [`serverless`](/docs/deployment/serverless-functions) (`list` / `dev` / `serve` / `build` / `deploy`), [`static`](/docs/deployment/static-sites) (`hosts` / `deploy`), `dormancy` (single-node scale-to-zero: fronts the app, stops it when idle, wakes on the next request; `--idle-grace-ms` / `--tick-ms`) |
|
|
27
27
|
| [Database](/docs/cli/migrate) | `migrate`, `db` (`plan` / `apply` / `plans` / `drift` / `squash` / `restore-snapshot` / `migrate` / `rollback` / `status` / `seed`) |
|
|
28
28
|
| [Update](/docs/cli/update) | `update` (`--to` / `--dry-run` / `--force` / `--exact`) — bump every `@voltro/*`, install, run the codemods that adapt your source to the new version |
|
|
29
29
|
| [Data transfer](/docs/cli/data) | `data` (`export` / `import` / `unpack` / `inspect` / `backup` / `restore`) — directory + single-file `.vbundle` bundles, streaming assets, masking, at-rest encryption |
|
|
@@ -1125,12 +1125,44 @@ voltro inspect rpc # procedures + workflow descript
|
|
|
1125
1125
|
voltro inspect metrics # rolling rpc latency buckets
|
|
1126
1126
|
voltro inspect cache # web data-cache stats (web apps only)
|
|
1127
1127
|
voltro inspect schedules # cron registrations + coordination mode
|
|
1128
|
+
voltro inspect schedules --failing # only broken crons — exits 1 if any (see below)
|
|
1128
1129
|
voltro inspect aggregates # materialised aggregate views
|
|
1129
1130
|
voltro inspect invoke --tag users.list --input '{}' # call a procedure over HTTP
|
|
1130
1131
|
```
|
|
1131
1132
|
|
|
1132
1133
|
Flags on every subcommand: `--process <name>` narrows to one api; `--format pretty|json` (default `pretty`). `invoke` additionally takes `--tag <procedureTag>` and `--input <json>`. With no subcommand it prints the endpoint map + the live processes it can reach.
|
|
1133
1134
|
|
|
1135
|
+
### `schedules --failing` — is any cron actually broken?
|
|
1136
|
+
|
|
1137
|
+
A schedule fires unattended: there is no user watching it fail. The plain
|
|
1138
|
+
`schedules` listing answers *which crons exist and when they fire next* — never
|
|
1139
|
+
whether they **work**. `--failing` rolls each schedule's recent runs
|
|
1140
|
+
(`/_voltro/inspect/schedules/runs`) into a verdict and prints only the broken
|
|
1141
|
+
ones:
|
|
1142
|
+
|
|
1143
|
+
```bash
|
|
1144
|
+
$ voltro inspect schedules --failing
|
|
1145
|
+
# schedules @api
|
|
1146
|
+
sprint.report 0 2 * * * FAILING x87
|
|
1147
|
+
every recorded run failed (last 20)
|
|
1148
|
+
Workflow "sprint.report" was started with an invalid payload. missing required field(s): teamId
|
|
1149
|
+
```
|
|
1150
|
+
|
|
1151
|
+
It **exits 1 when anything is failing**, so it works as a post-deploy gate and
|
|
1152
|
+
not only as something someone remembers to run:
|
|
1153
|
+
|
|
1154
|
+
```bash
|
|
1155
|
+
voltro inspect schedules --failing || echo "broken cron — do not promote"
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
A trailing success ends a streak (a recovered job is not reported), and
|
|
1159
|
+
`skipped` / `missed` runs are ignored — those are coordination outcomes (another
|
|
1160
|
+
pod took the tick, the process was down), not handler verdicts. A schedule that
|
|
1161
|
+
has never run is not "failing".
|
|
1162
|
+
|
|
1163
|
+
Pair it with `voltro logs --level error`: a failing handler now logs at **error**
|
|
1164
|
+
level, so the two surfaces agree.
|
|
1165
|
+
|
|
1134
1166
|
## The inspect HTTP surface
|
|
1135
1167
|
|
|
1136
1168
|
Every `voltro dev` / `voltro start` instance exposes a read-only introspection surface under `/_voltro/inspect/*`. The [Voltro Dashboard](/docs/observability/dashboard) consumes it to render the route sitemap, RPC list, subscription panel, workflow runs, and metrics. You can also hit the endpoints directly with `curl` (the `voltro inspect` subcommands above are the thin wrapper over exactly these).
|
|
@@ -1235,6 +1267,18 @@ voltro traces --errors --format json | jq '.traces[]'
|
|
|
1235
1267
|
|
|
1236
1268
|
Workflow: `voltro traces --errors --format json` to find a failure, then `voltro logs --trace <id> --format json` for the full chain (frontend → api → api, in order). The full flag set (`--min-duration`, `--status`, `--process`, …) lives in [Traces & logs from the shell](/docs/observability/cli).
|
|
1237
1269
|
|
|
1270
|
+
## `voltro cluster`
|
|
1271
|
+
|
|
1272
|
+
`voltro cluster status` gives a clustering snapshot of every running api — one row per instance with its `replicaId`, runner address, dialect, CDC flavour, coordination mode, and `server_id`, plus a flag for any SQL runner stuck on `localhost` (a common misconfiguration that silently breaks cross-instance work). Use it to confirm a multi-instance deployment actually formed a cluster rather than N isolated nodes.
|
|
1273
|
+
|
|
1274
|
+
```bash
|
|
1275
|
+
voltro cluster status # pretty table across every running api
|
|
1276
|
+
voltro cluster status --json # machine-readable (also --format json)
|
|
1277
|
+
voltro cluster status --process api # narrow to one named process
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
Like the rest of the inspect family it reads the live `/_voltro/inspect/*` surface, so an api has to be running.
|
|
1281
|
+
|
|
1238
1282
|
## `voltro test`
|
|
1239
1283
|
|
|
1240
1284
|
```bash
|
|
@@ -2052,3 +2096,43 @@ Use `voltro db apply` (the declarative diff), not `voltro db migrate` (the imper
|
|
|
2052
2096
|
|
|
2053
2097
|
- [Migrate](/docs/cli/migrate) — schema changes end-to-end
|
|
2054
2098
|
- [Build & start](/docs/cli/build-and-start) — production paths
|
|
2099
|
+
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
---
|
|
2103
|
+
|
|
2104
|
+
<!-- source: en/cli/env.md -->
|
|
2105
|
+
## Env
|
|
2106
|
+
|
|
2107
|
+
_voltro env — inspect and sync the typed-env manifest; emit env.generated.d.ts so CI tsc knows your env vars._
|
|
2108
|
+
|
|
2109
|
+
`voltro env` works with the **typed-env manifest** — the set of environment variables your app declares (via `envVar(...)`) and the framework requires. It's how a missing or malformed env var becomes a boot-time error with a clear message instead of a mysterious runtime failure.
|
|
2110
|
+
|
|
2111
|
+
```bash
|
|
2112
|
+
voltro env # check (default) — validate the current env against the manifest
|
|
2113
|
+
voltro env check # explicit
|
|
2114
|
+
voltro env sync # write/update .env.example from the manifest
|
|
2115
|
+
voltro env types # emit env.generated.d.ts (typed process.env for CI tsc)
|
|
2116
|
+
voltro env turbo # emit the turbo globalEnv/globalPassThroughEnv list
|
|
2117
|
+
```
|
|
2118
|
+
|
|
2119
|
+
## `check` (default)
|
|
2120
|
+
|
|
2121
|
+
Validates the current environment against the declared manifest and fails (non-zero) on a missing required var or a value that doesn't parse. Run it in a pre-deploy step to catch a misconfigured environment before the app boots. Run with no subcommand and it defaults to `check`.
|
|
2122
|
+
|
|
2123
|
+
## `sync` — keep `.env.example` honest
|
|
2124
|
+
|
|
2125
|
+
Writes `.env.example` from the manifest so the committed template always matches what the app actually reads — new `envVar(...)` declarations show up without hand-editing.
|
|
2126
|
+
|
|
2127
|
+
## `types` — typed `process.env` for CI
|
|
2128
|
+
|
|
2129
|
+
Emits `env.generated.d.ts`, which types `process.env` to your declared vars. Commit it (or generate it in CI before `tsc`) so a typo in a `process.env.MY_VAR` reference is a type error, not a runtime `undefined`.
|
|
2130
|
+
|
|
2131
|
+
## `turbo` — monorepo cache correctness
|
|
2132
|
+
|
|
2133
|
+
Emits the `globalEnv` / `globalPassThroughEnv` entries for `turbo.json`, so Turbo's cache invalidates when a relevant env var changes and passes the right vars through to tasks.
|
|
2134
|
+
|
|
2135
|
+
## Related
|
|
2136
|
+
|
|
2137
|
+
- [Secrets](/docs/cli/overview#common-env-vars) — generating secret values with `voltro secret`.
|
|
2138
|
+
- [Configuration](/docs/configuration) — declaring env vars with `envVar(...)`.
|
|
@@ -315,6 +315,8 @@ export default defineExecutor(notesSummary, async (_input, ctx) => {
|
|
|
315
315
|
|
|
316
316
|
It's a runtime identity (returns the handler unchanged) — the whole value is the compile check. The Effect error and requirement channels stay inferred; only the success value is constrained. A **descriptor-return** (reactive) executor is allowed through unchecked: the store produces the rows, so a value-level return type can't express the row-vs-`output` check. `defineExecutor` works the same for `defineMutation` / `defineAction` handlers.
|
|
317
317
|
|
|
318
|
+
> **Composing one executor inside another (e.g. a workflow `step`).** The value `defineExecutor` returns is typed as the `ExecutorReturn` UNION (`Output | Promise | Effect | descriptor-return`), so it has no `.pipe` — you can't feed the wrapped default export straight into another Effect. Export the handler's raw `Effect` separately (a named `export const execute = …`, or import the un-wrapped function) and compose THAT; keep the `defineExecutor`-wrapped default only as the procedure's entry point. The union return is deliberate — it's what lets one helper type every executor shape — so this is a "import the raw effect for composition" convention, not a gap to route around.
|
|
319
|
+
|
|
318
320
|
## Auto-optimistic source
|
|
319
321
|
|
|
320
322
|
`source` also connects query caches to mutation `target` metadata:
|
|
@@ -1577,6 +1579,61 @@ This is the Stripe-style contract — the **client** opts in by sending the head
|
|
|
1577
1579
|
- **Inbound webhooks already dedup** via [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) (provider key + `_voltro_webhook_*`) — don't double-cover them.
|
|
1578
1580
|
- **Atomic claim, non-atomic completion.** Two concurrent same-key requests resolve to exactly one execution (the `UNIQUE(scope,key)` insert is the arbiter). But the cached response isn't committed in the handler's own transaction — a crash between the handler committing and the record flipping to `completed` leaves the key in-flight (a retry `409`s until the TTL lapses, then re-runs). REST handlers aren't auto-transactional, so this is the honest ceiling.
|
|
1579
1581
|
|
|
1582
|
+
## Projecting an existing procedure — `publicApi`
|
|
1583
|
+
|
|
1584
|
+
You often want to *offer* an API you don't consume from your own frontend. When the procedure already exists as a query / mutation / action, you don't need to rewrite it as a REST route — annotate it with `publicApi` and the framework mounts ONE HTTP route that runs the **same** handler, under the same guards:
|
|
1585
|
+
|
|
1586
|
+
```ts
|
|
1587
|
+
// queries/absenceRequests.list.query.ts
|
|
1588
|
+
export default defineQuery({
|
|
1589
|
+
name: 'absenceRequests.list',
|
|
1590
|
+
input: Schema.Struct({ status: Schema.optional(Schema.String), limit: Schema.optional(Schema.Number) }),
|
|
1591
|
+
output: Schema.Array(AbsenceRequest),
|
|
1592
|
+
guards: [requireScope('absences:read')],
|
|
1593
|
+
publicApi: {}, // → GET /v1/absenceRequests/list?status=open&limit=20
|
|
1594
|
+
})
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
- **Method** derives from the kind: query → `GET`, mutation / action → `POST` (override with `method`).
|
|
1598
|
+
- **Path** derives from the tag: `/<version>/<tag-as-path>` (override with `path`; set `version`).
|
|
1599
|
+
- **Input binding** follows the method: for `GET` the descriptor's `input` schema is bound to the **query string**, otherwise to the JSON **body**. So filter and pagination fields are plain URL params — no separate input shape.
|
|
1600
|
+
- **Relations** need nothing extra: eager loading is resolved server-side by the executor, so `include` works identically over HTTP.
|
|
1601
|
+
- **Authorization is the same code** as the WebSocket path — the declarative `guards:`, the row filter, and tenant scoping all run before the handler. A procedure that denies on the socket denies here.
|
|
1602
|
+
- Also available per endpoint: `scopes` (extra API-key scopes), `rateLimit`, `idempotent`.
|
|
1603
|
+
|
|
1604
|
+
This pairs with [`crud.list`](/docs/data/crud): `filter` / `paginate` / `sort` / `include` on the executor plus `publicApi: {}` on the descriptor is a complete, filtered, paginated REST list endpoint in one declaration.
|
|
1605
|
+
|
|
1606
|
+
Use `publicApi` when the procedure already exists and the derived URL is fine; use `defineRestRoute` when you need a hand-shaped URL, path params, or a response that isn't the procedure's output.
|
|
1607
|
+
|
|
1608
|
+
### Live updates over HTTP — `stream: 'sse'`
|
|
1609
|
+
|
|
1610
|
+
A third party that can't open your WebSocket can still follow changes: `stream: 'sse'` on a QUERY projects it as Server-Sent Events — the initial `snapshot`, then a `delta` per change, until the client disconnects.
|
|
1611
|
+
|
|
1612
|
+
```ts
|
|
1613
|
+
export default defineQuery({
|
|
1614
|
+
name: 'orders.live',
|
|
1615
|
+
input: Schema.Struct({ status: Schema.optional(Schema.String) }),
|
|
1616
|
+
output: Schema.Array(Order),
|
|
1617
|
+
guards: [requireScope('orders:read')],
|
|
1618
|
+
publicApi: { stream: 'sse' }, // → GET /v1/orders/live?status=open (text/event-stream)
|
|
1619
|
+
})
|
|
1620
|
+
```
|
|
1621
|
+
|
|
1622
|
+
```js
|
|
1623
|
+
// any EventSource client — no Voltro SDK needed
|
|
1624
|
+
const es = new EventSource('/v1/orders/live?status=open')
|
|
1625
|
+
es.addEventListener('snapshot', (e) => setRows(JSON.parse(e.data).data))
|
|
1626
|
+
es.addEventListener('delta', (e) => applyDelta(JSON.parse(e.data)))
|
|
1627
|
+
```
|
|
1628
|
+
|
|
1629
|
+
Each event's `_tag` becomes the SSE `event:` name, so a client listens per kind instead of switching on a payload field. The framing handles the details that bite otherwise: embedded newlines are split across `data:` lines (a raw `\n` would truncate the event), a `retry:` hint is sent, and a keep-alive comment goes out every 15s so proxies don't drop an idle stream.
|
|
1630
|
+
|
|
1631
|
+
Same guarantees as the WebSocket path, because it is the same code: the declarative `guards:`, the row filter and tenant scoping all run before anything is emitted, and the client's disconnect tears the subscription down (including a disconnect *during* setup). A guard denial arrives as one `error` event rather than an HTTP status — by then the response headers are already sent.
|
|
1632
|
+
|
|
1633
|
+
`stream: 'sse'` on a mutation or action is ignored: there is nothing to subscribe to.
|
|
1634
|
+
|
|
1635
|
+
For a hand-written `defineRestRoute`, the same machinery is available directly — return `sse((emit) => unsubscribe)` from the handler and frame events with `sseFrame(event, data)` (both from `@voltro/protocol/rest`).
|
|
1636
|
+
|
|
1580
1637
|
## REST route vs Action
|
|
1581
1638
|
|
|
1582
1639
|
Both are unary request/response. Pick by transport + audience:
|
|
@@ -1947,6 +2004,120 @@ When no analytics sink is configured the framework provides the no-op sink: the
|
|
|
1947
2004
|
|
|
1948
2005
|
|
|
1949
2006
|
|
|
2007
|
+
---
|
|
2008
|
+
|
|
2009
|
+
<!-- source: en/data/crud.md -->
|
|
2010
|
+
## CRUD helpers
|
|
2011
|
+
|
|
2012
|
+
_crud.* secure-default handler helpers — tenant-scoped reads, column redaction, and null-not-throw getById, so the CRUD tail of a handler is one honest line._
|
|
2013
|
+
|
|
2014
|
+
Most of a plain list / get / create / update / delete handler is the same five lines every time — and getting those lines subtly wrong is how data leaks. The `crud.*` helpers from `@voltro/runtime` give you the **executor** with the secure defaults baked in; you still write the descriptor (schemas + `guards`), which is where the browser-safe wire contract and the authorization live.
|
|
2015
|
+
|
|
2016
|
+
```ts
|
|
2017
|
+
// accounts.list.query.server.ts
|
|
2018
|
+
import { crud } from '@voltro/runtime'
|
|
2019
|
+
|
|
2020
|
+
export default crud.list('accounts', { redact: ['apiSecret'] })
|
|
2021
|
+
```
|
|
2022
|
+
|
|
2023
|
+
```ts
|
|
2024
|
+
// accounts.list.query.ts — the descriptor stays hand-written + browser-safe
|
|
2025
|
+
import { defineQuery } from '@voltro/protocol'
|
|
2026
|
+
import { Schema } from 'effect'
|
|
2027
|
+
|
|
2028
|
+
export default defineQuery({
|
|
2029
|
+
name: 'accounts.list',
|
|
2030
|
+
input: Schema.Struct({}),
|
|
2031
|
+
// note: the wire output OMITS apiSecret, so it never reaches the client
|
|
2032
|
+
output: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })),
|
|
2033
|
+
})
|
|
2034
|
+
```
|
|
2035
|
+
|
|
2036
|
+
## What the defaults bake in
|
|
2037
|
+
|
|
2038
|
+
- **Tenant scope.** `crud.list` and `crud.getById` read through `ctx.store`, which auto-scopes a `tenant()` table. They never call `.unscoped()`, so a cross-tenant read is impossible through them — `payslips.list` cannot return another tenant's rows.
|
|
2039
|
+
- **Redaction.** A column a generated read must never ship — a credential, a token hash, a salary — is stripped from every returned row (reads and the row a `create` / `update` echoes). Two sources: a column marked [`.serverOnly()`](/docs/database/sensitivity) is stripped **automatically** (declare the exposure policy once at the schema and every crud read respects it — the single-source form), plus the per-call `redact: [...]` option for anything not worth a schema marker. Declare the same omission in the descriptor's `output` schema so the column never reaches the client at all; the helper is the runtime guarantee that it doesn't, whatever the schema says.
|
|
2040
|
+
- **`getById` returns `null`, never throws.** A reactive getter that throws takes its shared-WebSocket siblings down with it. `crud.getById` resolves `null` for an absent row.
|
|
2041
|
+
|
|
2042
|
+
## The helpers
|
|
2043
|
+
|
|
2044
|
+
| Helper | Executor it returns |
|
|
2045
|
+
|---|---|
|
|
2046
|
+
| `crud.list(table, { redact? })` | tenant-scoped list of every row, redacted |
|
|
2047
|
+
| `crud.getById(table, { redact? })` | one row by `input.id`, or `null` — redacted |
|
|
2048
|
+
| `crud.create(table, { redact? })` | insert `input`; id/tenant/audit auto-stamped; echoes the redacted row |
|
|
2049
|
+
| `crud.update(table, { redact? })` | patch `{ id, ...patch }`; returns the updated row or `null` |
|
|
2050
|
+
| `crud.remove(table)` | delete `input.id`; returns `{ deleted }` |
|
|
2051
|
+
| `crud.count(table, { filter? })` | `COUNT(*)` of the filtered, tenant-scoped set — the total for page-based UIs |
|
|
2052
|
+
|
|
2053
|
+
`redactColumns(rows, cols)` is exported standalone for a hand-written handler that isn't plain CRUD but still needs to redact declaratively.
|
|
2054
|
+
|
|
2055
|
+
## `crud.list` read ergonomics — filter, sort, paginate, include
|
|
2056
|
+
|
|
2057
|
+
A generated list isn't limited to "all rows". `crud.list` takes the ergonomics every real list view needs — all optional and additive on top of `redact`:
|
|
2058
|
+
|
|
2059
|
+
```ts
|
|
2060
|
+
export default crud.list('absenceRequests', {
|
|
2061
|
+
filter: (input) => ({ employeeId: input.employeeId, status: input.status }), // → WHERE
|
|
2062
|
+
paginate: true, // ?page=3&pageSize=20 (or ?limit=20&offset=40)
|
|
2063
|
+
sort: [{ column: 'createdAt', direction: 'desc' }], // multi-column
|
|
2064
|
+
include: { employee: { with: { team: true } } }, // eager relations, nested filter/sort
|
|
2065
|
+
redact: ['internalNote'],
|
|
2066
|
+
})
|
|
2067
|
+
```
|
|
2068
|
+
|
|
2069
|
+
- **`filter`** maps the request input to a `WHERE` — return a column→value map; an `undefined` field is ignored (an absent filter param is a no-op). Applied through the tenant-scoped `.where`.
|
|
2070
|
+
- **`paginate: true`** accepts BOTH paging styles, so a caller uses whichever its UI thinks in: **`page`** (1-based) + **`pageSize`** (default 100), or **`limit`** / **`offset`** (defaults 100 / 0). `page` wins when both are sent, and a `page` below 1 clamps to the first page rather than producing a negative offset. For the total a page-based UI needs ("page 3 of 12"), pair it with `crud.count` — see below.
|
|
2071
|
+
- **`maxPageSize`** caps how many rows ONE request may ask for (default **1000**). The page size is caller-controlled, so without a cap `?limit=1000000` is a one-request read of the whole table — and with `publicApi` that caller is anyone who can reach the URL. Values above the cap are clamped, not rejected; raise it deliberately for an export-style endpoint.
|
|
2072
|
+
- **`sort`** is a multi-column `orderBy`, applied in order.
|
|
2073
|
+
- **`include`** is the SAME spec [`.with(...)`](/docs/database/relations) takes, so nested relations and per-branch `where` / `orderBy` / `limit` (nested filtering and sort) all work. `getById` takes `include` too.
|
|
2074
|
+
|
|
2075
|
+
|
|
2076
|
+
Pass `crud.count` the **same** `filter` as the list (share the option object) so the total and the pages can't disagree about which rows they mean. It ignores paging fields on the input — it counts the whole filtered set, not the current page:
|
|
2077
|
+
|
|
2078
|
+
```ts
|
|
2079
|
+
// the total, for rendering "page 3 of 12" — SAME filter as the list
|
|
2080
|
+
export default crud.count('absenceRequests', {
|
|
2081
|
+
filter: (input) => ({ employeeId: input.employeeId, status: input.status }),
|
|
2082
|
+
})
|
|
2083
|
+
```
|
|
2084
|
+
|
|
2085
|
+
Declare the filter / pagination fields in the descriptor's `input` schema so the client can pass them; the executor reads them off `input`.
|
|
2086
|
+
|
|
2087
|
+
**You don't need a `toView` projection layer** — the `output` schema already shapes the wire result. `crud.list` returns full rows, and on encode Effect **strips every column the output schema doesn't declare** (a tight `Schema.Struct({ id, name })` ships only `id` + `name`, whatever else the row holds), and a `timestampMs` field normalizes a `Date` to epoch ms. So select fields by naming them in `output`, and normalize dates with the wire field schemas — no per-table view function. (Renames are expressible via a Schema transform if you need them.)
|
|
2088
|
+
|
|
2089
|
+
### `columns` — don't even READ what you drop
|
|
2090
|
+
|
|
2091
|
+
The output schema stops a column reaching the client; `columns` stops it being read at all. Use it when a table carries something wide that a list view never shows — a long text body, a big `json()` blob:
|
|
2092
|
+
|
|
2093
|
+
```ts
|
|
2094
|
+
export default crud.list('articles', { columns: ['id', 'title', 'createdAt'] })
|
|
2095
|
+
// the large `body` is never SELECTed, transferred from the DB, or decoded
|
|
2096
|
+
```
|
|
2097
|
+
|
|
2098
|
+
`.serverOnly()` columns are removed from the projection automatically — they're stripped from the response anyway, so reading them is pure waste.
|
|
2099
|
+
|
|
2100
|
+
**Trap:** an eager `include` branch joins on a foreign key, so a projection that omits that FK breaks the relation. Keep the FK in `columns` when you also pass `include`.
|
|
2101
|
+
|
|
2102
|
+
## What they deliberately don't do — authorization
|
|
2103
|
+
|
|
2104
|
+
A guard runs *before* the executor, so gating lives on the **descriptor**, not the handler — an executor cannot gate itself. Keep every write descriptor guarded:
|
|
2105
|
+
|
|
2106
|
+
```ts
|
|
2107
|
+
export default defineMutation({
|
|
2108
|
+
name: 'accounts.create',
|
|
2109
|
+
input: AccountInput,
|
|
2110
|
+
output: Account,
|
|
2111
|
+
guards: [requireScope('accounts:write')], // ← the gate; crud.create does not add one
|
|
2112
|
+
})
|
|
2113
|
+
```
|
|
2114
|
+
|
|
2115
|
+
## Scope — why the schema is still hand-written
|
|
2116
|
+
|
|
2117
|
+
These helpers give you the secure **handler**, not schema derivation. Deriving the descriptor's `input`/`output` from the table automatically would need the table VALUE inside the descriptor file — and a descriptor is loaded value-level by the browser client, so importing a table there drags the store and driver into the browser bundle (the boundary guard aborts the boot; `rowSchema` is server-only for exactly this reason). Full schema-derivation, and a boot audit that fails when a `tenant()` table's list reads unscoped or a write goes ungated, are a separate planned pass — the handler defaults above are the part that ships browser-safe today and closes the leak class.
|
|
2118
|
+
|
|
2119
|
+
|
|
2120
|
+
|
|
1950
2121
|
---
|
|
1951
2122
|
|
|
1952
2123
|
<!-- source: en/data/subscribers.md -->
|
|
@@ -225,6 +225,38 @@ Precedence, exactly:
|
|
|
225
225
|
3. **`.safe()`** on an encrypted column suppresses the implied secret and copies
|
|
226
226
|
it verbatim — say this only when you have genuinely reviewed the plaintext.
|
|
227
227
|
|
|
228
|
+
## `.serverOnly()` — never to a client
|
|
229
|
+
|
|
230
|
+
A THIRD, independent axis. `.sensitive()` / `.safe()` are about **data export
|
|
231
|
+
masking**; `.encrypted()` is about **storage at rest**; `.serverOnly()` is about
|
|
232
|
+
**wire exposure** — a column marked `.serverOnly()` is read normally by server
|
|
233
|
+
code but must NEVER be serialized to a client:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
keyHash: text().serverOnly(), // an auth middleware verifies it; a client never sees it
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The three are orthogonal — a column can carry any combination:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
keyHash: text().serverOnly(), // a hash you never ship (not secret at rest — it IS the digest)
|
|
243
|
+
recoveryNote: text().encrypted(), // encrypted at rest, but the owner may read it → not serverOnly
|
|
244
|
+
apiToken: text().encrypted().serverOnly(), // secret at rest AND never to a client
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Enforcement: the [`crud.*` read helpers](/docs/data/crud) strip `.serverOnly()`
|
|
248
|
+
columns from every returned row **automatically** — you declare the exposure
|
|
249
|
+
policy once at the schema and can't forget it on a handler. For a hand-written
|
|
250
|
+
query, omit the column from the `output` schema (and don't put it in the returned
|
|
251
|
+
object) — and `voltro dev` **warns at boot**, naming the query and column, if a
|
|
252
|
+
wire-reachable query's output declares a `.serverOnly()` column of its `source`
|
|
253
|
+
table, so a secret can't ship on the wire by omission-mistake.
|
|
254
|
+
|
|
255
|
+
Distinct from `.encrypted()` on purpose: encryption at rest says nothing about
|
|
256
|
+
who may receive the plaintext — a private note you decrypt *for its owner* is a
|
|
257
|
+
valid case, so treating "encrypted" as "never to a client" would be wrong. State
|
|
258
|
+
the exposure policy explicitly.
|
|
259
|
+
|
|
228
260
|
## Worked example
|
|
229
261
|
|
|
230
262
|
```ts
|
|
@@ -1044,6 +1044,35 @@ The framework deliberately avoids the Prisma / TypeORM "auto-generated junction"
|
|
|
1044
1044
|
|
|
1045
1045
|
3. **Schema is explicit**. Looking at your `database/` directory tells you exactly which tables exist. No hidden auto-generated tables to chase down at migration time.
|
|
1046
1046
|
|
|
1047
|
+
## Writing links — `store.links`
|
|
1048
|
+
|
|
1049
|
+
Reconciling the set of links from one row (a post's tags, a user's orgs) by hand — read the current rows, work out which to insert and which to delete — is fiddly and easy to get wrong. A drop-all-then-reinsert shortcut loses data when two requests overlap and makes a reactive subscription on the junction churn *every* row even when nothing changed. `ctx.store.links(junctionTable, anchor)` does the diff for you:
|
|
1050
|
+
|
|
1051
|
+
```ts
|
|
1052
|
+
// anchor names the source column + id; the target column is auto-detected
|
|
1053
|
+
await ctx.store.links('org_memberships', { userId: user.id }).set(orgIds) // reconcile to exactly orgIds
|
|
1054
|
+
await ctx.store.links('org_memberships', { userId: user.id }).add([orgId]) // idempotent — no-op if already linked
|
|
1055
|
+
await ctx.store.links('org_memberships', { userId: user.id }).remove([orgId])
|
|
1056
|
+
const orgIds = await ctx.store.links('org_memberships', { userId: user.id }).list()
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
`set(targetIds)` writes only the **difference**: the missing links are inserted, the surplus deleted, and links that are already correct are left untouched — so a reactive consumer sees a change only for what actually changed, and it returns `{ added, removed }`. `add` and `remove` read first and act only on the genuine delta, so both are idempotent.
|
|
1060
|
+
|
|
1061
|
+
The **target column** is the junction's *other* `reference()` column — the one the anchor doesn't name. A junction with anything but exactly two reference columns is refused (write it by hand with `insertMany` / `deleteMany`). The writes go through the normal store path, so a junction that carries `tenant()` / `audit()` gets those columns stamped as usual.
|
|
1062
|
+
|
|
1063
|
+
`set` / `add` / `remove` manage only the two FK columns. For a junction that carries **per-row payload** — a membership `role`, a `capacity` value — use `setRows`, which diffs on the (source, target) pair AND updates the payload:
|
|
1064
|
+
|
|
1065
|
+
```ts
|
|
1066
|
+
await ctx.store.links('team_capacities', { teamId: team.id }).setRows([
|
|
1067
|
+
{ projectId: 'p1', capacity: 40 },
|
|
1068
|
+
{ projectId: 'p2', capacity: 20 },
|
|
1069
|
+
])
|
|
1070
|
+
// added rows inserted with payload, removed deleted, a surviving row whose payload
|
|
1071
|
+
// CHANGED is updated, an unchanged one is left untouched → { added, removed, updated }
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
Only rows whose payload actually differs are written, so a reactive consumer sees a change exactly where the data changed — the diff-based replacement for a drop-and-reinsert on a data-carrying junction. Payload is compared by strict per-column equality (scalars like `capacity` / `role`).
|
|
1075
|
+
|
|
1047
1076
|
## SQL shape
|
|
1048
1077
|
|
|
1049
1078
|
The framework emits an INNER JOIN through the junction:
|
|
@@ -178,13 +178,20 @@ useT('does.not.exist') // ✗ compile error — not a catalog key
|
|
|
178
178
|
|
|
179
179
|
It's almost pure type refinement — the runtime is the same `useT` / `useTFn` / `<T>` (plus a `.dynamic` escape, below), only the signatures narrow to your catalog. Requires the base catalog to be `as const` (so its message strings survive as literal types).
|
|
180
180
|
|
|
181
|
-
**Scope.** Simple `{name}` and single-argument `{count, number}` placeholders are extracted and required.
|
|
181
|
+
**Scope.** Simple `{name}` and single-argument `{count, number}` placeholders are extracted and required. A *nested* inline ICU message (`{count, plural, one {…} other {…}}` / `select`) is fully parsed: the **top-level arg** (`count`) AND a real var nested inside a branch are both required, while a branch's literal text is never mistaken for a var. So for `'{count, plural, one {# blocker in {discipline}} other {# blockers in {discipline}}}'`, `t('blockers', { count, discipline })` is required — omit `discipline` and it's a compile error, not a render-time throw:
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
t('blockers', { count: n, discipline }) // ✓ both required — discipline lives inside the branches
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`<T>` gets a typed key with loose values, because its rich-text `<tag>` renderers can't be modelled by placeholder extraction.
|
|
182
188
|
|
|
183
189
|
### Runtime-computed keys — `t.dynamic`
|
|
184
190
|
|
|
185
191
|
For a key you build at runtime, do NOT cast it to the catalog key union — that union spans placeholder-bearing keys, so the call then demands a spurious 2nd ICU arg. Use `t.dynamic`, a plain-string escape with no forced args, on both `useT` and the `useTFn()` result:
|
|
186
192
|
|
|
187
193
|
```tsx
|
|
194
|
+
import { useTFn } from '@app/messages' // your createTypedMessages() barrel — `.dynamic` lives on these
|
|
188
195
|
const t = useTFn()
|
|
189
196
|
t.dynamic(`status.${row.state}`) // ✓ plain string, no forced arg
|
|
190
197
|
t.dynamic(`greeting.${kind}`, { name }) // values still allowed, but loose
|
|
@@ -985,6 +985,41 @@ export default (input: { prompt: string }) =>
|
|
|
985
985
|
})
|
|
986
986
|
```
|
|
987
987
|
|
|
988
|
+
### Writing a wrapper that PROVIDES a service
|
|
989
|
+
|
|
990
|
+
A helper that provides a service must be generic over `R` and **subtract** the tag
|
|
991
|
+
it provides, or it silently narrows what callers may pass it. The failure mode is
|
|
992
|
+
confusing because it shows up at the CALL site, not in the wrapper:
|
|
993
|
+
|
|
994
|
+
```ts
|
|
995
|
+
import { Context, Effect } from 'effect'
|
|
996
|
+
|
|
997
|
+
class Tenant extends Context.Tag('Tenant')<Tenant, { readonly id: string }>() {}
|
|
998
|
+
|
|
999
|
+
// WRONG — `R` defaults to `never`, so this only accepts effects that need
|
|
1000
|
+
// nothing else. Pass it an effect that also needs `Db` and it stops compiling.
|
|
1001
|
+
const withTenantBad = <A, E>(id: string, effect: Effect.Effect<A, E>) =>
|
|
1002
|
+
Effect.provideService(effect, Tenant, { id })
|
|
1003
|
+
|
|
1004
|
+
// RIGHT — generic over `R`, and the return type SUBTRACTS the tag it provided.
|
|
1005
|
+
const withTenant = <A, E, R>(
|
|
1006
|
+
id: string,
|
|
1007
|
+
effect: Effect.Effect<A, E, R>,
|
|
1008
|
+
): Effect.Effect<A, E, Exclude<R, Tenant>> =>
|
|
1009
|
+
Effect.provideService(effect, Tenant, { id })
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
`Exclude<R, Tenant>` is what makes the wrapper composable: the caller's remaining
|
|
1013
|
+
requirements pass through untouched, and only the tag you actually supplied
|
|
1014
|
+
disappears. Without it, the wrapper's own signature dictates the caller's entire
|
|
1015
|
+
requirement set.
|
|
1016
|
+
|
|
1017
|
+
The same rule applies to any callback the framework takes from you (an AI tool
|
|
1018
|
+
body, a media generator, a workflow step): declare its `R` as `unknown` rather
|
|
1019
|
+
than letting it default to `never`, then let the framework's own bridge discharge
|
|
1020
|
+
it. A callback typed `Effect<A, E, never>` cannot use ANY service, which is
|
|
1021
|
+
rarely what you meant.
|
|
1022
|
+
|
|
988
1023
|
## Context By Primitive
|
|
989
1024
|
|
|
990
1025
|
| Server file | Transaction | Typical store usage | External I/O | Returns |
|
|
@@ -793,6 +793,23 @@ Two consequences worth designing around:
|
|
|
793
793
|
a side effect), put it in an effect or use `useSubscription` — do not rely on
|
|
794
794
|
the loader firing a second time.
|
|
795
795
|
|
|
796
|
+
#### SSR first paint, then live — `initialSnapshot`
|
|
797
|
+
|
|
798
|
+
When a page wants the SSR-rendered data AND a live subscription, hand the loader's value to `useSubscription` as its `initialSnapshot`. The loader fetches the data on the server with `ctx.query`; the subscription shows that value at the first paint with `loading: false` — it is real server data, not a placeholder — then swaps to the live stream the instant its first snapshot arrives:
|
|
799
|
+
|
|
800
|
+
```tsx
|
|
801
|
+
export const loader = async ({ query }) => query('employees.me', {})
|
|
802
|
+
|
|
803
|
+
export default function Profile() {
|
|
804
|
+
const seed = useLoaderData<Employee>()
|
|
805
|
+
// First paint shows the SSR value; the WS stream takes over seamlessly.
|
|
806
|
+
const { data } = useSubscription<Employee>('app', 'employees.me', {}, { initialSnapshot: seed })
|
|
807
|
+
return <ProfileCard employee={data} />
|
|
808
|
+
}
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
Because the SSR markup and the hydration render read the same loader value, they match — no hydration flicker — and you don't hand-build a seed store to bridge the two. This is distinct from `fallback`, whose value never came from the server and so keeps `loading: true`; use exactly one of the two.
|
|
812
|
+
|
|
796
813
|
Client-side navigation is unchanged: moving to another route runs that route's
|
|
797
814
|
loaders in the browser as usual. A `spa` page has no server render, so its
|
|
798
815
|
loader runs on the client on first mount.
|
|
@@ -1308,6 +1308,37 @@ test('notes.summarise retries the LLM step', async () => {
|
|
|
1308
1308
|
|
|
1309
1309
|
The result includes `{ status, output, error, steps, runId }`. `runner.inspect(runId)` returns the assembled run later without re-running it.
|
|
1310
1310
|
|
|
1311
|
+
## A start with the wrong payload
|
|
1312
|
+
|
|
1313
|
+
`ctx.workflows.start(name, payload)` validates the payload against the workflow's
|
|
1314
|
+
`payload` schema **before** the engine sees it. A mismatch throws a
|
|
1315
|
+
`WorkflowPayloadError` naming three things:
|
|
1316
|
+
|
|
1317
|
+
```
|
|
1318
|
+
Workflow "sprint.report" was started with an invalid payload. missing required
|
|
1319
|
+
field(s): teamId. { readonly teamId: string } └─ ["teamId"] is missing
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
The error also carries them structurally — `workflowName`, `missingFields`,
|
|
1323
|
+
`_tag: 'WorkflowPayloadError'` — so a handler can branch on it.
|
|
1324
|
+
|
|
1325
|
+
This matters most where nobody is watching. A **cron** whose payload drifted from
|
|
1326
|
+
the workflow's schema fails on every single firing; the schedule run is recorded
|
|
1327
|
+
`failed` in `_voltro_schedule_runs` and the log line is an **error**, not a warn,
|
|
1328
|
+
so `voltro logs --level error` and any alert wired to it see it. A nightly job
|
|
1329
|
+
that has been dead since a refactor is the exact failure this pair of behaviours
|
|
1330
|
+
exists to surface.
|
|
1331
|
+
|
|
1332
|
+
An unknown workflow name lists the registered ones, so a rename reads differently
|
|
1333
|
+
from a deletion:
|
|
1334
|
+
|
|
1335
|
+
```
|
|
1336
|
+
Unknown workflow: sprint.reports. Registered workflows: billing.run, sprint.report.
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
Validation runs on the **decoded** value, so a `Schema.Date` payload accepts a
|
|
1340
|
+
`Date` — passing the already-domain-shaped value is correct and is not rejected.
|
|
1341
|
+
|
|
1311
1342
|
## Anti-patterns
|
|
1312
1343
|
|
|
1313
1344
|
- **Querying old table names.** Use `_voltro_workflow_runs`, `_voltro_workflow_run_steps`, and `_voltro_workflow_run_events`.
|