@voltro/cli 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +426 -0
  2. package/THIRD-PARTY-NOTICES.md +2 -2
  3. package/dist/{apiBuild-B2m4XK_8.js → apiBuild-BqhCSytw.js} +15 -15
  4. package/dist/apiBuild-N1R4V792.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/{commands-CaIhTsC2.js → commands-7EmYJ9Xg.js} +1774 -1577
  7. package/dist/dbCommand-CIrdFLp9.js +2 -0
  8. package/dist/{dbCommand-CC61CsAc.js → dbCommand-FUU12FvD.js} +265 -256
  9. package/dist/{dev-DPkQVUTP.js → dev-BvHT7WZa.js} +1 -1
  10. package/dist/{dev-NCuEhNxs.js → dev-MacSQ1Ll.js} +2019 -1626
  11. package/dist/fileConventions-3bffWssN.js +30 -0
  12. package/dist/frameworkTableAssembly-BsnCKzQ6.js +2 -0
  13. package/dist/{frameworkTableAssembly-sg41s14F.js → frameworkTableAssembly-Cw5zJz6n.js} +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/inspect-C9gjHwBk.js +2 -0
  16. package/dist/{inspect-_ldwsAwH.js → inspect-DuLUrZp9.js} +14 -1
  17. package/dist/{inspectMetrics-dcC7gnxu.js → inspectMetrics-EQwH7BI4.js} +15 -15
  18. package/dist/manifestBuild-BVwS1Z_6.js +2 -0
  19. package/dist/{manifestBuild-Bs1Uw22_.js → manifestBuild-Dneq4_Jx.js} +1 -1
  20. package/dist/serveCommand-5ZFiNO1R.js +1241 -0
  21. package/dist/serveEntry.js +2 -2
  22. package/dist/{start-C_4VUDT8.js → start-C-ZWSDpg.js} +2 -2
  23. package/dist/startEntry.js +2 -2
  24. package/package.json +17 -17
  25. package/templates/AGENTS.core.md +12 -5
  26. package/templates/AGENTS.md +14 -7
  27. package/templates/agent-docs/_index.md +2 -2
  28. package/templates/agent-docs/_manifest.json +3 -3
  29. package/templates/agent-docs/cli.md +15 -0
  30. package/templates/agent-docs/data.md +306 -2
  31. package/templates/agent-docs/database/migrations.md +40 -0
  32. package/templates/agent-docs/database/schema.md +80 -0
  33. package/templates/agent-docs/deployment.md +9 -0
  34. package/templates/agent-docs/plugins.md +1 -1
  35. package/templates/agent-docs/reference.md +2 -2
  36. package/templates/agent-docs/security.md +18 -0
  37. package/templates/agent-docs/templates/apibackends.md +5 -5
  38. package/templates/agent-docs/templates/overview.md +1 -1
  39. package/templates/agent-docs/whats-new.md +263 -169
  40. package/templates/agent-docs/workflows.md +44 -20
  41. package/templates/apps/api-ai/package.json +7 -7
  42. package/templates/apps/api-auth/package.json +8 -8
  43. package/templates/apps/api-backend/package.json +7 -7
  44. package/templates/apps/api-backend-deactivation/package.json +7 -7
  45. package/templates/apps/api-backend-mail/package.json +8 -8
  46. package/templates/apps/api-backend-mariadb/package.json +9 -9
  47. package/templates/apps/api-backend-storage/package.json +8 -8
  48. package/templates/apps/api-data-advanced/package.json +8 -8
  49. package/templates/apps/api-durable/README.md +2 -1
  50. package/templates/apps/api-durable/events/orders.event.ts +18 -0
  51. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +17 -15
  52. package/templates/apps/api-durable/package.json +8 -8
  53. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +6 -4
  54. package/templates/apps/api-feature-flags/package.json +9 -9
  55. package/templates/apps/api-governance/package.json +8 -8
  56. package/templates/apps/api-kv/package.json +8 -8
  57. package/templates/apps/api-moderation/package.json +8 -8
  58. package/templates/apps/api-observability/package.json +8 -8
  59. package/templates/apps/api-ratelimit/package.json +8 -8
  60. package/templates/apps/api-rbac/package.json +8 -8
  61. package/templates/apps/api-rest/package.json +7 -7
  62. package/templates/apps/api-saas/package.json +11 -11
  63. package/templates/apps/api-search/package.json +8 -8
  64. package/templates/apps/api-versioning/package.json +8 -8
  65. package/templates/apps/api-webhooks/README.md +2 -2
  66. package/templates/apps/api-webhooks/events/orders.event.ts +35 -0
  67. package/templates/apps/api-webhooks/package.json +9 -9
  68. package/templates/apps/api-webhooks/template.json +1 -1
  69. package/templates/apps/changelog/package.json +6 -6
  70. package/templates/apps/edge-functions/package.json +2 -2
  71. package/templates/apps/frontend-admin/package.json +8 -8
  72. package/templates/apps/frontend-app/package.json +8 -8
  73. package/templates/apps/frontend-blank/package.json +7 -7
  74. package/templates/apps/frontend-contact/package.json +7 -7
  75. package/templates/apps/frontend-dashboard/package.json +7 -7
  76. package/templates/apps/frontend-docs/package.json +7 -7
  77. package/templates/apps/frontend-i18n/package.json +6 -6
  78. package/templates/apps/frontend-landing/package.json +7 -7
  79. package/templates/apps/frontend-spa/package.json +7 -7
  80. package/templates/apps/frontend-ssr/package.json +7 -7
  81. package/templates/apps/frontend-ssr-api/package.json +8 -8
  82. package/templates/apps/frontend-static-blog/package.json +6 -6
  83. package/dist/apiBuild-UEM3QBke.js +0 -2
  84. package/dist/dbCommand-Cr__4ATv.js +0 -2
  85. package/dist/fileConventions-CBNPZVOV.js +0 -29
  86. package/dist/frameworkTableAssembly-DRJrAVr1.js +0 -2
  87. package/dist/inspect-BA67TF6v.js +0 -2
  88. package/dist/manifestBuild-i-fRHg_H.js +0 -2
  89. package/dist/serveCommand-C4gzDZzc.js +0 -1175
  90. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +0 -22
