@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.
Files changed (69) hide show
  1. package/dist/bin/palbase-backend.cjs +210 -24
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +7 -7
  4. package/dist/{chunk-TWX6JTGJ.js → chunk-26GH54D2.js} +3 -5
  5. package/dist/{chunk-TWX6JTGJ.js.map → chunk-26GH54D2.js.map} +1 -1
  6. package/dist/{chunk-VVMJEVQP.js → chunk-BRLJOXWS.js} +2 -2
  7. package/dist/{chunk-3TUJWHC2.js → chunk-C4ZA5AT2.js} +378 -19
  8. package/dist/chunk-C4ZA5AT2.js.map +1 -0
  9. package/dist/{chunk-RADVOY7Y.js → chunk-DCDHAKF3.js} +147 -33
  10. package/dist/chunk-DCDHAKF3.js.map +1 -0
  11. package/dist/{chunk-ENZ2RFFJ.js → chunk-GOPZPM2A.js} +3 -310
  12. package/dist/chunk-GOPZPM2A.js.map +1 -0
  13. package/dist/{chunk-CGNN2PUH.js → chunk-KATPXCJ5.js} +37 -4
  14. package/dist/{chunk-CGNN2PUH.js.map → chunk-KATPXCJ5.js.map} +1 -1
  15. package/dist/{chunk-YRVIWHJC.js → chunk-N54QZER3.js} +23 -323
  16. package/dist/chunk-N54QZER3.js.map +1 -0
  17. package/dist/chunk-QMFOL2K6.js +371 -0
  18. package/dist/chunk-QMFOL2K6.js.map +1 -0
  19. package/dist/chunk-XOX6RFPZ.js +383 -0
  20. package/dist/chunk-XOX6RFPZ.js.map +1 -0
  21. package/dist/db/index.cjs +41 -2
  22. package/dist/db/index.cjs.map +1 -1
  23. package/dist/db/index.d.cts +1 -1
  24. package/dist/db/index.d.ts +1 -1
  25. package/dist/db/index.js +12 -13
  26. package/dist/engine/index.cjs +210 -24
  27. package/dist/engine/index.cjs.map +1 -1
  28. package/dist/engine/index.d.cts +3 -3
  29. package/dist/engine/index.d.ts +3 -3
  30. package/dist/engine/index.js +7 -7
  31. package/dist/{index-DtCgaZAg.d.cts → index-BWgnGj68.d.cts} +185 -35
  32. package/dist/{index-b-Q3l7W5.d.ts → index-Bi74dcOu.d.ts} +9 -2
  33. package/dist/{index-8qy3kIuA.d.ts → index-Dg10RnZO.d.ts} +185 -35
  34. package/dist/{index-Zi7MptvP.d.cts → index-g6iQyYci.d.cts} +9 -2
  35. package/dist/index.cjs +135 -8
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +6 -23
  38. package/dist/index.d.ts +6 -23
  39. package/dist/index.js +48 -48
  40. package/dist/index.js.map +1 -1
  41. package/dist/openapi/index.d.cts +2 -2
  42. package/dist/openapi/index.d.ts +2 -2
  43. package/dist/openapi/index.js +3 -4
  44. package/dist/openapi/index.js.map +1 -1
  45. package/dist/{registry-CEYLH-Iz.d.cts → registry-6VT5RPeO.d.cts} +1 -1
  46. package/dist/{registry-BGJ-Al6F.d.ts → registry-B-sxJJN0.d.ts} +1 -1
  47. package/dist/test/index.cjs +733 -17
  48. package/dist/test/index.cjs.map +1 -1
  49. package/dist/test/index.d.cts +59 -4
  50. package/dist/test/index.d.ts +59 -4
  51. package/dist/test/index.js +56 -16
  52. package/dist/test/index.js.map +1 -1
  53. package/docs/README.md +1 -1
  54. package/docs/auth.md +102 -20
  55. package/docs/database.md +54 -0
  56. package/docs/llms-full.txt +215 -21
  57. package/docs/schema.md +58 -0
  58. package/package.json +1 -1
  59. package/template/db/public.ts +23 -0
  60. package/template/package.json +1 -1
  61. package/dist/chunk-3TUJWHC2.js.map +0 -1
  62. package/dist/chunk-ENZ2RFFJ.js.map +0 -1
  63. package/dist/chunk-RADVOY7Y.js.map +0 -1
  64. package/dist/chunk-SG4UTNOP.js +0 -374
  65. package/dist/chunk-SG4UTNOP.js.map +0 -1
  66. package/dist/chunk-VXPNPVAG.js +0 -40
  67. package/dist/chunk-VXPNPVAG.js.map +0 -1
  68. package/dist/chunk-YRVIWHJC.js.map +0 -1
  69. /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` 30.
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 application roles belong in
43
- `metadata`, not there.
42
+ `"authenticated"` for a signed-in user, so `user.role === "admin"` is always
43
+ false and reads like a working check.
44
44
 
45
- `emailVerified` is likewise read from the verified profile rather than a JWT
46
- claim: a claim is only true as of when the token was minted, so a user who
47
- verifies mid-session would keep reporting `false` until their token expired.
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
- ## Roles
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
- `auth: { role: "admin" }` gates on the caller's `metadata.role`:
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
- @Controller("/admin", { auth: { role: "admin" } })
57
- export default class AdminController {
58
- @Get("/stats")
59
- stats(): Promise<Stats> { … } // only metadata.role === "admin" reaches here
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`. Signed in with a different (or missing) role `403`. A
64
- role gate implies authentication, so the caller is resolved even on a route with
65
- no `@User()` parameter.
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
- The role is read from the verified profile, not from a claim baked into the
68
- token, so revoking it takes effect **within ~30 seconds** — the runtime caches a
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
@@ -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` 30.
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 application roles belong in
745
- `metadata`, not there.
744
+ `"authenticated"` for a signed-in user, so `user.role === "admin"` is always
745
+ false and reads like a working check.
746
746
 
747
- `emailVerified` is likewise read from the verified profile rather than a JWT
748
- claim: a claim is only true as of when the token was minted, so a user who
749
- verifies mid-session would keep reporting `false` until their token expired.
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
- ## Roles
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
- `auth: { role: "admin" }` gates on the caller's `metadata.role`:
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("/admin", { auth: { role: "admin" } })
759
- export default class AdminController {
760
- @Get("/stats")
761
- stats(): Promise<Stats> { … } // only metadata.role === "admin" reaches here
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`. Signed in with a different (or missing) role `403`. A
766
- role gate implies authentication, so the caller is resolved even on a route with
767
- no `@User()` parameter.
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
- The role is read from the verified profile, not from a claim baked into the
770
- token, so revoking it takes effect **within ~30 seconds** — the runtime caches a
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "30.0.1",
3
+ "version": "33.0.0",
4
4
  "description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
 
@@ -9,7 +9,7 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@palbase/backend": "^30.0.0",
12
+ "@palbase/backend": "^33.0.0",
13
13
  "reflect-metadata": "^0.2.2"
14
14
  },
15
15
  "engines": {