@palbase/backend 38.0.15 → 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.
- package/dist/bin/palbase-backend.cjs +46 -1
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +5 -5
- package/dist/{chunk-LFUNRWUE.js → chunk-4J6I4KVQ.js} +67 -3
- package/dist/{chunk-LFUNRWUE.js.map → chunk-4J6I4KVQ.js.map} +1 -1
- package/dist/{chunk-7WAGQ3VR.js → chunk-AR2I4M7I.js} +497 -17
- package/dist/chunk-AR2I4M7I.js.map +1 -0
- package/dist/{chunk-YANGWAIM.js → chunk-H7EKL6HC.js} +2 -2
- package/dist/{chunk-EHKEMHB4.js → chunk-JJSR4BUX.js} +49 -5
- package/dist/chunk-JJSR4BUX.js.map +1 -0
- package/dist/{chunk-CS6NQ6PO.js → chunk-JPTFYHP3.js} +2 -2
- package/dist/{chunk-CS6NQ6PO.js.map → chunk-JPTFYHP3.js.map} +1 -1
- package/dist/{chunk-X7UR3VXA.js → chunk-XZWOMPD3.js} +3 -2
- package/dist/chunk-XZWOMPD3.js.map +1 -0
- package/dist/{chunk-PDD55QWN.js → chunk-ZNQDL466.js} +2 -2
- package/dist/db/index.cjs +378 -14
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +3 -3
- package/dist/engine/index.cjs +46 -1
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +3 -3
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +5 -5
- package/dist/{index-CI7S0Wqv.d.cts → index-B0mjUnxy.d.cts} +25 -2
- package/dist/{index-CUy50OLU.d.ts → index-eN4KzGa5.d.ts} +555 -29
- package/dist/{index-DRx880KY.d.cts → index-pK2At5Yc.d.cts} +555 -29
- package/dist/{index-Ogi30dbt.d.ts → index-yDno9Ju9.d.ts} +25 -2
- package/dist/index.cjs +628 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +183 -8
- package/dist/index.d.ts +183 -8
- package/dist/index.js +74 -6
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +2 -2
- package/dist/openapi/index.d.ts +2 -2
- package/dist/openapi/index.js +2 -2
- package/dist/{registry-C7UCkQf0.d.cts → registry-9dNeVN5d.d.cts} +1 -1
- package/dist/{registry-DJcvbomD.d.ts → registry-B8ddZiMY.d.ts} +1 -1
- package/dist/test/index.cjs +19 -0
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/dist/test/index.js +20 -2
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +1 -1
- package/docs/database.md +79 -0
- package/docs/llms-full.txt +462 -33
- package/docs/schema.md +382 -32
- package/package.json +1 -1
- package/template/package.json +1 -1
- package/dist/chunk-7WAGQ3VR.js.map +0 -1
- package/dist/chunk-EHKEMHB4.js.map +0 -1
- package/dist/chunk-X7UR3VXA.js.map +0 -1
- /package/dist/{chunk-YANGWAIM.js.map → chunk-H7EKL6HC.js.map} +0 -0
- /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`
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
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
|
|
458
|
+
### Scope
|
|
404
459
|
|
|
405
|
-
|
|
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
|
|
410
|
-
| a partial index (`WHERE …`) | `
|
|
411
|
-
| an expression index (`lower(email)`, …) | `
|
|
412
|
-
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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,
|
|
493
|
+
import { defineTable, index, uuid, text, timestamp } from "@palbase/backend";
|
|
422
494
|
|
|
423
495
|
export const orders = defineTable("orders", {
|
|
424
496
|
columns: {
|
|
425
|
-
id:
|
|
426
|
-
status:
|
|
427
|
-
|
|
497
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
498
|
+
status: text().notNull(),
|
|
499
|
+
external_id: text().nullable(),
|
|
500
|
+
created_at: timestamp().defaultNow(),
|
|
428
501
|
},
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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