@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
package/docs/schema.md CHANGED
@@ -200,6 +200,52 @@ export const orgs = defineTable("orgs", {
200
200
  });
201
201
  ```
202
202
 
203
+ ### Composite foreign keys — `foreignKeys`
204
+
205
+ `.references()` on a column is single-column and single-target. Multi-tenant
206
+ integrity usually wants more than that: the parent must not merely EXIST, it must
207
+ belong to the same tenant. That is one constraint over two columns, and a
208
+ per-column model cannot say it.
209
+
210
+ ```ts
211
+ import { defineTable, foreignKey, uuid, bigint } from "@palbase/backend";
212
+
213
+ export const settlements = defineTable("settlements", {
214
+ columns: {
215
+ id: uuid().primaryKey().defaultRandom(),
216
+ household_id: uuid().notNull(),
217
+ entry_id: uuid().notNull(),
218
+ amount: bigint().notNull(),
219
+ },
220
+ foreignKeys: (c) => [
221
+ foreignKey("settlement_entry_household")
222
+ .on(c.col("household_id"), c.col("entry_id"))
223
+ .references(entries, "household_id", "id")
224
+ .onDelete("cascade"),
225
+ ],
226
+ });
227
+ ```
228
+
229
+ That emits `FOREIGN KEY (household_id, entry_id) REFERENCES entries (household_id, id)`:
230
+ a settlement cannot point at another household's entry, and the database is what
231
+ enforces it — under every escape hatch, for every writer.
232
+
233
+ - **`.on(...)`** takes column references from the callback's context, so a typo is
234
+ a compile error. Order is preserved and matches `.references()` position by
235
+ position; a length mismatch is refused where you write it, not mid-deploy.
236
+ - **`.references(table, ...cols)`** takes the table VALUE, not a thunk and not a
237
+ string — the target's column names are checked against its declaration.
238
+ - **`.onUpdate(a)` / `.onDelete(a)`** — `"cascade"`, `"restrict"`, `"set null"`,
239
+ `"set default"`, `"no action"`.
240
+ - **`.match("full")`** — with `MATCH SIMPLE` (the default) a single NULL among the
241
+ referencing columns skips the constraint entirely. `MATCH FULL` evaluates it
242
+ unless ALL of them are NULL. [`freeze`](#conditional-foreign-keys--freeze)
243
+ uses the SIMPLE escape on purpose; an ordinary FK usually does not want it.
244
+
245
+ **The two forms do not displace each other.** This declaration is tracked under
246
+ its own name; column-level ones are tracked as `<table>_<column>_fkey`. One table
247
+ can carry both.
248
+
203
249
  ### Relation names
204
250
 
205
251
  Every foreign key produces **two** named relations, and the two names are derived
@@ -369,7 +415,8 @@ used to reject passed after the rename.
369
415
 
370
416
  ## Indexes
371
417
 
372
- `indexes` declares plain (non-unique) btree indexes over an ordered column list:
418
+ `indexes` is a callback that receives the table's column context and returns
419
+ `index(name)` builders — one non-unique btree index each:
373
420
 
374
421
  ```ts
