@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,92 @@
|
|
|
1
|
+
# Tinybird
|
|
2
|
+
|
|
3
|
+
> Hosted-ClickHouse AnalyticsSink over Tinybird's Events API + Pipes — pay-as-you-go OLAP without operating a cluster.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/tinybird.md -->
|
|
10
|
+
## Tinybird
|
|
11
|
+
|
|
12
|
+
_Hosted-ClickHouse AnalyticsSink over Tinybird's Events API + Pipes — pay-as-you-go OLAP without operating a cluster._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-tinybird` is the hosted-ClickHouse `AnalyticsSink` — it ingests
|
|
15
|
+
via Tinybird's **Events API** and reads via **Pipes** (saved SQL endpoints), so
|
|
16
|
+
you get ClickHouse-grade OLAP on a pay-as-you-go service instead of operating a
|
|
17
|
+
cluster yourself. It's for teams that ALREADY have a Tinybird workspace; if you
|
|
18
|
+
want a zero-setup external OLAP sink that owns its own schema, use
|
|
19
|
+
[`@voltro/plugin-clickhouse`](/docs/plugins/clickhouse) instead. For the shared
|
|
20
|
+
`AnalyticsSink` API and the `useAnalytics()` read methods, see
|
|
21
|
+
[Analytics & warehouse sinks](/docs/plugins/analytics).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pnpm add @voltro/plugin-tinybird
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Wiring
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// app.config.ts
|
|
33
|
+
import { tinybirdAnalytics } from '@voltro/plugin-tinybird'
|
|
34
|
+
|
|
35
|
+
export default {
|
|
36
|
+
type: 'api' as const,
|
|
37
|
+
name: 'myApi',
|
|
38
|
+
store: 'postgres' as const,
|
|
39
|
+
analytics: tinybirdAnalytics({
|
|
40
|
+
token: process.env.TINYBIRD_TOKEN!,
|
|
41
|
+
region: 'eu', // 'eu' | 'us-east' | 'us-west' | 'asia-southeast'
|
|
42
|
+
datasource: 'voltro_events',
|
|
43
|
+
}),
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`track()` POSTs events to the Datasource via the Events API; `aggregate` /
|
|
48
|
+
`timeseries` / `topN` call your Pipes. There's no boot-time initialize — the
|
|
49
|
+
Datasource is auto-created on the first event, and the Pipes must already exist.
|
|
50
|
+
The trade-off is **deferred failure**: a bad token or missing Pipe boots clean
|
|
51
|
+
and surfaces as a typed `AnalyticsError` on the first call.
|
|
52
|
+
|
|
53
|
+
## Required Pipes
|
|
54
|
+
|
|
55
|
+
The plugin EXPECTS three canonical Pipes to exist in your workspace (create them
|
|
56
|
+
once via the `tb` CLI or the UI). It does NOT synthesize them — Tinybird's
|
|
57
|
+
`.pipe` DSL is too rich to generate from a generic spec:
|
|
58
|
+
|
|
59
|
+
- `events_aggregate` — params: `event`, `from`, `to`, `metric_expr`, `filter_sql`
|
|
60
|
+
- `events_timeseries` — same + `bucket_fn`
|
|
61
|
+
- `events_topn` — same + `group_expr`, `limit`
|
|
62
|
+
|
|
63
|
+
## Options
|
|
64
|
+
|
|
65
|
+
`tinybirdAnalytics(options)`:
|
|
66
|
+
|
|
67
|
+
| Option | Type | Default | Notes |
|
|
68
|
+
|---|---|---|---|
|
|
69
|
+
| `token` | `string` | — (**required**) | Tinybird workspace token. |
|
|
70
|
+
| `region` | `string` | `'eu'` | `'eu'` / `'us-east'` / `'us-west'` / `'asia-southeast'`. Selects the API base URL. |
|
|
71
|
+
| `apiUrl` | `string` | derived from `region` | Custom API base URL — overrides `region` (self-hosted Tinybird or preview envs). |
|
|
72
|
+
| `datasource` | `string` | `'voltro_events'` | The plugin POSTs events to `/v0/events?name=<datasource>`. |
|
|
73
|
+
| `pipes` | `{ aggregate?, timeseries?, topN? }` | the three canonical names | Override Pipe names if your team uses a different convention. |
|
|
74
|
+
|
|
75
|
+
`datasource` + the Pipe names are validated as identifiers at boot (they're
|
|
76
|
+
interpolated into request URLs), so a path-traversal name fails loudly rather
|
|
77
|
+
than building a bad URL.
|
|
78
|
+
|
|
79
|
+
> **Connection details come from `options`, not env.** The plugin reads no
|
|
80
|
+
> `TINYBIRD_*` variables itself — the example wires `process.env` into the
|
|
81
|
+
> options. Use `@voltro/env`'s `defineEnv` to declare the token as a secret.
|
|
82
|
+
|
|
83
|
+
## Provider escape hatch
|
|
84
|
+
|
|
85
|
+
For direct Pipe access beyond the cross-provider contract, import the `Tinybird`
|
|
86
|
+
Tag and `yield*` it for the configured client. Using the Tag binds your handler
|
|
87
|
+
to Tinybird — stay on `useAnalytics()` for provider-portable code.
|
|
88
|
+
|
|
89
|
+
## See also
|
|
90
|
+
|
|
91
|
+
- [Analytics & warehouse sinks](/docs/plugins/analytics) — the shared
|
|
92
|
+
`AnalyticsSink` contract, the `useAnalytics()` read API, and `composeAnalytics`.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# Row versioning
|
|
2
|
+
|
|
3
|
+
> Full row history + time-travel. audit() records who/when; versioning records what-changed-to-what — a value snapshot of every row on every write, with as-of queries.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/plugins/versioning.md -->
|
|
10
|
+
## Row versioning
|
|
11
|
+
|
|
12
|
+
_Full row history + time-travel. audit() records who/when; versioning records what-changed-to-what — a value snapshot of every row on every write, with as-of queries._
|
|
13
|
+
|
|
14
|
+
`@voltro/plugin-versioning` keeps a complete value history of selected tables. Where [`audit()`](/docs/plugins/audit) records *who* changed a row and *when*, versioning records *what* — a full snapshot of the row on every insert / update / delete — and lets you read any row **as of** a past instant. It rides the framework's post-commit ChangeEvent tap, so it captures every write that goes through the store with no per-handler wiring.
|
|
15
|
+
|
|
16
|
+
## Wiring
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// app.config.ts
|
|
20
|
+
import { versioningPlugin } from '@voltro/plugin-versioning'
|
|
21
|
+
|
|
22
|
+
export default {
|
|
23
|
+
type: 'api' as const, name: 'api',
|
|
24
|
+
plugins: [versioningPlugin({})],
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Every committed change to a listed table appends a row to `_voltro_row_history` (`tableName`, `rowId`, monotonic `version`, `op`, the full `data` snapshot, `changedBy`, `changedAt`, `traceId`, `subjectId`, `procedure`).
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### What gets versioned — opt OUT, not in
|
|
32
|
+
|
|
33
|
+
`versioningPlugin({})` covers **every table your app declares**. There is no list to write and none to maintain.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
versioningPlugin({}) // every app table
|
|
37
|
+
versioningPlugin({ exclude: [domainEvents] }) // opt one out
|
|
38
|
+
versioningPlugin({ include: [aiFlowsTable] }) // add a PLUGIN's table
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Both take table **values**, not names — a misspelling is a compile error at the call site, exactly as with `reference(() => table)`.
|
|
42
|
+
|
|
43
|
+
That shape replaced a `tables: string[]` list, and both of its failure modes were silent: you listed six tables, forgot the seventh, and nothing ever told you its history was missing; and nothing cross-checked the strings, so `'invoces'` recorded nothing forever while the plugin reported itself active at boot. Forgetting is now the safe direction.
|
|
44
|
+
|
|
45
|
+
**Framework- and plugin-owned tables (`_voltro_*`, `cluster_*`) are OUT by default.** There are 34 of them, and the busiest — the CDC log, the event log, the undo log, workflow events, webhook rate windows — are append-only. A full row snapshot per write there is the history of a history, at the highest write rate in the system. Name one in `include` to version it anyway; that also works for a plugin table your app never declares, which is the supported way to version one.
|
|
46
|
+
|
|
47
|
+
A table named in both `include` and `exclude` throws at construction — only you know which was the mistake.
|
|
48
|
+
|
|
49
|
+
**Check the boot line once after upgrading.** It prints the RESOLVED count, not the configured one:
|
|
50
|
+
|
|
51
|
+
```txt
|
|
52
|
+
versioning active · tables: 41 · historyTable: _voltro_row_history · retentionDays: 365
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If 41 surprises you, `exclude` is the knob. The retention sweep (`VOLTRO_ROW_HISTORY_TTL_HOURS`) still bounds age.
|
|
56
|
+
|
|
57
|
+
## What this is NOT — the grain
|
|
58
|
+
|
|
59
|
+
Versioning records **row changes, not domain events**. One entry per row per write, named by *table*. If your product has a user-facing audit feature whose entries are named after an aggregate root — one `Team` event for a call that writes `teams` + `roles` + `userTeams` + `userTeamRoles` — this is the layer **underneath** that, not a replacement for it.
|
|
60
|
+
|
|
61
|
+
The distinction is worth reading before you plan a migration onto it. A consumer with 331 hand-written audit calls set out to delete them all in favour of this tap and found three hours in that the grain is different: a table-keyed tap does not produce an aggregate-keyed trail with better coverage, it produces a *different artifact*. Their conclusion — and ours — is that the two compose:
|
|
62
|
+
|
|
63
|
+
- **versioning** answers "what did row R look like before, and after" — for every write, whether or not anyone remembered to record it;
|
|
64
|
+
- an **aggregate trail** (the [audit sink](/docs/plugins/audit), one row per mutation invocation) answers "what business operation happened, to which entity, and did it succeed";
|
|
65
|
+
- `traceId` joins them, so one request reads as one story.
|
|
66
|
+
|
|
67
|
+
What the tap *does* remove is the silent-data-loss failure mode. A forgotten hand-written call used to mean the change was recorded nowhere; with the tap it costs the aggregate *name*, not the record.
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
### `procedure` — which call, not just which trace
|
|
71
|
+
|
|
72
|
+
A row diff carries no intent. The same `DELETE` on a join table is a member being
|
|
73
|
+
removed, a team being deleted, or a membership expiring — and `before`/`after`
|
|
74
|
+
cannot separate them, because the difference is not in the data.
|
|
75
|
+
|
|
76
|
+
So the history row records the **rpc tag** of the call that caused it:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
tableName userTeams
|
|
80
|
+
rowId ut_7f2…
|
|
81
|
+
op delete
|
|
82
|
+
procedure teams.removeSubTeamMember ← which call it WAS
|
|
83
|
+
traceId 4bf92f35… ← which call it was
|
|
84
|
+
subjectId usr_anna
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`traceId` joins this row to everything else that happened in the same request;
|
|
88
|
+
`procedure` says what the request was trying to do. A UI rendering history for a
|
|
89
|
+
human needs the second one, and no diff can supply it.
|
|
90
|
+
|
|
91
|
+
Absent for a write with no procedure behind it — a seed, a `*.startup.tsx`, a
|
|
92
|
+
migration — with the same meaning as an absent `traceId`.
|
|
93
|
+
|
|
94
|
+
## The correlation bridge — joining *what changed* to *who called*
|
|
95
|
+
|
|
96
|
+
`ChangeEvent` carries the calling `traceId` and `subjectId`, so a history row can be joined to the [audit sink](/docs/plugins/audit) row for the **same call**. Before this, both trails existed and shipped and nothing connected them: versioning knew what changed, the audit sink knew who called and whether they were refused, and no key spanned the two.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { historyByTrace, historyBySubject } from '@voltro/plugin-versioning'
|
|
100
|
+
|
|
101
|
+
// What did this call change? (`byTrace`)
|
|
102
|
+
const touched = await historyByTrace(ctx.store, traceId, ctx.request.subject.tenantId)
|
|
103
|
+
|
|
104
|
+
// What has this actor changed, most recent first? (`bySubject`, bounded)
|
|
105
|
+
const byActor = await historyBySubject(ctx.store, actorId, ctx.request.subject.tenantId, 100)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Both are **tenant-scoped** like `rowHistory` — pass the caller's `tenantId`, and `undefined` skips the filter for system/admin paths only. `historyBySubject` takes a `limit` (default 100) because an actor's history is unbounded, and an entry point that returns all of it is one you call once in production and never again. Effect handlers use `historyByTraceEffect` / `historyBySubjectEffect`.
|
|
109
|
+
|
|
110
|
+
Both questions were previously unanswerable at any speed — `byRow` is the only other index, and entering through it means *already knowing which row you are asking about*, which is the wrong way round during an incident.
|
|
111
|
+
|
|
112
|
+
**`subjectId` is the CALLER, and it is not the same claim as `changedBy`.** `changedBy` falls back to the row's own `audit()` stamp, which is a proxy with three failure modes: it only exists on tables carrying `audit()`; it names the actor but never the call, so two writes by one person a second apart are indistinguishable; and it is `null` for every write through `AuthStrategyInput.store` / `PluginHttpRouteRequest.store`, since audit stamping is part of the Subject-dependent half the [boot store](/docs/authentication/strategies) omits. A login route writes through exactly that seam. `subjectId` has none of the three, so it is preferred and the stamp is the fallback.
|
|
113
|
+
|
|
114
|
+
**Absent means the write had no request behind it** — a seed, a `*.startup.tsx`, a schedule, a workflow step — or that it arrived from another replica, where stamping the local ambient trace would attribute a remote write to a local call. Treat "neither field" as *system*, unambiguously.
|
|
115
|
+
|
|
116
|
+
## `timing` — when the history row is written
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
versioningPlugin({ timing: 'in-transaction' })
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
| | `'post-commit'` (default) | `'in-transaction'` |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| When | after the domain write commits | inside the same transaction |
|
|
125
|
+
| Can lose an entry | yes — a crash in the window between COMMIT and the write | no |
|
|
126
|
+
| Can fail your mutation | no | **yes**, if the history insert fails |
|
|
127
|
+
| Cost | none on the write path | **two round-trips** + longer lock hold, per covered write |
|
|
128
|
+
|
|
129
|
+
**Two round-trips, not one.** Each covered write reads `MAX(version)` for the row and then appends. The read shipped as an omission — the recorder wrote a constant `version: 0` and a design note argued that ordering could come from `changedAt`. It could not: `changedAt` is millisecond-resolution, so two writes to one row inside one transaction tie routinely, and `rowAsOf` / `diffVersions` read the NUMBER. A trail that is merely late can be reasoned about; a mis-ordered one cannot. Budget accordingly — a mutation writing 3 covered rows pays ~6 round-trips of history overhead, and a bulk update over N rows pays 2N, as lock-hold time.
|
|
130
|
+
|
|
131
|
+
Version numbers are 1-based in **both** timings, so switching `timing` does not shift them.
|
|
132
|
+
|
|
133
|
+
**What `'in-transaction'` promises:** if the change committed, the entry is there. Post-commit cannot promise that — between COMMIT and the forked write there is a window, and a process that dies inside it leaves the change permanent and the trail silent. You then cannot tell *"no entry because nothing happened"* from *"no entry because we crashed"*, which is what makes a lossy trail useless as evidence. Retrying does not help: the process that would retry is the one that died.
|
|
134
|
+
|
|
135
|
+
**The price is not optional.** That guarantee is only obtainable by being willing to REFUSE. When the history insert fails — disk, lock timeout, constraint, dropped connection — a transaction offers exactly two outcomes: the mutation fails with it, or the error is swallowed and the change commits without its entry, which is post-commit's hole with the cost already paid. There is no third option, so a rare, explained rejection is the shape of the guarantee rather than a defect.
|
|
136
|
+
|
|
137
|
+
**Why the default is still `'post-commit'`.** In-transaction makes `_voltro_row_history` a hard dependency of every write path it covers: its availability becomes your write path's availability, and every covered write holds its locks longer. Post-commit loses at worst *one entry*; in-transaction can, at worst, stop writes to the covered tables entirely. For a compliance trail the second trade is the right one — for the undo / time-travel use this plugin also serves, it is not.
|
|
138
|
+
|
|
139
|
+
### Under CDC, and inside a transaction
|
|
140
|
+
|
|
141
|
+
Both work, and both took a fix. The event a subscriber receives under
|
|
142
|
+
`changeStrategy: 'cdc'` (the default on postgres and mariadb) is rebuilt from a
|
|
143
|
+
NOTIFY payload or a binlog row image, neither of which can carry a request
|
|
144
|
+
context — the write path now hands its identity across that boundary explicitly.
|
|
145
|
+
A write made on ANOTHER replica has no local identity to hand over and arrives
|
|
146
|
+
with neither field, which is the correct answer: absent means *no request
|
|
147
|
+
context on this replica*, not *nobody knows*.
|
|
148
|
+
|
|
149
|
+
Inside a transaction the identity used to be lost outright, which mattered more
|
|
150
|
+
than it sounds: framework mutations are auto-transactional, so that was every
|
|
151
|
+
handler write.
|
|
152
|
+
|
|
153
|
+
### Ordering, when you join to the audit sink
|
|
154
|
+
|
|
155
|
+
With `'in-transaction'` the version rows commit **before** the rpc interceptor records the call's outcome — the audit row is what says whether the call succeeded, so it can only be written once that is known. A reader joining on `traceId` may therefore briefly see version rows with no audit-sink row.
|
|
156
|
+
|
|
157
|
+
That is the correct order, not a race to engineer around: the change is durable, and the verdict on it arrives a moment later. Read the audit row as the authority on *outcome*, never as proof that a change happened.
|
|
158
|
+
|
|
159
|
+
### Two limits that hold in BOTH timings
|
|
160
|
+
|
|
161
|
+
- **`store.raw()` is absent from the trail.** The framework does not parse hand-written SQL, so a raw write produces no change event and no history row. Enabling `'in-transaction'` does not make coverage total.
|
|
162
|
+
- **A write made outside a transaction is recorded immediately after, not atomically.** A bare `ctx.store.updateMany(...)` is not in a transaction; framework mutations are auto-transactional, so a handler's writes do get the guarantee.
|
|
163
|
+
|
|
164
|
+
`'in-transaction'` requires a SQL store and **refuses the in-memory one at boot** rather than silently doing nothing — `memory` is the default dev store, and an option that appears to work where it is cheapest to try and stops working where it matters is worse than one that says so.
|
|
165
|
+
|
|
166
|
+
### What a snapshot contains — `.encrypted()` vs `.serverOnly()`
|
|
167
|
+
|
|
168
|
+
**`.encrypted()` columns are kept, and they are ciphertext.** A snapshot stores exactly what the source column stores:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{ "id": "sess_1", "secret": "enc:v1:a56iziEV9THLhzmJ:Vk0ux+0bECleTLBJkCa0Rg==:3AtMwP" }
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
So versioning a table with encrypted columns **does not widen exposure** — the history is exactly as readable as the row it came from. This is worth stating because "full row snapshot" reads alarming next to `.encrypted()`, and the cautious reader excludes the table. One did, and only found out by measuring.
|
|
175
|
+
|
|
176
|
+
**`.serverOnly()` columns ARE withheld**, and for a sharper reason than "a second copy": `crud.*` strips those columns from every row it returns, and a snapshot would smuggle the value back past that stripping inside a `json()` blob, where no column-level rule applies. A marker meaning *never serialize this to a client* cannot survive being re-exported through a different column's contents.
|
|
177
|
+
|
|
178
|
+
The withheld names are listed under `data._omitted`, so a reader can tell *"this column was withheld"* from *"this column did not exist then"*:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{ "id": "sess_1", "label": "mac", "_omitted": ["tokenHash"] }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`.sensitive()` is not involved either way — it is an export-masking marker for values that are legitimately readable in the app.
|
|
185
|
+
## Querying the timeline
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import { rowHistory, rowAsOf } from '@voltro/plugin-versioning'
|
|
189
|
+
|
|
190
|
+
// Every version of a row, oldest → newest — TENANT-SCOPED to the caller:
|
|
191
|
+
const history = await rowHistory(ctx.store, 'posts', postId, ctx.request.subject.tenantId)
|
|
192
|
+
// history[n] = { version, op, data, changedBy, changedAt, tenantId }
|
|
193
|
+
|
|
194
|
+
// The row's value as it was at a past moment (null if it didn't exist / was deleted then):
|
|
195
|
+
const lastMonth = await rowAsOf(ctx.store, 'posts', postId, ctx.request.subject.tenantId, new Date('2026-05-01'))
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Pass the caller's `tenantId` — reads are **tenant-scoped**: a row's value timeline is visible only to its own tenant (a `null`-tenant row from an untenanted source table stays visible to all; an anonymous caller sees only those). `rowAsOf` returns the latest version at or before the instant; a version that was a delete reads back as `null` (the row wasn't present then). Pure helpers (`selectAsOf`, `nextVersionNumber`, `diffSnapshots`) are exported + unit-tested. Effect handlers use the twins `rowHistoryEffect` / `rowAsOfEffect` (and `restoreAsOfEffect` / `diffVersionsEffect`) instead of hand-wrapping.
|
|
199
|
+
|
|
200
|
+
## Restore & diff
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { restoreAsOf, diffVersions } from '@voltro/plugin-versioning'
|
|
204
|
+
|
|
205
|
+
// Roll the LIVE row back to its state at a past instant (tenant-scoped like
|
|
206
|
+
// rowAsOf — no visible state then ⇒ null, nothing written). The restore goes
|
|
207
|
+
// through the store, so it is recorded as a NEW version.
|
|
208
|
+
const restored = await restoreAsOf(ctx.store, 'posts', postId, ctx.request.subject.tenantId, when)
|
|
209
|
+
|
|
210
|
+
// Field-level delta between two versions ({ field: { from, to } }), or null
|
|
211
|
+
// when either version isn't visible to the caller's tenant (or was pruned).
|
|
212
|
+
const delta = await diffVersions(ctx.store, 'posts', postId, ctx.request.subject.tenantId, 1, 3)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## Retention
|
|
216
|
+
|
|
217
|
+
`_voltro_row_history` is append-only — one full-row-JSON version per write — so it grows with write volume. It is bounded by the framework's retention sweep: **365 days by default**, tunable via `VOLTRO_ROW_HISTORY_TTL_HOURS` (raise for longer compliance windows, lower to cap storage; the resolved window is logged at boot). For hot rows, the `maxVersionsPerRow` option additionally caps the per-row COUNT — after each recorded change, versions older than the newest N are pruned. The TTL bounds age; the cap bounds depth.
|
|
218
|
+
|
|
219
|
+
## Notes
|
|
220
|
+
|
|
221
|
+
- **History is append-only** and non-blocking — the recorder runs on the framework-supervised change tap (an `Effect` the runtime forks), so a slow recorder can't block writes, and a record failure surfaces on the tap's typed error channel (logged, not silently swallowed). A concurrent same-row write racing the version numbering is retried (bounded) instead of dropped. For a hard audit guarantee pair it with `audit()` + a transactional write.
|
|
222
|
+
- It snapshots whatever goes through `ctx.store` — out-of-band DB writes (raw `psql`) are not seen.
|
|
223
|
+
|
|
224
|
+
## Permissions
|
|
225
|
+
|
|
226
|
+
`store:write` (writes the history table) + `store:changes:read` (the ChangeEvent tap).
|