@voltro/runtime 0.11.1 → 0.11.3

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 CHANGED
@@ -39,6 +39,99 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.11.3] — 2026-07-24
43
+
44
+ ### Added
45
+
46
+ - **@voltro/runtime** — `crud.*` secure-default CRUD handler helpers + `redactColumns` (A1 core). Each returns an executor you export as a `*.query.server.ts` / `*.mutation.server.ts` default — the descriptor (schemas + `guards`) stays hand-written and browser-safe:
47
+
48
+ ```ts
49
+ // accounts.list.query.server.ts
50
+ import { crud } from '@voltro/runtime'
51
+ export default crud.list('accounts', { redact: ['apiSecret'] })
52
+ ```
53
+
54
+ They bake in the invariants a hand-rolled CRUD generator kept getting wrong (the leak class was in the HANDLERS, not the schemas):
55
+
56
+ - **Tenant scope** — `list` / `getById` read through `ctx.store`, which auto-scopes a `tenant()` table; they never `.unscoped()`, so a cross-tenant read is impossible. - **Redaction** — `redact` columns are stripped from every returned row (a credential / secret / salary a read must never ship), on reads AND on the row a `create` / `update` echoes. `redactColumns(rows, cols)` is exported standalone for a hand-written handler that isn't plain CRUD. - **`getById` returns `null`, never throws** — a reactive getter that throws stalls its shared-WS siblings (pairs with the per-subscription error isolation).
57
+
58
+ What they deliberately DON'T do is authorize: a guard runs before the executor, so gating stays on the DESCRIPTOR (`guards: [...]`) — an executor can't gate itself. Keep write descriptors guarded.
59
+
60
+ Scope note: this is the browser-safe, codegen-free core. Deriving the descriptor SCHEMAS from a table (to drop the hand-written `Schema.Struct`) is structurally a codegen concern — a table VALUE can't be imported into a browser-loaded descriptor (it drags the store into the bundle; `rowSchema` is server-only for exactly this reason) — so full schema-derivation + a `.crud()` boot audit for the scope/gating discipline are a separate, planned pass. See `plans/framework-a1-defineCrud.md`.
61
+ - **@voltro/runtime** — `ctx.store.links(junctionTable, anchor)` — a diff-based writer for a many-to-many JUNCTION table (A2). It reconciles the links from one anchor row against a target-id list by writing only the DIFFERENCE:
62
+
63
+ ```ts
64
+ await ctx.store.links('post_tags', { postId: post.id }).set(tagIds) // add missing, remove surplus
65
+ await ctx.store.links('post_tags', { postId: post.id }).add([tagId]) // idempotent
66
+ await ctx.store.links('post_tags', { postId: post.id }).remove([tagId])
67
+ await ctx.store.links('post_tags', { postId: post.id }).list() // current target ids
68
+ ```
69
+
70
+ Why it belongs in the framework rather than every app: a drop-all-then-reinsert `setLinks` loses data when two writers overlap and makes a reactive subscription on the junction churn every row (flicker) even when nothing changed. `links().set()` touches only the rows that actually differ — the added are inserted, the removed deleted, the unchanged left in place — so a reactive consumer sees a change only for what changed, and `set()` returns `{ added, removed }`. `add`/`remove` are likewise idempotent (they read first and act only on the genuine delta).
71
+
72
+ `anchor` names the source column and its id (`{ postId: 'p1' }`); the target column is the junction's OTHER `reference()` column, auto-detected. A junction with anything but exactly two reference columns is refused with a message naming what it found — use plain `insertMany`/`deleteMany` for a non-standard junction. The writes go through the normal stamped/tenant-scoped store path, so tenant and audit columns are filled as usual. Additive: a new `links` method on `FluentStore` + the `JunctionLinks` interface.
73
+ - **@voltro/client, @voltro/web** — `useSubscription(..., { initialSnapshot })` — the last mile of "SSR-correct first paint, then live" (A5). Pass the value an SSR loader already fetched with `ctx.query` (read it in the component with `useLoaderData()`) and the subscription shows it at the first paint with `loading: false` — it IS real server data — then swaps to the live stream the instant its first snapshot arrives:
74
+
75
+ ```tsx
76
+ const seed = useLoaderData<Employee>()
77
+ const { data } = useSubscription('app', 'employees.me', {}, { initialSnapshot: seed })
78
+ ```
79
+
80
+ The SSR markup and the hydration render read the same loader value, so they match (no hydration flicker), and the app no longer hand-builds a seed store to bridge loader data into the first render. This is the difference from `fallback`, whose value never came from the server and so keeps `loading: true`; use exactly one of the two. Like `fallback`, `initialSnapshot` guarantees `data` is present, so the call gets the non-union result and needs no `loading` branch. Additive: a new `initialSnapshot` field on `SubscriptionOptions` + an overload; `@voltro/web` re-exports the client surface.
81
+ - **@voltro/cli** — `apis.<name>.authHeaders` in a web `app.config.ts` — a declarative per-reconnect auth-header resolver, so an authenticated split-origin web app no longer hand-mounts `VoltroRuntimeProvider` just to inject a rotating-token thunk (A4). The framework owns the client mount, the reconnect re-resolve, and the SSR-null case (the resolver runs browser-only — it never fires on the server):
82
+
83
+ ```ts
84
+ // app.config.ts
85
+ apis: {
86
+ api: {
87
+ package: '@app/api',
88
+ authHeaders: async () => ({ authorization: `Bearer ${await getToken()}` }),
89
+ },
90
+ }
91
+ ```
92
+
93
+ Because it's a FUNCTION, the codegen imports it from `app.config.ts` into the client bundle rather than serializing it — so a config that declares `authHeaders` must stay browser-safe (no `node:*` / server-only value imports; a pure env schema is fine, and tree-shakes out). It supersedes a static `headers` on the same api. The provider already resolved a `ResolvableHeaders` thunk fresh per connection generation; this just lets you declare it in config instead of hand-writing a `mount()` call.
94
+
95
+ ---
96
+
97
+ ## [0.11.2] — 2026-07-24
98
+
99
+ ### Added
100
+
101
+ - **@voltro/i18n** — Two escapes for adopting typed messages (`createTypedMessages`, #16) app-wide (#19):
102
+
103
+ - **`t.dynamic(runtimeKey, values?)`** — a first-class escape for a genuinely runtime-computed key, on both `useT` and the `useTFn()` result. It takes a plain string with NO forced ICU args, so it doesn't fight the strict literal-key surface. Until now the natural escape — casting a computed key to the catalog key union — made things WORSE: that union spans placeholder-bearing keys, so the call then demanded a spurious 2nd ICU arg. `t.dynamic` is the documented, discoverable alternative. - **`LooseTFunction`** — the widened `(id: string, values?) => string` signature to type a `t` pass-through across a package boundary that can't import the app catalog, instead of falling back to `(...args: any[]) => string`. A strict `TypedTFunction` is deliberately NOT assignable to it (a narrowed key param can't satisfy a wider one — that would erase the checking); pass `t.dynamic` at the boundary, which IS a `LooseTFunction`.
104
+
105
+ Additive: `TypedTFunction<C>` gains a `.dynamic` member (the callable surface is unchanged, so `Parameters<TypedTFunction<C>>[0]` and existing typed call sites still resolve). `createTypedMessages` attaches `.dynamic` in place on the two translate functions — no new per-render closure, so a captured `t`'s identity stays stable.
106
+ - **@voltro/testing, @voltro/database** — `fixtureRow(table, overrides)` (`@voltro/testing`) completes a partial test row so it satisfies the 0.11.1 required-column insert validation — WITHOUT disabling the check. It fills every NOT-NULL, no-default, non-auto-stamped column the payload omits with a schema-typed placeholder (a `oneOf` column takes its first allowed value; a `unique` column gets a distinct value per call so two fixtures don't collide; `timestamp`/`date` get a fixed epoch), then merges your overrides on top (an explicit value always wins). It leaves out exactly what a caller may omit — nullable, defaulted, and framework auto-stamped columns (id / tenant / audit) — and refuses to guess a structured type (`json` / `bytes` / `vector` / `array` / `interval` / `raw`), throwing a message that names the column and says to pass it explicitly.
107
+
108
+ The motivating case: 0.11.1 made the in-memory/test store reject the same partial inserts real Postgres always would (correct — it surfaced a latent prod bug), which turned lean fixtures (`insert(users, { id })`, an omitted required FK) into `TableValidationFailed`. The wrong fix is a `validateInserts: false` knob — it re-hides that bug class, and a test store laxer than production is a fake testing itself. `fixtureRow` is the right one: it makes the fixture COMPLETE.
109
+
110
+ ```ts
111
+ await ctx.store.insert('journal_entries', fixtureRow(journalEntries, {
112
+ tenantId, amount: '100.00', // the columns THIS test cares about
113
+ })) // entryNumber, postedAt, … auto-filled + unique
114
+ ```
115
+
116
+ It is a runtime filler for the loose `store.insert(name, row)` path (what fixtures use). For COMPILE-time payload typing, use `insertRow` / `upsertRow` from `@voltro/database`. The auto-stamped column set it skips is now exported as `AUTO_FILLED_COLUMNS` from `@voltro/database` — the same list `InferInsertRow` derives its optional columns from, single-sourced so the two can't drift.
117
+
118
+ ### Changed
119
+
120
+ - **@voltro/cli** — `voltro build` now emits **directly-executable** boot bundles for BOTH app kinds: the web start bundle (`.framework/dist-web/startBundle/startEntry.js`) and the api serve bundle (`.framework/dist-api/serveBundle/serveEntry.js`) each carry a main-guard that boots the app when run as `node <entry>.js`, and stays inert when imported (the `voltro start` / `voltro serve` dev fast paths are unchanged). Production containers can now use `CMD ["node", "…/startEntry.js"]` (or `serveEntry.js`) instead of `pnpm voltro start` / `pnpm voltro serve` — no pnpm process, no `@voltro/cli` bin at runtime — which is what makes `voltro prune-runtime` safe to enable on both: with the self-contained bundle as the real entrypoint, the @vercel/nft trace roots there and legitimately drops `@voltro/cli` and the whole inlined framework tree (a static site's `node_modules` collapses to ~0; a memory api's 146 MB → 11 MB). `prune-runtime` now also roots the trace at the serve bundle. The serve entry chdir's to the app root BEFORE its app-module registry keys are computed from cwd, preserving relocation-safety. Existing `pnpm voltro start` / `pnpm voltro serve` entrypoints keep working. The standalone Dockerfiles gain a build-time boot smoke that fails the build unless the pruned tree reaches ready.
121
+
122
+ ### Fixed
123
+
124
+ - **@voltro/i18n** — `createTypedMessages` (#16) no longer extracts phantom required vars from a nested plural/select message (#19). For `'{count, plural, one {# day} other {# days total duration}}'`, the type-level `ICUVars` parse was reading a branch's TEXT (`"# days total duration"`) as a bogus required arg name, so `useT('key', { count })` failed to typecheck even though it renders perfectly — and a real var nested inside a branch was dropped. `ICUArgName` now resolves to `never` for any candidate that isn't a valid ICU identifier (`^[A-Za-z0-9_]+$`), so branch text — which contains spaces / `#` / `—` — is never mistaken for a var. Only the top-level arg (`count`) is required, matching what the message actually needs.
125
+
126
+ Scope note: a REAL var nested inside a plural branch (`other {# — {discipline}}`) is still not collected, so it reads as not-required rather than wrongly-required — the safe direction. Apps that pluralise in JS over simple `{count}` messages (the Voltro idiom) were already fully typed and are unaffected.
127
+ - **@voltro/database, @voltro/runtime** — `InferInsertRow` (and thus `insertRow` / `upsertRow`, #15) no longer requires a non-nullable DB-generated (`generatedAs`) column (#20). A stored/virtual generated column declared without `.nullable()` and without a default was typed **required**, but MariaDB/Postgres REJECT an explicit value for a generated column — so the type forced the caller to pass a value the database refuses at runtime. `.generatedAs()` now marks the column optional-for-insert exactly like a `.default()` column (the DB supplies it), so it may be omitted; the whole payload guard on every real column stays intact.
128
+
129
+ Two runtime halves complete it, so the loose `store.insert(name, row)` path agrees: the required-column validation (`missingRequiredColumns`) skips generated columns — omitting one is correct, never a missing-column error — and the store write path now STRIPS any value a caller supplied for a generated column before the INSERT reaches the dialect (tracked on the schema registry as `generatedColumns`), so a value from an untyped insert can't blow up on MariaDB. A generated column is never caller-supplied; the framework and the DB own it end to end.
130
+
131
+ The `.generatedAs()` return type narrows from `this` to `ColumnBuilder<…, true>` (the HasDefault flag) — a purely more-permissive refinement: it only makes the column omittable, so no existing code stops compiling.
132
+
133
+ ---
134
+
42
135
  ## [0.11.1] — 2026-07-23
43
136
 
44
137
  ### Added
@@ -71,6 +164,8 @@ _Changes staged for the next release accumulate here (rolled up from
71
164
  - **@voltro/database, @voltro/runtime** — `store.insert` / `upsert` / `insertIgnore` now raise a clear, typed `TableValidationFailed` naming the column when the payload omits one that is NOT NULL, has no default, and isn't auto-stamped — instead of a raw dialect `SqlError: Failed to execute statement` (`Field '…' doesn't have a default value`) surfaced only on the INSERT path (so it lay dormant until the first row with no existing cache entry). An upsert / insertIgnore whose payload is missing one of its own `conflictColumns` is likewise named at the call (an absent conflict key can't match its target). The check runs AFTER stamping, so auto-id / tenant / audit columns never trip it, and skips nullable, defaulted, and id (`idScheme`) columns — exactly the ones a caller may legitimately omit.
72
165
 
73
166
  Two pure helpers back it — `missingRequiredColumns(table, row)` and `missingConflictColumns(conflictColumns, row)` (exported from `@voltro/database`). This is the runtime half of the "handler data silently disagrees with the schema" class; a compile-time payload type needs the column DSL to track `hasDefault` at the type level, which is a separate change.
167
+
168
+ **Migration impact — behaviour-breaking for lenient test fixtures.** The in-memory/test store now rejects the same partial inserts a real Postgres always would, so it stops being laxer than production — which is the point (it surfaced at least one latent prod bug where a NOT-NULL `text().unique()` column was written without a value). But a fixture that inserted a partial row (`{ id }` parents, an omitted required FK) and passed against the old lenient memory store now throws `TableValidationFailed`. There is no code-level codemod — the fix is fixture DATA: fill the required columns. Use the new `fixtureRow(table, overrides)` helper in `@voltro/testing`, which fills every NOT-NULL-no-default column with a schema-typed placeholder and merges your overrides on top, so a fixture complies without disabling the check. There is deliberately no opt-out to turn the validation off: a test store that accepts rows production rejects is a fake testing itself.
74
169
  - **@voltro/database, @voltro/plugin-ai-flows, @voltro/plugin-audit, @voltro/plugin-deactivation, @voltro/plugin-soft-delete** — Compile-time payload typing for writes (#15) — the type-level half that the runtime `TableValidationFailed` guard flagged as a separate change. `insertRow` / `upsertRow` take the TABLE OBJECT (not a string name), so the payload is checked against `InferInsertRow<T>`: every column is required EXCEPT nullable ones, columns with a default, and the framework-filled id/tenant/audit columns. A missing NOT-NULL-no-default column — the exact `lastRefreshedAt` / `teamId` omission from the report — is now a COMPILE error at the call, not a runtime SqlError only on the INSERT path; `upsertRow`'s `conflictColumns` are constrained to the table's own columns too.
75
170
 
76
171
  import { insertRow } from '@voltro/database' await insertRow(ctx.store, roadmapEpicStats, { teamId, lastRefreshedAt: new Date() })
package/dist/index.d.ts CHANGED
@@ -1519,6 +1519,48 @@ export declare const counter: (name: string, description?: string) => Metric.Met
1519
1519
  */
1520
1520
  export declare const countRunningWorkflows: (store: DataStore) => Promise<number>;
1521
1521
 
1522
+ /**
1523
+ * Secure-default CRUD executor factories. Each takes the table NAME (not the
1524
+ * table value — that would be a server import in a descriptor) and returns an
1525
+ * `(input, ctx) => …` executor for a `*.server.ts` default export.
1526
+ */
1527
+ export declare const crud: {
1528
+ /** Tenant-scoped list of every row, redacted. */
1529
+ list: (table: string, options?: CrudReadOptions) => (_input: unknown, ctx: AppContext) => Promise<ReadonlyArray<Row>>;
1530
+ /** One row by id, or `null` when absent — never throws. Redacted. */
1531
+ getById: (table: string, options?: CrudReadOptions) => (input: {
1532
+ readonly id: string;
1533
+ }, ctx: AppContext) => Promise<Row | null>;
1534
+ /** Insert the input as a new row (id / tenant / audit auto-stamped). The echoed
1535
+ * row is redacted. Guard the DESCRIPTOR — this does not gate. */
1536
+ create: (table: string, options?: CrudWriteOptions) => (input: Row, ctx: AppContext) => Promise<Row>;
1537
+ /** Patch a row by id (`{ id, ...patch }`); returns the updated row or `null`.
1538
+ * Redacted. Guard the DESCRIPTOR. */
1539
+ update: (table: string, options?: CrudWriteOptions) => (input: {
1540
+ readonly id: string;
1541
+ } & Record<string, unknown>, ctx: AppContext) => Promise<Row | null>;
1542
+ /** Delete a row by id; returns `{ deleted }`. Guard the DESCRIPTOR. */
1543
+ remove: (table: string) => (input: {
1544
+ readonly id: string;
1545
+ }, ctx: AppContext) => Promise<{
1546
+ readonly deleted: boolean;
1547
+ }>;
1548
+ };
1549
+
1550
+ /** Options common to a generated READ. */
1551
+ export declare interface CrudReadOptions {
1552
+ /** Columns stripped from every returned row — a secret/credential a generated
1553
+ * read must never ship (`bankIban`, `tokenHash`, `salary`). The wire schema on
1554
+ * the descriptor should omit them too, so they never reach the client at all;
1555
+ * this is the runtime half that guarantees it regardless. */
1556
+ readonly redact?: ReadonlyArray<string>;
1557
+ }
1558
+
1559
+ /** Options for a generated WRITE — `redact` applies to the row the write echoes. */
1560
+ export declare interface CrudWriteOptions {
1561
+ readonly redact?: ReadonlyArray<string>;
1562
+ }
1563
+
1522
1564
  /** Read the active context, if any. Tests use this to validate the
1523
1565
  * propagation; production code uses it only inside this module. */
1524
1566
  export declare const currentRoutingContext: () => RoutingContext | undefined;
@@ -1974,6 +2016,16 @@ export declare interface FieldChange {
1974
2016
  export declare const fingerprintFor: (table: string, predicate: Predicate, indexHint?: string) => string;
1975
2017
 
1976
2018
  export declare interface FluentStore extends Omit<MutationStore, 'update' | 'delete' | 'query'> {
2019
+ /**
2020
+ * Diff-based many-to-many link writer for a junction table. `anchor` names the
2021
+ * source column and its id (`{ postId: 'p1' }`); the target column is the
2022
+ * junction's other reference column, auto-detected. Only the difference is
2023
+ * written, so reactive consumers see one change per changed row, not a
2024
+ * drop+reinsert of the whole set.
2025
+ *
2026
+ * await ctx.store.links('post_tags', { postId: post.id }).set(tagIds)
2027
+ */
2028
+ links(junctionTable: string, anchor: Readonly<Record<string, string>>): JunctionLinks;
1977
2029
  /**
1978
2030
  * Execute a query descriptor and return the matching rows — TYPED.
1979
2031
  *
@@ -2454,6 +2506,34 @@ export declare type IvmState = ReadonlyMap<string, GroupState>;
2454
2506
  /** Project the public aggregate value from a group's accumulator. */
2455
2507
  export declare const ivmValue: (shape: AggregateShape, g: GroupState) => number | null;
2456
2508
 
2509
+ /**
2510
+ * Diff-based writer for a many-to-many JUNCTION table (A2). Reconciles the set
2511
+ * of links from ONE anchor row (`{ [sourceColumn]: id }`) against a target-id
2512
+ * list by writing only the DIFFERENCE — the added rows are inserted, the removed
2513
+ * rows are deleted, and rows already correct are left untouched. That is the
2514
+ * whole point over a drop-all-then-reinsert `setLinks`: a reactive subscription
2515
+ * on the junction sees a change event only for the rows that actually changed
2516
+ * (no flicker, no lost data if two writers overlap), and an unchanged link never
2517
+ * churns. The TARGET column is the junction's OTHER reference column (the one the
2518
+ * anchor doesn't name); a junction with anything but exactly two reference
2519
+ * columns is rejected with a message naming what it found.
2520
+ */
2521
+ export declare interface JunctionLinks {
2522
+ /** The current target ids linked to the anchor. */
2523
+ list(): Promise<ReadonlyArray<string>>;
2524
+ /** Reconcile the links to EXACTLY `targetIds` — insert the missing, delete the
2525
+ * surplus, leave the rest. Returns what changed. */
2526
+ set(targetIds: ReadonlyArray<string>): Promise<{
2527
+ readonly added: ReadonlyArray<string>;
2528
+ readonly removed: ReadonlyArray<string>;
2529
+ }>;
2530
+ /** Link `targetIds` that aren't linked yet (idempotent — existing links are
2531
+ * not re-inserted, so they emit no event). Returns the ids actually added. */
2532
+ add(targetIds: ReadonlyArray<string>): Promise<ReadonlyArray<string>>;
2533
+ /** Unlink `targetIds` that are currently linked. Returns the ids actually removed. */
2534
+ remove(targetIds: ReadonlyArray<string>): Promise<ReadonlyArray<string>>;
2535
+ }
2536
+
2457
2537
  export declare interface KvFacade {
2458
2538
  readonly kv: AsyncKv;
2459
2539
  /** The resolved Effect-native `Kv` service instance. Its methods close over
@@ -3611,6 +3691,13 @@ export declare const recordTimelineEvent: (change: CdcChange & {
3611
3691
  readonly tenantId?: string | null;
3612
3692
  }) => void;
3613
3693
 
3694
+ /**
3695
+ * Strip `redact` columns from a set of rows. Exposed on its own so a hand-written
3696
+ * handler that isn't a plain CRUD read can still redact declaratively and be
3697
+ * audited the same way. Pure — no store, no context.
3698
+ */
3699
+ export declare const redactColumns: <R extends Row>(rows: ReadonlyArray<R>, redact: ReadonlyArray<string>) => ReadonlyArray<R>;
3700
+
3614
3701
  /** Blank sensitive-looking columns. Returns a new object; null passes through. */
3615
3702
  export declare const redactRow: (row: Row_2 | null | undefined) => Row_2 | null;
3616
3703
 
@@ -3746,6 +3833,15 @@ export declare interface RegistryTableLike {
3746
3833
  * overwrites whatever the caller passed.
3747
3834
  */
3748
3835
  readonly computed?: (row: Readonly<Record<string, unknown>>) => unknown;
3836
+ /**
3837
+ * DB-generated column (`generatedAs`) — the database engine computes
3838
+ * it. MariaDB/Postgres reject an explicit value, so the MutationStore
3839
+ * strips any the caller supplied before the INSERT reaches the dialect.
3840
+ */
3841
+ readonly generatedAs?: {
3842
+ readonly expr: string;
3843
+ readonly stored: boolean;
3844
+ };
3749
3845
  }>;
3750
3846
  readonly appliedMixins?: ReadonlyArray<{
3751
3847
  readonly id?: string;
@@ -4783,6 +4879,12 @@ export declare interface SchemaInfo {
4783
4879
  * on every UPDATE against the merged post-update row.
4784
4880
  */
4785
4881
  readonly computedFields: ReadonlyMap<string, (row: Readonly<Record<string, unknown>>) => unknown>;
4882
+ /**
4883
+ * DB-generated columns (`generatedAs`). The MutationStore strips any
4884
+ * caller-supplied value for these before the INSERT — the engine computes
4885
+ * them and the dialect rejects an explicit value.
4886
+ */
4887
+ readonly generatedColumns: ReadonlySet<string>;
4786
4888
  /**
4787
4889
  * Columns that a query predicate can hit and benefit from an index:
4788
4890
  * - column-level `.index()`-flagged columns
@@ -4846,6 +4948,13 @@ export declare interface SchemaRegistry {
4846
4948
  * overwrites whatever the caller passed for that column.
4847
4949
  */
4848
4950
  computedFields(table: string): ReadonlyMap<string, (row: Readonly<Record<string, unknown>>) => unknown>;
4951
+ /**
4952
+ * DB-generated columns (`generatedAs`) for a table. Empty set if none.
4953
+ * Read by MutationStore.stampedForInsert to STRIP any caller-supplied
4954
+ * value before the INSERT — the DB engine computes these and MariaDB/
4955
+ * Postgres reject an explicit value.
4956
+ */
4957
+ generatedColumns(table: string): ReadonlySet<string>;
4849
4958
  /**
4850
4959
  * Optional pre-INSERT schema decoder for a table. `undefined` when
4851
4960
  * the table didn't ship `.validate(schema)`. The MutationStore runs