@voltro/cli 0.30.1 → 0.31.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 (124) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/apiBuild-BOqL1dlg.js +2 -0
  3. package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
  4. package/dist/appGraph-CvQCte0z.js +2 -0
  5. package/dist/appGraph-gQ_6GkQQ.js +110 -0
  6. package/dist/bin.js +3 -3
  7. package/dist/checkCommand-ClRvH_SN.js +2 -0
  8. package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
  9. package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
  10. package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
  11. package/dist/dbCommand-BwQ_Jl98.js +2 -0
  12. package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
  13. package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
  14. package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
  15. package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
  16. package/dist/index.js +1 -1
  17. package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
  18. package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
  19. package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
  20. package/dist/serveEntry.js +2 -2
  21. package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
  22. package/dist/startEntry.js +2 -2
  23. package/package.json +17 -17
  24. package/templates/AGENTS.md +43 -43
  25. package/templates/agent-docs/_index.md +43 -43
  26. package/templates/agent-docs/_manifest.json +84 -42
  27. package/templates/agent-docs/cli.md +4 -2
  28. package/templates/agent-docs/database/migrations.md +14 -1
  29. package/templates/agent-docs/internationalization.md +54 -3
  30. package/templates/agent-docs/plugins/ai-flows.md +148 -0
  31. package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
  32. package/templates/agent-docs/plugins/atlassian.md +378 -0
  33. package/templates/agent-docs/plugins/audit.md +324 -0
  34. package/templates/agent-docs/plugins/auth-auth0.md +92 -0
  35. package/templates/agent-docs/plugins/auth-clerk.md +90 -0
  36. package/templates/agent-docs/plugins/auth-kinde.md +88 -0
  37. package/templates/agent-docs/plugins/auth-oidc.md +95 -0
  38. package/templates/agent-docs/plugins/auth-supabase.md +103 -0
  39. package/templates/agent-docs/plugins/auth-workos.md +105 -0
  40. package/templates/agent-docs/plugins/auth.md +227 -0
  41. package/templates/agent-docs/plugins/billing.md +324 -0
  42. package/templates/agent-docs/plugins/broadcast.md +144 -0
  43. package/templates/agent-docs/plugins/cdc-out.md +116 -0
  44. package/templates/agent-docs/plugins/clickhouse.md +91 -0
  45. package/templates/agent-docs/plugins/datadog.md +105 -0
  46. package/templates/agent-docs/plugins/deactivation.md +114 -0
  47. package/templates/agent-docs/plugins/duckdb.md +82 -0
  48. package/templates/agent-docs/plugins/flags.md +146 -0
  49. package/templates/agent-docs/plugins/governance.md +143 -0
  50. package/templates/agent-docs/plugins/licensing.md +74 -0
  51. package/templates/agent-docs/plugins/logship.md +56 -0
  52. package/templates/agent-docs/plugins/mail.md +334 -0
  53. package/templates/agent-docs/plugins/moderation.md +65 -0
  54. package/templates/agent-docs/plugins/multitenancy.md +99 -0
  55. package/templates/agent-docs/plugins/notifications.md +252 -0
  56. package/templates/agent-docs/plugins/openapi.md +94 -0
  57. package/templates/agent-docs/plugins/postgis.md +140 -0
  58. package/templates/agent-docs/plugins/posthog.md +91 -0
  59. package/templates/agent-docs/plugins/presence.md +115 -0
  60. package/templates/agent-docs/plugins/prometheus.md +124 -0
  61. package/templates/agent-docs/plugins/ratelimit.md +274 -0
  62. package/templates/agent-docs/plugins/rbac.md +270 -0
  63. package/templates/agent-docs/plugins/scim.md +63 -0
  64. package/templates/agent-docs/plugins/search.md +116 -0
  65. package/templates/agent-docs/plugins/sentry.md +192 -0
  66. package/templates/agent-docs/plugins/soft-delete.md +72 -0
  67. package/templates/agent-docs/plugins/sso-saml.md +132 -0
  68. package/templates/agent-docs/plugins/storage.md +709 -0
  69. package/templates/agent-docs/plugins/tinybird.md +92 -0
  70. package/templates/agent-docs/plugins/versioning.md +226 -0
  71. package/templates/agent-docs/plugins/webhooks.md +585 -0
  72. package/templates/agent-docs/security.md +12 -1
  73. package/templates/agent-docs/whats-new.md +81 -37
  74. package/templates/apps/api-ai/package.json +7 -7
  75. package/templates/apps/api-auth/package.json +8 -8
  76. package/templates/apps/api-backend/package.json +7 -7
  77. package/templates/apps/api-backend-deactivation/package.json +7 -7
  78. package/templates/apps/api-backend-mail/package.json +8 -8
  79. package/templates/apps/api-backend-mariadb/package.json +9 -9
  80. package/templates/apps/api-backend-sqlite/package.json +8 -8
  81. package/templates/apps/api-backend-storage/package.json +8 -8
  82. package/templates/apps/api-cms/package.json +10 -10
  83. package/templates/apps/api-collab/package.json +8 -8
  84. package/templates/apps/api-data-advanced/package.json +8 -8
  85. package/templates/apps/api-durable/package.json +8 -8
  86. package/templates/apps/api-feature-flags/package.json +9 -9
  87. package/templates/apps/api-governance/package.json +8 -8
  88. package/templates/apps/api-kv/package.json +8 -8
  89. package/templates/apps/api-moderation/package.json +8 -8
  90. package/templates/apps/api-observability/package.json +8 -8
  91. package/templates/apps/api-ratelimit/package.json +8 -8
  92. package/templates/apps/api-rbac/package.json +8 -8
  93. package/templates/apps/api-rest/package.json +7 -7
  94. package/templates/apps/api-saas/package.json +11 -11
  95. package/templates/apps/api-saas-starter/package.json +10 -10
  96. package/templates/apps/api-search/package.json +8 -8
  97. package/templates/apps/api-status/package.json +8 -8
  98. package/templates/apps/api-versioning/package.json +8 -8
  99. package/templates/apps/api-webhooks/package.json +9 -9
  100. package/templates/apps/changelog/package.json +6 -6
  101. package/templates/apps/edge-functions/package.json +2 -2
  102. package/templates/apps/frontend-admin/package.json +8 -8
  103. package/templates/apps/frontend-app/package.json +8 -8
  104. package/templates/apps/frontend-auth/package.json +8 -8
  105. package/templates/apps/frontend-blank/package.json +7 -7
  106. package/templates/apps/frontend-cms/package.json +9 -9
  107. package/templates/apps/frontend-collab/package.json +9 -9
  108. package/templates/apps/frontend-contact/package.json +7 -7
  109. package/templates/apps/frontend-dashboard/package.json +7 -7
  110. package/templates/apps/frontend-docs/package.json +7 -7
  111. package/templates/apps/frontend-i18n/package.json +6 -6
  112. package/templates/apps/frontend-landing/package.json +7 -7
  113. package/templates/apps/frontend-portal/package.json +8 -8
  114. package/templates/apps/frontend-saas/package.json +8 -8
  115. package/templates/apps/frontend-spa/package.json +7 -7
  116. package/templates/apps/frontend-ssr/package.json +7 -7
  117. package/templates/apps/frontend-ssr-api/package.json +8 -8
  118. package/templates/apps/frontend-static-blog/package.json +6 -6
  119. package/templates/apps/frontend-status/package.json +8 -8
  120. package/templates/apps/mobile-app/package.json +2 -2
  121. package/dist/apiBuild-Ca6Sq5jy.js +0 -2
  122. package/dist/checkCommand-DHD0aBeR.js +0 -2
  123. package/dist/dbCommand-wfGi3FgG.js +0 -2
  124. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +0 -2
