@palbase/backend 38.0.16 → 39.1.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.
Files changed (58) hide show
  1. package/dist/bin/palbase-backend.cjs +46 -1
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +5 -5
  4. package/dist/{chunk-LFUNRWUE.js → chunk-4J6I4KVQ.js} +67 -3
  5. package/dist/{chunk-LFUNRWUE.js.map → chunk-4J6I4KVQ.js.map} +1 -1
  6. package/dist/{chunk-7WAGQ3VR.js → chunk-AR2I4M7I.js} +497 -17
  7. package/dist/chunk-AR2I4M7I.js.map +1 -0
  8. package/dist/{chunk-YANGWAIM.js → chunk-H7EKL6HC.js} +2 -2
  9. package/dist/{chunk-EHKEMHB4.js → chunk-JJSR4BUX.js} +49 -5
  10. package/dist/chunk-JJSR4BUX.js.map +1 -0
  11. package/dist/{chunk-CS6NQ6PO.js → chunk-JPTFYHP3.js} +2 -2
  12. package/dist/{chunk-CS6NQ6PO.js.map → chunk-JPTFYHP3.js.map} +1 -1
  13. package/dist/{chunk-X7UR3VXA.js → chunk-XZWOMPD3.js} +3 -2
  14. package/dist/chunk-XZWOMPD3.js.map +1 -0
  15. package/dist/{chunk-PDD55QWN.js → chunk-ZNQDL466.js} +2 -2
  16. package/dist/db/index.cjs +378 -14
  17. package/dist/db/index.cjs.map +1 -1
  18. package/dist/db/index.d.cts +1 -1
  19. package/dist/db/index.d.ts +1 -1
  20. package/dist/db/index.js +3 -3
  21. package/dist/engine/index.cjs +46 -1
  22. package/dist/engine/index.cjs.map +1 -1
  23. package/dist/engine/index.d.cts +3 -3
  24. package/dist/engine/index.d.ts +3 -3
  25. package/dist/engine/index.js +5 -5
  26. package/dist/{index-CI7S0Wqv.d.cts → index-B0mjUnxy.d.cts} +25 -2
  27. package/dist/{index-CUy50OLU.d.ts → index-eN4KzGa5.d.ts} +555 -29
  28. package/dist/{index-DRx880KY.d.cts → index-pK2At5Yc.d.cts} +555 -29
  29. package/dist/{index-Ogi30dbt.d.ts → index-yDno9Ju9.d.ts} +25 -2
  30. package/dist/index.cjs +629 -17
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +216 -8
  33. package/dist/index.d.ts +216 -8
  34. package/dist/index.js +75 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/openapi/index.cjs.map +1 -1
  37. package/dist/openapi/index.d.cts +2 -2
  38. package/dist/openapi/index.d.ts +2 -2
  39. package/dist/openapi/index.js +2 -2
  40. package/dist/{registry-C7UCkQf0.d.cts → registry-9dNeVN5d.d.cts} +1 -1
  41. package/dist/{registry-DJcvbomD.d.ts → registry-B8ddZiMY.d.ts} +1 -1
  42. package/dist/test/index.cjs +19 -0
  43. package/dist/test/index.cjs.map +1 -1
  44. package/dist/test/index.d.cts +1 -1
  45. package/dist/test/index.d.ts +1 -1
  46. package/dist/test/index.js +20 -2
  47. package/dist/test/index.js.map +1 -1
  48. package/docs/README.md +1 -1
  49. package/docs/database.md +129 -0
  50. package/docs/llms-full.txt +512 -33
  51. package/docs/schema.md +382 -32
  52. package/package.json +1 -1
  53. package/template/package.json +1 -1
  54. package/dist/chunk-7WAGQ3VR.js.map +0 -1
  55. package/dist/chunk-EHKEMHB4.js.map +0 -1
  56. package/dist/chunk-X7UR3VXA.js.map +0 -1
  57. /package/dist/{chunk-YANGWAIM.js.map → chunk-H7EKL6HC.js.map} +0 -0
  58. /package/dist/{chunk-PDD55QWN.js.map → chunk-ZNQDL466.js.map} +0 -0
@@ -90,7 +90,7 @@ service the controllers call.
90
90
 
91
91
  > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
92
92
  > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
93
- > and will not compile against `@palbase/backend` 38.
93
+ > and will not compile against `@palbase/backend` 39.
94
94
 
95
95
  ### Complete CRUD example (copy-pasteable, compiles)
96
96
 
@@ -1163,6 +1163,56 @@ await Database.public.todos.delete(todo.id);
1163
1163
 
1164
1164
  See [schema.md](./schema.md) for the full typed-table surface.
1165
1165
 
