@voltro/runtime 0.11.2 → 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 +55 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +1110 -1045
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,61 @@ _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
|
+
|
|
42
97
|
## [0.11.2] — 2026-07-24
|
|
43
98
|
|
|
44
99
|
### Added
|
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
|
|