@@ -0,0 +1,99 @@
1
+ # Multi-tenancy
2
+
3
+ > The tenant() schema mixin (auto-scope reads + auto-fill writes), the assertOwnTenant write-guard, and the typed TenantMismatch error.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/multitenancy.md -->
10
+ ## Multi-tenancy
11
+
12
+ _The tenant() schema mixin (auto-scope reads + auto-fill writes), the assertOwnTenant write-guard, and the typed TenantMismatch error._
13
+
14
+ `@voltro/plugin-multitenancy` is the first-party multi-tenancy primitive. It has two surfaces: a **schema mixin** (`tenant()`) and a **write-time guard** (`assertOwnTenant` + the typed `TenantMismatch` error).
15
+
16
+ **Status:** ✓ shipped.
17
+
18
+ ## The `tenant()` mixin
19
+
20
+ Add it to any table whose rows belong to a tenant. It contributes a `tenantId` reference (to the app's `tenants` table) and tells the runtime to auto-scope reads + auto-fill writes:
21
+
22
+ ```ts
23
+ import { table, id, text } from '@voltro/database'
24
+ import { tenant } from '@voltro/plugin-multitenancy'
25
+
26
+ export const projects = table('projects', {
27
+ id: id(),
28
+ name: text(),
29
+ }).with(tenant())
30
+ ```
31
+
32
+ `tenant()` transitively requires `audit()` — composing it pulls in the
33
+ `createdAt` / `updatedAt` / `createdBy` / `updatedBy` columns too. The mixin
34
+ is also available on the `./mixin` subpath (`@voltro/plugin-multitenancy/mixin`)
35
+ for projects that don't want the full barrel.
36
+
37
+ What the runtime does for a `tenant()`-marked table:
38
+
39
+ - **Reads are auto-scoped.** The runtime AND-merges `eq('tenantId', subject.tenantId)` into every subscription predicate against the table — tenant A never sees tenant B's rows, and a write in tenant A never wakes a subscription in tenant B.
40
+ - **Writes are auto-filled.** On insert, `tenantId` is stamped from `ctx.request.subject.tenantId` when the caller didn't pass it explicitly. The wrapper never overrides a value the caller did pass.
41
+
42
+ ## The write-guard — `assertOwnTenant`
43
+
44
+ Read isolation is automatic. **Writes are not** — a mutation receives raw, user-supplied input including `tenantId`, so without an explicit check a client authenticated as tenant A could submit `tenantId: 'B'` and land the row in B's data. Guard every tenant-scoped mutation:
45
+
46
+ ```ts
47
+ import { assertOwnTenant, TenantMismatch } from '@voltro/plugin-multitenancy'
48
+
49
+ const execute = async (input: { tenantId: string }, ctx) => {
50
+ assertOwnTenant(input.tenantId, ctx.request.subject)
51
+ // safe to use input.tenantId for the write
52
+ }
53
+ ```
54
+
55
+ `assertOwnTenant(inputTenantId, subject)` throws `TenantMismatch` when `inputTenantId` doesn't equal the subject's `tenantId`. Anonymous subjects have no tenant scope at all, so the guard always throws for them — anonymous + tenant-scoped writes need an `apiKey` / `serviceAccount` subject instead.
56
+
57
+ ## The typed error — `TenantMismatch`
58
+
59
+ `TenantMismatch` is a `Schema.TaggedError` carrying `inputTenantId` + `subjectTenantId` (empty string for anonymous subjects). Declare it on the mutation's `error:` schema so the rpc layer surfaces the rejection typed. **Import it from the browser-safe `@voltro/plugin-multitenancy/guard` subpath in the descriptor (`*.mutation.ts`)** — the package root also re-exports the schema mixin, which pulls `@voltro/database` into the client rpcGroup bundle (a browser-safety violation):
60
+
61
+ ```ts
62
+ import { defineMutation } from '@voltro/protocol'
63
+ import { TenantMismatch } from '@voltro/plugin-multitenancy/guard'
64
+ import { Schema } from 'effect'
65
+
66
+ export const createProject = defineMutation({
67
+ name: 'projects.create',
68
+ input: Schema.Struct({ tenantId: Schema.String, name: Schema.String }),
69
+ output: Schema.Struct({ id: Schema.String }),
70
+ error: TenantMismatch,
71
+ target: { table: 'projects', op: 'insert' },
72
+ })
73
+ ```
74
+
75
+ The client then pattern-matches on `error._tag === 'TenantMismatch'`.
76
+
77
+ ## Why it's a separate package
78
+
79
+ Multi-tenancy is a product decision, not a transport-protocol primitive — keeping it out of `@voltro/protocol` lets apps that don't need tenancy skip the dependency entirely, while `@voltro/protocol` stays focused on the wire (`Subject`, `AuthMiddleware`).
80
+
81
+ ## Data residency — pin a tenant to a region
82
+
83
+ For regulated / EU buyers ("EU data stays in the EU"), pin each tenant to a home **region** and route its request-scoped store there. This composes namespace isolation (a tenant's rows live in their own namespace) with region routing — declare the tenant→home mapping plus which regions *this* deployment serves:
84
+
85
+ ```ts
86
+ import { setResidencyConfig } from '@voltro/database'
87
+
88
+ setResidencyConfig({
89
+ homes: [
90
+ { tenantId: 'acme_eu', region: 'eu-west-1' },
91
+ { tenantId: 'globex_us', region: 'us-east-1', connectionKey: 'us_primary' },
92
+ ],
93
+ servableRegions: ['eu-west-1'], // this deployment serves only EU
94
+ })
95
+ ```
96
+
97
+ The serve pipeline binds the store per request via `bindResidentStore(subject, config, stores)`, where `stores` is the per-region `DataStore` handles this deployment holds. It returns `{ store, placement }` — `placement` being `{ region, namespace, connectionKey? }`, the tenant's rows in its home region.
98
+
99
+ It **fails closed** at every fork — no mapped home (or an anonymous caller) throws `TenantResidencyUnresolved`; a tenant homed in a region this deployment doesn't serve throws `TenantRegionUnavailable` (the gateway routes that request to the home region's deployment instead). There is no default store: a US deployment can never return a store for an EU-homed tenant. Per-region infra (the actual stores + connection strings via the secrets backend) is yours to provision; v1 routes the primary store — cross-region analytics across tenants is a separate concern.
@@ -0,0 +1,252 @@
1
+ # Notifications
2
+
3
+ > Unified notifications — one send API across email / Slack / SMS / push / in-app, with per-user channel preferences, an in-app inbox, and delivery records.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/notifications.md -->
10
+ ## Notifications
11
+
12
+ _Unified notifications — one send API across email / Slack / SMS / push / in-app, with per-user channel preferences, an in-app inbox, and delivery records._
13
+
14
+ `@voltro/plugin-notifications` is the **one** messaging answer instead of twenty brand wrappers: a single `send` across channels, per-user preferences, and an in-app inbox with unread counts — not a per-vendor SDK in every handler.
15
+
16
+ ## Wiring
17
+
18
+ ```ts
19
+ // app.config.ts
20
+ import { notificationsPlugin, consoleChannel, webhookChannel, emailChannel } from '@voltro/plugin-notifications'
21
+
22
+ export default {
23
+ type: 'api' as const,
24
+ name: 'api',
25
+ plugins: [
26
+ notificationsPlugin({
27
+ channels: [
28
+ consoleChannel(), // dev: writes to stdout
29
+ emailChannel((m) => myMailer(m)), // bridge to @voltro/plugin-mail
30
+ webhookChannel({ url: process.env.SLACK_WEBHOOK! }),// slack/teams/discord incoming webhook
31
+ ],
32
+ }),
33
+ ],
34
+ }
35
+ ```
36
+
37
+ The built-in **in-app** channel persists to the notification store and is appended automatically; `consoleChannel()`, `webhookChannel({ url, id?, format?, headers? })`, `emailChannel(send)`, `smsChannel(send)`, `pushChannel({ tokensFor, transport })` (see [Push](#push-apns-fcm)), and `customChannel(id, deliver)` cover the rest. A channel is just `{ id, deliver: (msg) => Promise<void> }` — bring your own.
38
+
39
+ `notificationsPlugin({ channels?, store?, digestWindowMs?, flushIntervalMs?, name? })` — the inbox, per-subject channel preferences, and delivery log **auto-persist to the framework DataStore by default**, durably, on every supported dialect. You only pass an explicit `store` for a **custom** backend (see [Store](#store)). `digestWindowMs` enables [digest/batching](#digest-batching); the scheduled flush (interval `flushIntervalMs`, default 30s) drains digest windows and quiet-hours deferrals.
40
+
41
+ ## Sending — `NotificationService`
42
+
43
+ ```ts
44
+ import { NotificationService } from '@voltro/plugin-notifications'
45
+
46
+ export default (input: { userId: string }, _ctx) => Effect.gen(function* () {
47
+ const notify = yield* NotificationService
48
+ yield* Effect.promise(() => notify.send({
49
+ to: input.userId,
50
+ category: 'order.shipped',
51
+ title: 'Your order shipped',
52
+ body: 'Track it in your account.',
53
+ // channels?: ['email', 'inApp'] — narrow this send to specific channels
54
+ }))
55
+ return { ok: true }
56
+ })
57
+ ```
58
+
59
+ `resolveChannels` picks the effective channel set: an explicit `channels:` on the send narrows to those (intersected with the configured channels); else every configured channel — then any channel the user has turned **off** for that category (a stored `ChannelPreference` with `enabled: false`) is dropped. A missing preference means on. Each delivery is recorded as a `DeliveryRecord` (channel, status, error?).
60
+
61
+ ## Push (APNs / FCM)
62
+
63
+ `pushChannel` is a first-class mobile-push channel — the built-in alternative to hand-rolling `customChannel`:
64
+
65
+ ```ts
66
+ import { pushChannel, PushTokenRejected } from '@voltro/plugin-notifications'
67
+
68
+ pushChannel({
69
+ tokensFor: async (subjectId) => myDeviceTokens(subjectId), // your device-token table
70
+ transport: async (payload) => {
71
+ // payload: { token, title, body, badge?, data } — the APNs / FCM shape.
72
+ const res = await sendToApns(payload) // your provider + AUTH secret live HERE
73
+ if (res.status === 410) throw new PushTokenRejected({ token: payload.token, reason: 'Unregistered' })
74
+ },
75
+ })
76
+ ```
77
+
78
+ `deliver` formats one `PushPayload` per device token and sends each. A `PushTokenRejected` is captured into the fan-out (recorded `failed`, naming the rejected token — **never** the auth secret) so your app can prune the dead token. The push AUTH secret lives in your `transport` closure — it never enters the package and is never logged.
79
+
80
+ ## Digest / batching
81
+
82
+ Set `digestWindowMs > 0` and multiple sends to the **same subject** within the window coalesce into **one** digest delivery, flushed on the window boundary:
83
+
84
+ ```ts
85
+ notificationsPlugin({ digestWindowMs: 5 * 60_000 }) // 5-minute rollup
86
+ ```
87
+
88
+ Three sends to `u1` inside the window produce ONE `digest`-category notification whose body lists all three and whose `data.items` carries them. A send that forces its own `channels:` bypasses the digest (explicit intent → deliver now). The scheduled flush (interval `flushIntervalMs`) delivers each window on its boundary.
89
+
90
+ ## Quiet hours (per-subject DND)
91
+
92
+ Each subject can set a Do-Not-Disturb window; a send during the window is **held** and delivered after (default) or **dropped**:
93
+
94
+ ```ts
95
+ import { NotificationService } from '@voltro/plugin-notifications'
96
+
97
+ export default (_input, _ctx) => Effect.gen(function* () {
98
+ const notify = yield* NotificationService
99
+ // 22:00 → 08:00 in the subject's zone; minutes past local midnight.
100
+ yield* Effect.promise(() => notify.setQuietHours({
101
+ subjectId: 'u1', startMinute: 22 * 60, endMinute: 8 * 60, tz: 'Europe/Berlin', policy: 'hold',
102
+ }))
103
+ return { ok: true }
104
+ })
105
+ ```
106
+
107
+ Windows may wrap midnight. `policy: 'hold'` defers the send to the window close (delivered by the scheduled flush); `policy: 'drop'` discards it. Subjects self-manage from the browser with `useQuietHours()`.
108
+
109
+ ## Broadcast / topic fan-out
110
+
111
+ `send` is single-recipient. To reach N subscribers of a topic in one call, subscribe subjects to a topic and `broadcast`:
112
+
113
+ ```ts
114
+ import { NotificationService } from '@voltro/plugin-notifications'
115
+
116
+ export default (_input, _ctx) => Effect.gen(function* () {
117
+ const notify = yield* NotificationService
118
+ yield* Effect.promise(() => notify.subscribe('release-notes', 'u1'))
119
+ const result = yield* Effect.promise(() => notify.broadcast('release-notes', {
120
+ category: 'news', title: 'v2 shipped', body: 'Read the changelog.',
121
+ }))
122
+ return { recipients: result.recipients } // one call → every subscriber
123
+ })
124
+ ```
125
+
126
+ Each fan-out send still honours that subject's preferences + quiet hours. Subjects self-subscribe from the browser with `useTopicSubscription()`.
127
+
128
+ ## Inbox + preferences (client)
129
+
130
+ The plugin ships routes — `notifications.inbox`, `unreadCount`, `markRead`, `preferences`, `setPreference`, plus the self-service `subscribe` / `unsubscribe` (topics) and `setQuietHours` / `clearQuietHours` (DND) — and matching hooks:
131
+
132
+ ```tsx
133
+ import {
134
+ useInbox, useUnreadCount, useMarkRead, useSetNotificationPreference,
135
+ useTopicSubscription, useQuietHours,
136
+ } from '@voltro/plugin-notifications/web'
137
+
138
+ const inbox = useInbox() // InboxItem[]
139
+ const unread = useUnreadCount() // number — drives the badge
140
+ const markRead = useMarkRead()
141
+ const setPref = useSetNotificationPreference()
142
+ const topics = useTopicSubscription() // { subscribe, unsubscribe }
143
+ const quiet = useQuietHours() // { set, clear }
144
+ ```
145
+
146
+ > **The plugin OWNS the `notifications.*` route tags** (`notifications.inbox`, `notifications.unreadCount`, `notifications.markRead`, `notifications.preferences`, `notifications.setPreference`, `notifications.subscribe`, `notifications.unsubscribe`, `notifications.setQuietHours`, `notifications.clearQuietHours`). An app must NOT also author its own `notifications.*` procedures — boot **fails** with a tag-collision error. Use the plugin OR hand-roll your own, never both.
147
+
148
+ ## Inbox states — read is not archived
149
+
150
+ `readAt` and `archivedAt` are two states, not one, and the distinction is the
151
+ one users care about: archiving is what EMPTIES the inbox.
152
+
153
+ ```ts
154
+ notifications.markRead // one item read
155
+ notifications.markUnread // …and back again
156
+ notifications.markAllRead // → { count } — how many CHANGED, not how many exist
157
+ notifications.archive // out of the inbox
158
+ notifications.unarchive // back into it
159
+ ```
160
+
161
+ Archiving does **not** mark an item read. An archived-but-unread item still
162
+ counts toward `unreadCount`, so a UI can show what the user actually did rather
163
+ than a state the framework inferred for them.
164
+
165
+ All five are scoped to the calling subject: an inbox action never reaches
166
+ another subject's row because an id happens to be guessable.
167
+
168
+
169
+ ## Store
170
+
171
+ **Durable by default — no config.** The plugin contributes six tables via its `extendSchema` and migrates them automatically: `_voltro_notification_inbox`, `_voltro_notification_preferences`, `_voltro_notification_deliveries`, `_voltro_notification_topic_subscriptions` (broadcast fan-out set), `_voltro_notification_quiet_hours` (per-subject DND window), and `_voltro_notification_held` (the digest/quiet-hours held queue). Once the app's store exists, the plugin auto-binds a `dataStoreNotificationStore` over those tables (via the plugin `bindDataStore` hook) and declares the `store:write` permission for them — so the inbox, per-subject channel preferences, and delivery log all persist to the framework DataStore on every supported dialect.
172
+
173
+ The append-only tables are bounded by the framework retention sweep: `_voltro_notification_deliveries` (time-TTL, 90d default), `_voltro_notification_inbox` (read-aware — unread items survive, 180d default), and `_voltro_notification_held` (safety-net on `flushAt`, 7d default; the scheduled flush normally drains a held row the moment it is due).
174
+
175
+ The in-memory store is just the **dev/test fallback** before the DataStore is bound. You only pass an explicit `store` for a **custom** backend:
176
+
177
+ ```ts
178
+ import { dataStoreNotificationStore, memoryNotificationStore } from '@voltro/plugin-notifications'
179
+
180
+ // dataStoreNotificationStore(store) — the durable default the plugin auto-binds.
181
+ // memoryNotificationStore() — the in-process dev/test fallback.
182
+ ```
183
+
184
+ The store is the swap point; the service + channels are storage-agnostic.
185
+
186
+ ## Dashboard panel
187
+
188
+ Both dashboards ship a **Notifications** panel (api apps): the **delivery log** (channel → recipient, category, sent/failed status) with per-channel + sent/failed counts, plus a per-subject **inbox lookup**. Read-only. Backed by `/_voltro/inspect/plugins/notifications/{deliveries,inbox}` (permission `inspect:read`).
189
+
190
+ ## Whose inbox is it — `resolveSubjectId`
191
+
192
+ ```ts
193
+ notificationsPlugin({
194
+ // Your addressing unit, not ours. An employee, a member, a contact —
195
+ // something that need not have an auth user. It almost always lives in a
196
+ // TABLE, so the resolver may be async.
197
+ resolveSubjectId: (ctx) => resolveCallerEmployeeId(ctx),
198
+ })
199
+ ```
200
+
201
+ By default an inbox belongs to `subject.id`. That is the framework's answer and
202
+ it is not always yours: a shift change, an absence request or a task reminder is
203
+ addressed to a PERSON, and your app may key that person by its own id.
204
+
205
+ Return your own id here and the whole surface follows — `inbox`, `unreadCount`,
206
+ `markRead`, `archive`, preferences, quiet hours.
207
+
208
+ ### Read this before you reach for it
209
+
210
+ **The subject is whatever signs in. If your addressing unit is not that, you are
211
+ addressing something nobody can read.**
212
+
213
+ That sentence was written by the team that adopted this option and then reversed
214
+ it, and it is the correction to an argument this page used to make. The earlier
215
+ version justified `resolveSubjectId` with a measured number: rows belonging to
216
+ people who had no auth user, which employee-keying would "reach" and `subject.id`
217
+ would not. The number was correct and the conclusion was backwards. An inbox
218
+ belongs to whoever can OPEN it, and only an account can open one. Keying by
219
+ employee did not deliver those rows — it made them look addressed, and charged a
220
+ translation on every read path and every push for the privilege.
221
+
222
+ So the question to ask first is not "what is our addressing unit" but **"can the
223
+ thing I am addressing sign in?"** If it cannot, this option gives you rows nobody
224
+ will ever see. Translate at the SENDING seam instead — once, where the producer
225
+ knows both ids — and leave the inbox keyed by the account.
226
+
227
+ `resolveSubjectId` remains right for the case it was built for: an app whose
228
+ sign-in identity genuinely IS its own id (a member, a contact, a tenant user)
229
+ rather than the framework's `subject.id`. That is a different situation from
230
+ having a second identity that some accounts happen to map to.
231
+
232
+ **The resolver may be async, and usually has to be.** An app that has its own
233
+ addressing unit keeps it in a table — an employee, a member and a contact are
234
+ all rows. If the mapping were in the token, this seam would not be needed at
235
+ all: `subject.id` would already be the right id. Return a `string`, a
236
+ `Promise<string>`, or `undefined`/`Promise<undefined>`; a sync resolver still
237
+ works unchanged.
238
+
239
+ It is deliberately **not cached** for you. A per-connection cache is the obvious
240
+ next step and it is yours to make: the first call would decide the answer for
241
+ the life of the connection, so a member created a second after connect resolves
242
+ to the fallback until reconnect. You know your invalidation; the framework does
243
+ not. Memoise inside your resolver if that trade is right for your app.
244
+
245
+ Absent keeps `subject.id`, so nothing changes for an app whose units line up.
246
+ Returning `undefined` — or throwing, or a rejected promise, if your resolver
247
+ reaches a database — falls back the same way rather than failing the read.
248
+
249
+
250
+ ## Permissions
251
+
252
+ `store:write` (in-app inbox + delivery records) + `inspect:read` (dashboard panel).
@@ -0,0 +1,94 @@
1
+ # OpenAPI
2
+
3
+ > Generate an OpenAPI 3.1 spec + Swagger-UI docs from your defineRestRoute descriptors and (opt-in) your rpc procedures. Your routes ARE the API docs — nothing hand-maintained.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/openapi.md -->
10
+ ## OpenAPI
11
+
12
+ _Generate an OpenAPI 3.1 spec + Swagger-UI docs from your defineRestRoute descriptors and (opt-in) your rpc procedures. Your routes ARE the API docs — nothing hand-maintained._
13
+
14
+ `@voltro/plugin-openapi` turns your [`defineRestRoute`](/docs/data/rest-routes) descriptors — and, opt-in, your rpc procedures (queries / mutations / actions / streams) — into a live OpenAPI 3.1 document + a Swagger-UI page. The descriptors already carry method, path, input/output Schemas, `summary` and deprecation; the rpc procedures carry input/output/error Schemas. The plugin reads them, so there is **zero hand-written API documentation** to drift.
15
+
16
+ ## Wiring
17
+
18
+ ```ts
19
+ // app.config.ts
20
+ import { openapiPlugin } from '@voltro/plugin-openapi'
21
+ import listCustomers from './routes/v1/customers.list.route'
22
+ import createCharge from './routes/v1/charge.route'
23
+
24
+ export default {
25
+ type: 'api' as const, name: 'api',
26
+ restRoutes: [listCustomers, createCharge],
27
+ plugins: [
28
+ openapiPlugin({
29
+ routes: [listCustomers, createCharge], // the same descriptors
30
+ info: { title: 'Payments API', version: '1.0.0' },
31
+ // specPath: '/openapi.json', docsPath: '/docs' (defaults; docsPath:false disables the UI)
32
+ // token: process.env.OPENAPI_DOCS_TOKEN, // opt-in Bearer gate (see Permissions)
33
+ }),
34
+ ],
35
+ }
36
+ ```
37
+
38
+ Mounts **`GET /openapi.json`** (the spec) and **`GET /docs`** (Swagger UI).
39
+
40
+ ## What it maps
41
+
42
+ - Each route → an OpenAPI path + operation, keyed by method.
43
+ - The input Schema's `query` fields → `parameters` (in: query); `params` → path params; `body` → a JSON `requestBody`.
44
+ - The output Schema → the `200` JSON response.
45
+ - `summary` / `description` → operation metadata; `deprecated` → `deprecated: true`; `example.request` / `example.response` → request-body / 200-response examples; `sunset` → the `x-sunset` extension.
46
+ - The serve pipeline's real error statuses are documented per operation — `400` (input decode, when the route has an `input` schema), `401`/`403` (when it has `guards`), `410` (when `sunset` is set), `500` (always) — all referencing one shared `components.schemas.VoltroHttpError` envelope.
47
+ - When any route declares `guards`, the document publishes `components.securitySchemes.bearerAuth` and guarded operations carry `security: [{ bearerAuth: [] }]`.
48
+
49
+ Schemas are converted with effect's `JSONSchema.make`, so your `effect/Schema` definitions become JSON Schema verbatim. Pure: `generateOpenApiSpec(routes, info)` is exported + unit-tested if you want the spec without serving it.
50
+
51
+ ## rpc procedures in the spec
52
+
53
+ Pass your rpc procedure descriptors via the `procedures` option and each query / mutation / action / stream is projected into the same spec. The framework's rpc surface is a **batched JSON-RPC protocol over one endpoint** (`POST /rpc`) — not a REST-per-resource API — so the projection is honest about that: every procedure becomes a `POST /rpc/<name>` operation whose body is the rpc **payload** (the `input` Schema), tagged `rpc` and flagged with an `x-voltro-rpc` extension. The operation description states the real transport; the path is a documentation projection, not a REST route you call directly.
54
+
55
+ ```ts
56
+ import { openapiPlugin } from '@voltro/plugin-openapi'
57
+ import listOrders from './orders.list.query'
58
+ import createOrder from './orders.create.mutation'
59
+ import charge from './billing.charge.action'
60
+ import runAgent from './agent.run.stream'
61
+
62
+ openapiPlugin({
63
+ routes: [listCustomers], // REST routes (optional)
64
+ procedures: [listOrders, createOrder, charge, runAgent], // rpc procedures
65
+ info: { title: 'API', version: '1.0.0' },
66
+ })
67
+ ```
68
+
69
+ Per procedure kind:
70
+
71
+ - **query** → `POST /rpc/<name>`; `input` → requestBody, `output` → the `200` response (documented as the *first snapshot*; the WebSocket rpc channel then pushes reactive updates).
72
+ - **mutation** / **action** → `input` → requestBody, `output` → `200`.
73
+ - **stream** → the `element` Schema is documented as a streaming response (`application/x-ndjson`, one element per line) — **not** faked as a single JSON value.
74
+ - The procedure's **typed error channel** (its `Schema.TaggedError` union) becomes a `422` response referencing the error schema; a procedure with no declared error documents only `200` + `500`.
75
+
76
+ Mixing rpc + REST in one spec is fine — the rpc operations are grouped under the `rpc` tag and flagged `x-voltro-rpc`, so a consumer can tell them apart from real REST routes. `procedures` is opt-in: omit it to keep the spec REST-only.
77
+
78
+ ## Access control
79
+
80
+ Both routes publish your full API contract (every path, param, and schema), and both are **unauthenticated by default** — mount them only where that's intended. Two levers narrow the surface:
81
+
82
+ - `docsPath: false` hides only the Swagger-UI viewer; the spec at `/openapi.json` stays served.
83
+ - `token` (or the `OPENAPI_DOCS_TOKEN` env var) requires `Authorization: Bearer <token>` on **both** routes — a missing or mismatched header gets a `401`. The same shape as [`@voltro/plugin-prometheus`](/docs/plugins/prometheus)'s gate. Leave it unset behind a trusted network boundary; set it for a public-internet deployment.
84
+
85
+ ```ts
86
+ openapiPlugin({
87
+ routes: [listCustomers, createCharge],
88
+ token: process.env.OPENAPI_DOCS_TOKEN, // unset → both routes are public
89
+ })
90
+ ```
91
+
92
+ ## Permissions
93
+
94
+ The plugin declares no runtime permissions — it only mounts the two GET routes built from descriptors you already declared.
@@ -0,0 +1,140 @@
1
+ # PostGIS
2
+
3
+ > Postgres-native geography / geometry columns, geometry constructors (point/line/polygon/multi/GeoJSON), spatial predicates (ST_DWithin, ST_Within, ST_Contains, ST_Intersects, ST_Buffer, bbox &&), ST_Distance projection + <-> KNN ordering, and GiST indexes via expressionIndex. Postgres-only by design.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/postgis.md -->
10
+ ## PostGIS
11
+
12
+ _Postgres-native geography / geometry columns, geometry constructors (point/line/polygon/multi/GeoJSON), spatial predicates (ST_DWithin, ST_Within, ST_Contains, ST_Intersects, ST_Buffer, bbox &&), ST_Distance projection + <-> KNN ordering, and GiST indexes via expressionIndex. Postgres-only by design._
13
+
14
+ `@voltro/plugin-postgis` adds postgres-native spatial column types and operators to the schema DSL. PostGIS is a postgres extension with no portable cross-dialect equivalent, so the plugin is **postgres-only** — a `geography()`/`geometry()` column FAILS LOUD at schema emission (`voltro migrate` / auto-migrate throws a `postgres-only` error) on any other dialect, and a spatial predicate throws in the SQL compiler.
15
+
16
+ **Status:** ✓ shipped.
17
+
18
+ ## Columns — `geography` / `geometry`
19
+
20
+ ```ts
21
+ import { table, id, text } from '@voltro/database'
22
+ import { geography } from '@voltro/plugin-postgis'
23
+
24
+ export const venues = table('venues', {
25
+ id: id(),
26
+ name: text(),
27
+ // SRID 4326 = WGS-84 lat/lon. Most common for global apps.
28
+ location: geography('Point', 4326),
29
+ })
30
+ ```
31
+
32
+ - **`geography(kind, srid)`** — spheroid-aware coordinates; distance calculations honour the earth's curvature. Use for global apps with WGS-84 lat/lon (SRID 4326).
33
+ - **`geometry(kind, srid)`** — planar (cartesian) coordinates; distance is straight-line in the SRID's reference frame. Use for local apps with projected coordinates (e.g. a UTM zone for one country).
34
+
35
+ `kind` is `'Point' | 'LineString' | 'Polygon' | 'MultiPoint' | 'MultiLineString' | 'MultiPolygon' | 'GeometryCollection'`. On postgres the migrator emits the real `geography(Point,4326)` DDL plus a one-time `CREATE EXTENSION IF NOT EXISTS postgis`; on any other dialect schema emission throws.
36
+
37
+ ## Value constructors
38
+
39
+ These build PostGIS WKT/EWKT (well-known text) values that the SQL compiler binds as literals; postgres' implicit text→geometry cast parses them at insert time. Coordinates are `[lon, lat]` (x before y) throughout.
40
+
41
+ ```ts
42
+ import { point, lineString, polygon, multiPoint, multiLineString, multiPolygon, geoJson } from '@voltro/plugin-postgis'
43
+
44
+ await ctx.store.insert('venues', {
45
+ name: 'HQ',
46
+ location: point(8.682, 50.110, 4326), // (lon, lat, srid) — Frankfurt
47
+ })
48
+
49
+ // A path / route.
50
+ const route = lineString([[8.68, 50.11], [8.69, 50.12], [8.70, 50.13]], 4326)
51
+
52
+ // polygon(rings, srid) — each ring is an array of [lon, lat] tuples; the first
53
+ // ring is the exterior, the rest are holes; each ring MUST be closed.
54
+ const area = polygon([[[8.6, 50.1], [8.7, 50.1], [8.7, 50.2], [8.6, 50.1]]], 4326)
55
+
56
+ // Multi-geometries.
57
+ const cluster = multiPoint([[8.68, 50.11], [8.70, 50.13]], 4326)
58
+ const lines = multiLineString([[[8.68, 50.11], [8.69, 50.12]]], 4326)
59
+ const zones = multiPolygon([[[[8.6, 50.1], [8.7, 50.1], [8.7, 50.2], [8.6, 50.1]]]], 4326)
60
+
61
+ // GeoJSON input — lowered to the same EWKT (SRID 4326 by the GeoJSON spec).
62
+ const fromGeoJson = geoJson({ type: 'Point', coordinates: [8.682, 50.110] })
63
+ ```
64
+
65
+ ## Spatial predicates
66
+
67
+ ```ts
68
+ import { queryFor } from '@voltro/database'
69
+ import { ST_DWithin, ST_Within, ST_Contains, ST_Intersects, ST_Buffer, bboxOverlaps, point, polygon, envelope } from '@voltro/plugin-postgis'
70
+
71
+ // Venues within 1km of a target point (meters; great-circle on geography).
72
+ await ctx.store.query(
73
+ queryFor(venues)
74
+ .where(ST_DWithin('location', point(8.682, 50.110, 4326), 1000))
75
+ .descriptor,
76
+ )
77
+
78
+ const cityCenter = polygon([[[8.65, 50.10], [8.72, 50.10], [8.72, 50.13], [8.65, 50.13], [8.65, 50.10]]], 4326)
79
+
80
+ // Points that lie INSIDE a polygon (the column is within the geom).
81
+ queryFor(venues).where(ST_Within('location', cityCenter))
82
+
83
+ // A region column that fully CONTAINS a point (column-first, always).
84
+ queryFor(regions).where(ST_Contains('boundary', point(8.682, 50.110, 4326)))
85
+
86
+ // Everything a 500 m buffer around a route touches.
87
+ queryFor(venues).where(ST_Buffer('location', point(8.682, 50.110, 4326), 500))
88
+
89
+ // Coarse, index-only bounding-box overlap — pair with an exact predicate or use alone.
90
+ queryFor(venues).where(bboxOverlaps('location', envelope(8.6, 50.05, 8.75, 50.15, 4326)))
91
+ ```
92
+
93
+ - **`ST_DWithin(column, geom, meters)`** — `column` is within `meters` of `geom` (metric distance; the column is cast to `geography`).
94
+ - **`ST_Within(column, geom)`** — `column`'s geometry is fully inside `geom` (the converse of `ST_Contains`). The right shape for "rows inside this polygon".
95
+ - **`ST_Contains(column, geom)`** — `column`'s geometry fully contains `geom`.
96
+ - **`ST_Intersects(column, geom)`** — `column`'s geometry shares any point with `geom`.
97
+ - **`ST_Buffer(column, geom, meters)`** — `column` intersects the `geom` grown by `meters` (a metric buffer via a geography round-trip). Shape-aware "within N metres of a line/polygon".
98
+ - **`bboxOverlaps(column, geom)`** — the `&&` bounding-box overlap operator: cheap, GiST-index-only, approximate. Build the box with `envelope(minLon, minLat, maxLon, maxLat, srid)`.
99
+
100
+ Spatial predicates are for one-shot `ctx.store.query` reads — they throw in the in-memory reactive matcher, so don't use them in subscriptions.
101
+
102
+ ## Distance projection + KNN ordering
103
+
104
+ `withDistance(...)` and `nearestBy(...)` are `QueryTransform`s applied via the builder's `.use(...)` seam (same mechanism as `hybridSearch`), so they compose with `.where(...)`, `.limit(k)`, and the spatial predicates. Postgres-only — the compiler throws on other dialects.
105
+
106
+ ```ts
107
+ import { queryFor } from '@voltro/database'
108
+ import { withDistance, nearestBy, ST_DWithin, point } from '@voltro/plugin-postgis'
109
+
110
+ // Distance-as-a-value: project ST_Distance in METRES onto each row.
111
+ const withMeters = await ctx.store.query(
112
+ queryFor(venues)
113
+ .use(withDistance('location', point(8.682, 50.110), { as: 'meters' }))
114
+ .where(ST_DWithin('location', point(8.682, 50.110), 5000))
115
+ .descriptor,
116
+ )
117
+ // withMeters[i].meters === distance in metres
118
+
119
+ // KNN: the k nearest venues via the indexed `<->` operator.
120
+ const nearest5 = await ctx.store.query(
121
+ queryFor(venues)
122
+ .use(nearestBy('location', point(8.682, 50.110)))
123
+ .limit(5)
124
+ .descriptor,
125
+ )
126
+ ```
127
+
128
+ - **`withDistance(column, geom, { as?, useGeography? })`** — surfaces `ST_Distance(column, geom) AS "<alias>"` (default alias `distance`). Metric metres by default; pass `{ useGeography: false }` for planar SRID-unit distance.
129
+ - **`nearestBy(column, geom, { direction? })`** — orders by `column <-> geom` (the KNN operator; leads any explicit `.orderBy(...)`). Pair with `.limit(k)` for `ORDER BY geom <-> point LIMIT k` — an indexed nearest-neighbour scan when a GiST index covers the column. `<->` is planar; add `withDistance` too if you need a metric distance value.
130
+
131
+ ## GiST index
132
+
133
+ A regular B-tree index is useless for spatial queries — declare a GiST index, the PostGIS-recommended access method, via the framework's `.expressionIndex(...)` with `{ kind: 'gist' }`:
134
+
135
+ ```ts
136
+ table('venues', { id: id(), location: geography('Point', 4326) })
137
+ .expressionIndex('venues_loc_gist', [{ expr: '"location"' }], { kind: 'gist' })
138
+ ```
139
+
140
+ On postgres this emits `CREATE INDEX … USING GIST` natively. Other dialects warn and fall back to a B-tree (which won't accelerate spatial predicates) — another reason this plugin is postgres-first.