@palbase/backend 38.0.16 → 39.0.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 +628 -17
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +183 -8
  33. package/dist/index.d.ts +183 -8
  34. package/dist/index.js +74 -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 +79 -0
  50. package/docs/llms-full.txt +462 -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
package/docs/README.md CHANGED
@@ -82,7 +82,7 @@ service the controllers call.
82
82
 
83
83
  > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
84
84
  > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
85
- > and will not compile against `@palbase/backend` 38.
85
+ > and will not compile against `@palbase/backend` 39.
86
86
 
87
87
  ### Complete CRUD example (copy-pasteable, compiles)
88
88
 
package/docs/database.md CHANGED
@@ -455,6 +455,85 @@ const rows = await Database.$query(
455
455
  A JavaScript array parameter is encoded as a Postgres array literal, so
456
456
  `= ANY($1::uuid[])` works.
457
457
 
458
+ **Do not reach for `$query` to take a lock.** That is the single most common
459
+ reason this escape hatch gets used, and there are typed primitives for it —
460
+ see the next section.
461
+
462
+ ### Locks — `$lockRows`, `$lockRowsWhere`, `$advisoryXactLock`, `lock: "update"`
463
+
464
+ Four surfaces, for four different things.
465
+
466
+ **`Database.$lockRows(table, ids)` — lock these rows, and let the layer order
467
+ them.** You declare the set you are about to touch; the ordering is not yours to
468
+ choose. Trusting the caller's order means two callers entering the same two rows
469
+ in opposite orders, and the bill for a deadlock is not cheap: `deadlock_timeout`
470
+ defaults to **1 second**, so the loser waits a full second before it errors.
471
+
472
+ ```ts
473
+ await Database.$lockRows("accounts", [input.from, input.to]);
474
+ ```
475
+
476
+ It de-duplicates and sorts the ids, then emits
477
+ `SELECT pk … WHERE pk IN (…) ORDER BY pk FOR NO KEY UPDATE` — the same lock mode
478
+ `updateMany`'s CTE takes, because two write paths must share one order and one
479
+ mode or each will wait on the other. It requires a single-column primary key and
480
+ throws if the table has none. An empty list locks nothing and returns.
481
+
482
+ **`Database.$lockRowsWhere(table, where, { mode })` — lock what a FILTER
483
+ matches.** `$lockRows` asks you to enumerate the set. "Lock every pending entry
484
+ on this account" cannot: the id list only comes out of a query, and the gap
485
+ between that query and the lock is exactly the race the lock exists to close.
486
+ This form selects and locks the set in ONE statement.
487
+
488
+ ```ts
489
+ await Database.$lockRowsWhere("entries", { account_id: id, status: "pending" });
490
+ const rows = await Database.$findMany("entries", { account_id: id, status: "pending" });
491
+ ```
492
+
493
+ The filter is the ordinary one — same compiler as `findMany` and `updateMany`,
494
+ so there is no second filter dialect and no `$query`. It emits
495
+ `SELECT pk … WHERE … ORDER BY pk <lock clause>`, and that `ORDER BY pk` is the
496
+ **same order `updateMany`'s CTE takes**: two transactions entering the same rows
497
+ by different routes cannot take them in opposite orders. Measured against live
498
+ Postgres in both directions.
499
+
500
+ The lock MODE is yours, and it is deliberately not equalised with `updateMany`'s:
501
+
502
+ | `mode` | Postgres clause | when |
503
+ |---|---|---|
504
+ | `"update"` (default) | `FOR UPDATE` | you are about to change these rows |
505
+ | `"share"` | `FOR SHARE` | "nobody may change these while I read them" |
506
+ | `"noKeyUpdate"` | `FOR NO KEY UPDATE` | `updateMany`'s own mode — non-key columns |
507
+
508
+ Forcing everyone onto one mode would make a reader take a stronger lock than it
509
+ needs. What prevents the deadlock is the ORDER, not the mode. An unrecognised
510
+ mode is refused rather than quietly downgraded — a silently weaker lock is
511
+ fail-open.
512
+
513
+ Two refusals worth knowing before you reach for it: an **empty filter** is
514
+ rejected (locking the whole table is not somewhere you should arrive by
515
+ accident — every writer waits until your transaction ends), and a table with no
516
+ single-column primary key is rejected, because the lock ORDER needs a key.
517
+
518
+ **`Database.$advisoryXactLock(key)` — lock a NAME, not a row.** For work that
519
+ has no row to lock: "only one statement close runs at a time", or serializing
520
+ retries of the same idempotency key before reading its result.
521
+
522
+ ```ts
523
+ await tx.$advisoryXactLock(`billing.close:${householdId}:${period}`);
524
+ ```
525
+
526
+ The key is text; Postgres wants a 64-bit integer, so it is hashed with
527
+ `hashtextextended` and **the text never enters the SQL**. Only the
528
+ transaction-scoped family is offered (`pg_advisory_xact_lock`): Postgres releases
529
+ it on commit or rollback, so it cannot be forgotten. The session-scoped version
530
+ is deliberately absent — the connection returns to the pool with the lock still
531
+ held, and the next request waits forever.
532
+
533
+ **`select(where, { lock: "update" })` — a real `FOR UPDATE`**, inside a
534
+ `$transaction` plan. Use it when the rows you want to lock are the rows you were
535
+ about to select anyway.
536
+
458
537
  ### Bulk writes with a conflict rule
459
538
 
460
539
  Inside a transaction, `insertMany` takes an optional conflict rule — which is how
@@ -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
 
@@ -1589,6 +1589,85 @@ const rows = await Database.$query(
1589
1589
  A JavaScript array parameter is encoded as a Postgres array literal, so
1590
1590
  `= ANY($1::uuid[])` works.
1591
1591
 
1592
+ **Do not reach for `$query` to take a lock.** That is the single most common
1593
+ reason this escape hatch gets used, and there are typed primitives for it —
1594
+ see the next section.
1595
+
1596
+ ### Locks — `$lockRows`, `$lockRowsWhere`, `$advisoryXactLock`, `lock: "update"`
1597
+
1598
+ Four surfaces, for four different things.
1599
+
1600
+ **`Database.$lockRows(table, ids)` — lock these rows, and let the layer order
1601
+ them.** You declare the set you are about to touch; the ordering is not yours to
1602
+ choose. Trusting the caller's order means two callers entering the same two rows
1603
+ in opposite orders, and the bill for a deadlock is not cheap: `deadlock_timeout`
1604
+ defaults to **1 second**, so the loser waits a full second before it errors.
1605
+
1606
+ ```ts
1607
+ await Database.$lockRows("accounts", [input.from, input.to]);
1608
+ ```
1609
+
1610
+ It de-duplicates and sorts the ids, then emits
1611
+ `SELECT pk … WHERE pk IN (…) ORDER BY pk FOR NO KEY UPDATE` — the same lock mode
1612
+ `updateMany`'s CTE takes, because two write paths must share one order and one
1613
+ mode or each will wait on the other. It requires a single-column primary key and
1614
+ throws if the table has none. An empty list locks nothing and returns.
1615
+
1616
+ **`Database.$lockRowsWhere(table, where, { mode })` — lock what a FILTER
1617
+ matches.** `$lockRows` asks you to enumerate the set. "Lock every pending entry
1618
+ on this account" cannot: the id list only comes out of a query, and the gap
1619
+ between that query and the lock is exactly the race the lock exists to close.
1620
+ This form selects and locks the set in ONE statement.
1621
+
1622
+ ```ts
1623
+ await Database.$lockRowsWhere("entries", { account_id: id, status: "pending" });
1624
+ const rows = await Database.$findMany("entries", { account_id: id, status: "pending" });
1625
+ ```
1626
+
1627
+ The filter is the ordinary one — same compiler as `findMany` and `updateMany`,
1628
+ so there is no second filter dialect and no `$query`. It emits
1629
+ `SELECT pk … WHERE … ORDER BY pk <lock clause>`, and that `ORDER BY pk` is the
1630
+ **same order `updateMany`'s CTE takes**: two transactions entering the same rows
1631
+ by different routes cannot take them in opposite orders. Measured against live
1632
+ Postgres in both directions.
1633
+
1634
+ The lock MODE is yours, and it is deliberately not equalised with `updateMany`'s:
1635
+
1636
+ | `mode` | Postgres clause | when |
1637
+ |---|---|---|
1638
+ | `"update"` (default) | `FOR UPDATE` | you are about to change these rows |
1639
+ | `"share"` | `FOR SHARE` | "nobody may change these while I read them" |
1640
+ | `"noKeyUpdate"` | `FOR NO KEY UPDATE` | `updateMany`'s own mode — non-key columns |
1641
+
1642
+ Forcing everyone onto one mode would make a reader take a stronger lock than it
1643
+ needs. What prevents the deadlock is the ORDER, not the mode. An unrecognised
1644
+ mode is refused rather than quietly downgraded — a silently weaker lock is
1645
+ fail-open.
1646
+
1647
+ Two refusals worth knowing before you reach for it: an **empty filter** is
1648
+ rejected (locking the whole table is not somewhere you should arrive by
1649
+ accident — every writer waits until your transaction ends), and a table with no
1650
+ single-column primary key is rejected, because the lock ORDER needs a key.
1651
+
1652
+ **`Database.$advisoryXactLock(key)` — lock a NAME, not a row.** For work that
1653
+ has no row to lock: "only one statement close runs at a time", or serializing
1654
+ retries of the same idempotency key before reading its result.
1655
+
1656
+ ```ts
1657
+ await tx.$advisoryXactLock(`billing.close:${householdId}:${period}`);
1658
+ ```
1659
+
1660
+ The key is text; Postgres wants a 64-bit integer, so it is hashed with
1661
+ `hashtextextended` and **the text never enters the SQL**. Only the
1662
+ transaction-scoped family is offered (`pg_advisory_xact_lock`): Postgres releases
1663
+ it on commit or rollback, so it cannot be forgotten. The session-scoped version
1664
+ is deliberately absent — the connection returns to the pool with the lock still
1665
+ held, and the next request waits forever.
1666
+
1667
+ **`select(where, { lock: "update" })` — a real `FOR UPDATE`**, inside a
1668
+ `$transaction` plan. Use it when the rows you want to lock are the rows you were
1669
+ about to select anyway.
1670
+
1592
1671
  ### Bulk writes with a conflict rule
1593
1672
 
1594
1673
  Inside a transaction, `insertMany` takes an optional conflict rule — which is how
@@ -2374,6 +2453,52 @@ export const orgs = defineTable("orgs", {
2374
2453
  });
2375
2454
  ```
2376
2455
 
2456
+ ### Composite foreign keys — `foreignKeys`
2457
+
2458
+ `.references()` on a column is single-column and single-target. Multi-tenant
2459
+ integrity usually wants more than that: the parent must not merely EXIST, it must
2460
+ belong to the same tenant. That is one constraint over two columns, and a
2461
+ per-column model cannot say it.
2462
+
2463
+ ```ts
2464
+ import { defineTable, foreignKey, uuid, bigint } from "@palbase/backend";
2465
+
2466
+ export const settlements = defineTable("settlements", {
2467
+ columns: {
2468
+ id: uuid().primaryKey().defaultRandom(),
2469
+ household_id: uuid().notNull(),
2470
+ entry_id: uuid().notNull(),
2471
+ amount: bigint().notNull(),
2472
+ },
2473
+ foreignKeys: (c) => [
2474
+ foreignKey("settlement_entry_household")
2475
+ .on(c.col("household_id"), c.col("entry_id"))
2476
+ .references(entries, "household_id", "id")
2477
+ .onDelete("cascade"),
2478
+ ],
2479
+ });
2480
+ ```
2481
+
2482
+ That emits `FOREIGN KEY (household_id, entry_id) REFERENCES entries (household_id, id)`:
2483
+ a settlement cannot point at another household's entry, and the database is what
2484
+ enforces it — under every escape hatch, for every writer.
2485
+
2486
+ - **`.on(...)`** takes column references from the callback's context, so a typo is
2487
+ a compile error. Order is preserved and matches `.references()` position by
2488
+ position; a length mismatch is refused where you write it, not mid-deploy.
2489
+ - **`.references(table, ...cols)`** takes the table VALUE, not a thunk and not a
2490
+ string — the target's column names are checked against its declaration.
2491
+ - **`.onUpdate(a)` / `.onDelete(a)`** — `"cascade"`, `"restrict"`, `"set null"`,
2492
+ `"set default"`, `"no action"`.
2493
+ - **`.match("full")`** — with `MATCH SIMPLE` (the default) a single NULL among the
2494
+ referencing columns skips the constraint entirely. `MATCH FULL` evaluates it
2495
+ unless ALL of them are NULL. [`freeze`](#conditional-foreign-keys--freeze)
2496
+ uses the SIMPLE escape on purpose; an ordinary FK usually does not want it.
2497
+
2498
+ **The two forms do not displace each other.** This declaration is tracked under
2499
+ its own name; column-level ones are tracked as `<table>_<column>_fkey`. One table
2500
+ can carry both.
2501
+
2377
2502
  ### Relation names
2378
2503
 
2379
2504
  Every foreign key produces **two** named relations, and the two names are derived
@@ -2543,7 +2668,8 @@ used to reject passed after the rename.
2543
2668
 
2544
2669
  ## Indexes
2545
2670
 
2546
- `indexes` declares plain (non-unique) btree indexes over an ordered column list:
2671
+ `indexes` is a callback that receives the table's column context and returns
2672
+ `index(name)` builders — one non-unique btree index each:
2547
2673
 
2548
2674
  ```ts
2549
2675
  export const sessions = defineTable("sessions", {
@@ -2552,12 +2678,17 @@ export const sessions = defineTable("sessions", {
2552
2678
  room_id: uuid().references(() => rooms.id),
2553
2679
  started_at: timestamp().defaultNow(),
2554
2680
  },
2555
- indexes: [
2556
- { name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
2681
+ indexes: (c) => [
2682
+ index("sessions_room_started_idx").on(c.col("room_id"), c.col("started_at")),
2557
2683
  ],
2558
2684
  });
2559
2685
  ```
2560
2686
 
2687
+ The older plain-object form — `indexes: [{ name, columns }]` — still typechecks
2688
+ and still means the same thing. The builder is what the rest of this section
2689
+ documents, because everything past a plain column list is only reachable
2690
+ through it.
2691
+
2561
2692
  On deploy each entry becomes a statement of its own — `CREATE INDEX IF NOT
2562
2693
  EXISTS <name> ON <table> (<columns>)` — not a clause of the `CREATE TABLE`. The
2563
2694
  name and every column are identifier-validated before any SQL is built.
@@ -2570,50 +2701,348 @@ knowing before you name an index:
2570
2701
  names.
2571
2702
  - Removing an entry from `indexes` therefore does **not** drop the index. Drop it
2572
2703
  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).
2704
+ - Changing an index that is already live does **nothing** — the deploy matches
2705
+ the name, sees it, and skips. This is the whole definition, not just the
2706
+ columns: edit `.where(…)`, `.onExpression(…)`, `.include(…)` or the sort under
2707
+ a live name and the database keeps the old index, silently. Give the new
2708
+ definition a NEW name (and drop the old index in a migration if you want it
2709
+ gone).
2576
2710
 
2577
- ### Scope: plain btree over columns, nothing else
2711
+ ### Scope
2578
2712
 
2579
- `indexes` models exactly one kind of index. It cannot express:
2713
+ The builder covers btree indexes, including the shapes that need a predicate or
2714
+ an expression:
2580
2715
 
2581
2716
  | what you want | declare it with |
2582
2717
  |---------------|-----------------|
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:
2718
+ | a multi-column index | `index("i").on(c.col("a"), c.col("b"))` |
2719
+ | a partial index (`WHERE …`) | `index("i").on(c.col("a")).where(c.col("status").eq("pending"))` |
2720
+ | an expression index (`lower(email)`, …) | `index("i").onExpression("lower(email)")` |
2721
+ | a covering index (`INCLUDE …`) | `index("i").on(c.col("a")).include(["b", "c"])` |
2722
+ | a sort that matches an `ORDER BY` | `index("i").on(c.col("a")).desc().nulls("last")` |
2723
+ | a UNIQUE **constraint** | `unique: [{ name, columns }]` on the table |
2724
+ | a UNIQUE **index** | `index("i").unique().on(c.col("a"))` |
2725
+ | a **partial UNIQUE** index | `index("i").unique().on(c.col("a")).where(…)` |
2726
+ | another method (`gin`, `gist`, `brin`, `hash`), an opclass, `CONCURRENTLY` | `raw()` |
2727
+
2728
+ `.where()` takes the same typed predicate language as `policy().using()`, so
2729
+ column names are checked at compile time. `.onExpression()` takes SQL text and
2730
+ emits it verbatim — the same trust posture as `policy().using("…")`.
2731
+
2732
+ **Both are checked against the live database before the push, not during it.**
2733
+ Every declared predicate and expression is probed twice: once as a prepared
2734
+ statement, and once as a real `CREATE INDEX` on an empty `LIKE` clone of the
2735
+ table. The second probe is the one that matters — an index predicate has rules a
2736
+ `SELECT` does not, so `where(p.existsIn(…))` (writable at the type level) and a
2737
+ non-`IMMUTABLE` function are both refused at push time instead of failing
2738
+ halfway through an apply.
2739
+
2740
+ **`.unique()` composes with everything else on the builder**, which is what makes
2741
+ the partial-unique shape declarative. `unique: [{ name, columns }]` produces a
2742
+ UNIQUE *constraint*, and a constraint cannot carry a `WHERE`; `index()` could
2743
+ carry the `WHERE` but could not be unique. Those were two halves that never met.
2593
2744
 
2594
2745
  ```ts
2595
- import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
2746
+ import { defineTable, index, uuid, text, timestamp } from "@palbase/backend";
2596
2747
 
2597
2748
  export const orders = defineTable("orders", {
2598
2749
  columns: {
2599
- id: uuid().primaryKey().defaultRandom(),
2600
- status: text().notNull(),
2601
- created_at: timestamp().defaultNow(),
2750
+ id: uuid().primaryKey().defaultRandom(),
2751
+ status: text().notNull(),
2752
+ external_id: text().nullable(),
2753
+ created_at: timestamp().defaultNow(),
2754
+ },
2755
+ indexes: (c) => [
2756
+ // Partial index.
2757
+ index("orders_pending_idx").on(c.col("created_at")).where(c.col("status").eq("pending")),
2758
+ // Partial UNIQUE: NULLs stay free, non-NULLs are unique. No `raw()`.
2759
+ index("orders_external_id_idx")
2760
+ .unique()
2761
+ .on(c.col("external_id"))
2762
+ .where(c.col("external_id").isNull(true)), // isNull(true) is IS NOT NULL
2763
+ ],
2764
+ });
2765
+ ```
2766
+
2767
+ Reach for `unique:` when you want a named CONSTRAINT (something a foreign key can
2768
+ reference); reach for `index().unique()` when you want a unique INDEX, which is
2769
+ the only one of the two that can be partial.
2770
+
2771
+ ### `raw()` — the last resort, and it takes TWO arguments
2772
+
2773
+ ```ts
2774
+ raw("<name>", "<sql>")
2775
+ ```
2776
+
2777
+ Most of what this list used to carry is declarative now, and the typed field is
2778
+ the one to reach for — it is diffed, checked against the live database before the
2779
+ push, and its errors arrive in your editor instead of halfway through an apply:
2780
+
2781
+ | you used to write `raw()` for | write instead |
2782
+ |---|---|
2783
+ | a CHECK constraint | [`checks`](#checks) — string or typed `check()` |
2784
+ | a partial / expression / covering index | [`index()`](#indexes) |
2785
+ | a partial UNIQUE index | `index("i").unique().where(…)` |
2786
+ | a composite FOREIGN KEY | [`foreignKeys`](#composite-foreign-keys--foreignkeys) |
2787
+ | a conditional FK ("frozen while …") | [`freeze`](#conditional-foreign-keys--freeze) |
2788
+ | a trigger that refuses a write | [`guards`](#guards--refusing-a-write-in-your-own-words) |
2789
+ | a one-off data fix | [`backfills`](#backfills--a-data-fix-that-runs-once) |
2790
+ | dropping a constraint | [`dropConstraints`](#dropping-a-constraint--dropconstraints) |
2791
+
2792
+ What is left for `raw()`: an index method other than btree (`gin`, `gist`,
2793
+ `brin`, `hash`), an opclass, `CONCURRENTLY`, `EXCLUDE` constraints, views, and
2794
+ PL/pgSQL functions.
2795
+
2796
+ **There is no `down`, and no teardown option of any kind.** One used to be
2797
+ accepted; it was carried into a generated `Down` that NOTHING ever ran, so an
2798
+ author who wrote one believed they had a rollback they did not have. Drop what a
2799
+ `raw()` created by declaring the drop as its own `raw()`.
2800
+
2801
+ `up` is emitted verbatim, and the body is **read before the push, not trusted
2802
+ blindly**: a body that reaches past what a schema declaration is allowed to do is
2803
+ refused up front rather than part-way through an apply. How a CHANGED body is
2804
+ treated depends on what the name addresses:
2805
+
2806
+ - **The name addresses a constraint or an index.** Tracked by
2807
+ NAME only — the deploy sees the catalog object and skips, so an edited body
2808
+ does nothing. Give it a new name, or drop and re-add it explicitly.
2809
+ - **The name addresses nothing in the catalog** (functions, triggers, grants,
2810
+ compound statements). The last applied body is recorded in
2811
+ `public.palbase_raw_objects` inside the same DDL transaction, and an edited
2812
+ body **is** re-applied. You do not need a new name to change a trigger.
2813
+
2814
+ ## Checks
2815
+
2816
+ `checks` declares named CHECK constraints — the invariants the database itself
2817
+ enforces, under every escape hatch, for every writer:
2818
+
2819
+ ```ts
2820
+ export const transfers = defineTable("transfers", {
2821
+ columns: {
2822
+ id: uuid().primaryKey().defaultRandom(),
2823
+ from_account_id: uuid().notNull(),
2824
+ to_account_id: uuid().notNull(),
2825
+ debited_value: bigint().notNull(),
2826
+ credited_value: bigint().notNull(),
2602
2827
  },
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
- ),
2828
+ checks: [
2829
+ { name: "transfer_positive", expr: "debited_value > 0 AND credited_value > 0" },
2830
+ { name: "transfer_distinct_accounts", expr: "from_account_id <> to_account_id" },
2609
2831
  ],
2610
2832
  });
2611
2833
  ```
2612
2834
 
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.
2835
+ `expr` is trusted SQL emitted verbatim (the same posture as `policy().using()`);
2836
+ `name` is identifier-validated.
2837
+
2838
+ **Or write the expression typed**, with `checks` as a callback and `check()`:
2839
+
2840
+ ```ts
2841
+ import { defineTable, check, bigint, text, uuid } from "@palbase/backend";
2842
+
2843
+ export const transfers = defineTable("transfers", {
2844
+ columns: {
2845
+ id: uuid().primaryKey().defaultRandom(),
2846
+ debited_value: bigint().notNull(),
2847
+ currency: text().nullable(),
2848
+ },
2849
+ checks: (c) => [
2850
+ check("transfer_positive", c.col("debited_value").gt(0)),
2851
+ check("transfer_currency_set", c.col("currency").isNull(true)),
2852
+ ],
2853
+ });
2854
+ ```
2855
+
2856
+ Both forms are accepted and both land on the wire as the same string — the
2857
+ callback compiles the expression to SQL where you declare it. The difference is
2858
+ WHEN a mistyped column name answers you. In the string form `debited_vlaue > 0`
2859
+ comes back from Postgres during `ALTER TABLE … ADD CONSTRAINT`, in the MIDDLE of
2860
+ a deploy; in the typed form it is a compile error. The callback shape is what
2861
+ makes that possible: a plain array cannot be contextualised with the table's own
2862
+ column names.
2863
+
2864
+ The typed expression is held to the CHECK context, which is NARROWER than a
2865
+ policy's. Postgres forbids subqueries in a CHECK, so `existsIn(…)` and
2866
+ `auth.uid()` are refused by `check()` where you write them — with a sentence
2867
+ saying what to use instead — rather than failing the apply. What is left is the
2868
+ part a CHECK can actually evaluate: columns, literals, comparisons, `and`/`or`,
2869
+ `not`, `isNull`.
2870
+
2871
+ **Unlike indexes, checks are diffed by BODY, not just by name.** Editing an
2872
+ `expr` under a live name recreates the constraint (`DROP` + `ADD`) — which is
2873
+ what you want, and it is why you should reach for `checks` before `raw()` for
2874
+ anything a CHECK can express.
2875
+
2876
+ That body compare works because the declared expression is round-tripped through
2877
+ Postgres' own parser and deparser before the diff: a scratch `NOT VALID`
2878
+ constraint carrying your text is added to the live table, `pg_get_constraintdef`
2879
+ is read back, and the scratch is dropped. Without it, `price > 0` would come
2880
+ back as `(price > 0)` and every deploy would re-drop and re-add every unchanged
2881
+ check forever.
2882
+
2883
+ **What a CHECK cannot do:** reference another table. Postgres forbids subqueries
2884
+ in a CHECK, so a cross-table invariant is not a check. Three declarative places
2885
+ take that work instead, in order of how much they cost you:
2886
+ [`foreignKeys`](#composite-foreign-keys--foreignkeys) when the rule is "the
2887
+ parent exists and matches", [`freeze`](#conditional-foreign-keys--freeze) when it
2888
+ holds only while some condition does, and
2889
+ [`guards`](#guards--refusing-a-write-in-your-own-words) when the rule is a
2890
+ predicate no constraint can carry.
2891
+
2892
+ ## Conditional foreign keys — `freeze`
2893
+
2894
+ "While a payment is `settled`, the invoice's amount and currency may not change."
2895
+ That is a foreign key that only applies part of the time, and it is declarative:
2896
+
2897
+ ```ts
2898
+ import { defineTable, freeze, bigint, text, uuid } from "@palbase/backend";
2899
+
2900
+ export const payments = defineTable("payments", {
2901
+ columns: {
2902
+ id: uuid().primaryKey().defaultRandom(),
2903
+ status: text().notNull(),
2904
+ inv_id: uuid().notNull(),
2905
+ amount: bigint().notNull(),
2906
+ currency: text().notNull(),
2907
+ },
2908
+ freeze: (c) => [
2909
+ freeze("pay_settled_inv")
2910
+ .when(c.col("status").eq("settled"))
2911
+ .columns("inv_id", "amount", "currency")
2912
+ .references(invoices, "id", "amount", "currency"),
2913
+ ],
2914
+ });
2915
+ ```
2916
+
2917
+ **It is not a trigger.** What gets created is a constraint: a UNIQUE on the
2918
+ parent's tuple, `STORED` generated columns on the child that are NULL while the
2919
+ condition is false, and an `ON UPDATE RESTRICT ON DELETE RESTRICT` foreign key
2920
+ over those. `MATCH SIMPLE`'s NULL escape — "a row with a NULL among the
2921
+ referencing columns need not satisfy the constraint" — is what gives you a
2922
+ conditional FK without a `WHERE`.
2923
+
2924
+ Measured against live Postgres, that buys four behaviours a trigger would not:
2925
+
2926
+ - while the condition does NOT hold, nothing is frozen — the row is ordinary;
2927
+ - a row whose columns do not match its parent **cannot be made `settled`**. The
2928
+ reverse direction is closed too; a trigger guarding the parent would have left
2929
+ it open;
2930
+ - a frozen row's columns cannot be changed, and its parent cannot be deleted;
2931
+ - when the condition lifts, the freeze **dissolves by itself**. Nothing has to
2932
+ remember to release it.
2933
+
2934
+ The chain ends at `.references()` on purpose: that is where the declaration is
2935
+ completed and checked, so a `freeze` missing its target cannot reach a table at
2936
+ all. `.columns()` and the target list must be the same length, and the frozen
2937
+ columns must be `NOT NULL` — a nullable one opens the same `MATCH SIMPLE` escape
2938
+ INDEPENDENTLY of your condition, which would mean the freeze silently never
2939
+ formed. The push refuses that declaration rather than shipping it.
2940
+
2941
+ ## Guards — refusing a write in your own words
2942
+
2943
+ Some rules no constraint can carry: write-once fields, state-machine
2944
+ transitions, a cross-row check whose predicate is not immutable, a
2945
+ transaction-level total. RLS cannot express them either — `OLD` and `NEW` are not
2946
+ names a policy expression has, and `USING`/`WITH CHECK` are evaluated against two
2947
+ separate rows with no scope that puts them side by side.
2948
+
2949
+ A guard is the narrow way to say it. **You write three things: which event, which
2950
+ condition, and what to say when refusing.**
2951
+
2952
+ ```ts
2953
+ import { defineTable, guard, text, uuid } from "@palbase/backend";
2954
+
2955
+ export const documents = defineTable("documents", {
2956
+ columns: {
2957
+ id: uuid().primaryKey().defaultRandom(),
2958
+ status: text().notNull(),
2959
+ body: text().notNull(),
2960
+ },
2961
+ guards: (g) => [
2962
+ guard("documents_frozen_body")
2963
+ .on("update")
2964
+ .when(g.col("status").eq("archived"))
2965
+ .refuse("An archived document's body cannot be changed", { column: "body" }),
2966
+ ],
2967
+ });
2968
+ ```
2969
+
2970
+ Everything else is a safe default and deliberately not on this surface: `BEFORE`,
2971
+ `FOR EACH ROW` (Postgres defaults to `STATEMENT`, which is wrong for a guard),
2972
+ `SECURITY INVOKER` — never `DEFINER` — a pinned `search_path`, an `UPDATE OF (…)`
2973
+ list derived from the columns your condition names, `IS DISTINCT FROM` for the
2974
+ change test (`<>` answers wrong on NULL), and the right `RETURN`. Exposing a knob
2975
+ turns it into a policy; these are not policies. There is also **no global escape
2976
+ hatch** — no session setting that lets a connection skip a guard.
2977
+
2978
+ **The refusal is machine-readable.** It arrives with SQLSTATE `PB001` — not
2979
+ `23514`, which is a real CHECK violation and would be indistinguishable — the
2980
+ guard's NAME in the `CONSTRAINT` field and, if you gave one, the field name in
2981
+ `COLUMN`. A client branches on those without parsing your sentence. Measured on
2982
+ a live refusal: `constraint=documents_frozen_body · column=body`. `detail` and
2983
+ `hint` land in Postgres' fields of the same name.
2984
+
2985
+ The name is required, and not for tidiness: Postgres runs triggers bound to one
2986
+ event **in alphabetical order by name**, and there is no other handle on ordering.
2987
+
2988
+ For a cross-row rule, `.exists(table, row => …)` puts the predicate in an
2989
+ `EXISTS` block instead of the trigger's `WHEN` clause — Postgres does not allow a
2990
+ subquery in `WHEN`. Inside the callback, `row.col(…)` is the OTHER table's
2991
+ column; an unqualified column is the row being written.
2992
+
2993
+ One shape is refused at push: an **`insert` guard cannot read a generated
2994
+ column**. They are empty in `NEW`, so the condition would silently never fire —
2995
+ fail-open on a refusal path.
2996
+
2997
+ ## Backfills — a data fix that runs once
2998
+
2999
+ Adding a column is declarative. FILLING it is not. That work used to live inside
3000
+ a `raw()` block, which re-runs on every deploy, so it was either written
3001
+ defensively idempotent or run by hand with no record that it happened.
3002
+
3003
+ ```ts
3004
+ import { backfill, defineTable, text, uuid } from "@palbase/backend";
3005
+
3006
+ export const ledger = defineTable("ledger", {
3007
+ columns: {
3008
+ id: uuid().primaryKey().defaultRandom(),
3009
+ currency: text().nullable(),
3010
+ },
3011
+ backfills: [
3012
+ backfill("ledger_currency_try", "UPDATE ledger SET currency = 'TRY' WHERE currency IS NULL"),
3013
+ ],
3014
+ });
3015
+ ```
3016
+
3017
+ It runs **after** the DDL, in its own phase and its own transaction, and **once
3018
+ per name**. The record lives in `public.palbase_backfills`, so "did this run?" is
3019
+ answered by the database rather than by memory.
3020
+
3021
+ **The NAME is the record.** Keeping the name and editing the SQL does NOT produce
3022
+ a second run — to correct a backfill, give it a new name. That is also why an
3023
+ empty body is refused: it would register the name and burn it permanently, and
3024
+ the real SQL written later would never run and never complain. The registry is
3025
+ global, so two tables cannot declare the same backfill name.
3026
+
3027
+ A plain array, not a callback: a backfill has no predicate to contextualise —
3028
+ its body is verbatim SQL.
3029
+
3030
+ ## Dropping a constraint — `dropConstraints`
3031
+
3032
+ The diff is **additive**: a constraint that exists in the database but not in your
3033
+ declaration is never dropped. Your schema file adds objects; it does not own the
3034
+ database's. So a deliberate removal has to be SAID:
3035
+
3036
+ ```ts
3037
+ export const orders = defineTable("orders", {
3038
+ columns: { /* … */ },
3039
+ dropConstraints: ["orders_legacy_status_check"],
3040
+ });
3041
+ ```
3042
+
3043
+ This is the one exception to the additive rule, and requiring it in writing is
3044
+ the point — the alternative is a deploy that drops a constraint because someone
3045
+ deleted a line.
2617
3046
 
2618
3047
  ## Typed DB access — by default
2619
3048