@voltro/database 0.2.2 → 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,26 @@ _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
+
42
62
  ## [0.2.2] — 2026-07-17
43
63
 
44
64
  ### Added
@@ -530,6 +530,30 @@ var r = class e {
530
530
  ...e.validatePatchSchema === void 0 ? {} : { validatePatchSchema: e.validatePatchSchema }
531
531
  });
532
532
  }),
533
+ uniqueActive: ((...t) => {
534
+ let n, r, i, a = Array.isArray(t[0]);
535
+ if (a ? (r = t[0], i = t[1], n = z(e.tableName, r)) : (n = t[0], r = t[1], i = t[2]), r.length === 0) throw Error(`uniqueActive '${n}' on table '${e.tableName}' has no fields — a partial UNIQUE must cover at least one column.`);
536
+ if (y(e.tableName, n, a, r), e.appliedIndexes.some((e) => e.name === n)) throw Error(`duplicate index '${n}' on table '${e.tableName}': each index name must be unique within a table (uniqueActive shares the index namespace).`);
537
+ let o = i?.where ?? "\"deletedAt\" IS NULL";
538
+ return V({
539
+ tableName: e.tableName,
540
+ fields: e.fields,
541
+ isReactive: e.isReactive,
542
+ appliedMixins: e.appliedMixins,
543
+ appliedIndexes: [...e.appliedIndexes, {
544
+ name: n,
545
+ fields: [...r],
546
+ where: o,
547
+ unique: !0
548
+ }],
549
+ appliedUniques: e.appliedUniques,
550
+ appliedFullText: e.appliedFullText,
551
+ appliedChecks: e.appliedChecks,
552
+ appliedPrimaryKey: e.appliedPrimaryKey,
553
+ ...e.insertSchema === void 0 ? {} : { insertSchema: e.insertSchema },
554
+ ...e.validatePatchSchema === void 0 ? {} : { validatePatchSchema: e.validatePatchSchema }
555
+ });
556
+ }),
533
557
  check: ((t, n) => {
534
558
  if (n.trim().length === 0) throw Error(`CHECK '${t}' on table '${e.tableName}' has an empty expression.`);
535
559
  if (y(e.tableName, t, !1, []), e.appliedChecks.some((e) => e.name === t)) throw Error(`duplicate CHECK '${t}' on table '${e.tableName}': each constraint name must be unique within a table.`);
package/dist/index.d.ts CHANGED
@@ -4532,6 +4532,37 @@ export declare interface Table<Name extends string, Fields extends Record<string
4532
4532
  dedup?: TableUnique['dedup'];
4533
4533
  }): Table<Name, Fields, Reactive, IxNames>;
4534
4534
  };
4535
+ /**
4536
+ * Declare a **partial UNIQUE** constraint that holds only among the rows
4537
+ * matching a predicate — the portable answer to "unique among the rows that
4538
+ * aren't soft-deleted". Emits a `CREATE UNIQUE INDEX … WHERE` on postgres /
4539
+ * sqlite / mssql.
4540
+ *
4541
+ * ```ts
4542
+ * table('project_roadmaps', { id, projectId, year, deletedAt, ... })
4543
+ * .softDelete()
4544
+ * .uniqueActive(['projectId', 'year']) // one ACTIVE roadmap per (project, year)
4545
+ * ```
4546
+ *
4547
+ * By default the predicate is `"deletedAt" IS NULL` (pairs with
4548
+ * `.softDelete()`); override with `{ where }` for a custom active-set
4549
+ * (`"status" = 'open'`). A soft-deleted row leaves the active set, so a NEW
4550
+ * row with the same key is allowed — no hand-written
4551
+ * `generatedAs("CASE WHEN …")` column, no resurrection footgun.
4552
+ *
4553
+ * **mysql / mariadb:** these engines have no partial-index support, so
4554
+ * `.uniqueActive()` FAILS LOUDLY at migrate time (a full unique index would
4555
+ * silently forbid re-creating a soft-deleted row). Use a generated STORED
4556
+ * column + `.unique()` there until the framework lowers it for you.
4557
+ */
4558
+ uniqueActive: {
4559
+ <const F extends readonly [keyof Fields & string, ...Array<keyof Fields & string>]>(fields: F, options?: {
4560
+ where?: string;
4561
+ }): Table<Name, Fields, Reactive, IxNames>;
4562
+ <const IxName extends string, const F extends readonly [keyof Fields & string, ...Array<keyof Fields & string>]>(name: IxName, fields: F, options?: {
4563
+ where?: string;
4564
+ }): Table<Name, Fields, Reactive, IxNames | IxName>;
4565
+ };
4535
4566
  /**
4536
4567
  * Declare a named table-level `CHECK` constraint — a DB-ENFORCED
4537
4568
  * invariant that holds regardless of which client writes the row
@@ -4700,6 +4731,20 @@ export declare interface TableIndex {
4700
4731
  * efConstruction, opclass}`). Ignored for kinds that don't read it.
4701
4732
  */
