@palbase/backend 30.0.1 → 33.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 +210 -24
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +7 -7
- package/dist/{chunk-TWX6JTGJ.js → chunk-26GH54D2.js} +3 -5
- package/dist/{chunk-TWX6JTGJ.js.map → chunk-26GH54D2.js.map} +1 -1
- package/dist/{chunk-VVMJEVQP.js → chunk-BRLJOXWS.js} +2 -2
- package/dist/{chunk-3TUJWHC2.js → chunk-C4ZA5AT2.js} +378 -19
- package/dist/chunk-C4ZA5AT2.js.map +1 -0
- package/dist/{chunk-RADVOY7Y.js → chunk-DCDHAKF3.js} +147 -33
- package/dist/chunk-DCDHAKF3.js.map +1 -0
- package/dist/{chunk-ENZ2RFFJ.js → chunk-GOPZPM2A.js} +3 -310
- package/dist/chunk-GOPZPM2A.js.map +1 -0
- package/dist/{chunk-CGNN2PUH.js → chunk-KATPXCJ5.js} +37 -4
- package/dist/{chunk-CGNN2PUH.js.map → chunk-KATPXCJ5.js.map} +1 -1
- package/dist/{chunk-YRVIWHJC.js → chunk-N54QZER3.js} +23 -323
- package/dist/chunk-N54QZER3.js.map +1 -0
- package/dist/chunk-QMFOL2K6.js +371 -0
- package/dist/chunk-QMFOL2K6.js.map +1 -0
- package/dist/chunk-XOX6RFPZ.js +383 -0
- package/dist/chunk-XOX6RFPZ.js.map +1 -0
- package/dist/db/index.cjs +41 -2
- 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 +12 -13
- package/dist/engine/index.cjs +210 -24
- 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 +7 -7
- package/dist/{index-DtCgaZAg.d.cts → index-BWgnGj68.d.cts} +185 -35
- package/dist/{index-b-Q3l7W5.d.ts → index-Bi74dcOu.d.ts} +9 -2
- package/dist/{index-8qy3kIuA.d.ts → index-Dg10RnZO.d.ts} +185 -35
- package/dist/{index-Zi7MptvP.d.cts → index-g6iQyYci.d.cts} +9 -2
- package/dist/index.cjs +135 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -23
- package/dist/index.d.ts +6 -23
- package/dist/index.js +48 -48
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.d.cts +2 -2
- package/dist/openapi/index.d.ts +2 -2
- package/dist/openapi/index.js +3 -4
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-CEYLH-Iz.d.cts → registry-6VT5RPeO.d.cts} +1 -1
- package/dist/{registry-BGJ-Al6F.d.ts → registry-B-sxJJN0.d.ts} +1 -1
- package/dist/test/index.cjs +733 -17
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +59 -4
- package/dist/test/index.d.ts +59 -4
- package/dist/test/index.js +56 -16
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +1 -1
- package/docs/auth.md +102 -20
- package/docs/database.md +54 -0
- package/docs/llms-full.txt +215 -21
- package/docs/schema.md +58 -0
- package/package.json +1 -1
- package/template/db/public.ts +23 -0
- package/template/package.json +1 -1
- package/dist/chunk-3TUJWHC2.js.map +0 -1
- package/dist/chunk-ENZ2RFFJ.js.map +0 -1
- package/dist/chunk-RADVOY7Y.js.map +0 -1
- package/dist/chunk-SG4UTNOP.js +0 -374
- package/dist/chunk-SG4UTNOP.js.map +0 -1
- package/dist/chunk-VXPNPVAG.js +0 -40
- package/dist/chunk-VXPNPVAG.js.map +0 -1
- package/dist/chunk-YRVIWHJC.js.map +0 -1
- /package/dist/{chunk-VVMJEVQP.js.map → chunk-BRLJOXWS.js.map} +0 -0
package/docs/README.md
CHANGED
|
@@ -73,7 +73,7 @@ service the controllers call.
|
|
|
73
73
|
|
|
74
74
|
> **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
|
|
75
75
|
> `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
|
|
76
|
-
> and will not compile against `@palbase/backend`
|
|
76
|
+
> and will not compile against `@palbase/backend` 33.
|
|
77
77
|
|
|
78
78
|
### Complete CRUD example (copy-pasteable, compiles)
|
|
79
79
|
|
package/docs/auth.md
CHANGED
|
@@ -39,35 +39,117 @@ interface User {
|
|
|
39
39
|
Every field is **server-resolved** from the verified profile — nothing here is
|
|
40
40
|
client-settable. `metadata` is your own `auth.users.metadata` (set through the
|
|
41
41
|
admin users API); `role` is the **database** role RLS reads and is always
|
|
42
|
-
`"authenticated"` for a signed-in user, so
|
|
43
|
-
|
|
42
|
+
`"authenticated"` for a signed-in user, so `user.role === "admin"` is always
|
|
43
|
+
false and reads like a working check.
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
Profile reads are cached for ~30 seconds, so a change shows up within that
|
|
49
|
-
window, not on the very next request.
|
|
45
|
+
Application roles are **`user.roles`** — a `string[]` resolved per request from
|
|
46
|
+
`auth.user_roles`, the table `palbase roles` writes. They were once kept in
|
|
47
|
+
`metadata`; they are not any more, and a role written there gates nothing.
|
|
50
48
|
|
|
51
|
-
|
|
49
|
+
`emailVerified`, `email`, `role` and `metadata` come from the **verified token**,
|
|
50
|
+
so they say what was true when it was minted: a user who confirms their address
|
|
51
|
+
mid-session keeps reporting `false` until their next token. `roles` is the
|
|
52
|
+
exception, and deliberately so — it is read from the table on every request, so
|
|
53
|
+
a grant or a revocation lands immediately.
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
## Roles and permissions
|
|
56
|
+
|
|
57
|
+
Roles are **yours**. There is no built-in "admin": you declare the roles your
|
|
58
|
+
app has, and what each one is allowed to do, from the command line —
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
palbase roles create member --default --permissions notes.create
|
|
62
|
+
palbase roles create moderator --permissions notes.create,notes.delete_any
|
|
63
|
+
palbase roles assign usr_01a0… moderator
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
— and `palbase spec` brings them back as constants, so a misspelled permission
|
|
67
|
+
is a compile error rather than a silent `403`:
|
|
54
68
|
|
|
55
69
|
```ts
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
import { Roles, Permissions } from "./palbe.gen";
|
|
71
|
+
Roles.moderator // 'moderator'
|
|
72
|
+
Permissions.notesDeleteAny // 'notes.delete_any'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`--default` marks the one role every new sign-up receives, written in the same
|
|
76
|
+
transaction as the user row.
|
|
77
|
+
|
|
78
|
+
### The endpoint gate
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
@Controller("/notes")
|
|
82
|
+
export default class NotesController {
|
|
83
|
+
// Anyone signed in.
|
|
84
|
+
@Get("") list(@User() user: UserT) { … }
|
|
85
|
+
|
|
86
|
+
// Only a caller holding the permission — the handler never runs otherwise.
|
|
87
|
+
@Delete("/{id}", { auth: { permission: "notes.delete_any" } })
|
|
88
|
+
remove(@Param("id") id: string) { … }
|
|
89
|
+
|
|
90
|
+
// Or by role, when the check really is "is this caller a moderator".
|
|
91
|
+
@Get("/queue", { auth: { role: "moderator" } })
|
|
92
|
+
queue() { … }
|
|
60
93
|
}
|
|
61
94
|
```
|
|
62
95
|
|
|
63
|
-
Not signed in → `401
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
Not signed in → `401`: declaring a permission or a role implies authentication,
|
|
97
|
+
so the caller is resolved even on a route with no `@User()` parameter. Signed in
|
|
98
|
+
without it → `403`, and the body names what was missing:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{ "error": "forbidden",
|
|
102
|
+
"error_description": "This endpoint requires the \"notes.delete_any\" permission" }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
If you deliberately open a route with `auth: { required: false, permission: … }`,
|
|
106
|
+
an unauthenticated caller reaches the gate and is refused with `403` — no
|
|
107
|
+
identity means no roles, and the gate answers that rather than waving them
|
|
108
|
+
through.
|
|
109
|
+
|
|
110
|
+
**Authority is in no claim.** The gate reads `auth.user_roles` inside the
|
|
111
|
+
transaction your handler is about to use — never a JWT claim — so
|
|
112
|
+
`palbase roles revoke` closes the door on the **very next request**, with the
|
|
113
|
+
same access token, no refresh and no re-login. Nothing is cached, and there is
|
|
114
|
+
no epoch to bump.
|
|
115
|
+
|
|
116
|
+
### The RLS half
|
|
117
|
+
|
|
118
|
+
The gate decides whether the handler runs. It cannot decide which **rows** the
|
|
119
|
+
handler sees — that is Postgres's job, and the same permissions are available
|
|
120
|
+
there:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
policies: () => [
|
|
124
|
+
policy("notes_owner").for("all").to("authenticated")
|
|
125
|
+
.using("user_id = (select auth.uid())")
|
|
126
|
+
.withCheck("user_id = (select auth.uid())"),
|
|
127
|
+
|
|
128
|
+
// Moderation. Permissive policies are OR'd, so these widen only.
|
|
129
|
+
policy("notes_moderate_read").for("select").to("authenticated")
|
|
130
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
131
|
+
policy("notes_delete_any").for("delete").to("authenticated")
|
|
132
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
133
|
+
]
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**The read policy is not optional, and leaving it out is the trap.**
|
|
137
|
+
`DELETE … WHERE id = $1` has to FIND the row first, and that read is governed by
|
|
138
|
+
the SELECT policies. With only the DELETE policy, a moderator holding the
|
|
139
|
+
permission deletes **nothing**: the row is invisible to them, the `WHERE`
|
|
140
|
+
matches nothing, and the statement answers `DELETE 0` — a silent no-op, not an
|
|
141
|
+
error. The rule: **if a permission writes to somebody else's row, a SELECT
|
|
142
|
+
policy has to show them that row.**
|
|
143
|
+
|
|
144
|
+
Two helpers are available in every policy body:
|
|
145
|
+
|
|
146
|
+
| helper | answers |
|
|
147
|
+
|---|---|
|
|
148
|
+
| `auth.has_permission(text)` | does the caller hold this permission, through any role they carry |
|
|
149
|
+
| `auth.app_roles()` | the caller's roles as `text[]`, sorted, `{}` when none |
|
|
66
150
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
verified identity for that long — rather than whenever the token happens to
|
|
70
|
-
expire.
|
|
151
|
+
Both take the caller from `auth.uid()` and accept no user id, so a policy cannot
|
|
152
|
+
ask about somebody else.
|
|
71
153
|
|
|
72
154
|
## Email verification
|
|
73
155
|
|
package/docs/database.md
CHANGED
|
@@ -171,6 +171,35 @@ that arrives in a request body is NOT a column reference and is refused as an
|
|
|
171
171
|
unknown operator, so spreading untrusted input into a filter cannot turn a
|
|
172
172
|
tenant predicate into a tautology.
|
|
173
173
|
|
|
174
|
+
### A table name from a variable does not compile — use a literal
|
|
175
|
+
|
|
176
|
+
Inside `Database.$transaction`, reading the table name out of a VARIABLE stops
|
|
177
|
+
compiling once a schema grows:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const purge = (t: keyof Tables) =>
|
|
181
|
+
Database.$transaction((tx) => [tx.public[t].deleteWhere({ id })]);
|
|
182
|
+
// ^
|
|
183
|
+
// error TS2590: Expression produces a union type that is too complex to represent.
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Measured:** clean up to seven tables, **failing at eight** — and at 12, 20, 26
|
|
187
|
+
and 40. What decides it is the table count together with tables carrying
|
|
188
|
+
DIFFERENT relation name sets; **the column count does not matter** (8 tables × 2
|
|
189
|
+
columns fails, 4 tables × 200 columns is clean), so "use fewer columns" is not a
|
|
190
|
+
remedy. The cause is TypeScript's own complexity ceiling: a variable key forces
|
|
191
|
+
the compiler to intersect every table's `where` parameter at once.
|
|
192
|
+
|
|
193
|
+
**Write the table name as a literal:**
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
await Database.$transaction((tx) => [tx.public.payments.deleteWhere({ id })]);
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
If the name really is only known at runtime, use the raw ops — `Database.$query`,
|
|
200
|
+
`Database.$deleteMany` — which take the table as a string. The same limit made
|
|
201
|
+
Kysely withdraw the pattern in v0.28; Drizzle never offered it.
|
|
202
|
+
|
|
174
203
|
### Filtering through a relation — `has`
|
|
175
204
|
|
|
176
205
|
A filter on the row next to yours is the most common reason to drop to raw SQL,
|
|
@@ -220,6 +249,31 @@ await Database.$transaction((tx) => {
|
|
|
220
249
|
});
|
|
221
250
|
```
|
|
222
251
|
|
|
252
|
+
### Fetching related rows — `with`
|
|
253
|
+
|
|
254
|
+
`has` FILTERS through a relation; `with` FETCHES through it. Same relation
|
|
255
|
+
graph, same names, one query:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
const rows = await Database.public.users.findMany({
|
|
259
|
+
where: { active: true },
|
|
260
|
+
with: { orders: { select: ["id", "amount"], limit: 5 } },
|
|
261
|
+
});
|
|
262
|
+
rows[0].orders[0].amount; // string — exact
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
It compiles to a `LEFT JOIN LATERAL` with `json_agg` — one statement, one round
|
|
266
|
+
trip, both tables' RLS applied. A `many` relation is an ARRAY (`[]` when empty,
|
|
267
|
+
never `null`, so `.map()` needs no guard); a `one` relation is a single row or
|
|
268
|
+
`null`.
|
|
269
|
+
|
|
270
|
+
The inner options are the same language: `select`, `where`, `orderBy`, `limit`.
|
|
271
|
+
|
|
272
|
+
**Money survives.** Every `numeric` and `bigint` column is cast to text on its
|
|
273
|
+
way into the JSON. Without that cast a `numeric` becomes a JSON *number* and
|
|
274
|
+
loses precision — measured on live pg16, `100000000000000000000000000` comes
|
|
275
|
+
back as `1e+26`. `integer` is left alone: it fits a double exactly.
|
|
276
|
+
|
|
223
277
|
### Ordering and paging
|
|
224
278
|
|
|
225
279
|
```ts
|
package/docs/llms-full.txt
CHANGED
|
@@ -81,7 +81,7 @@ service the controllers call.
|
|
|
81
81
|
|
|
82
82
|
> **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
|
|
83
83
|
> `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
|
|
84
|
-
> and will not compile against `@palbase/backend`
|
|
84
|
+
> and will not compile against `@palbase/backend` 33.
|
|
85
85
|
|
|
86
86
|
### Complete CRUD example (copy-pasteable, compiles)
|
|
87
87
|
|
|
@@ -741,35 +741,117 @@ interface User {
|
|
|
741
741
|
Every field is **server-resolved** from the verified profile — nothing here is
|
|
742
742
|
client-settable. `metadata` is your own `auth.users.metadata` (set through the
|
|
743
743
|
admin users API); `role` is the **database** role RLS reads and is always
|
|
744
|
-
`"authenticated"` for a signed-in user, so
|
|
745
|
-
|
|
744
|
+
`"authenticated"` for a signed-in user, so `user.role === "admin"` is always
|
|
745
|
+
false and reads like a working check.
|
|
746
746
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
Profile reads are cached for ~30 seconds, so a change shows up within that
|
|
751
|
-
window, not on the very next request.
|
|
747
|
+
Application roles are **`user.roles`** — a `string[]` resolved per request from
|
|
748
|
+
`auth.user_roles`, the table `palbase roles` writes. They were once kept in
|
|
749
|
+
`metadata`; they are not any more, and a role written there gates nothing.
|
|
752
750
|
|
|
753
|
-
|
|
751
|
+
`emailVerified`, `email`, `role` and `metadata` come from the **verified token**,
|
|
752
|
+
so they say what was true when it was minted: a user who confirms their address
|
|
753
|
+
mid-session keeps reporting `false` until their next token. `roles` is the
|
|
754
|
+
exception, and deliberately so — it is read from the table on every request, so
|
|
755
|
+
a grant or a revocation lands immediately.
|
|
754
756
|
|
|
755
|
-
|
|
757
|
+
## Roles and permissions
|
|
758
|
+
|
|
759
|
+
Roles are **yours**. There is no built-in "admin": you declare the roles your
|
|
760
|
+
app has, and what each one is allowed to do, from the command line —
|
|
761
|
+
|
|
762
|
+
```bash
|
|
763
|
+
palbase roles create member --default --permissions notes.create
|
|
764
|
+
palbase roles create moderator --permissions notes.create,notes.delete_any
|
|
765
|
+
palbase roles assign usr_01a0… moderator
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
— and `palbase spec` brings them back as constants, so a misspelled permission
|
|
769
|
+
is a compile error rather than a silent `403`:
|
|
770
|
+
|
|
771
|
+
```ts
|
|
772
|
+
import { Roles, Permissions } from "./palbe.gen";
|
|
773
|
+
Roles.moderator // 'moderator'
|
|
774
|
+
Permissions.notesDeleteAny // 'notes.delete_any'
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
`--default` marks the one role every new sign-up receives, written in the same
|
|
778
|
+
transaction as the user row.
|
|
779
|
+
|
|
780
|
+
### The endpoint gate
|
|
756
781
|
|
|
757
782
|
```ts
|
|
758
|
-
@Controller("/
|
|
759
|
-
export default class
|
|
760
|
-
|
|
761
|
-
|
|
783
|
+
@Controller("/notes")
|
|
784
|
+
export default class NotesController {
|
|
785
|
+
// Anyone signed in.
|
|
786
|
+
@Get("") list(@User() user: UserT) { … }
|
|
787
|
+
|
|
788
|
+
// Only a caller holding the permission — the handler never runs otherwise.
|
|
789
|
+
@Delete("/{id}", { auth: { permission: "notes.delete_any" } })
|
|
790
|
+
remove(@Param("id") id: string) { … }
|
|
791
|
+
|
|
792
|
+
// Or by role, when the check really is "is this caller a moderator".
|
|
793
|
+
@Get("/queue", { auth: { role: "moderator" } })
|
|
794
|
+
queue() { … }
|
|
762
795
|
}
|
|
763
796
|
```
|
|
764
797
|
|
|
765
|
-
Not signed in → `401
|
|
766
|
-
|
|
767
|
-
|
|
798
|
+
Not signed in → `401`: declaring a permission or a role implies authentication,
|
|
799
|
+
so the caller is resolved even on a route with no `@User()` parameter. Signed in
|
|
800
|
+
without it → `403`, and the body names what was missing:
|
|
801
|
+
|
|
802
|
+
```json
|
|
803
|
+
{ "error": "forbidden",
|
|
804
|
+
"error_description": "This endpoint requires the \"notes.delete_any\" permission" }
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
If you deliberately open a route with `auth: { required: false, permission: … }`,
|
|
808
|
+
an unauthenticated caller reaches the gate and is refused with `403` — no
|
|
809
|
+
identity means no roles, and the gate answers that rather than waving them
|
|
810
|
+
through.
|
|
811
|
+
|
|
812
|
+
**Authority is in no claim.** The gate reads `auth.user_roles` inside the
|
|
813
|
+
transaction your handler is about to use — never a JWT claim — so
|
|
814
|
+
`palbase roles revoke` closes the door on the **very next request**, with the
|
|
815
|
+
same access token, no refresh and no re-login. Nothing is cached, and there is
|
|
816
|
+
no epoch to bump.
|
|
817
|
+
|
|
818
|
+
### The RLS half
|
|
819
|
+
|
|
820
|
+
The gate decides whether the handler runs. It cannot decide which **rows** the
|
|
821
|
+
handler sees — that is Postgres's job, and the same permissions are available
|
|
822
|
+
there:
|
|
823
|
+
|
|
824
|
+
```ts
|
|
825
|
+
policies: () => [
|
|
826
|
+
policy("notes_owner").for("all").to("authenticated")
|
|
827
|
+
.using("user_id = (select auth.uid())")
|
|
828
|
+
.withCheck("user_id = (select auth.uid())"),
|
|
829
|
+
|
|
830
|
+
// Moderation. Permissive policies are OR'd, so these widen only.
|
|
831
|
+
policy("notes_moderate_read").for("select").to("authenticated")
|
|
832
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
833
|
+
policy("notes_delete_any").for("delete").to("authenticated")
|
|
834
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
835
|
+
]
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
**The read policy is not optional, and leaving it out is the trap.**
|
|
839
|
+
`DELETE … WHERE id = $1` has to FIND the row first, and that read is governed by
|
|
840
|
+
the SELECT policies. With only the DELETE policy, a moderator holding the
|
|
841
|
+
permission deletes **nothing**: the row is invisible to them, the `WHERE`
|
|
842
|
+
matches nothing, and the statement answers `DELETE 0` — a silent no-op, not an
|
|
843
|
+
error. The rule: **if a permission writes to somebody else's row, a SELECT
|
|
844
|
+
policy has to show them that row.**
|
|
845
|
+
|
|
846
|
+
Two helpers are available in every policy body:
|
|
847
|
+
|
|
848
|
+
| helper | answers |
|
|
849
|
+
|---|---|
|
|
850
|
+
| `auth.has_permission(text)` | does the caller hold this permission, through any role they carry |
|
|
851
|
+
| `auth.app_roles()` | the caller's roles as `text[]`, sorted, `{}` when none |
|
|
768
852
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
verified identity for that long — rather than whenever the token happens to
|
|
772
|
-
expire.
|
|
853
|
+
Both take the caller from `auth.uid()` and accept no user id, so a policy cannot
|
|
854
|
+
ask about somebody else.
|
|
773
855
|
|
|
774
856
|
## Email verification
|
|
775
857
|
|
|
@@ -1057,6 +1139,35 @@ that arrives in a request body is NOT a column reference and is refused as an
|
|
|
1057
1139
|
unknown operator, so spreading untrusted input into a filter cannot turn a
|
|
1058
1140
|
tenant predicate into a tautology.
|
|
1059
1141
|
|
|
1142
|
+
### A table name from a variable does not compile — use a literal
|
|
1143
|
+
|
|
1144
|
+
Inside `Database.$transaction`, reading the table name out of a VARIABLE stops
|
|
1145
|
+
compiling once a schema grows:
|
|
1146
|
+
|
|
1147
|
+
```ts
|
|
1148
|
+
const purge = (t: keyof Tables) =>
|
|
1149
|
+
Database.$transaction((tx) => [tx.public[t].deleteWhere({ id })]);
|
|
1150
|
+
// ^
|
|
1151
|
+
// error TS2590: Expression produces a union type that is too complex to represent.
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
**Measured:** clean up to seven tables, **failing at eight** — and at 12, 20, 26
|
|
1155
|
+
and 40. What decides it is the table count together with tables carrying
|
|
1156
|
+
DIFFERENT relation name sets; **the column count does not matter** (8 tables × 2
|
|
1157
|
+
columns fails, 4 tables × 200 columns is clean), so "use fewer columns" is not a
|
|
1158
|
+
remedy. The cause is TypeScript's own complexity ceiling: a variable key forces
|
|
1159
|
+
the compiler to intersect every table's `where` parameter at once.
|
|
1160
|
+
|
|
1161
|
+
**Write the table name as a literal:**
|
|
1162
|
+
|
|
1163
|
+
```ts
|
|
1164
|
+
await Database.$transaction((tx) => [tx.public.payments.deleteWhere({ id })]);
|
|
1165
|
+
```
|
|
1166
|
+
|
|
1167
|
+
If the name really is only known at runtime, use the raw ops — `Database.$query`,
|
|
1168
|
+
`Database.$deleteMany` — which take the table as a string. The same limit made
|
|
1169
|
+
Kysely withdraw the pattern in v0.28; Drizzle never offered it.
|
|
1170
|
+
|
|
1060
1171
|
### Filtering through a relation — `has`
|
|
1061
1172
|
|
|
1062
1173
|
A filter on the row next to yours is the most common reason to drop to raw SQL,
|
|
@@ -1106,6 +1217,31 @@ await Database.$transaction((tx) => {
|
|
|
1106
1217
|
});
|
|
1107
1218
|
```
|
|
1108
1219
|
|
|
1220
|
+
### Fetching related rows — `with`
|
|
1221
|
+
|
|
1222
|
+
`has` FILTERS through a relation; `with` FETCHES through it. Same relation
|
|
1223
|
+
graph, same names, one query:
|
|
1224
|
+
|
|
1225
|
+
```ts
|
|
1226
|
+
const rows = await Database.public.users.findMany({
|
|
1227
|
+
where: { active: true },
|
|
1228
|
+
with: { orders: { select: ["id", "amount"], limit: 5 } },
|
|
1229
|
+
});
|
|
1230
|
+
rows[0].orders[0].amount; // string — exact
|
|
1231
|
+
```
|
|
1232
|
+
|
|
1233
|
+
It compiles to a `LEFT JOIN LATERAL` with `json_agg` — one statement, one round
|
|
1234
|
+
trip, both tables' RLS applied. A `many` relation is an ARRAY (`[]` when empty,
|
|
1235
|
+
never `null`, so `.map()` needs no guard); a `one` relation is a single row or
|
|
1236
|
+
`null`.
|
|
1237
|
+
|
|
1238
|
+
The inner options are the same language: `select`, `where`, `orderBy`, `limit`.
|
|
1239
|
+
|
|
1240
|
+
**Money survives.** Every `numeric` and `bigint` column is cast to text on its
|
|
1241
|
+
way into the JSON. Without that cast a `numeric` becomes a JSON *number* and
|
|
1242
|
+
loses precision — measured on live pg16, `100000000000000000000000000` comes
|
|
1243
|
+
back as `1e+26`. `integer` is left alone: it fits a double exactly.
|
|
1244
|
+
|
|
1109
1245
|
### Ordering and paging
|
|
1110
1246
|
|
|
1111
1247
|
```ts
|
|
@@ -1648,6 +1784,38 @@ export const invoices = defineTable("invoices", {
|
|
|
1648
1784
|
});
|
|
1649
1785
|
```
|
|
1650
1786
|
|
|
1787
|
+
## Exact-precision columns: `bigint` / `numeric`
|
|
1788
|
+
|
|
1789
|
+
Both arrive as **`string`**, and that is correct: a JS number cannot hold
|
|
1790
|
+
Postgres' full `int8` range or an arbitrary-precision decimal. The driver
|
|
1791
|
+
serialises them as strings and the generated types say so.
|
|
1792
|
+
|
|
1793
|
+
Application code usually wants a number anyway, and rewriting
|
|
1794
|
+
`Number(row.amount)` / `String(x)` in every caller is where the two directions
|
|
1795
|
+
drift apart. Declare the conversion ONCE, on the column:
|
|
1796
|
+
|
|
1797
|
+
```ts
|
|
1798
|
+
amount_kurus: bigint().asNumber(), // row.amount_kurus: number
|
|
1799
|
+
rate: numeric().asDecimal(), // row.rate: string — declared, not defaulted
|
|
1800
|
+
```
|
|
1801
|
+
|
|
1802
|
+
| Declaration | Generated type | Runtime |
|
|
1803
|
+
|---|---|---|
|
|
1804
|
+
| `bigint()` / `numeric()` | `string` | untouched |
|
|
1805
|
+
| `.asNumber()` | `number` | `Number(v)` in, `String(v)` out |
|
|
1806
|
+
| `.asDecimal()` | `string` | `String(v)` — the same value, said out loud |
|
|
1807
|
+
|
|
1808
|
+
`.asNumber()` loses precision above 2^53; use `.asDecimal()` (and a decimal
|
|
1809
|
+
library) for values that can exceed it. Both are available only on `bigint()`
|
|
1810
|
+
and `numeric()` — any other column type refuses the call by name.
|
|
1811
|
+
|
|
1812
|
+
> **`.transform<T>()` is refused.** Its target type is a type parameter and does
|
|
1813
|
+
> not exist at runtime, so the type generator could never learn it: it printed
|
|
1814
|
+
> `string` while the engine handed back a number, silently. A codec is NAMED
|
|
1815
|
+
> instead of typed, which is what lets one declaration produce both the
|
|
1816
|
+
> conversion and the type. `palbase build` names the table and column when it
|
|
1817
|
+
> finds one.
|
|
1818
|
+
|
|
1651
1819
|
## Column builders
|
|
1652
1820
|
|
|
1653
1821
|
| Builder | Postgres type |
|
|
@@ -2089,6 +2257,32 @@ once per statement (an initPlan) instead of once per row. `auth.role()` and
|
|
|
2089
2257
|
`auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
|
|
2090
2258
|
rows.
|
|
2091
2259
|
|
|
2260
|
+
**`auth.has_permission('resource.action')`** answers whether the caller holds
|
|
2261
|
+
that permission through any role they carry, and **`auth.app_roles()`** returns
|
|
2262
|
+
their roles as a sorted `text[]` (`{}` when they hold none). Both read
|
|
2263
|
+
`auth.user_roles` — the table `palbase roles` writes — and both take the caller
|
|
2264
|
+
from `auth.uid()`, so a policy cannot ask about somebody else. A revoked role is
|
|
2265
|
+
gone from the very next statement; there is nothing cached.
|
|
2266
|
+
|
|
2267
|
+
```ts
|
|
2268
|
+
policy("notes_owner").for("all")
|
|
2269
|
+
.using("user_id = (select auth.uid())")
|
|
2270
|
+
.withCheck("user_id = (select auth.uid())"),
|
|
2271
|
+
|
|
2272
|
+
// Moderation. Permissive policies are OR'd, so these widen only.
|
|
2273
|
+
policy("notes_moderate_read").for("select")
|
|
2274
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
2275
|
+
policy("notes_delete_any").for("delete")
|
|
2276
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
2277
|
+
```
|
|
2278
|
+
|
|
2279
|
+
> **The read policy is not optional.** `DELETE … WHERE id = $1` has to FIND the
|
|
2280
|
+
> row first, and that read is governed by the SELECT policies. With only the
|
|
2281
|
+
> DELETE policy above, a moderator holding the permission deletes **nothing** —
|
|
2282
|
+
> the row is invisible to them, so the `WHERE` matches nothing and the statement
|
|
2283
|
+
> answers `DELETE 0`. No error, no log. If a permission writes to somebody
|
|
2284
|
+
> else's row, a SELECT policy has to show them that row.
|
|
2285
|
+
|
|
2092
2286
|
> Name policies with a `pb_` prefix. Palbase reconciliation only manages
|
|
2093
2287
|
> policies it authored (`pb_`-prefixed) and never touches policies created by
|
|
2094
2288
|
> other modules (storage, cron, …).
|
package/docs/schema.md
CHANGED
|
@@ -107,6 +107,38 @@ export const invoices = defineTable("invoices", {
|
|
|
107
107
|
});
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
+
## Exact-precision columns: `bigint` / `numeric`
|
|
111
|
+
|
|
112
|
+
Both arrive as **`string`**, and that is correct: a JS number cannot hold
|
|
113
|
+
Postgres' full `int8` range or an arbitrary-precision decimal. The driver
|
|
114
|
+
serialises them as strings and the generated types say so.
|
|
115
|
+
|
|
116
|
+
Application code usually wants a number anyway, and rewriting
|
|
117
|
+
`Number(row.amount)` / `String(x)` in every caller is where the two directions
|
|
118
|
+
drift apart. Declare the conversion ONCE, on the column:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
amount_kurus: bigint().asNumber(), // row.amount_kurus: number
|
|
122
|
+
rate: numeric().asDecimal(), // row.rate: string — declared, not defaulted
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
| Declaration | Generated type | Runtime |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `bigint()` / `numeric()` | `string` | untouched |
|
|
128
|
+
| `.asNumber()` | `number` | `Number(v)` in, `String(v)` out |
|
|
129
|
+
| `.asDecimal()` | `string` | `String(v)` — the same value, said out loud |
|
|
130
|
+
|
|
131
|
+
`.asNumber()` loses precision above 2^53; use `.asDecimal()` (and a decimal
|
|
132
|
+
library) for values that can exceed it. Both are available only on `bigint()`
|
|
133
|
+
and `numeric()` — any other column type refuses the call by name.
|
|
134
|
+
|
|
135
|
+
> **`.transform<T>()` is refused.** Its target type is a type parameter and does
|
|
136
|
+
> not exist at runtime, so the type generator could never learn it: it printed
|
|
137
|
+
> `string` while the engine handed back a number, silently. A codec is NAMED
|
|
138
|
+
> instead of typed, which is what lets one declaration produce both the
|
|
139
|
+
> conversion and the type. `palbase build` names the table and column when it
|
|
140
|
+
> finds one.
|
|
141
|
+
|
|
110
142
|
## Column builders
|
|
111
143
|
|
|
112
144
|
| Builder | Postgres type |
|
|
@@ -548,6 +580,32 @@ once per statement (an initPlan) instead of once per row. `auth.role()` and
|
|
|
548
580
|
`auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
|
|
549
581
|
rows.
|
|
550
582
|
|
|
583
|
+
**`auth.has_permission('resource.action')`** answers whether the caller holds
|
|
584
|
+
that permission through any role they carry, and **`auth.app_roles()`** returns
|
|
585
|
+
their roles as a sorted `text[]` (`{}` when they hold none). Both read
|
|
586
|
+
`auth.user_roles` — the table `palbase roles` writes — and both take the caller
|
|
587
|
+
from `auth.uid()`, so a policy cannot ask about somebody else. A revoked role is
|
|
588
|
+
gone from the very next statement; there is nothing cached.
|
|
589
|
+
|
|
590
|
+
```ts
|
|
591
|
+
policy("notes_owner").for("all")
|
|
592
|
+
.using("user_id = (select auth.uid())")
|
|
593
|
+
.withCheck("user_id = (select auth.uid())"),
|
|
594
|
+
|
|
595
|
+
// Moderation. Permissive policies are OR'd, so these widen only.
|
|
596
|
+
policy("notes_moderate_read").for("select")
|
|
597
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
598
|
+
policy("notes_delete_any").for("delete")
|
|
599
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
> **The read policy is not optional.** `DELETE … WHERE id = $1` has to FIND the
|
|
603
|
+
> row first, and that read is governed by the SELECT policies. With only the
|
|
604
|
+
> DELETE policy above, a moderator holding the permission deletes **nothing** —
|
|
605
|
+
> the row is invisible to them, so the `WHERE` matches nothing and the statement
|
|
606
|
+
> answers `DELETE 0`. No error, no log. If a permission writes to somebody
|
|
607
|
+
> else's row, a SELECT policy has to show them that row.
|
|
608
|
+
|
|
551
609
|
> Name policies with a `pb_` prefix. Palbase reconciliation only manages
|
|
552
610
|
> policies it authored (`pb_`-prefixed) and never touches policies created by
|
|
553
611
|
> other modules (storage, cron, …).
|
package/package.json
CHANGED
package/template/db/public.ts
CHANGED
|
@@ -30,6 +30,29 @@ const notes = defineTable("notes", {
|
|
|
30
30
|
.to("authenticated")
|
|
31
31
|
.using("user_id = (select auth.uid())")
|
|
32
32
|
.withCheck("user_id = (select auth.uid())"),
|
|
33
|
+
|
|
34
|
+
// MODERATION, when you want it — delete these two if you do not.
|
|
35
|
+
//
|
|
36
|
+
// Roles are yours and there is no built-in "admin": you declare them from
|
|
37
|
+
// the command line (`palbase roles create moderator --permissions
|
|
38
|
+
// notes.delete_any`) and `auth.has_permission` answers from the roles the
|
|
39
|
+
// caller actually holds right now. Permissive policies are OR'd, so these
|
|
40
|
+
// widen only: the owner rule above keeps working untouched.
|
|
41
|
+
//
|
|
42
|
+
// THE READ POLICY IS NOT OPTIONAL. `DELETE … WHERE id = $1` has to FIND the
|
|
43
|
+
// row first, and that read is governed by the SELECT policies — with only
|
|
44
|
+
// the delete rule below, a moderator holding the permission deletes NOTHING
|
|
45
|
+
// and gets no error, because the row is invisible to them and the WHERE
|
|
46
|
+
// matches nothing. If a permission writes to somebody else's row, a SELECT
|
|
47
|
+
// policy has to show them that row.
|
|
48
|
+
policy("notes_moderate_read")
|
|
49
|
+
.for("select")
|
|
50
|
+
.to("authenticated")
|
|
51
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
52
|
+
policy("notes_delete_any")
|
|
53
|
+
.for("delete")
|
|
54
|
+
.to("authenticated")
|
|
55
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
33
56
|
],
|
|
34
57
|
});
|
|
35
58
|
|