@@ -629,6 +629,86 @@ A mixin can also declare `requires: [otherMixin()]` (resolved depth-first) and `
629
629
 
630
630
 
631
631
 
632
+ ---
633
+
634
+ <!-- source: en/database/concurrency-and-expiry.md -->
635
+ ## Concurrency + expiry
636
+
637
+ _.version() for optimistic locking and expires() for time-limited rows — what each guarantees, on which dialect._
638
+
639
+ Two column-level tools for questions a schema cannot otherwise answer: **which write is newest**, and **when does this row stop counting**.
640
+
641
+ ## `.version()` — optimistic locking
642
+
643
+ Two clients read the same row and both write it. Without a version the second silently wins, and the first user's change is gone with no trace. That is the shape of every *"my edit disappeared"* report.
644
+
645
+ ```ts
646
+ export const documents = table('documents', {
647
+ id: id(),
648
+ title: text(),
649
+ version: integer().version(),
650
+ })
651
+ ```
652
+
653
+ From then on the store **increments** `version` on every update, and an update that carries an expectation fails when the row has moved on:
654
+
655
+ ```ts
656
+ // The client sends the version it read.
657
+ yield* ctx.store.update('documents', input.id, { title: input.title, version: input.version })
658
+ // → VersionConflict { expected: 3, actual: 7 } when four writes landed in between
659
+ ```
660
+
661
+ `VersionConflict` is a typed error, so it reaches the client typed and a UI can offer *reload and re-apply* instead of showing a crash. It carries **both** numbers, because "someone else changed it" is not actionable while "you had 3, it is now 7" is.
662
+
663
+ **Why not `updatedAt`.** A timestamp cannot do this job. Two writes in the same millisecond are indistinguishable, and across replicas the clocks disagree — a comparison that looks correct in a test loses rows under load. An integer the database owns is totally ordered and needs no clock. (`.version()` therefore rejects a `text()` or `timestamp()` column at declaration.)
664
+
665
+ **What it does not do.** It is not a history — it records *that* a row changed, not what to; use [`plugin-versioning`](/docs/plugins/versioning) for that. It is not a lock: a conflict is **reported**, never queued or merged, because merging two intents is a decision only your application can make. And it is not a retry — "re-apply my change on top of theirs" is correct for some changes and wrong for others, so you write it.
666
+
667
+ **Three details worth knowing:**
668
+
669
+ - The version a caller sends is an **expectation, never a write**. It is stripped from the patch, so a client cannot pin its own version and win every race.
670
+ - An update with **no** expectation is still last-write-wins — the default does not change — but the version **still advances**. A version that moved only for careful writers would be worse than none: it would sit still while a careless write changed the row.
671
+ - A row **deleted** underneath you is a conflict too, with `actual: null`. That is how you tell "deleted" from "changed".
672
+
673
+ ## `expires()` — a row with an end date
674
+
675
+ ```ts
676
+ export const inviteLinks = table('inviteLinks', {
677
+ id: id(),
678
+ email: text(),
679
+ }).with(expires())
680
+
681
+ await ctx.store.insert('inviteLinks', {
682
+ email,
683
+ expiresAt: new Date(Date.now() + 24 * 3_600_000),
684
+ })
685
+ ```
686
+
687
+ After that instant the row is **not returned by reads**. `expiresAt` is nullable and null means *never*, so adding the mixin to an existing table does not make its rows vanish.
688
+
689
+ Opt out for a deliberate read — an admin view, a grace-period check:
690
+
691
+ ```ts
692
+ ctx.store.select('inviteLinks').includeExpired()
693
+ ```
694
+
695
+ ### Read this before you rely on it
696
+
697
+ **Visibility and storage are two different guarantees, and only one of them holds everywhere.**
698
+
699
+ | | where | when |
700
+ | --- | --- | --- |
701
+ | **Invisible to reads** | every dialect | immediately, the instant it passes |
702
+ | **Physically deleted** | postgres only | eventually, by the retention sweep |
703
+
704
+ So an expired row is **invisible everywhere and still present in the database on four of five dialects**. That is the right trade — making visibility depend on the sweep would mean a row that vanished on postgres and kept serving on MariaDB — but it is a surprising one, and it matters: *do not treat an expired row as unreachable*. If the value must actually be gone, delete it, or do not store it in a row at all.
705
+
706
+ ## See also
707
+
708
+ [Column types](/docs/database/columns) · [Mutations](/docs/data/mutations) · [Soft delete](/docs/plugins/soft-delete)
709
+
710
+
711
+
632
712
  ---
633
713
 
634
714
  <!-- source: en/database/ids/index.md -->
@@ -956,6 +956,15 @@ Renaming `orders.total` → `orders.amount` under zero downtime is: add `amount`
956
956
  (expand) → backfill + dual-write → cut reads over → drop `total` (contract) — three
957
957
  releases, never one, so no in-flight pod ever references a column that isn't there.
958
958
 
959
+ **Make it a hard gate if you always rolling-deploy — `VOLTRO_ROLLING_DEPLOY=1`.**
960
+ The `db plan` ⚠ is advisory by default, because a maintenance-window / scale-to-zero
961
+ deploy has no overlap window and the framework can't tell which you run. If your
962
+ pipeline is *always* a rolling update, set `VOLTRO_ROLLING_DEPLOY=1` in the migration
963
+ Job's env: `voltro db apply` then **refuses** (exit 2) a plan containing a
964
+ rolling-unsafe operation instead of warning, so an un-split breaking change fails the
965
+ deploy rather than breaking pods at runtime. Override a specific apply with `--force`.
966
+ Unset (the default) leaves today's advisory behaviour untouched.
967
+
959
968
  ### If you DO run `voltro dev` in a cluster (dev / staging only)
960
969
 
961
970
  `voltro dev` binds a small **boot-health surface** on its own port so a probe can
@@ -70,7 +70,7 @@ Status legend: ✓ shipped · ◐ partial · — planned.
70
70
  | `@voltro/plugin-storage` | ✓ | File storage — public (CDN-direct) + private (access policy + per-object grants), S3 / R2 / GCS / MinIO / filesystem providers, presigned URLs, `listRefs` browse/search, HTTP Range (206) serving, dashboard browser |
71
71
  | `@voltro/plugin-postgis` | ✓ | Postgres-native `geography` / `geometry` columns + spatial predicates (`ST_DWithin`, `ST_Contains`, `ST_Intersects`); GiST indexes via `.expressionIndex(..., { kind: 'gist' })`. No `ST_Distance` projection yet. Postgres-only by design (fails loud elsewhere). [→ details](/docs/plugins/postgis) |
72
72
  | `@voltro/plugin-broadcast` | ✓ | Cross-replica reactivity — fans out app-mutation change events to every replica over a pub/sub bus (Redis / NATS). Closes the single-instance gap for every non-postgres dialect. [→ details](/docs/plugins/broadcast) |
73
- | `@voltro/plugin-webhooks` | ✓ | Incoming + outgoing webhooks — `defineIncomingWebhook` (signature verify + idempotency, Stripe/GitHub/Slack presets) and `defineOutgoingEvent` (durable delivery workflow, HMAC signing, retries, filters). [→ details](/docs/plugins/webhooks) |
73
+ | `@voltro/plugin-webhooks` | ✓ | Incoming + outgoing webhooks — `defineIncomingWebhook` (signature verify + idempotency, Stripe/GitHub/Slack presets) and `defineEvent` (durable delivery workflow, HMAC signing, retries, filters). [→ details](/docs/plugins/webhooks) |
74
74
  | `@voltro/plugin-auth-{workos,kinde,clerk,auth0,supabase,oidc}` | ✓ | Six IdP adapters over the shared `jwtBearerStrategy` — JWKS verify + claims→tenant mapping; WorkOS additionally ships hosted-login OAuth primitives (`workosAuthorizationUrl` / `workosAuthenticateWithCode`) for a redirect-based SSO login flow. [→ details](/docs/authentication/external-idp) |
75
75
  | `@voltro/plugin-analytics-postgres` | ✓ | First-party lite — events on the main DataStore, cross-dialect (postgres / mysql / mariadb / mssql / sqlite / turso). [→ details](/docs/plugins/analytics#voltroplugin-analytics-postgres) |
76
76
  | `@voltro/plugin-duckdb` | ✓ | Embedded DuckDB sidecar — real OLAP performance, no external service. [→ details](/docs/plugins/analytics#voltroplugin-duckdb) |
@@ -361,7 +361,7 @@ Events include lifecycle changes, timers, signals, and updates recorded by the w
361
361
 
362
362
  ## `useWorkflowDomainEvents(apiName, filters?, options?)`
363
363
 
364
- Subscribes to the **domain events** an app emitted through `ctx.events.emit(...)` — the business-event log behind [event triggers](/docs/workflows/event-triggers), not one run's internal timeline.
364
+ Subscribes to the **domain events** an app emitted through `ctx.events.publish(...)` — the business-event log behind [event triggers](/docs/workflows/event-triggers), not one run's internal timeline.
365
365
 
366
366
  ```tsx