4702
4733
  readonly kindOptions?: IndexKindOptions;
4734
+ /**
4735
+ * UNIQUE partial index — set ONLY by `.uniqueActive(...)`. Emits
4736
+ * `CREATE UNIQUE INDEX … WHERE <where>` on postgres / sqlite / mssql, so
4737
+ * uniqueness holds only among rows matching `where` (e.g. the non-soft-
4738
+ * deleted rows). Distinct from `.index(..., { where })` (non-unique) and
4739
+ * from `.unique(...)` (a full constraint with no predicate). Because a
4740
+ * unique+partial index has no `information_schema` constraint form and the
4741
+ * declarative planner is predicate-blind, these are emitted by the
4742
+ * fresh-schema DDL path (like every partial predicate) and kept OUT of the
4743
+ * declarative index snapshot. mysql/mariadb have no partial-index support —
4744
+ * `.uniqueActive()` fails loudly there (use a generated column) rather than
4745
+ * silently dropping the predicate into a full unique index.
4746
+ */
4747
+ readonly unique?: boolean;
4703
4748
  }
4704
4749
 
4705
4750
  export declare interface TableLike {
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as e, A as t, B as n, C as r, Ct as i, D as a, Dt as o, E as s, Et as c, F as l, G as u, H as d, I as f, J as p, K as m, L as h, M as g, N as _, O as ee, Ot as v, P as y, Q as b, R as x, S as te, St as S, T as C, Tt as ne, U as re, V as ie, W as ae, X as oe, Y as se, Z as ce, _ as le, _t as ue, a as de, at as fe, b as pe, bt as me, c as he, ct as ge, d as _e, dt as ve, et as ye, f as be, ft as xe, g as Se, gt as Ce, h as we, ht as Te, i as w, it as Ee, j as De, k as Oe, kt as ke, l as Ae, lt as je, m as Me, mt as Ne, n as Pe, nt as Fe, o as Ie, ot as Le, p as Re, pt as ze, q as Be, r as Ve, rt as He, s as Ue, st as We, t as Ge, tt as Ke, u as qe, ut as Je, v as Ye, vt as T, w as Xe, wt as Ze, x as Qe, xt as E, y as $e, yt as D, z as et } from "./fileBased-cc9IzIjU.js";
1
+ import { $ as e, A as t, B as n, C as r, Ct as i, D as a, Dt as o, E as s, Et as c, F as l, G as u, H as d, I as f, J as p, K as m, L as h, M as g, N as _, O as ee, Ot as v, P as y, Q as b, R as x, S as te, St as S, T as C, Tt as ne, U as re, V as ie, W as ae, X as oe, Y as se, Z as ce, _ as le, _t as ue, a as de, at as fe, b as pe, bt as me, c as he, ct as ge, d as _e, dt as ve, et as ye, f as be, ft as xe, g as Se, gt as Ce, h as we, ht as Te, i as w, it as Ee, j as De, k as Oe, kt as ke, l as Ae, lt as je, m as Me, mt as Ne, n as Pe, nt as Fe, o as Ie, ot as Le, p as Re, pt as ze, q as Be, r as Ve, rt as He, s as Ue, st as We, t as Ge, tt as Ke, u as qe, ut as Je, v as Ye, vt as T, w as Xe, wt as Ze, x as Qe, xt as E, y as $e, yt as D, z as et } from "./fileBased-CRXgPJkv.js";
2
2
  import { Chunk as tt, Data as nt, Effect as rt, Option as O, Stream as it } from "effect";
3
3
  import { ansi as k } from "@voltro/logger";
4
4
  //#region src/arrayCodec.ts
package/dist/sql.d.ts CHANGED
@@ -1754,6 +1754,37 @@ declare interface Table<Name extends string, Fields extends Record<string, Colum
1754
1754
  dedup?: TableUnique['dedup'];