1166
+ ## Tenant-scoped CRUD — `defineRepository`
1167
+
1168
+ Most repository classes are the same two lines per method: add the tenant column
1169
+ to the predicate, then unwrap the result. Writing that by hand is what makes it
1170
+ forgettable, and one forgotten predicate shows one tenant's row to another.
1171
+ `defineRepository` produces a BASE CLASS that writes the predicate once.
1172
+
1173
+ ```ts
1174
+ import { defineRepository } from "@palbase/backend";
1175
+
1176
+ export class TodoRepository extends defineRepository(
1177
+ Database.public.todos,
1178
+ { tenant: "household_id" },
1179
+ ) {}
1180
+ ```
1181
+
1182
+ The subclass gets six typed methods, all scoped to the tenant you pass first:
1183
+ `list(tenant)`, `find(tenant, id)`, `insert(tenant, values)`,
1184
+ `update(tenant, id, patch)`, `updateScoped(tenant, id, patch)` and
1185
+ `delete(tenant, id)`. Add your own methods in the subclass body as usual.
1186
+
1187
+ `insert` does NOT take the tenant column in its payload — the repository writes
1188
+ it. `update` throws `NotFound` when nothing matches, so the caller does not need
1189
+ a `null` branch; `updateScoped` runs the same predicate and returns `null`
1190
+ instead, for when absence is a value.
1191
+
1192
+ The base class constructor takes no arguments, so the DI container resolves the
1193
+ subclass by its own name with no extra metadata. `Database` stays ambient rather
1194
+ than injected: it is request-scoped, so a reference captured in a constructor
1195
+ would carry one request's client into another.
1196
+
1197
+ ### A row key that is not `id`
1198
+
1199
+ The row key defaults to `id`, but it is not fixed — `defineTable` does not
1200
+ require an `id` column, and a primary key can sit on any column. Name it:
1201
+
1202
+ ```ts
1203
+ export class DocRepository extends defineRepository(
1204
+ Database.public.docs,
1205
+ { tenant: "org_id", key: "slug" },
1206
+ ) {}
1207
+
1208
+ await new DocRepository().find("org_1", "getting-started");
1209
+ ```
1210
+
1211
+ The key parameter carries the column's OWN type, so a numeric key is typed
1212
+ `number` rather than assumed to be a string. If the table has no `id` column and
1213
+ you do not name a key, the surface refuses at compile time and the refusal says
1214
+ what to do (`MissingRowKey`).
1215
+
1166
1216
  ## Transaction boundaries and retries
1167
1217
 
1168
1218
  Ordinary Database writes commit when the request completes. `$transaction(fn)`
