@voltro/cli 0.11.3 → 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 +173 -0
- package/THIRD-PARTY-NOTICES.md +4 -4
- package/dist/apiBuild-D9MtlD02.js +2 -0
- package/dist/{apiBuild-zFDv0u8y.js → apiBuild-DHHuiwXF.js} +4 -4
- package/dist/bin.js +13 -6
- package/dist/{commands-BDGYrBhK.js → commands-BwbMqR-1.js} +1982 -1854
- package/dist/dev-BsBsIv93.js +2 -0
- package/dist/{dev-CGt0PP1f.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-SRtv8KDy.js → inspectMetrics-D4EJ8LTi.js} +331 -323
- package/dist/{serveCommand-p6e5Ahgx.js → serveCommand-ARqSs73o.js} +397 -350
- package/dist/serveEntry.js +3 -3
- package/dist/{start-Crl39M38.js → start-DyJE6w1R.js} +281 -273
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/agent-docs/_manifest.json +1 -1
- package/templates/agent-docs/cli.md +86 -2
- package/templates/agent-docs/data.md +104 -1
- package/templates/agent-docs/database/misc.md +32 -0
- package/templates/agent-docs/database/querying.md +14 -1
- package/templates/agent-docs/internationalization.md +3 -8
- package/templates/agent-docs/reference.md +35 -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-BmPdmhgi.js +0 -2
- package/dist/dev-D_PGP9Kx.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",
|
|
@@ -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(...)`.
|
|
@@ -1579,6 +1579,61 @@ This is the Stripe-style contract — the **client** opts in by sending the head
|
|
|
1579
1579
|
- **Inbound webhooks already dedup** via [`@voltro/plugin-webhooks`](/docs/plugins/webhooks) (provider key + `_voltro_webhook_*`) — don't double-cover them.
|
|
1580
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.
|
|
1581
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
|
+
|
|
1582
1637
|
## REST route vs Action
|
|
1583
1638
|
|
|
1584
1639
|
Both are unary request/response. Pick by transport + audience:
|
|
@@ -1981,7 +2036,7 @@ export default defineQuery({
|
|
|
1981
2036
|
## What the defaults bake in
|
|
1982
2037
|
|
|
1983
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.
|
|
1984
|
-
- **Redaction.**
|
|
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.
|
|
1985
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.
|
|
1986
2041
|
|
|
1987
2042
|
## The helpers
|
|
@@ -1993,9 +2048,57 @@ export default defineQuery({
|
|
|
1993
2048
|
| `crud.create(table, { redact? })` | insert `input`; id/tenant/audit auto-stamped; echoes the redacted row |
|
|
1994
2049
|
| `crud.update(table, { redact? })` | patch `{ id, ...patch }`; returns the updated row or `null` |
|
|
1995
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 |
|
|
1996
2052
|
|
|
1997
2053
|
`redactColumns(rows, cols)` is exported standalone for a hand-written handler that isn't plain CRUD but still needs to redact declaratively.
|
|
1998
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
|
+
|
|
1999
2102
|
## What they deliberately don't do — authorization
|
|
2000
2103
|
|
|
2001
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:
|
|
@@ -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
|
|
@@ -1058,7 +1058,20 @@ const orgIds = await ctx.store.links('org_memberships', { userId: user.id }).lis
|
|
|
1058
1058
|
|
|
1059
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
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.
|
|
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`).
|
|
1062
1075
|
|
|
1063
1076
|
## SQL shape
|
|
1064
1077
|
|
|
@@ -178,18 +178,13 @@ 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.
|
|
182
|
-
|
|
183
|
-
To get that nested var **compiler-required**, split the plural into simple sibling keys and pick with a JS branch — each key is then a plain message where every var is top-level:
|
|
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:
|
|
184
182
|
|
|
185
183
|
```tsx
|
|
186
|
-
|
|
187
|
-
count === 1
|
|
188
|
-
? t('blocker.one', { count, discipline }) // ✓ both vars required
|
|
189
|
-
: t('blocker.other', { count, discipline })
|
|
184
|
+
t('blockers', { count: n, discipline }) // ✓ both required — discipline lives inside the branches
|
|
190
185
|
```
|
|
191
186
|
|
|
192
|
-
|
|
187
|
+
`<T>` gets a typed key with loose values, because its rich-text `<tag>` renderers can't be modelled by placeholder extraction.
|
|
193
188
|
|
|
194
189
|
### Runtime-computed keys — `t.dynamic`
|
|
195
190
|
|
|
@@ -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 |
|
|
@@ -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`.
|
|
@@ -11,16 +11,16 @@
|
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@effect/platform": "^0.96.1",
|
|
13
13
|
"@effect/rpc": "^0.75.1",
|
|
14
|
-
"@voltro/ai": "0.11.
|
|
15
|
-
"@voltro/cli": "0.11.
|
|
16
|
-
"@voltro/database": "0.11.
|
|
17
|
-
"@voltro/env": "0.11.
|
|
18
|
-
"@voltro/protocol": "0.11.
|
|
19
|
-
"@voltro/runtime": "0.11.
|
|
14
|
+
"@voltro/ai": "0.11.4",
|
|
15
|
+
"@voltro/cli": "0.11.4",
|
|
16
|
+
"@voltro/database": "0.11.4",
|
|
17
|
+
"@voltro/env": "0.11.4",
|
|
18
|
+
"@voltro/protocol": "0.11.4",
|
|
19
|
+
"@voltro/runtime": "0.11.4",
|
|
20
20
|
"effect": "^3.21.2"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@voltro/testing": "0.11.
|
|
23
|
+
"@voltro/testing": "0.11.4",
|
|
24
24
|
"typescript": "^5.7.0",
|
|
25
25
|
"vitest": "^3.0.0"
|
|
26
26
|
}
|
|
@@ -12,17 +12,17 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.11.
|
|
16
|
-
"@voltro/database": "0.11.
|
|
17
|
-
"@voltro/env": "0.11.
|
|
18
|
-
"@voltro/plugin-auth": "0.11.
|
|
19
|
-
"@voltro/protocol": "0.11.
|
|
20
|
-
"@voltro/runtime": "0.11.
|
|
21
|
-
"@voltro/sql-postgres": "0.11.
|
|
15
|
+
"@voltro/cli": "0.11.4",
|
|
16
|
+
"@voltro/database": "0.11.4",
|
|
17
|
+
"@voltro/env": "0.11.4",
|
|
18
|
+
"@voltro/plugin-auth": "0.11.4",
|
|
19
|
+
"@voltro/protocol": "0.11.4",
|
|
20
|
+
"@voltro/runtime": "0.11.4",
|
|
21
|
+
"@voltro/sql-postgres": "0.11.4",
|
|
22
22
|
"effect": "^3.21.2"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@voltro/testing": "0.11.
|
|
25
|
+
"@voltro/testing": "0.11.4",
|
|
26
26
|
"typescript": "^5.7.0",
|
|
27
27
|
"vitest": "^3.0.0"
|
|
28
28
|
}
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.11.
|
|
16
|
-
"@voltro/database": "0.11.
|
|
17
|
-
"@voltro/env": "0.11.
|
|
18
|
-
"@voltro/plugin-multitenancy": "0.11.
|
|
19
|
-
"@voltro/protocol": "0.11.
|
|
20
|
-
"@voltro/runtime": "0.11.
|
|
15
|
+
"@voltro/cli": "0.11.4",
|
|
16
|
+
"@voltro/database": "0.11.4",
|
|
17
|
+
"@voltro/env": "0.11.4",
|
|
18
|
+
"@voltro/plugin-multitenancy": "0.11.4",
|
|
19
|
+
"@voltro/protocol": "0.11.4",
|
|
20
|
+
"@voltro/runtime": "0.11.4",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.11.
|
|
24
|
+
"@voltro/testing": "0.11.4",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|
|
@@ -12,16 +12,16 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@effect/platform": "^0.96.1",
|
|
14
14
|
"@effect/rpc": "^0.75.1",
|
|
15
|
-
"@voltro/cli": "0.11.
|
|
16
|
-
"@voltro/database": "0.11.
|
|
17
|
-
"@voltro/env": "0.11.
|
|
18
|
-
"@voltro/plugin-deactivation": "0.11.
|
|
19
|
-
"@voltro/protocol": "0.11.
|
|
20
|
-
"@voltro/runtime": "0.11.
|
|
15
|
+
"@voltro/cli": "0.11.4",
|
|
16
|
+
"@voltro/database": "0.11.4",
|
|
17
|
+
"@voltro/env": "0.11.4",
|
|
18
|
+
"@voltro/plugin-deactivation": "0.11.4",
|
|
19
|
+
"@voltro/protocol": "0.11.4",
|
|
20
|
+
"@voltro/runtime": "0.11.4",
|
|
21
21
|
"effect": "^3.21.2"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@voltro/testing": "0.11.
|
|
24
|
+
"@voltro/testing": "0.11.4",
|
|
25
25
|
"typescript": "^5.7.0",
|
|
26
26
|
"vitest": "^3.0.0"
|
|
27
27
|
}
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@react-email/components": "^1.0.12",
|
|
14
14
|
"@react-email/render": "^1.4.0",
|
|
15
|
-
"@voltro/cli": "0.11.
|
|
16
|
-
"@voltro/database": "0.11.
|
|
17
|
-
"@voltro/env": "0.11.
|
|
18
|
-
"@voltro/plugin-mail": "0.11.
|
|
19
|
-
"@voltro/plugin-multitenancy": "0.11.
|
|
20
|
-
"@voltro/protocol": "0.11.
|
|
21
|
-
"@voltro/runtime": "0.11.
|
|
15
|
+
"@voltro/cli": "0.11.4",
|
|
16
|
+
"@voltro/database": "0.11.4",
|
|
17
|
+
"@voltro/env": "0.11.4",
|
|
18
|
+
"@voltro/plugin-mail": "0.11.4",
|
|
19
|
+
"@voltro/plugin-multitenancy": "0.11.4",
|
|
20
|
+
"@voltro/protocol": "0.11.4",
|
|
21
|
+
"@voltro/runtime": "0.11.4",
|
|
22
22
|
"effect": "^3.21.2",
|
|
23
23
|
"react": "^19.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@voltro/testing": "0.11.
|
|
26
|
+
"@voltro/testing": "0.11.4",
|
|
27
27
|
"typescript": "^5.7.0",
|
|
28
28
|
"vitest": "^3.0.0"
|
|
29
29
|
}
|