375
422
  export const sessions = defineTable("sessions", {
@@ -378,12 +425,17 @@ export const sessions = defineTable("sessions", {
378
425
  room_id: uuid().references(() => rooms.id),
379
426
  started_at: timestamp().defaultNow(),
380
427
  },
381
- indexes: [
382
- { name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
428
+ indexes: (c) => [
429
+ index("sessions_room_started_idx").on(c.col("room_id"), c.col("started_at")),
383
430
  ],
384
431
  });
385
432
  ```
386
433
 
434
+ The older plain-object form — `indexes: [{ name, columns }]` — still typechecks
435
+ and still means the same thing. The builder is what the rest of this section
436
+ documents, because everything past a plain column list is only reachable
437
+ through it.
438
+
387
439
  On deploy each entry becomes a statement of its own — `CREATE INDEX IF NOT
388
440
  EXISTS <name> ON <table> (<columns>)` — not a clause of the `CREATE TABLE`. The
389
441
  name and every column are identifier-validated before any SQL is built.
@@ -396,50 +448,348 @@ knowing before you name an index:
396
448
  names.
397
449
  - Removing an entry from `indexes` therefore does **not** drop the index. Drop it
398
450
  in an explicit [migration](./migrations.md).
399
- - Changing `columns` under a name that is already live does **nothing** — the
400
- deploy matches the name, sees it, and skips. Give the new column list a NEW
401
- name (and drop the old index in a migration if you want it gone).
451
+ - Changing an index that is already live does **nothing** — the deploy matches
452
+ the name, sees it, and skips. This is the whole definition, not just the
453
+ columns: edit `.where(…)`, `.onExpression(…)`, `.include(…)` or the sort under
454
+ a live name and the database keeps the old index, silently. Give the new
455
+ definition a NEW name (and drop the old index in a migration if you want it
456
+ gone).
402
457
 
403
- ### Scope: plain btree over columns, nothing else
458
+ ### Scope
404
459
 
405
- `indexes` models exactly one kind of index. It cannot express:
460
+ The builder covers btree indexes, including the shapes that need a predicate or
461
+ an expression:
406
462
 
407
463
  | what you want | declare it with |
408
464
  |---------------|-----------------|
409
- | a UNIQUE index | `unique: [{ name, columns }]` on the table |
410
- | a partial index (`WHERE …`) | `raw()` |
411
- | an expression index (`lower(email)`, …) | `raw()` |
412
- | any other method (`gin`, `gist`, `hnsw`, …) | `raw()` |
413
-
414
- The limit is deliberate, not an oversight. Comparing a partial predicate or an
415
- expression against the live database needs the same round-trip through Postgres'
416
- own deparse that `checks` uses; without it an edited predicate would silently do
417
- nothing. Rather than ship a half-working partial-index path, the typed field
418
- stays columns-only and `raw()` carries the rest:
465
+ | a multi-column index | `index("i").on(c.col("a"), c.col("b"))` |
466
+ | a partial index (`WHERE …`) | `index("i").on(c.col("a")).where(c.col("status").eq("pending"))` |
467
+ | an expression index (`lower(email)`, …) | `index("i").onExpression("lower(email)")` |
468
+ | a covering index (`INCLUDE …`) | `index("i").on(c.col("a")).include(["b", "c"])` |
469
+ | a sort that matches an `ORDER BY` | `index("i").on(c.col("a")).desc().nulls("last")` |
470
+ | a UNIQUE **constraint** | `unique: [{ name, columns }]` on the table |
471
+ | a UNIQUE **index** | `index("i").unique().on(c.col("a"))` |
472
+ | a **partial UNIQUE** index | `index("i").unique().on(c.col("a")).where(…)` |
473
+ | another method (`gin`, `gist`, `brin`, `hash`), an opclass, `CONCURRENTLY` | `raw()` |
474
+
475
+ `.where()` takes the same typed predicate language as `policy().using()`, so
476
+ column names are checked at compile time. `.onExpression()` takes SQL text and
477
+ emits it verbatim — the same trust posture as `policy().using("…")`.
478
+
479
+ **Both are checked against the live database before the push, not during it.**
480
+ Every declared predicate and expression is probed twice: once as a prepared
481
+ statement, and once as a real `CREATE INDEX` on an empty `LIKE` clone of the
482
+ table. The second probe is the one that matters — an index predicate has rules a
483
+ `SELECT` does not, so `where(p.existsIn(…))` (writable at the type level) and a
484
+ non-`IMMUTABLE` function are both refused at push time instead of failing
485
+ halfway through an apply.
486
+
487
+ **`.unique()` composes with everything else on the builder**, which is what makes
488
+ the partial-unique shape declarative. `unique: [{ name, columns }]` produces a
489
+ UNIQUE *constraint*, and a constraint cannot carry a `WHERE`; `index()` could
490
+ carry the `WHERE` but could not be unique. Those were two halves that never met.
419
491
 
420
492
  ```ts
421
- import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
493
+ import { defineTable, index, uuid, text, timestamp } from "@palbase/backend";
422
494
 
423
495
  export const orders = defineTable("orders", {
424
496
  columns: {
425
- id: uuid().primaryKey().defaultRandom(),
426
- status: text().notNull(),
427
- created_at: timestamp().defaultNow(),
497
+ id: uuid().primaryKey().defaultRandom(),
498
+ status: text().notNull(),
499
+ external_id: text().nullable(),
500
+ created_at: timestamp().defaultNow(),
428
501
  },
429
- raw: [
430
- raw(
431
- "orders_pending_idx",
432
- "CREATE INDEX IF NOT EXISTS orders_pending_idx ON orders (created_at) WHERE status = 'pending'",
433
- { down: "DROP INDEX IF EXISTS orders_pending_idx" },
434
- ),
502
+ indexes: (c) => [
503
+ // Partial index.
504
+ index("orders_pending_idx").on(c.col("created_at")).where(c.col("status").eq("pending")),
505
+ // Partial UNIQUE: NULLs stay free, non-NULLs are unique. No `raw()`.
506
+ index("orders_external_id_idx")
507
+ .unique()
508
+ .on(c.col("external_id"))
509
+ .where(c.col("external_id").isNull(true)), // isNull(true) is IS NOT NULL
435
510
  ],
436
511
  });
437
512
  ```
438
513
 
439
- `raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
440
- `indexes`, is tracked by NAME only a changed body needs a new name. Pass
441
- `down` yourself for an index: the default drop statement drops a *constraint*,
442
- and an index is not one.
514
+ Reach for `unique:` when you want a named CONSTRAINT (something a foreign key can
515
+ reference); reach for `index().unique()` when you want a unique INDEX, which is
516
+ the only one of the two that can be partial.
517
+
518
+ ### `raw()` — the last resort, and it takes TWO arguments
519
+
520
+ ```ts
521
+ raw("<name>", "<sql>")
522
+ ```
523
+
524
+ Most of what this list used to carry is declarative now, and the typed field is
525
+ the one to reach for — it is diffed, checked against the live database before the
526
+ push, and its errors arrive in your editor instead of halfway through an apply:
527
+
528
+ | you used to write `raw()` for | write instead |
529
+ |---|---|
530
+ | a CHECK constraint | [`checks`](#checks) — string or typed `check()` |
531
+ | a partial / expression / covering index | [`index()`](#indexes) |
532
+ | a partial UNIQUE index | `index("i").unique().where(…)` |
533
+ | a composite FOREIGN KEY | [`foreignKeys`](#composite-foreign-keys--foreignkeys) |
534
+ | a conditional FK ("frozen while …") | [`freeze`](#conditional-foreign-keys--freeze) |
535
+ | a trigger that refuses a write | [`guards`](#guards--refusing-a-write-in-your-own-words) |
536
+ | a one-off data fix | [`backfills`](#backfills--a-data-fix-that-runs-once) |
537
+ | dropping a constraint | [`dropConstraints`](#dropping-a-constraint--dropconstraints) |
538
+
539
+ What is left for `raw()`: an index method other than btree (`gin`, `gist`,
540
+ `brin`, `hash`), an opclass, `CONCURRENTLY`, `EXCLUDE` constraints, views, and
541
+ PL/pgSQL functions.
542
+
543
+ **There is no `down`, and no teardown option of any kind.** One used to be
544
+ accepted; it was carried into a generated `Down` that NOTHING ever ran, so an
545
+ author who wrote one believed they had a rollback they did not have. Drop what a
546
+ `raw()` created by declaring the drop as its own `raw()`.
547
+
548
+ `up` is emitted verbatim, and the body is **read before the push, not trusted
549
+ blindly**: a body that reaches past what a schema declaration is allowed to do is
550
+ refused up front rather than part-way through an apply. How a CHANGED body is
551
+ treated depends on what the name addresses:
552
+
553
+ - **The name addresses a constraint or an index.** Tracked by
554
+ NAME only — the deploy sees the catalog object and skips, so an edited body
555
+ does nothing. Give it a new name, or drop and re-add it explicitly.
556
+ - **The name addresses nothing in the catalog** (functions, triggers, grants,
557
+ compound statements). The last applied body is recorded in
558
+ `public.palbase_raw_objects` inside the same DDL transaction, and an edited
559
+ body **is** re-applied. You do not need a new name to change a trigger.
560
+
561
+ ## Checks
562
+
563
+ `checks` declares named CHECK constraints — the invariants the database itself
564
+ enforces, under every escape hatch, for every writer:
565
+
566
+ ```ts
567
+ export const transfers = defineTable("transfers", {
568
+ columns: {
569
+ id: uuid().primaryKey().defaultRandom(),
570
+ from_account_id: uuid().notNull(),
571
+ to_account_id: uuid().notNull(),
572
+ debited_value: bigint().notNull(),
573
+ credited_value: bigint().notNull(),
574
+ },
575
+ checks: [
576
+ { name: "transfer_positive", expr: "debited_value > 0 AND credited_value > 0" },
577
+ { name: "transfer_distinct_accounts", expr: "from_account_id <> to_account_id" },
578
+ ],
579
+ });
580
+ ```
581
+
582
+ `expr` is trusted SQL emitted verbatim (the same posture as `policy().using()`);
583
+ `name` is identifier-validated.
584
+
585
+ **Or write the expression typed**, with `checks` as a callback and `check()`:
586
+
587
+ ```ts
588
+ import { defineTable, check, bigint, text, uuid } from "@palbase/backend";
589
+
590
+ export const transfers = defineTable("transfers", {
591
+ columns: {
592
+ id: uuid().primaryKey().defaultRandom(),
593
+ debited_value: bigint().notNull(),
594
+ currency: text().nullable(),
595
+ },
596
+ checks: (c) => [
597
+ check("transfer_positive", c.col("debited_value").gt(0)),
598
+ check("transfer_currency_set", c.col("currency").isNull(true)),
599
+ ],
600
+ });
601
+ ```
602
+
603
+ Both forms are accepted and both land on the wire as the same string — the
604
+ callback compiles the expression to SQL where you declare it. The difference is
605
+ WHEN a mistyped column name answers you. In the string form `debited_vlaue > 0`
606
+ comes back from Postgres during `ALTER TABLE … ADD CONSTRAINT`, in the MIDDLE of
607
+ a deploy; in the typed form it is a compile error. The callback shape is what
608
+ makes that possible: a plain array cannot be contextualised with the table's own
609
+ column names.
610
+
611
+ The typed expression is held to the CHECK context, which is NARROWER than a
612
+ policy's. Postgres forbids subqueries in a CHECK, so `existsIn(…)` and
613
+ `auth.uid()` are refused by `check()` where you write them — with a sentence
614
+ saying what to use instead — rather than failing the apply. What is left is the
615
+ part a CHECK can actually evaluate: columns, literals, comparisons, `and`/`or`,
616
+ `not`, `isNull`.
617
+
618
+ **Unlike indexes, checks are diffed by BODY, not just by name.** Editing an
619
+ `expr` under a live name recreates the constraint (`DROP` + `ADD`) — which is
620
+ what you want, and it is why you should reach for `checks` before `raw()` for
621
+ anything a CHECK can express.
622
+
623
+ That body compare works because the declared expression is round-tripped through
624
+ Postgres' own parser and deparser before the diff: a scratch `NOT VALID`
625
+ constraint carrying your text is added to the live table, `pg_get_constraintdef`
626
+ is read back, and the scratch is dropped. Without it, `price > 0` would come
627
+ back as `(price > 0)` and every deploy would re-drop and re-add every unchanged
628
+ check forever.
629
+
630
+ **What a CHECK cannot do:** reference another table. Postgres forbids subqueries
631
+ in a CHECK, so a cross-table invariant is not a check. Three declarative places
632
+ take that work instead, in order of how much they cost you:
633
+ [`foreignKeys`](#composite-foreign-keys--foreignkeys) when the rule is "the
634
+ parent exists and matches", [`freeze`](#conditional-foreign-keys--freeze) when it
635
+ holds only while some condition does, and
636
+ [`guards`](#guards--refusing-a-write-in-your-own-words) when the rule is a
637
+ predicate no constraint can carry.
638
+
639
+ ## Conditional foreign keys — `freeze`
640
+
641
+ "While a payment is `settled`, the invoice's amount and currency may not change."
642
+ That is a foreign key that only applies part of the time, and it is declarative:
643
+
644
+ ```ts
645
+ import { defineTable, freeze, bigint, text, uuid } from "@palbase/backend";
646
+
647
+ export const payments = defineTable("payments", {
648
+ columns: {
649
+ id: uuid().primaryKey().defaultRandom(),
650
+ status: text().notNull(),
651
+ inv_id: uuid().notNull(),
652
+ amount: bigint().notNull(),
653
+ currency: text().notNull(),
654
+ },
655
+ freeze: (c) => [
656
+ freeze("pay_settled_inv")
657
+ .when(c.col("status").eq("settled"))
658
+ .columns("inv_id", "amount", "currency")
659
+ .references(invoices, "id", "amount", "currency"),
660
+ ],
661
+ });
662
+ ```
663
+
664
+ **It is not a trigger.** What gets created is a constraint: a UNIQUE on the
665
+ parent's tuple, `STORED` generated columns on the child that are NULL while the
666
+ condition is false, and an `ON UPDATE RESTRICT ON DELETE RESTRICT` foreign key
667
+ over those. `MATCH SIMPLE`'s NULL escape — "a row with a NULL among the
668
+ referencing columns need not satisfy the constraint" — is what gives you a
669
+ conditional FK without a `WHERE`.
670
+
671
+ Measured against live Postgres, that buys four behaviours a trigger would not:
672
+
673
+ - while the condition does NOT hold, nothing is frozen — the row is ordinary;
674
+ - a row whose columns do not match its parent **cannot be made `settled`**. The
675
+ reverse direction is closed too; a trigger guarding the parent would have left
676
+ it open;
677
+ - a frozen row's columns cannot be changed, and its parent cannot be deleted;
678
+ - when the condition lifts, the freeze **dissolves by itself**. Nothing has to
679
+ remember to release it.
680
+
681
+ The chain ends at `.references()` on purpose: that is where the declaration is
682
+ completed and checked, so a `freeze` missing its target cannot reach a table at
683
+ all. `.columns()` and the target list must be the same length, and the frozen
684
+ columns must be `NOT NULL` — a nullable one opens the same `MATCH SIMPLE` escape
685
+ INDEPENDENTLY of your condition, which would mean the freeze silently never
686
+ formed. The push refuses that declaration rather than shipping it.
687
+
688
+ ## Guards — refusing a write in your own words
689
+
690
+ Some rules no constraint can carry: write-once fields, state-machine
691
+ transitions, a cross-row check whose predicate is not immutable, a
692
+ transaction-level total. RLS cannot express them either — `OLD` and `NEW` are not
693
+ names a policy expression has, and `USING`/`WITH CHECK` are evaluated against two
694
+ separate rows with no scope that puts them side by side.
695
+
696
+ A guard is the narrow way to say it. **You write three things: which event, which
697
+ condition, and what to say when refusing.**
698
+
699
+ ```ts
700
+ import { defineTable, guard, text, uuid } from "@palbase/backend";
701
+
702
+ export const documents = defineTable("documents", {
703
+ columns: {
704
+ id: uuid().primaryKey().defaultRandom(),
705
+ status: text().notNull(),
706
+ body: text().notNull(),
707
+ },
708
+ guards: (g) => [
709
+ guard("documents_frozen_body")
710
+ .on("update")
711
+ .when(g.col("status").eq("archived"))
712
+ .refuse("An archived document's body cannot be changed", { column: "body" }),
713
+ ],
714
+ });
715
+ ```
716
+
717
+ Everything else is a safe default and deliberately not on this surface: `BEFORE`,
718
+ `FOR EACH ROW` (Postgres defaults to `STATEMENT`, which is wrong for a guard),
719
+ `SECURITY INVOKER` — never `DEFINER` — a pinned `search_path`, an `UPDATE OF (…)`
720
+ list derived from the columns your condition names, `IS DISTINCT FROM` for the
721
+ change test (`<>` answers wrong on NULL), and the right `RETURN`. Exposing a knob
722
+ turns it into a policy; these are not policies. There is also **no global escape
723
+ hatch** — no session setting that lets a connection skip a guard.
724
+
725
+ **The refusal is machine-readable.** It arrives with SQLSTATE `PB001` — not
726
+ `23514`, which is a real CHECK violation and would be indistinguishable — the
727
+ guard's NAME in the `CONSTRAINT` field and, if you gave one, the field name in
728
+ `COLUMN`. A client branches on those without parsing your sentence. Measured on
729
+ a live refusal: `constraint=documents_frozen_body · column=body`. `detail` and
730
+ `hint` land in Postgres' fields of the same name.
731
+
732
+ The name is required, and not for tidiness: Postgres runs triggers bound to one
733
+ event **in alphabetical order by name**, and there is no other handle on ordering.
734
+
735
+ For a cross-row rule, `.exists(table, row => …)` puts the predicate in an
736
+ `EXISTS` block instead of the trigger's `WHEN` clause — Postgres does not allow a
737
+ subquery in `WHEN`. Inside the callback, `row.col(…)` is the OTHER table's
738
+ column; an unqualified column is the row being written.
739
+
740
+ One shape is refused at push: an **`insert` guard cannot read a generated
741
+ column**. They are empty in `NEW`, so the condition would silently never fire —
742
+ fail-open on a refusal path.
743
+
744
+ ## Backfills — a data fix that runs once
745
+
746
+ Adding a column is declarative. FILLING it is not. That work used to live inside
747
+ a `raw()` block, which re-runs on every deploy, so it was either written
748
+ defensively idempotent or run by hand with no record that it happened.
749
+
750
+ ```ts
751
+ import { backfill, defineTable, text, uuid } from "@palbase/backend";
752
+
753
+ export const ledger = defineTable("ledger", {
754
+ columns: {
755
+ id: uuid().primaryKey().defaultRandom(),
756
+ currency: text().nullable(),
757
+ },
758
+ backfills: [
759
+ backfill("ledger_currency_try", "UPDATE ledger SET currency = 'TRY' WHERE currency IS NULL"),
760
+ ],
761
+ });
762
+ ```
763
+
764
+ It runs **after** the DDL, in its own phase and its own transaction, and **once
765
+ per name**. The record lives in `public.palbase_backfills`, so "did this run?" is
766
+ answered by the database rather than by memory.
767
+
768
+ **The NAME is the record.** Keeping the name and editing the SQL does NOT produce
769
+ a second run — to correct a backfill, give it a new name. That is also why an
770
+ empty body is refused: it would register the name and burn it permanently, and
771
+ the real SQL written later would never run and never complain. The registry is
772
+ global, so two tables cannot declare the same backfill name.
773
+
774
+ A plain array, not a callback: a backfill has no predicate to contextualise —
775
+ its body is verbatim SQL.
776
+
777
+ ## Dropping a constraint — `dropConstraints`
778
+
779
+ The diff is **additive**: a constraint that exists in the database but not in your
780
+ declaration is never dropped. Your schema file adds objects; it does not own the
781
+ database's. So a deliberate removal has to be SAID:
782
+
783
+ ```ts
784
+ export const orders = defineTable("orders", {
785
+ columns: { /* … */ },
786
+ dropConstraints: ["orders_legacy_status_check"],
787
+ });
788
+ ```
789
+
790
+ This is the one exception to the additive rule, and requiring it in writing is
791
+ the point — the alternative is a deploy that drops a constraint because someone
792
+ deleted a line.
443
793
 
444
794
  ## Typed DB access — by default
445
795
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "38.0.16",
3
+ "version": "39.1.0",
4
4
  "description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,7 +9,7 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@palbase/backend": "^38.0.0",
12
+ "@palbase/backend": "^39.0.0",
13
13
  "reflect-metadata": "^0.2.2"
14
14
  },
15
15
  "engines": {