@@ -1589,6 +1639,85 @@ const rows = await Database.$query(
1589
1639
  A JavaScript array parameter is encoded as a Postgres array literal, so
1590
1640
  `= ANY($1::uuid[])` works.
1591
1641
 
1642
+ **Do not reach for `$query` to take a lock.** That is the single most common
1643
+ reason this escape hatch gets used, and there are typed primitives for it —
1644
+ see the next section.
1645
+
1646
+ ### Locks — `$lockRows`, `$lockRowsWhere`, `$advisoryXactLock`, `lock: "update"`
1647
+
1648
+ Four surfaces, for four different things.
1649
+
1650
+ **`Database.$lockRows(table, ids)` — lock these rows, and let the layer order
1651
+ them.** You declare the set you are about to touch; the ordering is not yours to
1652
+ choose. Trusting the caller's order means two callers entering the same two rows
1653
+ in opposite orders, and the bill for a deadlock is not cheap: `deadlock_timeout`
1654
+ defaults to **1 second**, so the loser waits a full second before it errors.
1655
+
1656
+ ```ts
1657
+ await Database.$lockRows("accounts", [input.from, input.to]);
1658
+ ```
1659
+
1660
+ It de-duplicates and sorts the ids, then emits
1661
+ `SELECT pk … WHERE pk IN (…) ORDER BY pk FOR NO KEY UPDATE` — the same lock mode
1662
+ `updateMany`'s CTE takes, because two write paths must share one order and one
1663
+ mode or each will wait on the other. It requires a single-column primary key and
1664
+ throws if the table has none. An empty list locks nothing and returns.
1665
+
1666
+ **`Database.$lockRowsWhere(table, where, { mode })` — lock what a FILTER
1667
+ matches.** `$lockRows` asks you to enumerate the set. "Lock every pending entry
1668
+ on this account" cannot: the id list only comes out of a query, and the gap
1669
+ between that query and the lock is exactly the race the lock exists to close.
1670
+ This form selects and locks the set in ONE statement.
1671
+
1672
+ ```ts
1673
+ await Database.$lockRowsWhere("entries", { account_id: id, status: "pending" });
1674
+ const rows = await Database.$findMany("entries", { account_id: id, status: "pending" });
1675
+ ```
1676
+
1677
+ The filter is the ordinary one — same compiler as `findMany` and `updateMany`,
1678
+ so there is no second filter dialect and no `$query`. It emits
1679
+ `SELECT pk … WHERE … ORDER BY pk <lock clause>`, and that `ORDER BY pk` is the
1680
+ **same order `updateMany`'s CTE takes**: two transactions entering the same rows
1681
+ by different routes cannot take them in opposite orders. Measured against live
1682
+ Postgres in both directions.
1683
+
1684
+ The lock MODE is yours, and it is deliberately not equalised with `updateMany`'s:
1685
+
1686
+ | `mode` | Postgres clause | when |
1687
+ |---|---|---|
1688
+ | `"update"` (default) | `FOR UPDATE` | you are about to change these rows |
1689
+ | `"share"` | `FOR SHARE` | "nobody may change these while I read them" |
1690
+ | `"noKeyUpdate"` | `FOR NO KEY UPDATE` | `updateMany`'s own mode — non-key columns |
1691
+
1692
+ Forcing everyone onto one mode would make a reader take a stronger lock than it
1693
+ needs. What prevents the deadlock is the ORDER, not the mode. An unrecognised
1694
+ mode is refused rather than quietly downgraded — a silently weaker lock is
1695
+ fail-open.
1696
+
1697
+ Two refusals worth knowing before you reach for it: an **empty filter** is
1698
+ rejected (locking the whole table is not somewhere you should arrive by
1699
+ accident — every writer waits until your transaction ends), and a table with no
1700
+ single-column primary key is rejected, because the lock ORDER needs a key.
1701
+
1702
+ **`Database.$advisoryXactLock(key)` — lock a NAME, not a row.** For work that
1703
+ has no row to lock: "only one statement close runs at a time", or serializing
1704
+ retries of the same idempotency key before reading its result.
1705
+
1706
+ ```ts
1707
+ await tx.$advisoryXactLock(`billing.close:${householdId}:${period}`);
1708
+ ```
1709
+
1710
+ The key is text; Postgres wants a 64-bit integer, so it is hashed with
1711
+ `hashtextextended` and **the text never enters the SQL**. Only the
1712
+ transaction-scoped family is offered (`pg_advisory_xact_lock`): Postgres releases
1713
+ it on commit or rollback, so it cannot be forgotten. The session-scoped version
1714
+ is deliberately absent — the connection returns to the pool with the lock still
1715
+ held, and the next request waits forever.
1716
+
1717
+ **`select(where, { lock: "update" })` — a real `FOR UPDATE`**, inside a
1718
+ `$transaction` plan. Use it when the rows you want to lock are the rows you were
1719
+ about to select anyway.
1720
+
1592
1721
  ### Bulk writes with a conflict rule
1593
1722
 
1594
1723
  Inside a transaction, `insertMany` takes an optional conflict rule — which is how
@@ -2374,6 +2503,52 @@ export const orgs = defineTable("orgs", {
2374
2503
  });
2375
2504
  ```
2376
2505
 
2506
+ ### Composite foreign keys — `foreignKeys`
2507
+
2508
+ `.references()` on a column is single-column and single-target. Multi-tenant
2509
+ integrity usually wants more than that: the parent must not merely EXIST, it must
2510
+ belong to the same tenant. That is one constraint over two columns, and a
2511
+ per-column model cannot say it.
2512
+
2513
+ ```ts
2514
+ import { defineTable, foreignKey, uuid, bigint } from "@palbase/backend";
2515
+
2516
+ export const settlements = defineTable("settlements", {
2517
+ columns: {
2518
+ id: uuid().primaryKey().defaultRandom(),
2519
+ household_id: uuid().notNull(),
2520
+ entry_id: uuid().notNull(),
2521
+ amount: bigint().notNull(),
2522
+ },
2523
+ foreignKeys: (c) => [
2524
+ foreignKey("settlement_entry_household")
2525
+ .on(c.col("household_id"), c.col("entry_id"))
2526
+ .references(entries, "household_id", "id")
2527
+ .onDelete("cascade"),
2528
+ ],
2529
+ });
2530
+ ```
2531
+
2532
+ That emits `FOREIGN KEY (household_id, entry_id) REFERENCES entries (household_id, id)`:
2533
+ a settlement cannot point at another household's entry, and the database is what
2534
+ enforces it — under every escape hatch, for every writer.
2535
+
2536
+ - **`.on(...)`** takes column references from the callback's context, so a typo is
2537
+ a compile error. Order is preserved and matches `.references()` position by
2538
+ position; a length mismatch is refused where you write it, not mid-deploy.
2539
+ - **`.references(table, ...cols)`** takes the table VALUE, not a thunk and not a
2540
+ string — the target's column names are checked against its declaration.
2541
+ - **`.onUpdate(a)` / `.onDelete(a)`** — `"cascade"`, `"restrict"`, `"set null"`,
2542
+ `"set default"`, `"no action"`.
2543
+ - **`.match("full")`** — with `MATCH SIMPLE` (the default) a single NULL among the
2544
+ referencing columns skips the constraint entirely. `MATCH FULL` evaluates it
2545
+ unless ALL of them are NULL. [`freeze`](#conditional-foreign-keys--freeze)
2546
+ uses the SIMPLE escape on purpose; an ordinary FK usually does not want it.
2547
+
2548
+ **The two forms do not displace each other.** This declaration is tracked under
2549
+ its own name; column-level ones are tracked as `<table>_<column>_fkey`. One table
2550
+ can carry both.
2551
+
2377
2552
  ### Relation names
2378
2553
 
2379
2554
  Every foreign key produces **two** named relations, and the two names are derived
@@ -2543,7 +2718,8 @@ used to reject passed after the rename.
2543
2718
 
2544
2719
  ## Indexes
2545
2720
 
2546
- `indexes` declares plain (non-unique) btree indexes over an ordered column list:
2721
+ `indexes` is a callback that receives the table's column context and returns
2722
+ `index(name)` builders — one non-unique btree index each:
2547
2723
 
2548
2724
  ```ts
2549
2725
  export const sessions = defineTable("sessions", {
@@ -2552,12 +2728,17 @@ export const sessions = defineTable("sessions", {
2552
2728
  room_id: uuid().references(() => rooms.id),
2553
2729
  started_at: timestamp().defaultNow(),
2554
2730
  },
2555
- indexes: [
2556
- { name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
2731
+ indexes: (c) => [
2732
+ index("sessions_room_started_idx").on(c.col("room_id"), c.col("started_at")),
2557
2733
  ],
2558
2734
  });
2559
2735
  ```
2560
2736
 
2737
+ The older plain-object form — `indexes: [{ name, columns }]` — still typechecks
2738
+ and still means the same thing. The builder is what the rest of this section
2739
+ documents, because everything past a plain column list is only reachable
2740
+ through it.
2741
+
2561
2742
  On deploy each entry becomes a statement of its own — `CREATE INDEX IF NOT
2562
2743
  EXISTS <name> ON <table> (<columns>)` — not a clause of the `CREATE TABLE`. The
2563
2744
  name and every column are identifier-validated before any SQL is built.
@@ -2570,50 +2751,348 @@ knowing before you name an index:
2570
2751
  names.
2571
2752
  - Removing an entry from `indexes` therefore does **not** drop the index. Drop it
2572
2753
  in an explicit [migration](./migrations.md).
2573
- - Changing `columns` under a name that is already live does **nothing** — the
2574
- deploy matches the name, sees it, and skips. Give the new column list a NEW
2575
- name (and drop the old index in a migration if you want it gone).
2754
+ - Changing an index that is already live does **nothing** — the deploy matches
2755
+ the name, sees it, and skips. This is the whole definition, not just the
2756
+ columns: edit `.where(…)`, `.onExpression(…)`, `.include(…)` or the sort under
2757
+ a live name and the database keeps the old index, silently. Give the new
2758
+ definition a NEW name (and drop the old index in a migration if you want it
2759
+ gone).
2576
2760
 
2577
- ### Scope: plain btree over columns, nothing else
2761
+ ### Scope
2578
2762
 
2579
- `indexes` models exactly one kind of index. It cannot express:
2763
+ The builder covers btree indexes, including the shapes that need a predicate or
2764
+ an expression:
2580
2765
 
2581
2766
  | what you want | declare it with |
2582
2767
  |---------------|-----------------|
2583
- | a UNIQUE index | `unique: [{ name, columns }]` on the table |
2584
- | a partial index (`WHERE …`) | `raw()` |
2585
- | an expression index (`lower(email)`, …) | `raw()` |
2586
- | any other method (`gin`, `gist`, `hnsw`, …) | `raw()` |
2587
-
2588
- The limit is deliberate, not an oversight. Comparing a partial predicate or an
2589
- expression against the live database needs the same round-trip through Postgres'
2590
- own deparse that `checks` uses; without it an edited predicate would silently do
2591
- nothing. Rather than ship a half-working partial-index path, the typed field
2592
- stays columns-only and `raw()` carries the rest:
2768
+ | a multi-column index | `index("i").on(c.col("a"), c.col("b"))` |
2769
+ | a partial index (`WHERE …`) | `index("i").on(c.col("a")).where(c.col("status").eq("pending"))` |
2770
+ | an expression index (`lower(email)`, …) | `index("i").onExpression("lower(email)")` |
2771
+ | a covering index (`INCLUDE …`) | `index("i").on(c.col("a")).include(["b", "c"])` |
2772
+ | a sort that matches an `ORDER BY` | `index("i").on(c.col("a")).desc().nulls("last")` |
2773
+ | a UNIQUE **constraint** | `unique: [{ name, columns }]` on the table |
2774
+ | a UNIQUE **index** | `index("i").unique().on(c.col("a"))` |
2775
+ | a **partial UNIQUE** index | `index("i").unique().on(c.col("a")).where(…)` |
2776
+ | another method (`gin`, `gist`, `brin`, `hash`), an opclass, `CONCURRENTLY` | `raw()` |
2777
+
2778
+ `.where()` takes the same typed predicate language as `policy().using()`, so
2779
+ column names are checked at compile time. `.onExpression()` takes SQL text and
2780
+ emits it verbatim — the same trust posture as `policy().using("…")`.
2781
+
2782
+ **Both are checked against the live database before the push, not during it.**
2783
+ Every declared predicate and expression is probed twice: once as a prepared
2784
+ statement, and once as a real `CREATE INDEX` on an empty `LIKE` clone of the
2785
+ table. The second probe is the one that matters — an index predicate has rules a
2786
+ `SELECT` does not, so `where(p.existsIn(…))` (writable at the type level) and a
2787
+ non-`IMMUTABLE` function are both refused at push time instead of failing
2788
+ halfway through an apply.
2789
+
2790
+ **`.unique()` composes with everything else on the builder**, which is what makes
2791
+ the partial-unique shape declarative. `unique: [{ name, columns }]` produces a
2792
+ UNIQUE *constraint*, and a constraint cannot carry a `WHERE`; `index()` could
2793
+ carry the `WHERE` but could not be unique. Those were two halves that never met.
2593
2794
 
2594
2795
  ```ts
2595
- import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
2796
+ import { defineTable, index, uuid, text, timestamp } from "@palbase/backend";
2596
2797
 
2597
2798
  export const orders = defineTable("orders", {
2598
2799
  columns: {
2599
- id: uuid().primaryKey().defaultRandom(),
2600
- status: text().notNull(),
2601
- created_at: timestamp().defaultNow(),
2800
+ id: uuid().primaryKey().defaultRandom(),
2801
+ status: text().notNull(),
2802
+ external_id: text().nullable(),
2803
+ created_at: timestamp().defaultNow(),
2804
+ },
2805
+ indexes: (c) => [
2806
+ // Partial index.
2807
+ index("orders_pending_idx").on(c.col("created_at")).where(c.col("status").eq("pending")),
2808
+ // Partial UNIQUE: NULLs stay free, non-NULLs are unique. No `raw()`.
2809
+ index("orders_external_id_idx")
2810
+ .unique()
2811
+ .on(c.col("external_id"))
2812
+ .where(c.col("external_id").isNull(true)), // isNull(true) is IS NOT NULL
2813
+ ],
2814
+ });
2815
+ ```
2816
+
2817
+ Reach for `unique:` when you want a named CONSTRAINT (something a foreign key can
2818
+ reference); reach for `index().unique()` when you want a unique INDEX, which is
2819
+ the only one of the two that can be partial.
2820
+
2821
+ ### `raw()` — the last resort, and it takes TWO arguments
2822
+
2823
+ ```ts
2824
+ raw("<name>", "<sql>")
2825
+ ```
2826
+
2827
+ Most of what this list used to carry is declarative now, and the typed field is
2828
+ the one to reach for — it is diffed, checked against the live database before the
2829
+ push, and its errors arrive in your editor instead of halfway through an apply:
2830
+
2831
+ | you used to write `raw()` for | write instead |
2832
+ |---|---|
2833
+ | a CHECK constraint | [`checks`](#checks) — string or typed `check()` |
2834
+ | a partial / expression / covering index | [`index()`](#indexes) |
2835
+ | a partial UNIQUE index | `index("i").unique().where(…)` |
2836
+ | a composite FOREIGN KEY | [`foreignKeys`](#composite-foreign-keys--foreignkeys) |
2837
+ | a conditional FK ("frozen while …") | [`freeze`](#conditional-foreign-keys--freeze) |
2838
+ | a trigger that refuses a write | [`guards`](#guards--refusing-a-write-in-your-own-words) |
2839
+ | a one-off data fix | [`backfills`](#backfills--a-data-fix-that-runs-once) |
2840
+ | dropping a constraint | [`dropConstraints`](#dropping-a-constraint--dropconstraints) |
2841
+
2842
+ What is left for `raw()`: an index method other than btree (`gin`, `gist`,
2843
+ `brin`, `hash`), an opclass, `CONCURRENTLY`, `EXCLUDE` constraints, views, and
2844
+ PL/pgSQL functions.
2845
+
2846
+ **There is no `down`, and no teardown option of any kind.** One used to be
2847
+ accepted; it was carried into a generated `Down` that NOTHING ever ran, so an
2848
+ author who wrote one believed they had a rollback they did not have. Drop what a
2849
+ `raw()` created by declaring the drop as its own `raw()`.
2850
+
2851
+ `up` is emitted verbatim, and the body is **read before the push, not trusted
2852
+ blindly**: a body that reaches past what a schema declaration is allowed to do is
2853
+ refused up front rather than part-way through an apply. How a CHANGED body is
2854
+ treated depends on what the name addresses:
2855
+
2856
+ - **The name addresses a constraint or an index.** Tracked by
2857
+ NAME only — the deploy sees the catalog object and skips, so an edited body
2858
+ does nothing. Give it a new name, or drop and re-add it explicitly.
2859
+ - **The name addresses nothing in the catalog** (functions, triggers, grants,
2860
+ compound statements). The last applied body is recorded in
2861
+ `public.palbase_raw_objects` inside the same DDL transaction, and an edited
2862
+ body **is** re-applied. You do not need a new name to change a trigger.
2863
+
2864
+ ## Checks
2865
+
2866
+ `checks` declares named CHECK constraints — the invariants the database itself
2867
+ enforces, under every escape hatch, for every writer:
2868
+
2869
+ ```ts
2870
+ export const transfers = defineTable("transfers", {
2871
+ columns: {
2872
+ id: uuid().primaryKey().defaultRandom(),
2873
+ from_account_id: uuid().notNull(),
2874
+ to_account_id: uuid().notNull(),
2875
+ debited_value: bigint().notNull(),
2876
+ credited_value: bigint().notNull(),
2877
+ },
2878
+ checks: [
2879
+ { name: "transfer_positive", expr: "debited_value > 0 AND credited_value > 0" },
2880
+ { name: "transfer_distinct_accounts", expr: "from_account_id <> to_account_id" },
2881
+ ],
2882
+ });
2883
+ ```
2884
+
2885
+ `expr` is trusted SQL emitted verbatim (the same posture as `policy().using()`);
2886
+ `name` is identifier-validated.
2887
+
2888
+ **Or write the expression typed**, with `checks` as a callback and `check()`:
2889
+
2890
+ ```ts
2891
+ import { defineTable, check, bigint, text, uuid } from "@palbase/backend";
2892
+
2893
+ export const transfers = defineTable("transfers", {
2894
+ columns: {
2895
+ id: uuid().primaryKey().defaultRandom(),
2896
+ debited_value: bigint().notNull(),
2897
+ currency: text().nullable(),
2898
+ },
2899
+ checks: (c) => [
2900
+ check("transfer_positive", c.col("debited_value").gt(0)),
2901
+ check("transfer_currency_set", c.col("currency").isNull(true)),
2902
+ ],
2903
+ });
2904
+ ```
2905
+
2906
+ Both forms are accepted and both land on the wire as the same string — the
2907
+ callback compiles the expression to SQL where you declare it. The difference is
2908
+ WHEN a mistyped column name answers you. In the string form `debited_vlaue > 0`
2909
+ comes back from Postgres during `ALTER TABLE … ADD CONSTRAINT`, in the MIDDLE of
2910
+ a deploy; in the typed form it is a compile error. The callback shape is what
2911
+ makes that possible: a plain array cannot be contextualised with the table's own
2912
+ column names.
2913
+
2914
+ The typed expression is held to the CHECK context, which is NARROWER than a
2915
+ policy's. Postgres forbids subqueries in a CHECK, so `existsIn(…)` and
2916
+ `auth.uid()` are refused by `check()` where you write them — with a sentence
2917
+ saying what to use instead — rather than failing the apply. What is left is the
2918
+ part a CHECK can actually evaluate: columns, literals, comparisons, `and`/`or`,
2919
+ `not`, `isNull`.
2920
+
2921
+ **Unlike indexes, checks are diffed by BODY, not just by name.** Editing an
2922
+ `expr` under a live name recreates the constraint (`DROP` + `ADD`) — which is
2923
+ what you want, and it is why you should reach for `checks` before `raw()` for
2924
+ anything a CHECK can express.
2925
+
2926
+ That body compare works because the declared expression is round-tripped through
2927
+ Postgres' own parser and deparser before the diff: a scratch `NOT VALID`
2928
+ constraint carrying your text is added to the live table, `pg_get_constraintdef`
2929
+ is read back, and the scratch is dropped. Without it, `price > 0` would come
2930
+ back as `(price > 0)` and every deploy would re-drop and re-add every unchanged
2931
+ check forever.
2932
+
2933
+ **What a CHECK cannot do:** reference another table. Postgres forbids subqueries
2934
+ in a CHECK, so a cross-table invariant is not a check. Three declarative places
2935
+ take that work instead, in order of how much they cost you:
2936
+ [`foreignKeys`](#composite-foreign-keys--foreignkeys) when the rule is "the
2937
+ parent exists and matches", [`freeze`](#conditional-foreign-keys--freeze) when it
2938
+ holds only while some condition does, and
2939
+ [`guards`](#guards--refusing-a-write-in-your-own-words) when the rule is a
2940
+ predicate no constraint can carry.
2941
+
2942
+ ## Conditional foreign keys — `freeze`
2943
+
2944
+ "While a payment is `settled`, the invoice's amount and currency may not change."
2945
+ That is a foreign key that only applies part of the time, and it is declarative:
2946
+
2947
+ ```ts
2948
+ import { defineTable, freeze, bigint, text, uuid } from "@palbase/backend";
2949
+
2950
+ export const payments = defineTable("payments", {
2951
+ columns: {
2952
+ id: uuid().primaryKey().defaultRandom(),
2953
+ status: text().notNull(),
2954
+ inv_id: uuid().notNull(),
2955
+ amount: bigint().notNull(),
2956
+ currency: text().notNull(),
2957
+ },
2958
+ freeze: (c) => [
2959
+ freeze("pay_settled_inv")
2960
+ .when(c.col("status").eq("settled"))
2961
+ .columns("inv_id", "amount", "currency")
2962
+ .references(invoices, "id", "amount", "currency"),
2963
+ ],
2964
+ });
2965
+ ```
2966
+
2967
+ **It is not a trigger.** What gets created is a constraint: a UNIQUE on the
2968
+ parent's tuple, `STORED` generated columns on the child that are NULL while the
2969
+ condition is false, and an `ON UPDATE RESTRICT ON DELETE RESTRICT` foreign key
2970
+ over those. `MATCH SIMPLE`'s NULL escape — "a row with a NULL among the
2971
+ referencing columns need not satisfy the constraint" — is what gives you a
2972
+ conditional FK without a `WHERE`.
2973
+
2974
+ Measured against live Postgres, that buys four behaviours a trigger would not:
2975
+
2976
+ - while the condition does NOT hold, nothing is frozen — the row is ordinary;
2977
+ - a row whose columns do not match its parent **cannot be made `settled`**. The
2978
+ reverse direction is closed too; a trigger guarding the parent would have left
2979
+ it open;
2980
+ - a frozen row's columns cannot be changed, and its parent cannot be deleted;
2981
+ - when the condition lifts, the freeze **dissolves by itself**. Nothing has to
2982
+ remember to release it.
2983
+
2984
+ The chain ends at `.references()` on purpose: that is where the declaration is
2985
+ completed and checked, so a `freeze` missing its target cannot reach a table at
2986
+ all. `.columns()` and the target list must be the same length, and the frozen
2987
+ columns must be `NOT NULL` — a nullable one opens the same `MATCH SIMPLE` escape
2988
+ INDEPENDENTLY of your condition, which would mean the freeze silently never
2989
+ formed. The push refuses that declaration rather than shipping it.
2990
+
2991
+ ## Guards — refusing a write in your own words
2992
+
2993
+ Some rules no constraint can carry: write-once fields, state-machine
2994
+ transitions, a cross-row check whose predicate is not immutable, a
2995
+ transaction-level total. RLS cannot express them either — `OLD` and `NEW` are not
2996
+ names a policy expression has, and `USING`/`WITH CHECK` are evaluated against two
2997
+ separate rows with no scope that puts them side by side.
2998
+
2999
+ A guard is the narrow way to say it. **You write three things: which event, which
3000
+ condition, and what to say when refusing.**
3001
+
3002
+ ```ts
3003
+ import { defineTable, guard, text, uuid } from "@palbase/backend";
3004
+
3005
+ export const documents = defineTable("documents", {
3006
+ columns: {
3007
+ id: uuid().primaryKey().defaultRandom(),
3008
+ status: text().notNull(),
3009
+ body: text().notNull(),
2602
3010
  },
2603
- raw: [
2604
- raw(
2605
- "orders_pending_idx",
2606
- "CREATE INDEX IF NOT EXISTS orders_pending_idx ON orders (created_at) WHERE status = 'pending'",
2607
- { down: "DROP INDEX IF EXISTS orders_pending_idx" },
2608
- ),
3011
+ guards: (g) => [
3012
+ guard("documents_frozen_body")
3013
+ .on("update")
3014
+ .when(g.col("status").eq("archived"))
3015
+ .refuse("An archived document's body cannot be changed", { column: "body" }),
2609
3016
  ],
2610
3017
  });
2611
3018
  ```
2612
3019
 
2613
- `raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
2614
- `indexes`, is tracked by NAME only a changed body needs a new name. Pass
2615
- `down` yourself for an index: the default drop statement drops a *constraint*,
2616
- and an index is not one.
3020
+ Everything else is a safe default and deliberately not on this surface: `BEFORE`,
3021
+ `FOR EACH ROW` (Postgres defaults to `STATEMENT`, which is wrong for a guard),
3022
+ `SECURITY INVOKER` never `DEFINER` a pinned `search_path`, an `UPDATE OF (…)`
3023
+ list derived from the columns your condition names, `IS DISTINCT FROM` for the
3024
+ change test (`<>` answers wrong on NULL), and the right `RETURN`. Exposing a knob
3025
+ turns it into a policy; these are not policies. There is also **no global escape
3026
+ hatch** — no session setting that lets a connection skip a guard.
3027
+
3028
+ **The refusal is machine-readable.** It arrives with SQLSTATE `PB001` — not
3029
+ `23514`, which is a real CHECK violation and would be indistinguishable — the
3030
+ guard's NAME in the `CONSTRAINT` field and, if you gave one, the field name in
3031
+ `COLUMN`. A client branches on those without parsing your sentence. Measured on
3032
+ a live refusal: `constraint=documents_frozen_body · column=body`. `detail` and
3033
+ `hint` land in Postgres' fields of the same name.
3034
+
3035
+ The name is required, and not for tidiness: Postgres runs triggers bound to one
3036
+ event **in alphabetical order by name**, and there is no other handle on ordering.
3037
+
3038
+ For a cross-row rule, `.exists(table, row => …)` puts the predicate in an
3039
+ `EXISTS` block instead of the trigger's `WHEN` clause — Postgres does not allow a
3040
+ subquery in `WHEN`. Inside the callback, `row.col(…)` is the OTHER table's
3041
+ column; an unqualified column is the row being written.
3042
+
3043
+ One shape is refused at push: an **`insert` guard cannot read a generated
3044
+ column**. They are empty in `NEW`, so the condition would silently never fire —
3045
+ fail-open on a refusal path.
3046
+
3047
+ ## Backfills — a data fix that runs once
3048
+
3049
+ Adding a column is declarative. FILLING it is not. That work used to live inside
3050
+ a `raw()` block, which re-runs on every deploy, so it was either written
3051
+ defensively idempotent or run by hand with no record that it happened.
3052
+
3053
+ ```ts
3054
+ import { backfill, defineTable, text, uuid } from "@palbase/backend";
3055
+
3056
+ export const ledger = defineTable("ledger", {
3057
+ columns: {
3058
+ id: uuid().primaryKey().defaultRandom(),
3059
+ currency: text().nullable(),
3060
+ },
3061
+ backfills: [
3062
+ backfill("ledger_currency_try", "UPDATE ledger SET currency = 'TRY' WHERE currency IS NULL"),
3063
+ ],
3064
+ });
3065
+ ```
3066
+
3067
+ It runs **after** the DDL, in its own phase and its own transaction, and **once
3068
+ per name**. The record lives in `public.palbase_backfills`, so "did this run?" is
3069
+ answered by the database rather than by memory.
3070
+
3071
+ **The NAME is the record.** Keeping the name and editing the SQL does NOT produce
3072
+ a second run — to correct a backfill, give it a new name. That is also why an
3073
+ empty body is refused: it would register the name and burn it permanently, and
3074
+ the real SQL written later would never run and never complain. The registry is
3075
+ global, so two tables cannot declare the same backfill name.
3076
+
3077
+ A plain array, not a callback: a backfill has no predicate to contextualise —
3078
+ its body is verbatim SQL.
3079
+
3080
+ ## Dropping a constraint — `dropConstraints`
3081
+
3082
+ The diff is **additive**: a constraint that exists in the database but not in your
3083
+ declaration is never dropped. Your schema file adds objects; it does not own the
3084
+ database's. So a deliberate removal has to be SAID:
3085
+
3086
+ ```ts
3087
+ export const orders = defineTable("orders", {
3088
+ columns: { /* … */ },
3089
+ dropConstraints: ["orders_legacy_status_check"],
3090
+ });
3091
+ ```
3092
+
3093
+ This is the one exception to the additive rule, and requiring it in writing is
3094
+ the point — the alternative is a deploy that drops a constraint because someone
3095
+ deleted a line.
2617
3096
 
2618
3097
  ## Typed DB access — by default
2619
3098