367
367
  const { events } = useWorkflowDomainEvents('app', { name: 'order.paid', limit: 50 })
@@ -1131,7 +1131,7 @@ Templates come in **three kinds**, matching the three things you deploy:
1131
1131
  | [`api-rest`](/docs/templates/api-rest) | Public REST API — `defineRestRoute` (query/path/body, scope guards, Idempotency-Key) + `@voltro/plugin-openapi` (OpenAPI 3.1 spec + Swagger UI at `/docs`). Zero-infra boot. |
1132
1132
  | [`api-saas`](/docs/templates/api-saas) | The SaaS plugin bundle — billing entitlements + notifications + analytics + presence, wired turnkey; one `projects.create` exercises three together. Zero-infra boot. |
1133
1133
  | [`api-observability`](/docs/templates/api-observability) | Production-readiness — Prometheus `/metrics` + a custom counter, Sentry (inert without a DSN), tracing, and a `@voltro/testing` unit test (`voltro test`). Zero-infra boot. |
1134
- | [`api-webhooks`](/docs/templates/api-webhooks) | First-class webhooks both ways — a signature-verified incoming `*.webhook.tsx` receiver + an outgoing `defineOutgoingEvent` emitted via a durable signed delivery workflow. Zero-infra boot. |
1134
+ | [`api-webhooks`](/docs/templates/api-webhooks) | First-class webhooks both ways — a signature-verified incoming `*.webhook.tsx` receiver + an outgoing `defineEvent` emitted via a durable signed delivery workflow. Zero-infra boot. |
1135
1135
 