1755
1755
  }): Table<Name, Fields, Reactive, IxNames>;
1756
1756
  };
1757
+ /**
1758
+ * Declare a **partial UNIQUE** constraint that holds only among the rows
1759
+ * matching a predicate — the portable answer to "unique among the rows that
1760
+ * aren't soft-deleted". Emits a `CREATE UNIQUE INDEX … WHERE` on postgres /
1761
+ * sqlite / mssql.
1762
+ *
1763
+ * ```ts
1764
+ * table('project_roadmaps', { id, projectId, year, deletedAt, ... })
1765
+ * .softDelete()
1766
+ * .uniqueActive(['projectId', 'year']) // one ACTIVE roadmap per (project, year)
1767
+ * ```
1768
+ *
1769
+ * By default the predicate is `"deletedAt" IS NULL` (pairs with
1770
+ * `.softDelete()`); override with `{ where }` for a custom active-set
1771
+ * (`"status" = 'open'`). A soft-deleted row leaves the active set, so a NEW
1772
+ * row with the same key is allowed — no hand-written
1773
+ * `generatedAs("CASE WHEN …")` column, no resurrection footgun.
1774
+ *
1775
+ * **mysql / mariadb:** these engines have no partial-index support, so
1776
+ * `.uniqueActive()` FAILS LOUDLY at migrate time (a full unique index would
1777
+ * silently forbid re-creating a soft-deleted row). Use a generated STORED
1778
+ * column + `.unique()` there until the framework lowers it for you.
1779
+ */
1780
+ uniqueActive: {
1781
+ <const F extends readonly [keyof Fields & string, ...Array<keyof Fields & string>]>(fields: F, options?: {
1782
+ where?: string;
1783
+ }): Table<Name, Fields, Reactive, IxNames>;
1784
+ <const IxName extends string, const F extends readonly [keyof Fields & string, ...Array<keyof Fields & string>]>(name: IxName, fields: F, options?: {
1785
+ where?: string;
1786
+ }): Table<Name, Fields, Reactive, IxNames | IxName>;
1787
+ };
1757
1788
  /**
1758
1789
  * Declare a named table-level `CHECK` constraint — a DB-ENFORCED
1759
1790
  * invariant that holds regardless of which client writes the row
@@ -1920,6 +1951,20 @@ declare interface TableIndex {
1920
1951
  * efConstruction, opclass}`). Ignored for kinds that don't read it.
1921
1952
  */
1922
1953
  readonly kindOptions?: IndexKindOptions;
1954
+ /**
1955
+ * UNIQUE partial index — set ONLY by `.uniqueActive(...)`. Emits
1956
+ * `CREATE UNIQUE INDEX … WHERE <where>` on postgres / sqlite / mssql, so
1957
+ * uniqueness holds only among rows matching `where` (e.g. the non-soft-
1958
+ * deleted rows). Distinct from `.index(..., { where })` (non-unique) and
1959
+ * from `.unique(...)` (a full constraint with no predicate). Because a
1960
+ * unique+partial index has no `information_schema` constraint form and the
1961
+ * declarative planner is predicate-blind, these are emitted by the
1962
+ * fresh-schema DDL path (like every partial predicate) and kept OUT of the
1963
+ * declarative index snapshot. mysql/mariadb have no partial-index support —
1964
+ * `.uniqueActive()` fails loudly there (use a generated column) rather than
1965
+ * silently dropping the predicate into a full unique index.
1966
+ */
1967
+ readonly unique?: boolean;
1923
1968
  }
1924
1969
 
1925
1970
  declare interface TableLike {
package/dist/sql.js CHANGED
@@ -1,4 +1,4 @@
1
- import { L as e, R as t, V as n, a as r, c as i, et as a, i as o, n as s, ot as c, r as l, t as u } from "./fileBased-cc9IzIjU.js";
1
+ import { L as e, R as t, V as n, a as r, c as i, et as a, i as o, n as s, ot as c, r as l, t as u } from "./fileBased-CRXgPJkv.js";
2
2
  import { Effect as d } from "effect";
3
3
  import { createLogger as f } from "@voltro/logger";
4
4
  import { SqlClient as p } from "@effect/sql";
@@ -347,11 +347,11 @@ var _ = (e, t, n, r = 400) => {
347
347
  return `(${t.expr})`;
348
348
  });
349
349
  return n !== void 0 && i.length === 1 ? `${i[0]} ${n}` : i.join(", ");
