@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 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