1136
1136
  ## Web frontends (`kind: web`)
1137
1137
 
@@ -39,6 +39,24 @@ This matters because a caller-supplied URL is ordinary product surface — a
39
39
  scraper, a webhook-registration form, an importer, a "test this connection"
40
40
  button. Those reach `HttpClient` with whatever the user typed.
41
41
 
42
+ ### Handling a refusal
43
+
44
+ A blocked request fails on the **error channel**, as an `HttpClientError.RequestError` whose `cause` is the `SsrfBlockedError`. So the handler that supplied the URL can decide what it means:
45
+
46
+ ```ts
47
+ yield* enrich(userSuppliedUrl).pipe(
48
+ Effect.catchTag('RequestError', (e) =>
49
+ e.cause instanceof SsrfBlockedError
50
+ ? Effect.succeed(none) // an optional enrichment: skip it
51
+ : Effect.fail(e)), // a real transport failure: keep failing
52
+ )
53
+ ```
54
+
55
+ Two things worth knowing if you are upgrading from a version before this was a failure:
56
+
57
+ - **It used to be a defect** (`Effect.die`), so code that handled it did so with `Effect.catchAllDefect`. That arm no longer fires. If you also have an `Effect.mapError` above it, the rejection now reaches `mapError` FIRST — and a delivery that classified an SSRF block as *terminal* in the defect arm will be reclassified as whatever `mapError` returns. A consumer hit exactly this: a permanent condition became retryable and burned the full backoff budget re-attempting a request that can never succeed. Nothing in the type system can see that, because the error channel already carried `HttpClientError`.
58
+ - **`description` names the policy** (`SSRF policy: blocked private/reserved address …`), which is what you want in a log line; the `cause` is what you want in a branch.
59
+
42
60
  ### Allowing a target on purpose
