@voltro/runtime 0.2.1 → 0.3.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 CHANGED
@@ -39,6 +39,42 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.3.0] — 2026-07-18
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/cli** — `voltro build` precompiles the whole serve path (framework + effect + `@voltro` inlined, app modules as lazy chunks) into a single **serve bundle**, and `voltro serve` boots from it in-process — cutting `serve: ready` from ~1000 ms to ~180 ms (5–6×; the win is larger on a cold scale-to-zero container). The app's declared SQL driver is inlined (only the native leaf like `pg` stays external, resolved at runtime so it survives the deploy relocation). **Production now serves ONLY from the bundle and NEVER transpiles on demand:** the bundle build externalises unresolvable optional peers (e.g. `@react-email/render` behind `@voltro/plugin-mail`) so it always builds; a bundle-build failure is **fatal** (`voltro build` exits non-zero); and an unbuilt production `voltro serve` fails loud instead of falling back to tsx. The build toolchain (`tsx`, `esbuild`, `vite`, `@vitejs/plugin-react`, `@tailwindcss/vite` + their native tree: rolldown/lightningcss/postcss/jiti) moves to **`optionalDependencies`** of `@voltro/cli`, so `pnpm --prod --no-optional deploy` yields a serve image with none of it — a prod API image's `node_modules` drops ~305 MB → ~131 MB, structurally, with no fragile prune list. `voltro dev` and a non-production local `voltro serve` are unchanged (still tsx). **Migration:** in production (`NODE_ENV=production`) run `voltro build` before `voltro serve`. The generated Dockerfiles already do; a custom Dockerfile / start script adds a `voltro build .` step before `voltro serve .` (`voltro update` prints this — see the 0.3.0 codemod note).
47
+
48
+ ### Added
49
+
50
+ - **@voltro/protocol, @voltro/runtime, @voltro/plugin-rbac** — Declarative authorization `guards:` on `defineMutation` / `defineQuery` / `defineAction`. The framework enforces the declared scope(s) in the dispatch spine BEFORE the executor (for a mutation, before the transaction opens), fails with a typed `ScopeError`, and auto-merges `ScopeError` into the wire error union so the client decodes the denial typed. Guards are browser-safe DATA (scope strings + a pure `resource: (input) => id` extractor). Checks run against the caller's EFFECTIVE scope set — raw subject scopes ∪ `@voltro/plugin-rbac` role-derived scopes — via a new canonical effective-scope seam in `@voltro/protocol` (`effectiveScopes` / `setEffectiveScopes` / `checkGuards`), which rbac now publishes to (so a role-granted scope satisfies a `guards:` entry and the in-handler `permission()` identically). Adds `ctx.access` (`has` / `hasAny` / `require` / `scopes`) — the cast-free typed authorization slice on every handler context. Enforcement is single-sourced in the shared serve pipeline, so `voltro dev` and `voltro serve` can't drift.
51
+ - **@voltro/protocol, @voltro/client** — Nested / path-targeted auto-optimistic. A mutation `target` can now patch a nested array INSIDE a query's value — a JSON array column (`snapshot.projects`) or a computed/shaped result — at item granularity, via `path` (dot-path to the array), `by` (item key, default `id`), and `match` (a pure predicate that scopes the patch to the entries whose current value satisfies it, preventing a patch bleeding across sibling subscriptions that share a source table). Previously auto-optimistic only patched the flat top-level row array keyed by `id`; nested values needed a hand-written `.withOptimistic` reducer. `path`/`by`/`match` are browser-safe descriptor data (a dot-path string + pure predicate), same discipline as `identify`/`shape`. A path insert is applied even on a computed entry (it targets a known document, not a blind top-level add).
52
+ - **@voltro/runtime** — `ctx.store.applyDefined(input, keys)` (and a standalone `applyDefined` export from `@voltro/runtime`) — builds a partial-update patch keeping only the listed keys whose value the caller actually provided (`!== undefined`; a defined falsy value like `0`/`''`/`false` is kept). Collapses the per-field `if (input.x !== undefined) patch.x = input.x` idiom every partial-update mutation hand-writes.
53
+ - **@voltro/database** — `.uniqueActive([cols], opts?)` on the table builder — a portable partial-UNIQUE constraint that holds only among the rows matching a predicate (default `"deletedAt" IS NULL`, pairing with `.softDelete()`). Emits `CREATE UNIQUE INDEX … WHERE` on postgres / sqlite / mssql, so a soft-deleted row leaves the active set and a NEW row with the same key inserts cleanly — no hand-written `generatedAs("CASE WHEN …")` column and no resurrection footgun. On mysql / mariadb (no partial-index support) it FAILS LOUDLY at migrate time rather than silently emitting a full unique index that would forbid re-creating a soft-deleted key — the generated-STORED-column lowering for those dialects is a follow-up. Kept out of the declarative index snapshot (the incremental planner is predicate-blind and would misclassify a unique+partial index as a full constraint), so the fresh-schema DDL path is its sole emitter and there is no re-diff churn. Live-verified against postgres.
54
+ - **@voltro/cli** — `voltro update` upgrades an app to the latest framework: it bumps every `@voltro/*` dependency, installs with the detected package manager, and runs the codemods shipped with the target version. Codemods are authored with `defineCodemod` + an import-scoped ts-morph helper toolkit (`renameImport`, `renameModuleSpecifier`, `renameJsxProp`, `renameObjectKey`, `add`/`removeImport`, structural `changeCallArgs`/`wrapCall`, `annotate`) and run against the app source; a `manual` kind surfaces written steps for changes that can't be automated. Breaking public-API changes now ship a codemod (or an explicit `codemod: none`), enforced by the changelog gate. Framework-owned `_voltro_*` table changes continue to ride the declarative differ on `voltro db apply` / `voltro dev` boot — `update` does not touch the database.
55
+
56
+ ### Fixed
57
+
58
+ - **@voltro/database** — The core-table registry (`registerCoreTables` / `requireActors` / `requireTenants`) now stores its state on a process-global `Symbol.for` singleton, the same mechanism the main table registry already uses — instead of module-local `let` bindings. Module-local state splits when the `@voltro/database` module is duplicated in a process (e.g. resolved through both the `.` and `./sql` entry points, or a bundled framework copy alongside an externally-resolved one): one instance's `registerCoreTables` becomes invisible to the instance that reads it, surfacing as a spurious `core 'actors' table not registered` at store construction. Pinning it to `globalThis` makes every copy share one store, matching the table registry's already-global behaviour.
59
+
60
+ ---
61
+
62
+ ## [0.2.2] — 2026-07-17
63
+
64
+ ### Added
65
+
66
+ - **@voltro/cli** — `voltro build` now precompiles the whole serve path (framework + effect + `@voltro` inlined, app modules as lazy chunks) into a single **serve bundle** (`.framework/dist-api/serveBundle/serveEntry.js`), and `voltro serve` boots from it in-process — no child `node --import tsx`, no CLI command graph, no per-module resolution of the ~2700-module framework graph. This cuts `serve: ready` from ~1000 ms to ~180 ms (~5–6×) on both driverless (memory) and driver-backed (postgres) apps; the win is larger on a cold scale-to-zero container where module resolution dominates. The app's declared SQL driver is inlined into the bundle so it shares the framework's single effect instance (only the native leaf like `pg` stays external, resolved at runtime so it survives the deploy relocation). Fully fallback-safe: a missing, stale, or corrupt bundle degrades to the standard tsx serve path, so it can never stop `voltro serve` from booting. Nothing to configure — building an API app produces the bundle and serving prefers it automatically.
67
+
68
+ ### Fixed
69
+
70
+ - **@voltro/database** — The core-table registry (`registerCoreTables` / `requireActors` / `requireTenants`) now stores its state on a process-global `Symbol.for` singleton, the same mechanism the main table registry already uses — instead of module-local `let` bindings. Module-local state splits when the `@voltro/database` module is duplicated in a process (e.g. resolved through both the `.` and `./sql` entry points, or a bundled framework copy alongside an externally-resolved one): one instance's `registerCoreTables` becomes invisible to the instance that reads it, surfacing as a spurious `core 'actors' table not registered` at store construction. Pinning it to `globalThis` makes every copy share one store, matching the table registry's already-global behaviour.
71
+
72
+ ### Internal (no consumer-facing effect)
73
+
74
+ - **@voltro/cli** — `appModuleLoader` now accepts lazy `() => import()` loaders alongside eager module namespaces (the eager path — today's `apiEntry.js` bundle — is unchanged). Groundwork for the serve bundle: app modules registered as lazy loaders evaluate on first `importAppModule` (during `runServe`, after `registerCoreTables`) rather than eagerly at bundle-import time. No consumer-facing effect on its own.
75
+
76
+ ---
77
+
42
78
  ## [0.2.1] — 2026-07-17
43
79
 
44
80
  ### Fixed
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ import { DialectReplicationAdapter } from '@voltro/database';
12
12
  import { Duration } from 'effect';
13
13
  import { Effect } from 'effect';
14
14
  import { FieldCipher } from '@voltro/database';
15
+ import { GuardCheckSpec } from '@voltro/protocol';
15
16
  import * as http from 'node:http';
16
17
  import { HttpClient } from '@effect/platform';
17
18
  import { HttpRequestInterceptor } from '@voltro/protocol';
@@ -45,6 +46,7 @@ import { RpcGroup } from '@effect/rpc';
45
46
  import { RpcInterceptor } from '@voltro/protocol';
46
47
  import { Sampler } from '@opentelemetry/sdk-trace-base';
47
48
  import { Schema } from 'effect';
49
+ import { ScopeError } from '@voltro/protocol';
48
50
  import { SpanProcessor } from '@opentelemetry/sdk-trace-base';
49
51
  import { spawn } from 'node:child_process';
50
52
  import { SqlClient } from '@effect/sql';
@@ -687,9 +689,34 @@ export declare interface ApiKeyStore {
687
689
  readonly patch: (id: string, fields: Partial<ApiKeyRow>) => Promise<void>;
688
690
  }
689
691
 
692
+ /**
693
+ * Typed authorization slice on `ctx.access` — the ergonomic, cast-free face of
694
+ * the caller's EFFECTIVE scope set (raw subject scopes ∪ rbac role-derived
695
+ * scopes). Replaces the `ctx as unknown as GuardCtx` shape apps hand-rolled to
696
+ * reach the subject's permissions: a handler reads `ctx.access.has('x')` /
697
+ * `yield* ctx.access.require('x')` directly, and it stays in lockstep with the
698
+ * declarative `guards:` the framework enforces (same effective-scope seam).
699
+ */
700
+ export declare interface AppAccess {
701
+ /** True if the caller holds `scope` (or the `admin:full` bypass). */
702
+ readonly has: (scope: string) => boolean;
703
+ /** True if the caller holds AT LEAST ONE of `scopes`. */
704
+ readonly hasAny: (scopes: ReadonlyArray<string>) => boolean;
705
+ /** Effect guard — fails with a typed `ScopeError` when the caller lacks
706
+ * `scope`. `yield* ctx.access.require('notes:write')` at the top of a
707
+ * handler mirrors a descriptor `guards:` entry for in-body branching. */
708
+ readonly require: (scope: string) => Effect.Effect<void, ScopeError>;
709
+ /** The caller's effective scope set (live — reflects rbac resolution). */
710
+ readonly scopes: ReadonlyArray<string>;
711
+ }
712
+
690
713
  export declare interface AppContext {
691
714
  readonly store: FluentStore;
692
715
  readonly request: RuntimeContext;
716
+ /** Typed authorization slice — the caller's effective scopes. Use
717
+ * `ctx.access.has(scope)` / `yield* ctx.access.require(scope)` instead of
718
+ * reaching into `ctx.request.subject.scopes`. Always present. */
719
+ readonly access: AppAccess;
693
720
  /** Cache — always present (memory backend by default). Use
694
721
  * `ctx.cache.wrap(key, { ttlMs, tags }, () => expensive())` to cache
695
722
  * derived work; tagged entries auto-invalidate when a mutation writes a
@@ -716,6 +743,15 @@ export declare interface AppContext {
716
743
  readonly events?: EventsAppContext;
717
744
  }
718
745
 
746
+ /**
747
+ * Pure helper backing `ctx.store.applyDefined`. Picks the listed keys from
748
+ * `input` whose value is not `undefined`, so a PATCH sets exactly the fields
749
+ * the caller sent — no accidental overwrite of an omitted field with
750
+ * `undefined`, no hand-written per-field guards. Exported standalone for use
751
+ * outside a handler (tests, seeds).
752
+ */
753
+ export declare const applyDefined: <I extends Record<string, unknown>>(input: I, keys: ReadonlyArray<keyof I & string>) => Partial<I>;
754
+
719
755
  /**
720
756
  * Apply ONE CDC delta to the materialized state, returning the new state and any
721
757
  * groups that need a rescan (min/max whose extreme was deleted). insert/sum/avg/
@@ -1522,6 +1558,17 @@ export declare interface FluentStore extends Omit<MutationStore, 'update' | 'del
1522
1558
  delete(table: string): DeleteBuilder;
1523
1559
  /** Keyed delete by primary key. */
1524
1560
  delete(table: string, primaryKey: string): Promise<boolean>;
1561
+ /**
1562
+ * Build an update patch from `input`, keeping ONLY the listed `keys` whose
1563
+ * value the caller actually PROVIDED (`!== undefined`). Collapses the
1564
+ * per-field `if (input.x !== undefined) patch.x = input.x` idiom every
1565
+ * partial-update mutation hand-writes:
1566
+ *
1567
+ * ```ts
1568
+ * await ctx.store.update('notes', input.id, ctx.store.applyDefined(input, ['title', 'body', 'dueAt']))
1569
+ * ```
1570
+ */
1571
+ applyDefined<I extends Record<string, unknown>>(input: I, keys: ReadonlyArray<keyof I & string>): Partial<I>;
1525
1572
  }
1526
1573
 
1527
1574
  export declare interface FluentStoreBackend {
@@ -2022,6 +2069,12 @@ export declare const makeApiKeyService: (store: ApiKeyStore, opts?: {
2022
2069
  prefix?: string;
2023
2070
  }) => ApiKeyServiceShape;
2024
2071
 
2072
+ /** Build the `ctx.access` slice for a subject. Reads the effective-scope seam
2073
+ * LIVE (via getters/closures) so a scope rbac resolves after context
2074
+ * construction is still reflected. Used by the shared AppContext builder AND
2075
+ * the test-context factory so `ctx.access` can't drift between them. */
2076
+ export declare const makeAppAccess: (subject: Subject) => AppAccess;
2077
+
2025
2078
  /** Build the `ctx.kv` facade. `KV_BACKEND` picks the backend; unknown values
2026
2079
  * fall back to `database`. */
2027
2080
  export declare const makeAsyncKv: ({ store, env }: KvFacadeOptions) => Promise<KvFacade>;
@@ -2285,6 +2338,7 @@ export declare interface MutationLike {
2285
2338
  readonly descriptor: {
2286
2339
  readonly name: string;
2287
2340
  readonly source?: string | ReadonlyArray<string> | undefined;
2341
+ readonly guards?: ReadonlyArray<GuardCheckSpec> | undefined;
2288
2342
  };
2289
2343
  executor(input: unknown, ctx: unknown): unknown;
2290
2344
  }