350
- }, d = (e, n, a, o, s, c = "") => {
351
- let d = l(e, n, o, s);
352
- if (d.skip) return "";
353
- let f = u(a, d.opclass), p = `${d.withSuffix}${c}`, m = pe(n, r, i);
354
- return r === "mssql" ? `IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = '${e}') CREATE INDEX ${t(e, r)} ON ${m}${d.using} (${f})${p};` : r === "mysql" ? `CREATE INDEX ${t(e, r)} ON ${m}${d.using} (${f})${p};` : `CREATE INDEX IF NOT EXISTS ${t(e, r)} ON ${m}${d.using} (${f})${p};`;
350
+ }, d = (e, n, a, o, s, c = "", d = !1) => {
351
+ let f = l(e, n, o, s);
352
+ if (f.skip) return "";
353
+ let p = u(a, f.opclass), m = `${f.withSuffix}${c}`, h = pe(n, r, i), g = d ? "UNIQUE " : "";
354
+ return r === "mssql" ? `IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = '${e}') CREATE ${g}INDEX ${t(e, r)} ON ${h}${f.using} (${p})${m};` : r === "mysql" ? `CREATE ${g}INDEX ${t(e, r)} ON ${h}${f.using} (${p})${m};` : `CREATE ${g}INDEX IF NOT EXISTS ${t(e, r)} ON ${h}${f.using} (${p})${m};`;
355
355
  };
356
356
  for (let n of e.appliedFullText ?? []) if (r === "postgres") {
357
357
  let i = n.config ?? "english", o = `${n.name}_tsv`, s = n.columns.map((e) => {
@@ -369,9 +369,10 @@ var _ = (e, t, n, r = 400) => {
369
369
  D(`[voltro:migrate] json-path index '${t.name}' on '${e.tableName}': ${r} cannot index a JSON expression directly — skipping. For an indexed JSON path on ${r}, add a generated column for the path (text().generatedAs(...) / .stored()) and .index([...]) it, then filter on that column instead of jsonField(...).`);
370
370
  continue;
371
371
  }
372
+ if (t.unique && t.where !== void 0 && t.where.length > 0 && (r === "mysql" || r === "mariadb")) throw Error(`@voltro/migrate: uniqueActive index '${t.name}' on '${e.tableName}' cannot be expressed on ${r} — it has no partial (WHERE) index support, and a full UNIQUE index would forbid re-creating a soft-deleted row. Use a generated STORED column (text().generatedAs("CASE WHEN \`deletedAt\` IS NULL THEN <key> ELSE NULL END", { stored: true })) + .unique([...]) on ${r}, or move this table to postgres/sqlite/mssql.`);
372
373
  let n = "";
373
374
  t.where !== void 0 && t.where.length > 0 && (r === "mysql" || r === "mariadb" ? D(`[voltro:migrate] partial index '${t.name}' on '${e.tableName}': ${r} does not support WHERE clauses on indexes — emitting a full index`) : n = ` WHERE ${t.where}`);
374
- let i = d(t.name, e.tableName, t.fields, t.kind, t.kindOptions, n);
375
+ let i = d(t.name, e.tableName, t.fields, t.kind, t.kindOptions, n, t.unique === !0);
375
376
  i !== "" && a.push(i), t.fields.every((e) => typeof e == "string") && s.add(c(t.fields));
376
377
  }
377
378
  for (let [t, n] of Object.entries(e.fields)) {
@@ -660,6 +661,7 @@ BEGIN
660
661
  });
661
662
  let o = e.appliedIndexes ?? [];
662
663
  for (let t of o) {
664
+ if (t.unique && t.where) continue;
663
665
  let n = t.fields.map((e) => typeof e == "string" ? e : "jsonPath" in e ? `(json:${e.jsonPath.column}->${e.jsonPath.path.join(".")}${e.jsonPath.numeric ? "::num" : ""})` : `(${e.expr})`), i = t.fields.some((e) => typeof e != "string");
664
666
  r({
665
667
  name: t.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/database",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Browser-safe schema DSL, query builder, and cross-dialect migration planner for Voltro — one schema, every SQL backend.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@effect/sql": "^0.51.1",
41
- "@voltro/logger": "0.2.2",
41
+ "@voltro/logger": "0.3.0",
42
42
  "typeid-js": "^1.2.0",
43
43
  "ulidx": "^2.4.1"
44
44
  },