@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.
- 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 +629 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -8
- package/dist/index.d.ts +216 -8
- package/dist/index.js +75 -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 +129 -0
- package/docs/llms-full.txt +512 -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/llms-full.txt
CHANGED
|
@@ -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`
|
|
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`
|
|
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
|
-
|
|
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
|
|
2574
|
-
|
|
2575
|
-
|
|
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
|
|
2761
|
+
### Scope
|
|
2578
2762
|
|
|
2579
|
-
|
|
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
|
|
2584
|
-
| a partial index (`WHERE …`) | `
|
|
2585
|
-
| an expression index (`lower(email)`, …) | `
|
|
2586
|
-
|
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
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,
|
|
2796
|
+
import { defineTable, index, uuid, text, timestamp } from "@palbase/backend";
|
|
2596
2797
|
|
|
2597
2798
|
export const orders = defineTable("orders", {
|
|
2598
2799
|
columns: {
|
|
2599
|
-
id:
|
|
2600
|
-
status:
|
|
2601
|
-
|
|
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
|
-
|
|
2604
|
-
|
|
2605
|
-
"
|
|
2606
|
-
"
|
|
2607
|
-
|
|
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
|
-
|
|
2614
|
-
`
|
|
2615
|
-
`
|
|
2616
|
-
|
|
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
|
|