@voltro/testing 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.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { Effect as e, Layer as t, Schema as n } from "effect";
2
2
  import { allRegisteredTables as r } from "@voltro/database";
3
3
  import { installEnvSnapshot as i } from "@voltro/env";
4
- import { InMemoryDataStore as a, makeSchemaRegistry as o, wrapStoreWithMixinBehaviour as s } from "@voltro/runtime";
5
- import { anonymousSubject as c } from "@voltro/protocol";
6
- import { CurrentWorkflowRunId as l, inMemoryWorkflowEngineLayer as u, makeInMemoryRecorder as d } from "@voltro/workflow";
4
+ import { InMemoryDataStore as a, makeAppAccess as o, makeSchemaRegistry as s, wrapStoreWithMixinBehaviour as c } from "@voltro/runtime";
5
+ import { anonymousSubject as l } from "@voltro/protocol";
6
+ import { CurrentWorkflowRunId as u, inMemoryWorkflowEngineLayer as d, makeInMemoryRecorder as f } from "@voltro/workflow";
7
7
  //#region src/invoke.ts
8
- var f = async (e, t, r, i) => {
8
+ var p = async (e, t, r, i) => {
9
9
  let a = e.input;
10
10
  return t(await n.decodeUnknownPromise(a)(r), i);
11
- }, p = class {
11
+ }, m = class {
12
12
  currentMs;
13
13
  constructor(e = /* @__PURE__ */ new Date("2026-01-01T00:00:00Z")) {
14
14
  this.currentMs = typeof e == "number" ? e : e.getTime();
@@ -20,9 +20,9 @@ var f = async (e, t, r, i) => {
20
20
  return new Date(this.currentMs);
21
21
  }
22
22
  advance(e) {
23
- this.currentMs += typeof e == "number" ? e : m(e);
23
+ this.currentMs += typeof e == "number" ? e : h(e);
24
24
  }
25
- }, m = (e) => {
25
+ }, h = (e) => {
26
26
  let t = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(e);
27
27
  if (!t) throw Error(`mockClock: cannot parse duration '${e}'`);
28
28
  let n = Number(t[1]), r = t[2];
@@ -34,7 +34,7 @@ var f = async (e, t, r, i) => {
34
34
  case "d": return n * 864e5;
35
35
  default: throw Error(`mockClock: unknown unit '${r}'`);
36
36
  }
37
- }, h = class {
37
+ }, g = class {
38
38
  sent = [];
39
39
  send(e) {
40
40
  this.sent.push({
@@ -48,7 +48,7 @@ var f = async (e, t, r, i) => {
48
48
  clear() {
49
49
  this.sent.length = 0;
50
50
  }
51
- }, g = class {
51
+ }, _ = class {
52
52
  queue;
53
53
  calls = [];
54
54
  constructor(e) {
@@ -63,7 +63,7 @@ var f = async (e, t, r, i) => {
63
63
  remaining() {
64
64
  return this.queue.length;
65
65
  }
66
- }, _ = (e) => e, v = "00000000000000000000000000000000", y = (e) => {
66
+ }, v = (e) => e, y = "00000000000000000000000000000000", b = (e) => {
67
67
  let t = /* @__PURE__ */ new Map(), n = /* @__PURE__ */ new Map(), r = (n) => {
68
68
  let r = t.get(n);
69
69
  return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
@@ -102,7 +102,7 @@ var f = async (e, t, r, i) => {
102
102
  return i(e, o, n.ttlMs, n.tags), o;
103
103
  }
104
104
  };
105
- }, b = (e) => {
105
+ }, x = (e) => {
106
106
  let t = /* @__PURE__ */ new Map(), n = (n) => {
107
107
  let r = t.get(n);
108
108
  return r === void 0 ? !1 : r.expiresAt !== null && r.expiresAt <= e() ? (t.delete(n), !1) : !0;
@@ -130,37 +130,38 @@ var f = async (e, t, r, i) => {
130
130
  t.clear();
131
131
  }
132
132
  };
133
- }, x = (e = {}) => {
133
+ }, S = (e = {}) => {
134
134
  i({
135
135
  ...process.env,
136
136
  ...e.env ?? {}
137
137
  });
138
- let t = o(e.tables ?? r()), n = new a(e.store ?? {}), l = new p(e.clockStart), u = new h(), d = new g(e.llmResponses ?? []), f = y(() => l.now()), m = b(() => l.now()), _ = (r) => {
138
+ let t = s(e.tables ?? r()), n = new a(e.store ?? {}), u = new m(e.clockStart), d = new g(), f = new _(e.llmResponses ?? []), p = b(() => u.now()), h = x(() => u.now()), v = (r) => {
139
139
  let i = {
140
140
  subject: r,
141
- traceId: v
141
+ traceId: y
142
142
  };
143
143
  return {
144
- clock: l,
145
- email: u,
146
- llm: d,
144
+ clock: u,
145
+ email: d,
146
+ llm: f,
147
147
  ...e.ai === void 0 ? {} : { ai: e.ai },
148
148
  request: i,
149
- cache: f,
150
- kv: m,
151
- store: s(n, {
149
+ access: o(r),
150
+ cache: p,
151
+ kv: h,
152
+ store: c(n, {
152
153
  subject: r,
153
154
  schemaRegistry: t
154
155
  }),
155
- withSubject: (e, t) => Promise.resolve(t(_(e))),
156
- withTenant: (e, t) => Promise.resolve(t(_({
156
+ withSubject: (e, t) => Promise.resolve(t(v(e))),
157
+ withTenant: (e, t) => Promise.resolve(t(v({
157
158
  ...r,
158
159
  tenantId: e
159
160
  })))
160
161
  };
161
162
  };
162
- return _(e.subject ?? c(null));
163
- }, S = (e) => {
163
+ return v(e.subject ?? l(null));
164
+ }, C = (e) => {
164
165
  let t = /* @__PURE__ */ new Map();
165
166
  for (let n of e) {
166
167
  let e = t.get(n.stepName);
@@ -186,19 +187,19 @@ var f = async (e, t, r, i) => {
186
187
  });
187
188
  }
188
189
  return n.sort((e, t) => e._startedAt - t._startedAt), n.map(({ _startedAt: e, ...t }) => t);
189
- }, C = (n) => {
190
+ }, w = (n) => {
190
191
  let r = n.workflows ?? [], i = /* @__PURE__ */ new Map(), a = 0;
191
192
  return {
192
193
  start: async (o, s) => {
193
194
  let c = r.find((e) => e.workflow.name === o);
194
195
  if (c === void 0) throw Error(`makeWorkflowRunner: no workflow named '${o}' — pass it in makeWorkflowRunner({ ctx, workflows: [{ workflow, execute }] }).`);
195
- let f = `wfrun_test_${++a}`, p = d(), m = c.workflow.toLayer(c.execute).pipe(t.provideMerge(u)), h = /* @__PURE__ */ new Date(), g = c.workflow.execute(s).pipe(e.locally(l, f), e.provide(p.layer), e.provide(m), e.either), _ = await e.runPromise(g), v = S(p.readSteps()), y = /* @__PURE__ */ new Date(), b;
196
+ let l = `wfrun_test_${++a}`, p = f(), m = c.workflow.toLayer(c.execute).pipe(t.provideMerge(d)), h = /* @__PURE__ */ new Date(), g = c.workflow.execute(s).pipe(e.locally(u, l), e.provide(p.layer), e.provide(m), e.either), _ = await e.runPromise(g), v = C(p.readSteps()), y = /* @__PURE__ */ new Date(), b;
196
197
  if (_._tag === "Right") b = {
197
198
  status: "succeeded",
198
199
  output: _.right,
199
200
  error: null,
200
201
  steps: v,
201
- runId: f
202
+ runId: l
202
203
  };
203
204
  else {
204
205
  let e = _.left;
@@ -210,12 +211,12 @@ var f = async (e, t, r, i) => {
210
211
  message: e && typeof e == "object" && "message" in e && typeof e.message == "string" ? e.message : String(_.left)
211
212
  },
212
213
  steps: v,
213
- runId: f
214
+ runId: l
214
215
  };
215
216
  }
216
- return i.set(f, {
217
- id: f,
218
- executionId: f,
217
+ return i.set(l, {
218
+ id: l,
219
+ executionId: l,
219
220
  name: o,
220
221
  status: b.status,
221
222
  input: s,
@@ -232,6 +233,6 @@ var f = async (e, t, r, i) => {
232
233
  },
233
234
  inspect: async (e) => i.get(e) ?? null
234
235
  };
235
- }, w = 1;
236
+ }, T = 1;
236
237
  //#endregion
237
- export { p as MockClock, h as MockEmail, g as MockLLM, w as TESTING_PRESET_VERSION, f as invoke, x as makeTestContext, C as makeWorkflowRunner, _ as mockStore };
238
+ export { m as MockClock, g as MockEmail, _ as MockLLM, T as TESTING_PRESET_VERSION, p as invoke, S as makeTestContext, w as makeWorkflowRunner, v as mockStore };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/testing",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Test utilities for Voltro apps — deterministic clock, captured emails, queued LLM responses, scoped subject/tenant runners, and a cross-dialect parity harness.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -37,11 +37,11 @@
37
37
  "node": ">=24.0.0"
38
38
  },
39
39
  "dependencies": {
40
- "@voltro/database": "0.2.1",
41
- "@voltro/env": "0.2.1",
42
- "@voltro/protocol": "0.2.1",
43
- "@voltro/runtime": "0.2.1",
44
- "@voltro/workflow": "0.2.1"
40
+ "@voltro/database": "0.3.0",
41
+ "@voltro/env": "0.3.0",
42
+ "@voltro/protocol": "0.3.0",
43
+ "@voltro/runtime": "0.3.0",
44
+ "@voltro/workflow": "0.3.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "effect": "^3.21.4"