43
61
 
44
62
  ```ts
@@ -663,7 +663,7 @@ const execute = async (
663
663
  export default execute
664
664
  ```
665
665
 
666
- `TenantMismatch` is imported from the browser-safe `@voltro/plugin-multitenancy/guard` subpath, NOT the package root — the root re-exports the schema mixin, which would drag `@voltro/database` into the client rpcGroup bundle. `ctx.events.emit(...)` is post-commit safe: the event is recorded and fans out only once the row is durably written.
666
+ `TenantMismatch` is imported from the browser-safe `@voltro/plugin-multitenancy/guard` subpath, NOT the package root — the root re-exports the schema mixin, which would drag `@voltro/database` into the client rpcGroup bundle. `ctx.events.publish(...)` is post-commit safe: the event is recorded and fans out only once the row is durably written.
667
667
 
668
668
  ## Event trigger — start a workflow from a domain event
669
669
 
@@ -1019,7 +1019,7 @@ Watch the run in the dashboard's **Workflows** tab (the `awaitSignal` gate also
1019
1019
 
1020
1020
  ## Anti-patterns
1021
1021
 
1022
- - **Emitting the domain event before the write commits.** `orders.place` emits `order.placed` *after* `ctx.store.insert` returns `ctx.events.emit` is post-commit safe so a rolled-back transaction never starts a workflow for an order that doesn't exist. Don't emit eagerly inside the same expression as the insert.
1022
+ - **Worrying about publishing before the write commits.** You no longer have to: inside a mutation `ctx.events.publish` fires on COMMIT and not at all on rollback, so a rolled-back transaction cannot start a workflow for an order that does not exist and cannot tell a client it does either. Publish wherever it reads best.
1023
1023
  - **Doing external I/O inside a mutation.** The place mutation only inserts + emits. HTTP calls, payments, and other side effects belong in a workflow `step()` (journaled + replayed) or an action — a mutation runs in a transaction and can't roll back an HTTP side effect.
1024
1024
  - **Tracking the workflow run handle to send a signal.** `orders.approve` re-derives the executionId from the payload via the deterministic `idempotencyKey` instead. Storing the handle from the place mutation is unnecessary and breaks across restarts.
1025
1025
  - **Dropping the cross-tenant write guard.** Tables with `tenant()` get automatic SUBSCRIPTION scoping, but a mutation that writes raw rows still needs `assertOwnTenant(input.tenantId, ctx.request.subject)` — and `TenantMismatch` must be imported from `@voltro/plugin-multitenancy/guard` in the descriptor, never the package root (the root leaks `@voltro/database` into the browser bundle).
