@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.
- package/CHANGELOG.md +163 -0
- package/dist/apiBuild-BOqL1dlg.js +2 -0
- package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
- package/dist/appGraph-CvQCte0z.js +2 -0
- package/dist/appGraph-gQ_6GkQQ.js +110 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-ClRvH_SN.js +2 -0
- package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
- package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
- package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
- package/dist/dbCommand-BwQ_Jl98.js +2 -0
- package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
- package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
- package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
- package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
- package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
- package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
- package/dist/serveEntry.js +2 -2
- package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +43 -43
- package/templates/agent-docs/_index.md +43 -43
- package/templates/agent-docs/_manifest.json +84 -42
- package/templates/agent-docs/cli.md +4 -2
- package/templates/agent-docs/database/migrations.md +14 -1
- package/templates/agent-docs/internationalization.md +54 -3
- package/templates/agent-docs/plugins/ai-flows.md +148 -0
- package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
- package/templates/agent-docs/plugins/atlassian.md +378 -0
- package/templates/agent-docs/plugins/audit.md +324 -0
- package/templates/agent-docs/plugins/auth-auth0.md +92 -0
- package/templates/agent-docs/plugins/auth-clerk.md +90 -0
- package/templates/agent-docs/plugins/auth-kinde.md +88 -0
- package/templates/agent-docs/plugins/auth-oidc.md +95 -0
- package/templates/agent-docs/plugins/auth-supabase.md +103 -0
- package/templates/agent-docs/plugins/auth-workos.md +105 -0
- package/templates/agent-docs/plugins/auth.md +227 -0
- package/templates/agent-docs/plugins/billing.md +324 -0
- package/templates/agent-docs/plugins/broadcast.md +144 -0
- package/templates/agent-docs/plugins/cdc-out.md +116 -0
- package/templates/agent-docs/plugins/clickhouse.md +91 -0
- package/templates/agent-docs/plugins/datadog.md +105 -0
- package/templates/agent-docs/plugins/deactivation.md +114 -0
- package/templates/agent-docs/plugins/duckdb.md +82 -0
- package/templates/agent-docs/plugins/flags.md +146 -0
- package/templates/agent-docs/plugins/governance.md +143 -0
- package/templates/agent-docs/plugins/licensing.md +74 -0
- package/templates/agent-docs/plugins/logship.md +56 -0
- package/templates/agent-docs/plugins/mail.md +334 -0
- package/templates/agent-docs/plugins/moderation.md +65 -0
- package/templates/agent-docs/plugins/multitenancy.md +99 -0
- package/templates/agent-docs/plugins/notifications.md +252 -0
- package/templates/agent-docs/plugins/openapi.md +94 -0
- package/templates/agent-docs/plugins/postgis.md +140 -0
- package/templates/agent-docs/plugins/posthog.md +91 -0
- package/templates/agent-docs/plugins/presence.md +115 -0
- package/templates/agent-docs/plugins/prometheus.md +124 -0
- package/templates/agent-docs/plugins/ratelimit.md +274 -0
- package/templates/agent-docs/plugins/rbac.md +270 -0
- package/templates/agent-docs/plugins/scim.md +63 -0
- package/templates/agent-docs/plugins/search.md +116 -0
- package/templates/agent-docs/plugins/sentry.md +192 -0
- package/templates/agent-docs/plugins/soft-delete.md +72 -0
- package/templates/agent-docs/plugins/sso-saml.md +132 -0
- package/templates/agent-docs/plugins/storage.md +709 -0
- package/templates/agent-docs/plugins/tinybird.md +92 -0
- package/templates/agent-docs/plugins/versioning.md +226 -0
- package/templates/agent-docs/plugins/webhooks.md +585 -0
- package/templates/agent-docs/security.md +12 -1
- package/templates/agent-docs/whats-new.md +81 -37
- 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-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +10 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- 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-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +9 -9
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +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-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- 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/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +2 -2
- package/dist/apiBuild-Ca6Sq5jy.js +0 -2
- package/dist/checkCommand-DHD0aBeR.js +0 -2
- package/dist/dbCommand-wfGi3FgG.js +0 -2
- 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.
|