@palbase/backend 24.2.0 → 25.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 +101 -60
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +17 -13
- package/dist/bin/palbase-backend.js.map +1 -1
- package/dist/{chunk-EIXCY4SS.js → chunk-43A3KGWL.js} +80 -49
- package/dist/chunk-43A3KGWL.js.map +1 -0
- package/dist/{chunk-ERDL5VAE.js → chunk-5CMLOAEF.js} +2 -2
- package/dist/chunk-OEQBHE2Z.js +825 -0
- package/dist/chunk-OEQBHE2Z.js.map +1 -0
- package/dist/{chunk-7Z6MGMXQ.js → chunk-XJ2RSHEU.js} +11 -5
- package/dist/chunk-XJ2RSHEU.js.map +1 -0
- package/dist/{chunk-UWSYTUGM.js → chunk-ZQRWW37O.js} +44 -1
- package/dist/chunk-ZQRWW37O.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +29 -13
- package/dist/db/env.d.ts +29 -13
- package/dist/db/index.cjs +233 -110
- 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 +11 -1
- package/dist/engine/index.cjs +87 -50
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +2 -2
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +3 -3
- package/dist/{index-C0PMn5jl.d.ts → index-BF1f0DfA.d.ts} +5 -2
- package/dist/{index-DAwHMppB.d.cts → index-CoaDN9dL.d.cts} +5 -2
- package/dist/{index-ByBMibIJ.d.ts → index-Ct1iiB4N.d.ts} +232 -60
- package/dist/{index-D4rts8T7.d.cts → index-CwaWRhyc.d.cts} +232 -60
- package/dist/index.cjs +572 -296
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -20
- package/dist/index.d.ts +124 -20
- package/dist/index.js +164 -216
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +100 -36
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.js +59 -2
- package/dist/openapi/index.js.map +1 -1
- package/docs/README.md +64 -31
- package/docs/endpoints.md +25 -28
- package/docs/llms-full.txt +465 -148
- package/docs/schema.md +338 -86
- package/docs/services.md +39 -4
- package/package.json +1 -1
- package/template/AGENTS.md +119 -314
- package/template/CLAUDE.md +13 -0
- package/template/controllers/notes.controller.ts +6 -13
- package/template/db/public.ts +38 -0
- package/template/models/notes/create.ts +38 -0
- package/template/package.json +6 -3
- package/template/services/note.service.test.ts +45 -0
- package/template/services/note.service.ts +2 -2
- package/dist/chunk-7Z6MGMXQ.js.map +0 -1
- package/dist/chunk-EIXCY4SS.js.map +0 -1
- package/dist/chunk-LCL7TUAI.js +0 -534
- package/dist/chunk-LCL7TUAI.js.map +0 -1
- package/dist/chunk-UWSYTUGM.js.map +0 -1
- package/template/db/schema.ts +0 -35
- /package/dist/{chunk-ERDL5VAE.js.map → chunk-5CMLOAEF.js.map} +0 -0
package/docs/schema.md
CHANGED
|
@@ -1,50 +1,108 @@
|
|
|
1
1
|
# Schema & typed database access
|
|
2
2
|
|
|
3
|
-
Declare your tables
|
|
3
|
+
Declare your tables under `db/`, **one file per schema**: `db/public.ts` is the
|
|
4
|
+
schema Palbase expects to find, `db/billing.ts` declares a second one. Each file
|
|
5
|
+
default-exports a `defineSchema("<name>", { tables })` call. That drives
|
|
4
6
|
[migrations](./migrations.md) (additive changes auto-apply on deploy; type
|
|
5
7
|
changes need an explicit migration) and makes `Database.tables.*` typed
|
|
6
8
|
everywhere — by default, with no import and no generic.
|
|
7
9
|
|
|
10
|
+
> Coming from a single `db/schema.ts` with tables declared inline? That layout is
|
|
11
|
+
> gone, and a push says so by name. The migration guide at
|
|
12
|
+
> `/docs/backend/schema-migration` walks the four changes with before/after code.
|
|
13
|
+
|
|
8
14
|
## Defining a schema
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
A table is declared with `defineTable("<name>", { … })` and is a **value that
|
|
17
|
+
knows its own name**. `defineSchema` takes the schema's name and an ARRAY of
|
|
18
|
+
those values — never a dictionary, because a name in a dictionary key is a second
|
|
19
|
+
place the name is written, and a table built under a key does not yet know what
|
|
20
|
+
to call itself when a sibling references it.
|
|
21
|
+
|
|
22
|
+
Each table's only required field is `columns`; `rls` and `policies` enable
|
|
12
23
|
[Row-Level Security](#row-level-security-rls), and `indexes` declares plain
|
|
13
24
|
btree [indexes](#indexes).
|
|
14
25
|
|
|
15
26
|
```ts
|
|
16
27
|
import {
|
|
17
|
-
defineSchema,
|
|
18
|
-
uuid, text, integer, boolean, timestamp, jsonb, enumType,
|
|
28
|
+
defineSchema, defineTable,
|
|
29
|
+
uuid, text, integer, boolean, timestamp, jsonb, enumType, ownedByUser,
|
|
19
30
|
} from "@palbase/backend";
|
|
20
31
|
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
32
|
+
export const rooms = defineTable("rooms", {
|
|
33
|
+
columns: {
|
|
34
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
35
|
+
name: text().notNull(),
|
|
36
|
+
capacity: integer().nullable(),
|
|
37
|
+
is_active: boolean().default(true),
|
|
38
|
+
created_at: timestamp().defaultNow(),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const sessions = defineTable("sessions", {
|
|
43
|
+
columns: {
|
|
44
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
45
|
+
room_id: uuid().references(() => rooms.id).onDelete("cascade"),
|
|
46
|
+
user_id: ownedByUser(),
|
|
47
|
+
data: jsonb().nullable(),
|
|
48
|
+
started_at: timestamp().defaultNow(),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const orders = defineTable("orders", {
|
|
53
|
+
columns: {
|
|
54
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
55
|
+
status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
|
|
56
|
+
amount: integer().notNull(),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export default defineSchema("public", {
|
|
61
|
+
tables: [rooms, sessions, orders],
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A `defineTable` value **is its columns** — `rooms.id` is the `id` builder, which
|
|
66
|
+
is what makes `references(() => rooms.id)` an ordinary expression. The table's own
|
|
67
|
+
metadata hangs off a symbol rather than a plain field, so a column may be called
|
|
68
|
+
`name`, `columns`, `rls` or `indexes` without shadowing the table's identity.
|
|
69
|
+
|
|
70
|
+
### One file per schema, and `exposed`
|
|
71
|
+
|
|
72
|
+
The schema name comes from the declaration; the file name must agree with it. A
|
|
73
|
+
`db/billing.ts` declaring `defineSchema("accounts", …)` is refused at push with
|
|
74
|
+
both names in the error.
|
|
75
|
+
|
|
76
|
+
`exposed` decides whether a schema is served over `/v1/db`, and the default is
|
|
77
|
+
NOT uniform: **`public` defaults to `true`**, every other schema to `false`. The
|
|
78
|
+
asymmetry is deliberate — `public` is reachable today and stays reachable, because
|
|
79
|
+
a uniform default would silently 404 every existing project's `/v1/db` traffic on
|
|
80
|
+
upgrade, while a schema you add later is not on the internet just because you
|
|
81
|
+
declared it.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
// db/public.ts — reachable, and you write nothing to get that
|
|
85
|
+
export default defineSchema("public", { tables: [rooms, sessions] });
|
|
86
|
+
|
|
87
|
+
// db/billing.ts — declared and typed, but not reachable from a client
|
|
88
|
+
export default defineSchema("billing", { tables: [invoices] });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Write the field only to go against the grain — `exposed: false` closes `public`,
|
|
92
|
+
`exposed: true` opens a second schema. Server-side `Database.*` ignores it either
|
|
93
|
+
way: your controllers, jobs and hooks read every schema you declared.
|
|
94
|
+
|
|
95
|
+
A foreign key may cross schemas: import the table binding and point at it.
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// db/billing.ts
|
|
99
|
+
import { lists } from "./public";
|
|
100
|
+
|
|
101
|
+
export const invoices = defineTable("invoices", {
|
|
102
|
+
columns: {
|
|
103
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
104
|
+
list_id: uuid().references(() => lists.id),
|
|
105
|
+
amount: numeric(),
|
|
48
106
|
},
|
|
49
107
|
});
|
|
50
108
|
```
|
|
@@ -63,27 +121,202 @@ export default defineSchema({
|
|
|
63
121
|
|
|
64
122
|
Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
|
|
65
123
|
`.default(value)`, `.defaultRandom()` (uuid → `gen_random_uuid()`),
|
|
66
|
-
`.defaultNow()` (timestamp → `now()`), `.references(table,
|
|
67
|
-
`.
|
|
124
|
+
`.defaultNow()` (timestamp → `now()`), `.references(() => table.column, opts?)`,
|
|
125
|
+
`.selfReferences("column", opts?)`,
|
|
126
|
+
`.onDelete("cascade" | "set null" | "restrict" | "no action")`, `.ignored()`.
|
|
127
|
+
|
|
128
|
+
## Foreign keys
|
|
129
|
+
|
|
130
|
+
The target of `references` is a **thunk**, not a direct reference. The callback is
|
|
131
|
+
invoked inside `defineSchema`, where every binding exists and every table already
|
|
132
|
+
knows its name — which is what makes a cycle expressible at all: in `x → y, y → x`
|
|
133
|
+
the second table does not exist yet when the first is built.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
list_id: uuid().references(() => lists.id),
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Pointing at this same table** takes no thunk and no annotation — the target
|
|
140
|
+
table is the one being declared, so there is nothing to defer:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
parent_id: uuid().nullable().selfReferences("id"),
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Naming a column the table does not have is refused where you declare it.
|
|
147
|
+
|
|
148
|
+
**Two tables that point at each other** need an explicit return type on ONE side,
|
|
149
|
+
and one is enough — measured. Without it TypeScript chases its own tail (TS7022):
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { type AnyColumn } from "@palbase/backend";
|
|
153
|
+
|
|
154
|
+
export const users = defineTable("users", {
|
|
155
|
+
columns: {
|
|
156
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
157
|
+
primary_org_id: uuid().nullable().references((): AnyColumn => orgs.id),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
export const orgs = defineTable("orgs", {
|
|
162
|
+
columns: {
|
|
163
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
164
|
+
owner_id: uuid().nullable().references(() => users.id),
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Two foreign keys to the same table** would derive the same relation name from
|
|
170
|
+
their columns, so name one of them:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
billing_address_id: uuid().references(() => addresses.id, { as: "billing_address" }),
|
|
174
|
+
shipping_address_id: uuid().references(() => addresses.id, { as: "shipping_address" }),
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Rows that belong to a user
|
|
178
|
+
|
|
179
|
+
There is no `public.users` table: auth users live in the `auth` schema of the same
|
|
180
|
+
Postgres. Three column factories declare a real foreign key onto it. They are
|
|
181
|
+
factories rather than chain methods because the column type, its nullability and
|
|
182
|
+
its `ON DELETE` are part of what each one MEANS — so they cannot be written wrong.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
user_id: ownedByUser(), // text, NOT NULL, ON DELETE CASCADE
|
|
186
|
+
edited_by: userRef({ onDelete: "set null" }).nullable(),
|
|
187
|
+
device_id: installationRef({ onDelete: "cascade" }),
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
| | `ownedByUser()` | `userRef({ onDelete })` | `installationRef({ onDelete })` |
|
|
191
|
+
|---|---|---|---|
|
|
192
|
+
| References | `auth.users(id)` | `auth.users(id)` | `auth.installations(id)` |
|
|
193
|
+
| Means | the row **belongs to** that user | the row **points at** a user | the row is scoped to an app install |
|
|
194
|
+
| `ON DELETE` | `cascade`, no argument | required: `cascade` / `set null` | required: `cascade` / `set null` |
|
|
195
|
+
| Account erasure follows it | yes | no | no |
|
|
196
|
+
| Per table | **at most one** | unlimited | unlimited |
|
|
197
|
+
|
|
198
|
+
`ownedByUser()` takes no `onDelete` because there is only one correct answer:
|
|
199
|
+
ownership is what account erasure walks, so a row owned by an account has to go
|
|
200
|
+
when the account does. `"set null"` on a `userRef` needs a `.nullable()` column.
|
|
201
|
+
|
|
202
|
+
**One `ownedByUser()` per table, enforced.** Two on one table are refused at push
|
|
203
|
+
with both column names in the error. The rule exists because the alternative was
|
|
204
|
+
worse than a refusal: when several columns could reference `auth.users`, the owner
|
|
205
|
+
was whichever came FIRST IN DECLARATION ORDER — so moving a `created_by` above a
|
|
206
|
+
`user_id` silently changed which rows an account deletion took with it.
|
|
207
|
+
|
|
208
|
+
An installation reference is **not** ownership. A user-owned row still needs its
|
|
209
|
+
own `ownedByUser()`, or erasing the account leaves it behind.
|
|
210
|
+
|
|
211
|
+
## Removing a column
|
|
212
|
+
|
|
213
|
+
A deploy applies the schema while the PREVIOUS release is still answering requests, so
|
|
214
|
+
dropping a column that release still names breaks it the instant the change lands. The
|
|
215
|
+
gate therefore refuses the drop — and until now it refused every drop, with no way
|
|
216
|
+
through: deleting the code that used the column changed nothing, because the gate never
|
|
217
|
+
read your code.
|
|
218
|
+
|
|
219
|
+
`.ignored()` is how you tell it. The mark is a PROMISE about the release that carries
|
|
220
|
+
it: **this release neither reads nor writes this column, and never names it in a filter,
|
|
221
|
+
a sort or a SET.**
|
|
222
|
+
|
|
223
|
+
Removing a column is therefore two deploys:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// 1. Mark it. The column stays; nothing breaks; no DDL is produced.
|
|
227
|
+
const notes = defineTable("notes", {
|
|
228
|
+
columns: { id: uuid().primaryKey(), old_body: text().ignored() },
|
|
229
|
+
});
|
|
230
|
+
export default defineSchema("public", { tables: [notes] });
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
// 2. Ship that. Then delete the column and ship again — this time the gate passes,
|
|
235
|
+
// because the release now serving promised it does not name the column.
|
|
236
|
+
const notes = defineTable("notes", { columns: { id: uuid().primaryKey() } });
|
|
237
|
+
export default defineSchema("public", { tables: [notes] });
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`palbase db plan` tells you which step you are on before you push. Locally,
|
|
241
|
+
`palbase db apply` is not restricted — local is where you experiment, and there is no
|
|
242
|
+
traffic to protect.
|
|
243
|
+
|
|
244
|
+
### When you cannot wait two deploys
|
|
245
|
+
|
|
246
|
+
There is an escape, and it is deliberately loud:
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
palbase push --accept-breaking
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
It opens the gate for one push. Use it when the running release is ALREADY broken and
|
|
253
|
+
the fix is the very change the gate refuses — an incident, not an inconvenience. Outside
|
|
254
|
+
that, two deploys cost less than the one this can break.
|
|
255
|
+
|
|
256
|
+
It is not silent, and that is the whole design: the push prints the consents it is
|
|
257
|
+
sending, and the server records a `BREAK-GLASS` line naming the digest that was serving
|
|
258
|
+
and every object the gate had refused. So the decision has an author and a time, and
|
|
259
|
+
whoever asks later why a column disappeared finds the answer instead of a normal-looking
|
|
260
|
+
push.
|
|
261
|
+
|
|
262
|
+
Two things it will not do. It does not apply to a cloud push — `--accept-breaking` there
|
|
263
|
+
is refused by name rather than ignored, because the gate needs to know what is serving
|
|
264
|
+
and only a linked checkout can tell it. And it does not skip the data-loss consent:
|
|
265
|
+
`--approve` is a separate question about erasing rows, and answering one does not answer
|
|
266
|
+
the other.
|
|
267
|
+
|
|
268
|
+
The word is `ignored` and not `deprecated` on purpose: deprecation is defined, in
|
|
269
|
+
RFC 9745 and in the GraphQL spec alike, as changing NO behaviour. This changes what a
|
|
270
|
+
deploy will accept.
|
|
271
|
+
|
|
272
|
+
**What the mark cannot do.** No static mark can PROVE your code does not name the
|
|
273
|
+
column — that question is only answerable from traffic. `.ignored()` is your promise.
|
|
274
|
+
What the gate adds is that it reads the promise from the release that is ACTUALLY
|
|
275
|
+
SERVING, not from the file in front of you.
|
|
276
|
+
|
|
277
|
+
## Renaming an enum value
|
|
278
|
+
|
|
279
|
+
There is no `renamedFrom` for enum values, and the reason is measured rather than
|
|
280
|
+
stylistic: on PostgreSQL 16, `ALTER TYPE … RENAME VALUE` is an **atomic cutover**. While
|
|
281
|
+
the rename is uncommitted a writer using the OLD label succeeds and one using the NEW
|
|
282
|
+
label fails; at commit that flips. The two names are never both valid, so there is no
|
|
283
|
+
window in which a running release can be migrated across — the instant the rename
|
|
284
|
+
commits, that release's writes fail with `invalid input value for enum`.
|
|
285
|
+
|
|
286
|
+
Add a value instead, and let the old one die:
|
|
287
|
+
|
|
288
|
+
```sql
|
|
289
|
+
-- 1. Add the new label. This IS safe while the previous release serves.
|
|
290
|
+
-- (Declare it in db/public.ts; the rail emits ALTER TYPE … ADD VALUE.)
|
|
291
|
+
-- 2. Move the data:
|
|
292
|
+
UPDATE posts SET status = 'review' WHERE status = 'onay';
|
|
293
|
+
-- 3. Stop naming the old value in the next release.
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Postgres cannot delete an enum label, so `'onay'` stays — unused and harmless.
|
|
297
|
+
|
|
298
|
+
**One trap, measured.** A rename is followed automatically by everything Postgres stores
|
|
299
|
+
as a parse tree: column defaults, enum-typed CHECKs, views, materialized-view
|
|
300
|
+
definitions, partial-index predicates, RLS policies, partition bounds, generated columns.
|
|
301
|
+
It is NOT followed by anything stored as TEXT. A constraint written
|
|
302
|
+
`CHECK (status::text = 'onay')` keeps its text after a rename and now checks a label that
|
|
303
|
+
no longer exists — a dead constraint, silently. Measured on PG16: an INSERT the check
|
|
304
|
+
used to reject passed after the rename.
|
|
68
305
|
|
|
69
306
|
## Indexes
|
|
70
307
|
|
|
71
308
|
`indexes` declares plain (non-unique) btree indexes over an ordered column list:
|
|
72
309
|
|
|
73
310
|
```ts
|
|
74
|
-
export
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
room_id: uuid().notNull().references("rooms", "id"),
|
|
80
|
-
started_at: timestamp().defaultNow(),
|
|
81
|
-
},
|
|
82
|
-
indexes: [
|
|
83
|
-
{ name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
|
|
84
|
-
],
|
|
85
|
-
},
|
|
311
|
+
export const sessions = defineTable("sessions", {
|
|
312
|
+
columns: {
|
|
313
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
314
|
+
room_id: uuid().references(() => rooms.id),
|
|
315
|
+
started_at: timestamp().defaultNow(),
|
|
86
316
|
},
|
|
317
|
+
indexes: [
|
|
318
|
+
{ name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
|
|
319
|
+
],
|
|
87
320
|
});
|
|
88
321
|
```
|
|
89
322
|
|
|
@@ -95,7 +328,7 @@ name and every column are identifier-validated before any SQL is built.
|
|
|
95
328
|
knowing before you name an index:
|
|
96
329
|
|
|
97
330
|
- An index that exists in the database but is not in `indexes` is never dropped.
|
|
98
|
-
|
|
331
|
+
Your schema file does not own the database's indexes; it only adds the ones it
|
|
99
332
|
names.
|
|
100
333
|
- Removing an entry from `indexes` therefore does **not** drop the index. Drop it
|
|
101
334
|
in an explicit [migration](./migrations.md).
|
|
@@ -121,10 +354,9 @@ nothing. Rather than ship a half-working partial-index path, the typed field
|
|
|
121
354
|
stays columns-only and `raw()` carries the rest:
|
|
122
355
|
|
|
123
356
|
```ts
|
|
124
|
-
import {
|
|
357
|
+
import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
|
|
125
358
|
|
|
126
|
-
|
|
127
|
-
orders: {
|
|
359
|
+
export const orders = defineTable("orders", {
|
|
128
360
|
columns: {
|
|
129
361
|
id: uuid().primaryKey().defaultRandom(),
|
|
130
362
|
status: text().notNull(),
|
|
@@ -137,7 +369,7 @@ orders: {
|
|
|
137
369
|
{ down: "DROP INDEX IF EXISTS orders_pending_idx" },
|
|
138
370
|
),
|
|
139
371
|
],
|
|
140
|
-
}
|
|
372
|
+
});
|
|
141
373
|
```
|
|
142
374
|
|
|
143
375
|
`raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
|
|
@@ -147,12 +379,34 @@ and an index is not one.
|
|
|
147
379
|
|
|
148
380
|
## Typed DB access — by default
|
|
149
381
|
|
|
150
|
-
You do **not** wire anything per endpoint. Saving `db
|
|
382
|
+
You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
|
|
151
383
|
`palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
|
|
152
384
|
of the schema, no generic, no cast:
|
|
153
385
|
|
|
154
386
|
```ts
|
|
155
|
-
|
|
387
|
+
// services/room.service.ts — the layer that touches the database.
|
|
388
|
+
import { Database } from "@palbase/backend";
|
|
389
|
+
|
|
390
|
+
type RoomsTable = typeof Database.tables.rooms; // typed from db/schema.ts
|
|
391
|
+
|
|
392
|
+
export class RoomService {
|
|
393
|
+
private readonly rooms: RoomsTable;
|
|
394
|
+
constructor(rooms: RoomsTable) { this.rooms = rooms; }
|
|
395
|
+
|
|
396
|
+
async create(name: string) {
|
|
397
|
+
const room = await this.rooms.insert({ name });
|
|
398
|
+
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
399
|
+
// room.nope ← compile error
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export const roomService = new RoomService(Database.tables.rooms);
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
// controllers/rooms.controller.ts — HTTP only; no `Database` import here.
|
|
408
|
+
import { Controller, Post, Body, z } from "@palbase/backend";
|
|
409
|
+
import { roomService } from "../services/room.service.js";
|
|
156
410
|
|
|
157
411
|
const CreateRoomBody = z.object({ name: z.string() });
|
|
158
412
|
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
@@ -162,10 +416,8 @@ export default class RoomsController {
|
|
|
162
416
|
@Post("")
|
|
163
417
|
// The return type names the 200 schema — `z.infer<typeof RoomOut>` works
|
|
164
418
|
// inline, no separate `export type` needed.
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
168
|
-
// room.nope ← compile error
|
|
419
|
+
create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
|
|
420
|
+
return roomService.create(body.name);
|
|
169
421
|
}
|
|
170
422
|
}
|
|
171
423
|
```
|
|
@@ -263,36 +515,37 @@ rows.
|
|
|
263
515
|
### Owner-scoped `todos` example
|
|
264
516
|
|
|
265
517
|
```ts
|
|
266
|
-
import {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
// `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
279
|
-
policies: [
|
|
280
|
-
// Read: a user sees only their own todos.
|
|
281
|
-
policy("pb_todos_owner_select")
|
|
282
|
-
.for("select")
|
|
283
|
-
.to("authenticated")
|
|
284
|
-
.using("owner = (select auth.uid())"),
|
|
285
|
-
|
|
286
|
-
// Write: a user can insert/update/delete only rows they own.
|
|
287
|
-
policy("pb_todos_owner_write")
|
|
288
|
-
.for("all")
|
|
289
|
-
.to("authenticated")
|
|
290
|
-
.using("owner = (select auth.uid())")
|
|
291
|
-
.withCheck("owner = (select auth.uid())"),
|
|
292
|
-
],
|
|
293
|
-
},
|
|
518
|
+
import {
|
|
519
|
+
defineSchema, defineTable, policy, ownedByUser,
|
|
520
|
+
uuid, text, boolean, timestamp,
|
|
521
|
+
} from "@palbase/backend";
|
|
522
|
+
|
|
523
|
+
export const todos = defineTable("todos", {
|
|
524
|
+
columns: {
|
|
525
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
526
|
+
owner: ownedByUser(), // text FK onto auth.users(id), NOT NULL, CASCADE
|
|
527
|
+
title: text().notNull(),
|
|
528
|
+
done: boolean().default(false),
|
|
529
|
+
created_at: timestamp().defaultNow(),
|
|
294
530
|
},
|
|
531
|
+
// `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
532
|
+
policies: [
|
|
533
|
+
// Read: a user sees only their own todos.
|
|
534
|
+
policy("pb_todos_owner_select")
|
|
535
|
+
.for("select")
|
|
536
|
+
.to("authenticated")
|
|
537
|
+
.using("owner = (select auth.uid())"),
|
|
538
|
+
|
|
539
|
+
// Write: a user can insert/update/delete only rows they own.
|
|
540
|
+
policy("pb_todos_owner_write")
|
|
541
|
+
.for("all")
|
|
542
|
+
.to("authenticated")
|
|
543
|
+
.using("owner = (select auth.uid())")
|
|
544
|
+
.withCheck("owner = (select auth.uid())"),
|
|
545
|
+
],
|
|
295
546
|
});
|
|
547
|
+
|
|
548
|
+
export default defineSchema("public", { tables: [todos] });
|
|
296
549
|
```
|
|
297
550
|
|
|
298
551
|
With this in place, `await Database.tables.todos.findMany({})` returns only the
|
|
@@ -311,4 +564,3 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
|
|
|
311
564
|
> Changing a policy's body (its `USING`/`WITH CHECK` SQL) in place is not yet
|
|
312
565
|
> auto-applied — rename the policy (new `(table, name)`) or drop the old one in
|
|
313
566
|
> a hand-written migration. Policy DROP/rewrite churn is a documented TODO.
|
|
314
|
-
|
package/docs/services.md
CHANGED
|
@@ -273,16 +273,51 @@ stand-in and never needs a database.
|
|
|
273
273
|
|
|
274
274
|
```ts
|
|
275
275
|
// services/note.service.test.ts — `npm test`, no database
|
|
276
|
-
import assert from "node:assert/strict";
|
|
277
276
|
import { test } from "node:test";
|
|
277
|
+
import assert from "node:assert/strict";
|
|
278
|
+
|
|
278
279
|
import { NoteService } from "./note.service.ts";
|
|
279
280
|
|
|
280
|
-
|
|
281
|
+
// WHY THIS TEST NEEDS NO DATABASE
|
|
282
|
+
//
|
|
283
|
+
// `NoteService` is handed the table it works on rather than reaching for the
|
|
284
|
+
// singleton itself. That constructor is the seam: a stand-in goes in here, and
|
|
285
|
+
// the logic — which rows, whose, in what order — is exercised without a
|
|
286
|
+
// database. Test your own services the same way.
|
|
287
|
+
//
|
|
288
|
+
// When you want the whole database surface instead of one table, `fakeDatabase()`
|
|
289
|
+
// from `@palbase/backend/test` is the stand-in.
|
|
290
|
+
//
|
|
291
|
+
// Node's ESM resolver wants the extension on a relative import inside a test
|
|
292
|
+
// (`./note.service.ts`); this scaffold's `tsconfig.json` allows it.
|
|
293
|
+
|
|
294
|
+
test("list asks only for the caller's notes", async () => {
|
|
281
295
|
const seen: unknown[] = [];
|
|
282
|
-
const
|
|
283
|
-
|
|
296
|
+
const notes = {
|
|
297
|
+
findMany: async (where: unknown) => {
|
|
298
|
+
seen.push(where);
|
|
299
|
+
return [];
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
await new NoteService(notes as never).list("u_1");
|
|
304
|
+
|
|
284
305
|
assert.deepEqual(seen, [{ user_id: "u_1" }]);
|
|
285
306
|
});
|
|
307
|
+
|
|
308
|
+
test("create writes ownership from the argument, never from the body", async () => {
|
|
309
|
+
const written: unknown[] = [];
|
|
310
|
+
const notes = {
|
|
311
|
+
insert: async (row: unknown) => {
|
|
312
|
+
written.push(row);
|
|
313
|
+
return row;
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
await new NoteService(notes as never).create("u_1", "hello");
|
|
318
|
+
|
|
319
|
+
assert.deepEqual(written, [{ user_id: "u_1", body: "hello" }]);
|
|
320
|
+
});
|
|
286
321
|
```
|
|
287
322
|
|
|
288
323
|
Node's ESM resolver wants the extension on a relative import inside a test
|
package/package.json
CHANGED