@voltro/database 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
@@ -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
@@ -859,13 +859,13 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
859
859
  ...t,
860
860
  value: Hn(t.value)
861
861
  };
862
- }, Hn = (e) => e == null ? e : e instanceof Date ? e.toISOString() : typeof e == "boolean" ? +!!e : Array.isArray(e) ? e.map((e) => Hn(e)) : e, Un, Wn, Gn = (e) => {
863
- e.actors !== void 0 && (Un = e.actors), e.tenants !== void 0 && (Wn = e.tenants);
864
- }, Kn = (e, t) => () => {
862
+ }, Hn = (e) => e == null ? e : e instanceof Date ? e.toISOString() : typeof e == "boolean" ? +!!e : Array.isArray(e) ? e.map((e) => Hn(e)) : e, Un = Symbol.for("@voltro/database/coreTablesRegistry"), Wn = globalThis, Gn = Wn[Un] ?? (Wn[Un] = {}), Kn = (e) => {
863
+ e.actors !== void 0 && (Gn.actors = e.actors), e.tenants !== void 0 && (Gn.tenants = e.tenants);
864
+ }, qn = (e, t) => () => {
865
865
  let n = e();
866
866
  if (n === void 0) throw Error(`core '${t}' table not registered; expected the framework CLI or your migrate step to call registerCoreTables({ ${t}: () => ${t}Table }) before the database handle is constructed`);
867
867
  return n();
868
- }, qn = () => Kn(() => Un, "actors"), Jn = () => Kn(() => Wn, "tenants"), Yn = n("actors", {
868
+ }, Jn = () => qn(() => Gn.actors, "actors"), Yn = () => qn(() => Gn.tenants, "tenants"), Xn = n("actors", {
869
869
  id: T(),
870
870
  kind: o().oneOf([
871
871
  "user",
@@ -875,65 +875,65 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
875
875
  ]),
876
876
  displayName: o().nullable(),
877
877
  createdAt: v().default("now")
878
- }), Xn = (e, t) => ({
878
+ }), Zn = (e, t) => ({
879
879
  column: e,
880
880
  op: "eq",
881
881
  value: t
882
- }), Zn = (e, t) => ({
882
+ }), Qn = (e, t) => ({
883
883
  column: e,
884
884
  op: "neq",
885
885
  value: t
886
- }), Qn = (e, t) => ({
886
+ }), $n = (e, t) => ({
887
887
  column: e,
888
888
  op: "gt",
889
889
  value: t
890
- }), $n = (e, t) => ({
890
+ }), er = (e, t) => ({
891
891
  column: e,
892
892
  op: "gte",
893
893
  value: t
894
- }), er = (e, t) => ({
894
+ }), tr = (e, t) => ({
895
895
  column: e,
896
896
  op: "lt",
897
897
  value: t
898
- }), tr = (e, t) => ({
898
+ }), nr = (e, t) => ({
899
899
  column: e,
900
900
  op: "lte",
901
901
  value: t
902
- }), nr = (e, t) => ({
902
+ }), rr = (e, t) => ({
903
903
  column: e,
904
904
  op: "in",
905
905
  value: t
906
- }), rr = (e, t) => ({
906
+ }), ir = (e, t) => ({
907
907
  column: e,
908
908
  op: "notIn",
909
909
  value: t
910
- }), ir = (e, t) => ({
910
+ }), ar = (e, t) => ({
911
911
  column: e,
912
912
  op: "contains",
913
913
  value: t
914
- }), ar = (e, t) => ({
914
+ }), or = (e, t) => ({
915
915
  column: e,
916
916
  op: "arrayContains",
917
917
  value: t
918
- }), or = (e, t) => ({
918
+ }), sr = (e, t) => ({
919
919
  column: e,
920
920
  op: "arrayOverlaps",
921
921
  value: t
922
- }), sr = (e, t) => ({
922
+ }), cr = (e, t) => ({
923
923
  column: e,
924
924
  op: "arrayHas",
925
925
  value: t
926
- }), cr = (e) => ({
926
+ }), lr = (e) => ({
927
927
  column: e,
928
928
  op: "isNull"
929
- }), lr = (e) => ({
929
+ }), ur = (e) => ({
930
930
  column: e,
931
931
  op: "isNotNull"
932
- }), ur = (e, t) => ({
932
+ }), dr = (e, t) => ({
933
933
  column: e,
934
934
  op: "spatial",
935
935
  value: t
936
- }), dr = (...e) => ({ and: e }), fr = (...e) => ({ or: e }), pr = (e) => ({ not: e }), mr = (e, ...t) => {
936
+ }), fr = (...e) => ({ and: e }), pr = (...e) => ({ or: e }), mr = (e) => ({ not: e }), hr = (e, ...t) => {
937
937
  if (t.length === 0) throw Error(`jsonField("${e}"): at least one path segment is required`);
938
938
  let n = (n, r) => ({
939
939
  column: e,
@@ -954,29 +954,29 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
954
954
  isNull: () => n("isNull"),
955
955
  isNotNull: () => n("isNotNull")
956
956
  };
957
- }, hr = (e) => "descriptor" in e ? e.descriptor : e, gr = (e, t) => ({
957
+ }, gr = (e) => "descriptor" in e ? e.descriptor : e, _r = (e, t) => ({
958
958
  column: e,
959
959
  op: "subquery-in",
960
- subquery: hr(t)
961
- }), _r = (e, t) => ({
960
+ subquery: gr(t)
961
+ }), vr = (e, t) => ({
962
962
  column: e,
963
963
  op: "subquery-not-in",
964
- subquery: hr(t)
965
- }), vr = (e) => ({
966
- op: "exists",
967
- subquery: hr(e)
964
+ subquery: gr(t)
968
965
  }), yr = (e) => ({
966
+ op: "exists",
967
+ subquery: gr(e)
968
+ }), br = (e) => ({
969
969
  op: "not-exists",
970
- subquery: hr(e)
971
- }), br = (e, t) => e == null || t == null ? null : typeof e == typeof t ? e === t ? 0 : e > t ? 1 : -1 : null, xr = (e, t, n = null) => {
970
+ subquery: gr(e)
971
+ }), xr = (e, t) => e == null || t == null ? null : typeof e == typeof t ? e === t ? 0 : e > t ? 1 : -1 : null, Sr = (e, t, n = null) => {
972
972
  if (!t) return !1;
973
- if ("not" in e) return !xr(e.not, t, n);
973
+ if ("not" in e) return !Sr(e.not, t, n);
974
974
  if ("and" in e) {
975
- for (let r of e.and) if (!xr(r, t, n)) return !1;
975
+ for (let r of e.and) if (!Sr(r, t, n)) return !1;
976
976
  return !0;
977
977
  }
978
978
  if ("or" in e) {
979
- for (let r of e.or) if (xr(r, t, n)) return !0;
979
+ for (let r of e.or) if (Sr(r, t, n)) return !0;
980
980
  return !1;
981
981
  }
982
982
  if (e.op === "subquery-in" || e.op === "subquery-not-in") {
@@ -995,10 +995,10 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
995
995
  switch (r.op) {
996
996
  case "eq": return i === r.value;
997
997
  case "neq": return i !== r.value;
998
- case "gt": return (br(i, r.value) ?? -1) > 0;
999
- case "gte": return (br(i, r.value) ?? -1) >= 0;
1000
- case "lt": return (br(i, r.value) ?? 1) < 0;
1001
- case "lte": return (br(i, r.value) ?? 1) <= 0;
998
+ case "gt": return (xr(i, r.value) ?? -1) > 0;
999
+ case "gte": return (xr(i, r.value) ?? -1) >= 0;
1000
+ case "lt": return (xr(i, r.value) ?? 1) < 0;
1001
+ case "lte": return (xr(i, r.value) ?? 1) <= 0;
1002
1002
  case "in": return Array.isArray(r.value) && r.value.includes(i);
1003
1003
  case "notIn": return Array.isArray(r.value) && !r.value.includes(i);
1004
1004
  case "contains": return typeof i == "string" && typeof r.value == "string" && i.toLowerCase().includes(r.value.toLowerCase());
@@ -1015,17 +1015,17 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1015
1015
  case "isNotNull": return i != null;
1016
1016
  case "spatial": throw Error("@voltro/plugin-postgis: spatial predicates (ST_DWithin / ST_Contains / ST_Within / ST_Intersects / bbox &&) are not evaluable in-memory — use them in one-shot ctx.store.query reads, not reactive subscriptions.");
1017
1017
  }
1018
- }, Sr = function* (e) {
1018
+ }, Cr = function* (e) {
1019
1019
  if ("not" in e) {
1020
- yield* Sr(e.not);
1020
+ yield* Cr(e.not);
1021
1021
  return;
1022
1022
  }
1023
1023
  if ("and" in e) {
1024
- for (let t of e.and) yield* Sr(t);
1024
+ for (let t of e.and) yield* Cr(t);
1025
1025
  return;
1026
1026
  }
1027
1027
  if ("or" in e) {
1028
- for (let t of e.or) yield* Sr(t);
1028
+ for (let t of e.or) yield* Cr(t);
1029
1029
  return;
1030
1030
  }
1031
1031
  e.op === "subquery-in" || e.op === "subquery-not-in" ? yield {
@@ -1035,27 +1035,27 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1035
1035
  kind: "exists",
1036
1036
  descriptor: e.subquery
1037
1037
  });
1038
- }, Cr = function* (e, t = !0) {
1038
+ }, wr = function* (e, t = !0) {
1039
1039
  if ("not" in e) {
1040
- yield* Cr(e.not, !1);
1040
+ yield* wr(e.not, !1);
1041
1041
  return;
1042
1042
  }
1043
1043
  if ("and" in e) {
1044
- for (let n of e.and) yield* Cr(n, t);
1044
+ for (let n of e.and) yield* wr(n, t);
1045
1045
  return;
1046
1046
  }
1047
1047
  if ("or" in e) {
1048
- for (let t of e.or) yield* Cr(t, !1);
1048
+ for (let t of e.or) yield* wr(t, !1);
1049
1049
  return;
1050
1050
  }
1051
1051
  e.op === "subquery-in" || e.op === "subquery-not-in" || e.op === "exists" || e.op === "not-exists" || (yield {
1052
1052
  ...e,
1053
1053
  conjunctive: t
1054
1054
  });
1055
- }, wr = class extends nt.TaggedError("TableStreamError") {}, Tr = 1e3, Er = (e, t, n, r) => {
1055
+ }, Tr = class extends nt.TaggedError("TableStreamError") {}, Er = 1e3, Dr = (e, t, n, r) => {
1056
1056
  let i = O.match(r, {
1057
1057
  onNone: () => e.where,
1058
- onSome: (n) => e.where ? dr(e.where, Qn(t, n)) : Qn(t, n)
1058
+ onSome: (n) => e.where ? fr(e.where, $n(t, n)) : $n(t, n)
1059
1059
  });
1060
1060
  return {
1061
1061
  table: e.table,
@@ -1070,19 +1070,19 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1070
1070
  ...e.includeDeleted === void 0 ? {} : { includeDeleted: e.includeDeleted },
1071
1071
  ...e.crossTenant === void 0 ? {} : { crossTenant: e.crossTenant }
1072
1072
  };
1073
- }, Dr = (e, t) => {
1074
- let n = t.pk ?? "id", r = t.chunkSize ?? Tr;
1073
+ }, Or = (e, t) => {
1074
+ let n = t.pk ?? "id", r = t.chunkSize ?? Er;
1075
1075
  return it.paginateChunkEffect(O.none(), (i) => {
1076
1076
  let a = rt.tryPromise({
1077
- try: () => e.query(Er(t, n, r, i)),
1078
- catch: (e) => new wr({
1077
+ try: () => e.query(Dr(t, n, r, i)),
1078
+ catch: (e) => new Tr({
1079
1079
  table: t.table,
1080
1080
  cause: e
1081
1081
  })
1082
1082
  });
1083
1083
  return (t.retry ? a.pipe(rt.retry(t.retry)) : a).pipe(rt.map((e) => [tt.fromIterable(e), e.length === r && e.length > 0 ? O.some(O.some(e[e.length - 1][n])) : O.none()]));
1084
1084
  });
1085
- }, Or = (e) => {
1085
+ }, kr = (e) => {
1086
1086
  let t = e.alpha ?? .5;
1087
1087
  if (t < 0 || t > 1) throw Error(`hybridSearch: alpha must be in [0, 1], got ${t}`);
1088
1088
  return (n) => {
@@ -1109,14 +1109,14 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1109
1109
  }
1110
1110
  };
1111
1111
  };
1112
- }, kr = async (e, t) => {
1112
+ }, Ar = async (e, t) => {
1113
1113
  let n = e.as ?? "embedding", r = t.row[e.from];
1114
1114
  if (typeof r != "string" || r.length === 0) return {};
1115
1115
  let i = await t.embed(r);
1116
1116
  return { [n]: i };
1117
- }, Ar = (e) => {
1117
+ }, jr = (e) => {
1118
1118
  if (!Number.isInteger(e.dimensions) || e.dimensions <= 0) throw Error(`vectorEmbedding: dimensions must be a positive integer, got ${String(e.dimensions)}.`);
1119
- let t = e.as ?? "embedding", n = (t) => kr(e, t);
1119
+ let t = e.as ?? "embedding", n = (t) => Ar(e, t);
1120
1120
  return d({
1121
1121
  id: "voltro/vectorEmbedding",
1122
1122
  fields: { [t]: ke(e.dimensions) },
@@ -1134,19 +1134,19 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1134
1134
  beforeUpdate: n
1135
1135
  }
1136
1136
  });
1137
- }, jr = 63, Mr = "br_pr", Y = class extends Error {
1137
+ }, Mr = 63, Nr = "br_pr", Y = class extends Error {
1138
1138
  _tag = "BranchNameInvalid";
1139
1139
  constructor(e) {
1140
1140
  super(e), this.name = "BranchNameInvalid";
1141
1141
  }
1142
- }, Nr = (e, t) => {
1142
+ }, Pr = (e, t) => {
1143
1143
  if (!Number.isInteger(t) || t < 0) throw new Y(`prNumber must be a non-negative integer, got ${String(t)}`);
1144
1144
  let n = Zt(e);
1145
1145
  if (n.replace(/_/g, "") === "") throw new Y(`app slug '${e}' has no usable identifier characters`);
1146
- let r = `${Mr}${t}_${n}`;
1147
- if (r.length > jr) throw new Y(`branch namespace '${r}' exceeds the ${jr}-byte identifier limit — use a shorter app slug`);
1146
+ let r = `${Nr}${t}_${n}`;
1147
+ if (r.length > Mr) throw new Y(`branch namespace '${r}' exceeds the ${Mr}-byte identifier limit — use a shorter app slug`);
1148
1148
  return r;
1149
- }, Pr = (e) => e.startsWith(Mr), Fr = {
1149
+ }, Fr = (e) => e.startsWith(Nr), Ir = {
1150
1150
  requested: {
1151
1151
  provision: "provisioning",
1152
1152
  destroy: "destroying"
@@ -1165,18 +1165,18 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1165
1165
  fail: "failed"
1166
1166
  },
1167
1167
  destroyed: {}
1168
- }, Ir = class extends Error {
1168
+ }, Lr = class extends Error {
1169
1169
  from;
1170
1170
  event;
1171
1171
  _tag = "BranchTransitionInvalid";
1172
1172
  constructor(e, t) {
1173
1173
  super(`invalid branch transition: ${e} --${t}-->`), this.from = e, this.event = t, this.name = "BranchTransitionInvalid";
1174
1174
  }
1175
- }, Lr = (e, t) => {
1176
- let n = Fr[e][t];
1177
- if (n === void 0) throw new Ir(e, t);
1175
+ }, Rr = (e, t) => {
1176
+ let n = Ir[e][t];
1177
+ if (n === void 0) throw new Lr(e, t);
1178
1178
  return n;
1179
- }, Rr = (e, t, n, r) => {
1179
+ }, zr = (e, t, n, r) => {
1180
1180
  if (n === "copy" && (r === void 0 || r === "")) throw new Y("seed 'copy' requires a parentNamespace to snapshot from");
1181
1181
  let i = [{
1182
1182
  kind: "create-namespace",
@@ -1197,13 +1197,13 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1197
1197
  detail: `run boot seeds into ${e}`
1198
1198
  });
1199
1199
  return i;
1200
- }, zr = (e) => {
1201
- if (!Pr(e)) throw new Y(`refusing to plan teardown of '${e}' — not a branch namespace`);
1200
+ }, Br = (e) => {
1201
+ if (!Fr(e)) throw new Y(`refusing to plan teardown of '${e}' — not a branch namespace`);
1202
1202
  return [{
1203
1203
  kind: "drop-namespace",
1204
1204
  detail: e
1205
1205
  }];
1206
- }, Br = (e) => {
1206
+ }, Vr = (e) => {
1207
1207
  if (e === void 0 || e === "") return !1;
1208
1208
  try {
1209
1209
  let t = new URL(e).hostname.toLowerCase();
@@ -1211,27 +1211,27 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1211
1211
  } catch {
1212
1212
  return /\bneon\.(tech|build)\b/i.test(e);
1213
1213
  }
1214
- }, Vr = (e) => e.prefer === void 0 ? e.seed === "copy" && Br(e.dbUrl) ? "neon-cow" : "namespace" : e.prefer, Hr = (e, t = {}) => [{
1214
+ }, Hr = (e) => e.prefer === void 0 ? e.seed === "copy" && Vr(e.dbUrl) ? "neon-cow" : "namespace" : e.prefer, Ur = (e, t = {}) => [{
1215
1215
  kind: "neon-branch-create",
1216
1216
  detail: `${t.parentBranch ?? "main"} → ${e} (copy-on-write)`
1217
- }], Ur = (e) => {
1218
- if (!Pr(e)) throw new Y(`refusing to plan teardown of '${e}' — not a branch namespace`);
1217
+ }], Wr = (e) => {
1218
+ if (!Fr(e)) throw new Y(`refusing to plan teardown of '${e}' — not a branch namespace`);
1219
1219
  return [{
1220
1220
  kind: "neon-branch-delete",
1221
1221
  detail: e
1222
1222
  }];
1223
- }, Wr = (e) => e.mechanism === "neon-cow" ? Hr(e.branchId, e.neonParentBranch === void 0 ? {} : { parentBranch: e.neonParentBranch }) : Rr(e.branchId, e.tableNames, e.seed, e.parentNamespace), Gr = (e, t) => e === "neon-cow" ? Ur(t) : zr(t), Kr = (e, t, n) => {
1223
+ }, Gr = (e) => e.mechanism === "neon-cow" ? Ur(e.branchId, e.neonParentBranch === void 0 ? {} : { parentBranch: e.neonParentBranch }) : zr(e.branchId, e.tableNames, e.seed, e.parentNamespace), Kr = (e, t) => e === "neon-cow" ? Wr(t) : Br(t), qr = (e, t, n) => {
1224
1224
  let r = e.filter((e) => e.state !== "destroyed" && e.state !== "destroying"), i = r.filter((e) => n - e.createdAtMs > t.ttlMs).map((e) => e.namespace), a = new Set(i);
1225
1225
  return {
1226
1226
  evict: i,
1227
1227
  rejectNew: r.filter((e) => !a.has(e.namespace)).length >= t.maxBranches
1228
1228
  };
1229
- }, qr = async (e, t) => {
1230
- let n = Nr(e.appSlug, e.prNumber), r = Vr({
1229
+ }, Jr = async (e, t) => {
1230
+ let n = Pr(e.appSlug, e.prNumber), r = Hr({
1231
1231
  seed: e.seed,
1232
1232
  ...e.dbUrl === void 0 ? {} : { dbUrl: e.dbUrl },
1233
1233
  ...e.prefer === void 0 ? {} : { prefer: e.prefer }
1234
- }), i = Wr({
1234
+ }), i = Gr({
1235
1235
  mechanism: r,
1236
1236
  branchId: n,
1237
1237
  tableNames: e.tableNames,
@@ -1256,10 +1256,10 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1256
1256
  mechanism: r,
1257
1257
  steps: i
1258
1258
  };
1259
- }, Jr = async (e, t, n) => {
1260
- let r = Gr(t, e);
1259
+ }, Yr = async (e, t, n) => {
1260
+ let r = Kr(t, e);
1261
1261
  return t === "neon-cow" ? await n.neonBranchDelete(e) : await n.dropNamespace(e), r;
1262
- }, Yr = (e, t, n) => Kr(e, t, n), X = (e) => `"${e.replace(/"/g, "\"\"")}"`, Xr = (e) => {
1262
+ }, Xr = (e, t, n) => qr(e, t, n), X = (e) => `"${e.replace(/"/g, "\"\"")}"`, Zr = (e) => {
1263
1263
  let t = () => {
1264
1264
  throw Error("namespace branch executor: Neon ops require a neon-cow executor (use a Neon connection for copy-on-write branches)");
1265
1265
  };
@@ -1271,18 +1271,18 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1271
1271
  neonBranchCreate: t,
1272
1272
  neonBranchDelete: t
1273
1273
  };
1274
- }, Zr = class extends Error {
1274
+ }, Qr = class extends Error {
1275
1275
  _tag = "TenantResidencyUnresolved";
1276
1276
  constructor(e) {
1277
1277
  super(e), this.name = "TenantResidencyUnresolved";
1278
1278
  }
1279
- }, Qr = class extends Error {
1279
+ }, $r = class extends Error {
1280
1280
  region;
1281
1281
  _tag = "TenantRegionUnavailable";
1282
1282
  constructor(e, t) {
1283
1283
  super(t), this.region = e, this.name = "TenantRegionUnavailable";
1284
1284
  }
1285
- }, $r, ei = (e) => {
1285
+ }, ei, ti = (e) => {
1286
1286
  if (e.homes === void 0) throw Error("setResidencyConfig: `homes` is required");
1287
1287
  if (e.servableRegions === void 0 || e.servableRegions.length === 0) throw Error("setResidencyConfig: `servableRegions` must be non-empty (which regions this deployment serves)");
1288
1288
  let t = /* @__PURE__ */ new Map();
@@ -1291,32 +1291,32 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1291
1291
  if (e !== void 0 && e !== n.region) throw Error(`residency: tenant '${n.tenantId}' is mapped to two regions ('${e}' and '${n.region}')`);
1292
1292
  t.set(n.tenantId, n.region);
1293
1293
  }
1294
- return $r = e, e;
1295
- }, ti = () => $r, ni = () => {
1296
- $r = void 0;
1297
- }, ri = (e, t) => {
1298
- if (e == null || e === "") throw new Zr("residency is on but the request has no resolvable tenant — refusing to bind a store");
1294
+ return ei = e, e;
1295
+ }, ni = () => ei, ri = () => {
1296
+ ei = void 0;
1297
+ }, ii = (e, t) => {
1298
+ if (e == null || e === "") throw new Qr("residency is on but the request has no resolvable tenant — refusing to bind a store");
1299
1299
  let n = t.homes.find((t) => t.tenantId === e);
1300
- if (n === void 0) throw new Zr(`no residency home is mapped for tenant '${e}' — refusing to fall back to a default store (would be a residency violation)`);
1300
+ if (n === void 0) throw new Qr(`no residency home is mapped for tenant '${e}' — refusing to fall back to a default store (would be a residency violation)`);
1301
1301
  return n;
1302
- }, ii = (e, t) => t.servableRegions.includes(e), ai = (e, t) => {
1303
- let n = ri(e.tenantId, t), r = Qt(n.tenantId);
1302
+ }, ai = (e, t) => t.servableRegions.includes(e), oi = (e, t) => {
1303
+ let n = ii(e.tenantId, t), r = Qt(n.tenantId);
1304
1304
  return {
1305
1305
  region: n.region,
1306
1306
  namespace: r,
1307
1307
  ...n.connectionKey === void 0 ? {} : { connectionKey: n.connectionKey }
1308
1308
  };
1309
- }, oi = (e, t, n) => {
1310
- let r = ai(e, t);
1311
- if (!ii(r.region, t)) throw new Qr(r.region, `tenant homed in region '${r.region}', which this deployment does not serve — route to the home region`);
1309
+ }, si = (e, t, n) => {
1310
+ let r = oi(e, t);
1311
+ if (!ai(r.region, t)) throw new $r(r.region, `tenant homed in region '${r.region}', which this deployment does not serve — route to the home region`);
1312
1312
  let i = n.get(r.region);
1313
- if (i === void 0) throw new Qr(r.region, `no store handle for home region '${r.region}' (declared servable but not wired) — failing closed`);
1313
+ if (i === void 0) throw new $r(r.region, `no store handle for home region '${r.region}' (declared servable but not wired) — failing closed`);
1314
1314
  return {
1315
1315
  store: i,
1316
1316
  placement: r
1317
1317
  };
1318
- }, si = async (e, t, n, r) => {
1319
- let { store: i, placement: a } = oi(e, t, n);
1318
+ }, ci = async (e, t, n, r) => {
1319
+ let { store: i, placement: a } = si(e, t, n);
1320
1320
  return await r(i, a), a;
1321
1321
  }, Z = (e) => {
1322
1322
  if (typeof e == "string") return e;
@@ -1325,21 +1325,21 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1325
1325
  return `(json:${t}->${n.join(".")}${r ? "::num" : ""})`;
1326
1326
  }
1327
1327
  return `(${e.expr})`;
1328
- }, Q = (e) => e.map(Z), ci = (e, t) => {
1328
+ }, Q = (e) => e.map(Z), li = (e, t) => {
1329
1329
  if (e.length !== t.length) return !1;
1330
1330
  for (let n = 0; n < e.length; n++) if (Z(e[n]) !== Z(t[n])) return !1;
1331
1331
  return !0;
1332
- }, li = (e, t) => {
1332
+ }, ui = (e, t) => {
1333
1333
  if (e.length >= t.length) return !1;
1334
1334
  for (let n = 0; n < e.length; n++) if (Z(e[n]) !== Z(t[n])) return !1;
1335
1335
  return !0;
1336
- }, ui = (e) => {
1336
+ }, di = (e) => {
1337
1337
  let t = [], n = e.appliedIndexes, r = e.tableName;
1338
1338
  for (let e = 0; e < n.length; e++) {
1339
1339
  let i = n[e];
1340
1340
  for (let a = e + 1; a < n.length; a++) {
1341
1341
  let e = n[a];
1342
- if (ci(i.fields, e.fields)) {
1342
+ if (li(i.fields, e.fields)) {
1343
1343
  let [n, a] = i.name < e.name ? [i, e] : [e, i];
1344
1344
  t.push({
1345
1345
  kind: "duplicate-fields",
@@ -1350,13 +1350,13 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1350
1350
  });
1351
1351
  continue;
1352
1352
  }
1353
- li(i.fields, e.fields) ? t.push({
1353
+ ui(i.fields, e.fields) ? t.push({
1354
1354
  kind: "redundant-prefix",
1355
1355
  table: r,
1356
1356
  redundant: i,
1357
1357
  coveredBy: e,
1358
1358
  message: `'${r}' index '${i.name}' on [${Q(i.fields).join(", ")}] is a leading prefix of '${e.name}' on [${Q(e.fields).join(", ")}]. Every B-tree dialect we support (Postgres / MySQL / MariaDB / MSSQL / SQLite) can serve a [${Q(i.fields).join(", ")}] query from '${e.name}' too via the leading-prefix rule, so '${i.name}' adds write cost without read benefit. Drop '${i.name}' — unless you specifically need a smaller index for cache locality on a write-heavy table (rare; profile first).`
1359
- }) : li(e.fields, i.fields) && t.push({
1359
+ }) : ui(e.fields, i.fields) && t.push({
1360
1360
  kind: "redundant-prefix",
1361
1361
  table: r,
1362
1362
  redundant: e,
@@ -1366,23 +1366,23 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1366
1366
  }
1367
1367
  }
1368
1368
  return t;
1369
- }, di = (e) => {
1369
+ }, fi = (e) => {
1370
1370
  let t = [];
1371
- for (let n of e) t.push(...ui(n));
1371
+ for (let n of e) t.push(...di(n));
1372
1372
  return t;
1373
- }, fi = "\x1B[", pi = `${fi}38;2;245;158;11m`, mi = `${fi}38;2;16;185;129m`, hi = (e, t) => `${e}${t}${k.reset}`, $ = (e, t, n) => e ? typeof t == "function" ? t(n) : hi(t, n) : n, gi = (e, t = {}) => {
1374
- let n = t.color ?? !0, r = t.indent ?? " ", i = (e) => $(n, k.dim, e.padEnd(11)), a = (e) => $(n, (e) => k.bold(k.brand(e)), e), o = (e) => $(n, k.brightRed24, e), s = (e) => $(n, mi, e), c = (e) => $(n, pi, e), l = (e) => $(n, k.dim, e), u = `${c("index audit")} ${l("·")} ${$(n, k.bold, e.kind)}`, d = e.redundant.fields.length, f = Q(e.coveredBy.fields.slice(d)), p = (e, t) => {
1373
+ }, pi = "\x1B[", mi = `${pi}38;2;245;158;11m`, hi = `${pi}38;2;16;185;129m`, gi = (e, t) => `${e}${t}${k.reset}`, $ = (e, t, n) => e ? typeof t == "function" ? t(n) : gi(t, n) : n, _i = (e, t = {}) => {
1374
+ let n = t.color ?? !0, r = t.indent ?? " ", i = (e) => $(n, k.dim, e.padEnd(11)), a = (e) => $(n, (e) => k.bold(k.brand(e)), e), o = (e) => $(n, k.brightRed24, e), s = (e) => $(n, hi, e), c = (e) => $(n, mi, e), l = (e) => $(n, k.dim, e), u = `${c("index audit")} ${l("·")} ${$(n, k.bold, e.kind)}`, d = e.redundant.fields.length, f = Q(e.coveredBy.fields.slice(d)), p = (e, t) => {
1375
1375
  let n = e.slice(0, t), r = e.slice(t), i = [];
1376
1376
  return n.length > 0 && i.push(s(`[${n.join(", ")}]`)), r.length > 0 && i.push(c(`+ [${r.join(", ")}]`)), i.join(" ");
1377
1377
  }, m = [];
1378
1378
  return m.push(u), m.push(""), m.push(`${i("table")}${a(e.table)}`), m.push(""), m.push(`${i("redundant")}${o(e.redundant.name)}`), m.push(`${i("")}${p(Q(e.redundant.fields), d)}`), m.push(""), m.push(`${i("covered by")}${s(e.coveredBy.name)}`), m.push(`${i("")}${p(Q(e.coveredBy.fields), d)}`), f.length > 0 && m.push(`${i("")}${l(`└ extra columns: ${f.join(", ")}`)}`), m.push(""), e.kind === "redundant-prefix" ? (m.push(`${i("why")}B-tree indexes serve queries that filter on a LEADING PREFIX of`), m.push(`${i("")}their column list. The wider index already covers every`), m.push(`${i("")}query the narrower one can serve.`), m.push(`${i("")}${l("Same rule on Postgres / MySQL / MariaDB / MSSQL / SQLite.")}`)) : (m.push(`${i("why")}Two indexes on the same columns under different names.`), m.push(`${i("")}The B-tree engine builds both — same coverage, double cost.`), m.push(`${i("")}${l("Same rule on Postgres / MySQL / MariaDB / MSSQL / SQLite.")}`)), m.push(""), m.push(`${i("cost")}Every ${a("INSERT")} / ${a("UPDATE")} / ${a("DELETE")} on this table writes`), m.push(`${i("")}to ${$(n, k.bold, "both")} B-trees. Pure write overhead, zero read benefit.`), m.push(""), m.push(`${i("fix")}Drop ${o(e.redundant.name)}`), m.push(`${i("")}${l("Keep only if profiling shows the smaller index gives a")}`), m.push(`${i("")}${l("measurable cache-locality boost on a write-heavy hot path.")}`), m.push(`${i("")}${l("Silence with VOLTRO_INDEX_AUDIT=off if intentional.")}`), m.map((e) => `${r}${e}`).join("\n");
1379
- }, _i = (e, t) => ({
1379
+ }, vi = (e, t) => ({
1380
1380
  name: e,
1381
1381
  body: t
1382
- }), vi = (e) => {
1382
+ }), yi = (e) => {
1383
1383
  if (e.lifecycle === "cron" && !e.cron) throw Error(`seed "${e.id}": lifecycle 'cron' requires a 'cron' field`);
1384
1384
  if (e.lifecycle === "onSchemaChange" && (!e.watchedTables || e.watchedTables.length === 0)) throw Error(`seed "${e.id}": lifecycle 'onSchemaChange' requires at least one entry in 'watchedTables'`);
1385
- let t = e.steps({ step: _i });
1385
+ let t = e.steps({ step: vi });
1386
1386
  if (t.length === 0) throw Error(`seed "${e.id}": at least one step is required`);
1387
1387
  return {
1388
1388
  __seed: !0,
@@ -1394,13 +1394,13 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1394
1394
  ...e.fingerprint === void 0 ? {} : { fingerprint: e.fingerprint },
1395
1395
  steps: t
1396
1396
  };
1397
- }, yi = (e) => typeof e == "object" && !!e && "__seed" in e && e.__seed === !0, bi = (e, { apply: t, undo: n }) => ({
1397
+ }, bi = (e) => typeof e == "object" && !!e && "__seed" in e && e.__seed === !0, xi = (e, { apply: t, undo: n }) => ({
1398
1398
  name: e,
1399
1399
  apply: t,
1400
1400
  undo: n
1401
- }), xi = (e) => {
1401
+ }), Si = (e) => {
1402
1402
  if (!/^\d{14}_/.test(e.id)) throw Error(`migration "${e.id}": id must start with a 14-digit timestamp followed by "_" (convention: <YYYYMMDDHHMMSS>_<slug>)`);
1403
- let t = e.steps({ step: bi });
1403
+ let t = e.steps({ step: xi });
1404
1404
  if (t.length === 0) throw Error(`migration "${e.id}": at least one step is required`);
1405
1405
  return {
1406
1406
  __migration: !0,
@@ -1408,7 +1408,7 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1408
1408
  name: e.name,
1409
1409
  steps: t
1410
1410
  };
1411
- }, Si = (e) => typeof e == "object" && !!e && "__migration" in e && e.__migration === !0, Ci = n("_voltro_migrations", {
1411
+ }, Ci = (e) => typeof e == "object" && !!e && "__migration" in e && e.__migration === !0, wi = n("_voltro_migrations", {
1412
1412
  id: T({ prefix: "mig" }),
1413
1413
  name: o(),
1414
1414
  hash: o(),
@@ -1420,7 +1420,7 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1420
1420
  workflowRunId: o().nullable(),
1421
1421
  createdAt: v().default("now"),
1422
1422
  updatedAt: v().default("now").onUpdate("now")
1423
- }).index(["status"]).reactive(), wi = n("_voltro_seeds", {
1423
+ }).index(["status"]).reactive(), Ti = n("_voltro_seeds", {
1424
1424
  id: T({ prefix: "seed" }),
1425
1425
  name: o(),
1426
1426
  lifecycle: o(),
@@ -1436,7 +1436,7 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1436
1436
  workflowRunId: o().nullable(),
1437
1437
  createdAt: v().default("now"),
1438
1438
  updatedAt: v().default("now").onUpdate("now")
1439
- }).index(["lifecycle"]).index(["nextRunAt"]).reactive(), Ti = n("_voltro_migration_plans", {
1439
+ }).index(["lifecycle"]).index(["nextRunAt"]).reactive(), Ei = n("_voltro_migration_plans", {
1440
1440
  id: T({ prefix: "plan" }),
1441
1441
  fingerprint: o(),
1442
1442
  operations: E(),
@@ -1451,21 +1451,21 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1451
1451
  appliedAt: v().default("now"),
1452
1452
  createdAt: v().default("now"),
1453
1453
  updatedAt: v().default("now").onUpdate("now")
1454
- }).index(["fingerprint"]).index(["environment"]).reactive(), Ei = n("_voltro_idempotency", {
1454
+ }).index(["fingerprint"]).index(["environment"]).reactive(), Di = n("_voltro_idempotency", {
1455
1455
  id: T({ prefix: "idem" }),
1456
1456
  scope: o(),
1457
1457
  key: o(),
1458
1458
  status: o(),
1459
1459
  response: E().nullable(),
1460
1460
  createdAt: v().default("now")
1461
- }).unique(["scope", "key"]).index(["createdAt"]).reactive(), Di = n("_voltro_kv", {
1461
+ }).unique(["scope", "key"]).index(["createdAt"]).reactive(), Oi = n("_voltro_kv", {
1462
1462
  id: T({ prefix: "kv" }),
1463
1463
  key: o().unique(),
1464
1464
  value: o().nullable(),
1465
1465
  expiresAt: v().nullable(),
1466
1466
  createdAt: v().default("now"),
1467
1467
  updatedAt: v().default("now").onUpdate("now")
1468
- }).index(["expiresAt"]).reactive(), Oi = n("_voltro_api_keys", {
1468
+ }).index(["expiresAt"]).reactive(), ki = n("_voltro_api_keys", {
1469
1469
  id: T({ prefix: "apikey" }),
1470
1470
  tenantId: o().nullable(),
1471
1471
  name: o(),
@@ -1477,42 +1477,42 @@ var at = (e) => e === "mysql" || e === "mariadb" || e === "mssql" || e === "sqli
1477
1477
  revokedAt: v().nullable(),
1478
1478
  createdBy: o().nullable(),
1479
1479
  createdAt: v().default("now")
1480
- }).index("byTenant", ["tenantId"]).reactive(), ki = [
1481
- Ci,
1480
+ }).index("byTenant", ["tenantId"]).reactive(), Ai = [
1482
1481
  wi,
1483
- Ti
1484
- ], Ai = (e, t, n) => ({
1482
+ Ti,
1483
+ Ei
1484
+ ], ji = (e, t, n) => ({
1485
1485
  _tag: "PrimaryKeyConflictError",
1486
1486
  table: e,
1487
1487
  key: t,
1488
1488
  cause: n
1489
- }), ji = (e) => typeof e == "object" && !!e && e._tag === "PrimaryKeyConflictError", Mi = /* @__PURE__ */ new Map(), Ni = (e) => {
1489
+ }), Mi = (e) => typeof e == "object" && !!e && e._tag === "PrimaryKeyConflictError", Ni = /* @__PURE__ */ new Map(), Pi = (e) => {
1490
1490
  if (!e.table || !e.timeColumn) throw Error("registerRetention: table + timeColumn are required");
1491
1491
  if (!Number.isFinite(e.ttlMs) || e.ttlMs <= 0) throw Error(`registerRetention(${e.table}): ttlMs must be a positive number`);
1492
- Mi.set(e.table, e);
1493
- }, Pi = () => [...Mi.values()], Fi = () => Mi.clear(), Ii = async (e, t, n, r = 2e4) => {
1492
+ Ni.set(e.table, e);
1493
+ }, Fi = () => [...Ni.values()], Ii = () => Ni.clear(), Li = async (e, t, n, r = 2e4) => {
1494
1494
  let i = new Date(t - e.ttlMs), a = 0;
1495
1495
  for (;;) {
1496
1496
  let t = await n(e.table, e.timeColumn, i, r, e.where);
1497
1497
  if (a += t, t < r) break;
1498
1498
  }
1499
1499
  return a;
1500
- }, Li = (e, t) => {
1500
+ }, Ri = (e, t) => {
1501
1501
  let n = Number(e ?? String(t));
1502
1502
  return (Number.isFinite(n) && n > 0 ? n : t) * 36e5;
1503
- }, Ri = "_voltro_cdc_offsets", zi = n(Ri, {
1503
+ }, zi = "_voltro_cdc_offsets", Bi = n(zi, {
1504
1504
  id: T({ prefix: "cdcoff" }),
1505
1505
  replicaId: o(),
1506
1506
  streamName: o(),
1507
1507
  binlogFile: o(),
1508
1508
  binlogPosition: o(),
1509
1509
  updatedAt: v()
1510
- }).index(["replicaId"]), Bi = n(Ri, {
1510
+ }).index(["replicaId"]), Vi = n(zi, {
1511
1511
  id: T({ prefix: "cdcoff" }),
1512
1512
  replicaId: o(),
1513
1513
  streamName: o(),
1514
1514
  ctVersion: o(),
1515
1515
  updatedAt: v()
1516
- }).index(["replicaId"]), Vi = "postgres";
1516
+ }).index(["replicaId"]), Hi = "postgres";
1517
1517
  //#endregion
1518
- export { Y as BranchNameInvalid, Ir as BranchTransitionInvalid, Ri as CDC_OFFSETS_TABLE, We as ColumnBuilder, lt as ENCRYPTED_PREFIX, Vi as ENGINE, Ge as FILE_MIGRATION_PATTERN, oe as MAX_IDENTIFIER_LENGTH, wr as TableStreamError, Jt as TenantNamespaceInvalid, qt as TenantNamespaceUnresolved, Qr as TenantRegionUnavailable, Zr as TenantResidencyUnresolved, ce as WARN_IDENTIFIER_LENGTH, Oi as _voltroApiKeysTable, zi as _voltroCdcOffsetsTable, Ei as _voltroIdempotencyTable, Di as _voltroKvTable, Ti as _voltroMigrationPlansTable, Ci as _voltroMigrationsTable, Bi as _voltroMssqlCdcOffsetsTable, wi as _voltroSeedsTable, Yn as actorsTable, Yr as admitBranch, He as allEnumRenames, kt as allRegisteredRelations, De as allRegisteredTables, dr as and, ge as array, ar as arrayContains, sr as arrayHas, or as arrayOverlaps, zt as attachEagerLoads, di as auditAllTableIndexes, ui as auditTableIndexes, Ae as avg, qe as avgOver, je as bigint, oi as bindResidentStore, Je as boolean, Nr as branchNamespaceName, ve as bytes, Ee as clearEnumRenames, At as clearRelationsRegistry, ni as clearResidencyConfig, Fi as clearRetentions, g as clearTableRegistry, Sr as collectSubqueries, _e as column, dn as compileEagerJson, R as compilePredicate, $t as compileRawFragment, z as compileSelect, kr as computeEmbedding, ir as contains, be as count, Re as countDistinct, Me as databaseHandle, xe as date, ze as dbEnum, Ne as decimal, fe as declareEnumRename, It as decodeRowsFromSchema, pt as decryptFieldsOnRead, xi as defineMigration, d as defineMixin, vi as defineSeed, we as denseRank, u as deriveTypeIdPrefix, ct as deserializeArraysOnRead, b as drainIdentifierWarnings, Te as dropped, Rt as encodeRowForSchema, ft as encryptFieldsForWrite, dt as encryptedColumnsOf, Kr as enforceBranchLimits, _ as ensureTableRegistered, Xn as eq, L as escapeLike, xr as evaluatePredicate, Se as except, vr as exists, gi as formatIndexAuditIssue, ki as frameworkTables, m as generateId, p as generateSnowflake, Ce as geography, ue as geometry, Le as getEnumRenames, Ot as getRelation, M as getRelations, ti as getResidencyConfig, y as getTable, Qn as gt, $n as gte, Kt as hasEagerLoads, Or as hybridSearch, T as id, nr as inSet, gr as inSubquery, A as inferForeignKey, D as integer, le as intersect, me as interval, Pr as isBranchNamespace, ut as isEncrypted, Pe as isFileMigration, Si as isMigrationDefinition, Br as isNeonConnection, lr as isNotNull, cr as isNull, ji as isPrimaryKeyConflictError, ii as isRegionServable, bt as isRelationsSpec, yi as isSeedDefinition, h as isSqliteFamily, de as isView, E as json, mr as jsonField, et as jsonIndex, Ye as lag, $e as lead, Pi as listRetentions, er as lt, tr as lte, Xr as makeNamespaceBranchExecutor, St as many, Ct as manyToMany, S as materializeFields, pe as max, Ve as migration, Qe as min, re as mixin, Zn as neq, Lr as nextBranchState, pr as not, yr as notExists, rr as notInSet, _r as notInSubquery, i as numeric, xt as one, fr as or, te as paginateById, Wr as planBranch, Rr as planBranchProvision, zr as planBranchTeardown, Gr as planBranchTeardownFor, Hr as planNeonBranchProvision, Ur as planNeonBranchTeardown, Ai as primaryKeyConflictError, qr as provisionBranch, si as provisionResidentTenant, I as qualifyTable, r as queryFor, Ie as queryForView, x as quoteIdent, Xe as rank, Ze as raw, ne as real, c as reference, Gn as registerCoreTables, Dt as registerDiscoveredRelations, Et as registerRelations, Ni as registerRetention, l as registerTable, yt as relations, qn as requireActors, f as requireTable, Jn as requireTenants, se as resetSnowflake, ai as residentPlacement, Vr as resolveBranchMechanism, C as resolveFullTextIndex, Be as resolveIdScheme, ae as resolveMixinGraph, ri as resolveTenantHome, Qt as resolveTenantNamespace, Li as retentionTtlMsFromEnv, s as rowNumber, Zt as sanitizeIdentifierFragment, st as serializeArraysForWrite, ei as setResidencyConfig, ur as spatialPredicate, Dr as streamTable, a as sum, ee as sumOver, Ii as sweepRetention, n as table, Jr as teardownBranch, o as text, v as timestamp, Oe as union, t as unionAll, e as validateColumnName, ye as validateIndexName, Ke as validateTableName, Fe as validateTypeIdPrefix, ke as vector, ie as vectorDistanceToOpclass, Ar as vectorEmbedding, Ue as view, he as viewSql, Cr as walkLeaves };
1518
+ export { Y as BranchNameInvalid, Lr as BranchTransitionInvalid, zi as CDC_OFFSETS_TABLE, We as ColumnBuilder, lt as ENCRYPTED_PREFIX, Hi as ENGINE, Ge as FILE_MIGRATION_PATTERN, oe as MAX_IDENTIFIER_LENGTH, Tr as TableStreamError, Jt as TenantNamespaceInvalid, qt as TenantNamespaceUnresolved, $r as TenantRegionUnavailable, Qr as TenantResidencyUnresolved, ce as WARN_IDENTIFIER_LENGTH, ki as _voltroApiKeysTable, Bi as _voltroCdcOffsetsTable, Di as _voltroIdempotencyTable, Oi as _voltroKvTable, Ei as _voltroMigrationPlansTable, wi as _voltroMigrationsTable, Vi as _voltroMssqlCdcOffsetsTable, Ti as _voltroSeedsTable, Xn as actorsTable, Xr as admitBranch, He as allEnumRenames, kt as allRegisteredRelations, De as allRegisteredTables, fr as and, ge as array, or as arrayContains, cr as arrayHas, sr as arrayOverlaps, zt as attachEagerLoads, fi as auditAllTableIndexes, di as auditTableIndexes, Ae as avg, qe as avgOver, je as bigint, si as bindResidentStore, Je as boolean, Pr as branchNamespaceName, ve as bytes, Ee as clearEnumRenames, At as clearRelationsRegistry, ri as clearResidencyConfig, Ii as clearRetentions, g as clearTableRegistry, Cr as collectSubqueries, _e as column, dn as compileEagerJson, R as compilePredicate, $t as compileRawFragment, z as compileSelect, Ar as computeEmbedding, ar as contains, be as count, Re as countDistinct, Me as databaseHandle, xe as date, ze as dbEnum, Ne as decimal, fe as declareEnumRename, It as decodeRowsFromSchema, pt as decryptFieldsOnRead, Si as defineMigration, d as defineMixin, yi as defineSeed, we as denseRank, u as deriveTypeIdPrefix, ct as deserializeArraysOnRead, b as drainIdentifierWarnings, Te as dropped, Rt as encodeRowForSchema, ft as encryptFieldsForWrite, dt as encryptedColumnsOf, qr as enforceBranchLimits, _ as ensureTableRegistered, Zn as eq, L as escapeLike, Sr as evaluatePredicate, Se as except, yr as exists, _i as formatIndexAuditIssue, Ai as frameworkTables, m as generateId, p as generateSnowflake, Ce as geography, ue as geometry, Le as getEnumRenames, Ot as getRelation, M as getRelations, ni as getResidencyConfig, y as getTable, $n as gt, er as gte, Kt as hasEagerLoads, kr as hybridSearch, T as id, rr as inSet, _r as inSubquery, A as inferForeignKey, D as integer, le as intersect, me as interval, Fr as isBranchNamespace, ut as isEncrypted, Pe as isFileMigration, Ci as isMigrationDefinition, Vr as isNeonConnection, ur as isNotNull, lr as isNull, Mi as isPrimaryKeyConflictError, ai as isRegionServable, bt as isRelationsSpec, bi as isSeedDefinition, h as isSqliteFamily, de as isView, E as json, hr as jsonField, et as jsonIndex, Ye as lag, $e as lead, Fi as listRetentions, tr as lt, nr as lte, Zr as makeNamespaceBranchExecutor, St as many, Ct as manyToMany, S as materializeFields, pe as max, Ve as migration, Qe as min, re as mixin, Qn as neq, Rr as nextBranchState, mr as not, br as notExists, ir as notInSet, vr as notInSubquery, i as numeric, xt as one, pr as or, te as paginateById, Gr as planBranch, zr as planBranchProvision, Br as planBranchTeardown, Kr as planBranchTeardownFor, Ur as planNeonBranchProvision, Wr as planNeonBranchTeardown, ji as primaryKeyConflictError, Jr as provisionBranch, ci as provisionResidentTenant, I as qualifyTable, r as queryFor, Ie as queryForView, x as quoteIdent, Xe as rank, Ze as raw, ne as real, c as reference, Kn as registerCoreTables, Dt as registerDiscoveredRelations, Et as registerRelations, Pi as registerRetention, l as registerTable, yt as relations, Jn as requireActors, f as requireTable, Yn as requireTenants, se as resetSnowflake, oi as residentPlacement, Hr as resolveBranchMechanism, C as resolveFullTextIndex, Be as resolveIdScheme, ae as resolveMixinGraph, ii as resolveTenantHome, Qt as resolveTenantNamespace, Ri as retentionTtlMsFromEnv, s as rowNumber, Zt as sanitizeIdentifierFragment, st as serializeArraysForWrite, ti as setResidencyConfig, dr as spatialPredicate, Or as streamTable, a as sum, ee as sumOver, Li as sweepRetention, n as table, Yr as teardownBranch, o as text, v as timestamp, Oe as union, t as unionAll, e as validateColumnName, ye as validateIndexName, Ke as validateTableName, Fe as validateTypeIdPrefix, ke as vector, ie as vectorDistanceToOpclass, jr as vectorEmbedding, Ue as view, he as viewSql, wr as walkLeaves };
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.1",
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.1",
41
+ "@voltro/logger": "0.3.0",
42
42
  "typeid-js": "^1.2.0",
43
43
  "ulidx": "^2.4.1"
44
44
  },