@@ -2212,7 +2212,7 @@ The package also ships `MockClock` / `MockEmail` / `mockAi` / `makeWorkflowRunne
2212
2212
  <!-- source: en/templates/api-webhooks.md -->
2213
2213
  ## API · Webhooks
2214
2214
 
2215
- _First-class webhooks both ways — a signature-verified incoming *.webhook.tsx receiver (genericProvider HMAC) that rejects forged traffic before your handler runs, plus an outgoing defineOutgoingEvent a mutation emits to subscribed targets via a durable signed delivery workflow. Zero-infra boot._
2215
+ _First-class webhooks both ways — a signature-verified incoming *.webhook.tsx receiver (genericProvider HMAC) that rejects forged traffic before your handler runs, plus an outgoing defineEvent a mutation emits to subscribed targets via a durable signed delivery workflow. Zero-infra boot._
2216
2216
 
2217
2217
  Webhooks **both ways**, wired turnkey: a **signature-verified incoming receiver** that rejects forged traffic before your code runs, and an **outgoing event** a mutation emits to subscribed targets through a durable, signed, retried delivery workflow. Webhooks are **file-convention** (drop a `*.webhook.tsx`, it's auto-discovered) — not a `plugins:[]` entry. Boots **zero-infra** (`store: 'memory'`). Template id: **`api-webhooks`**.
2218
2218
 
@@ -2231,7 +2231,7 @@ apps/acme/api/
2231
2231
  ├── .env # VOLTRO_WEBHOOK_SECRET_ORDERS (dev signing secret)
2232
2232
  ├── database/schema.ts # orders + webhookTables() bookkeeping
2233
2233
  ├── webhooks/orders.webhook.tsx # INCOMING — signature-verified receiver
2234
- ├── events/order.completed.webhook.tsx # OUTGOING — defineOutgoingEvent
2234
+ ├── events/order.completed.webhook.tsx # OUTGOING — defineEvent
2235
2235
  ├── mutations/orders.fulfill.mutation.ts # create order + emit (+ .server.ts)
2236
2236
  └── queries/orders.list.query.ts # reactive list (+ .server.ts)
2237
2237
  ```
@@ -2281,7 +2281,7 @@ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:4000/webhooks/
2281
2281
 
2282
2282
  ```ts
2283
2283
  // events/order.completed.webhook.tsx
2284
- export default defineOutgoingEvent({ id: 'order.completed', payload: Schema.Struct({ /* … */ }), version: 1 })
2284
+ export default defineEvent({ id: 'order.completed', payload: Schema.Struct({ /* … */ }), version: 1 })
2285
2285
 
2286
2286
  // mutations/orders.fulfill.mutation.server.ts — after the row commits:
2287
2287
  const { eventId, deliveries } = await useWebhooks(ctx).emit('order.completed', { orderId, tenantId, /* … */ })
@@ -43,7 +43,7 @@ The four `api-backend*` shapes share the same minimal `notes` domain and differ
43
43
  | [`api-rest`](/docs/templates/api-rest) | **Public REST + OpenAPI** — `defineRestRoute` endpoints (query/path/body, scope guards, `Idempotency-Key`) + `@voltro/plugin-openapi` (Swagger UI at `/docs`, generated from the descriptors). Zero-infra boot. |
44
44
  | [`api-saas`](/docs/templates/api-saas) | **The SaaS plugin bundle** — billing entitlements (a quota gate), in-app + console notifications, analytics events, and live presence, wired turnkey. One `projects.create` exercises billing + analytics + notifications together. |
45
45
  | [`api-observability`](/docs/templates/api-observability) | **Metrics + errors + tests** — `@voltro/plugin-prometheus` (`GET /metrics` + a custom counter), `@voltro/plugin-sentry` (inert without a DSN), tracing, and a `@voltro/testing` unit test. Zero-infra boot. |
46
- | [`api-webhooks`](/docs/templates/api-webhooks) | **First-class webhooks** — a signature-verified incoming `*.webhook.tsx` receiver (rejects forged traffic before your handler) + an outgoing `defineOutgoingEvent` a mutation emits via a durable signed delivery workflow. Zero-infra boot. |
46
+ | [`api-webhooks`](/docs/templates/api-webhooks) | **First-class webhooks** — a signature-verified incoming `*.webhook.tsx` receiver (rejects forged traffic before your handler) + an outgoing `defineEvent` a mutation emits via a durable signed delivery workflow. Zero-infra boot. |
47
47
  | [`api-search`](/docs/templates/api-search) | **Full-text search in sync** — `@voltro/plugin-search` mirrors every table write into an index via the post-commit change tap; the synthesized `search.query` rpc returns tenant-scoped hits. Memory backend (zero infra); one line to Typesense/Meilisearch/Algolia. |
48
48
  | [`api-feature-flags`](/docs/templates/api-feature-flags) | **Feature flags as code** — `@voltro/plugin-flags`: kill-switch / %-rollout / targeting, a declarative `gatedBy` map + an in-handler `requireFlag` guard (both fail typed `FlagDisabled`), `useFlag()` on the web. Memory store. |
49
49
  | [`api-ratelimit`](/docs/templates/api-ratelimit) | **Per-endpoint rate limiting** — `@voltro/plugin-ratelimit` intercepts rpc: a default + rules (sliding-window / token-bucket, by subject/tenant/…), typed `RateLimited` auto-merged into the error union. Memory store. |