@palbase/backend 24.3.0 → 25.0.1

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 (61) hide show
  1. package/dist/bin/palbase-backend.cjs +103 -60
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +17 -13
  4. package/dist/bin/palbase-backend.js.map +1 -1
  5. package/dist/{chunk-EIXCY4SS.js → chunk-34I4GB7D.js} +82 -49
  6. package/dist/chunk-34I4GB7D.js.map +1 -0
  7. package/dist/{chunk-UWSYTUGM.js → chunk-35PNTIRN.js} +48 -1
  8. package/dist/chunk-35PNTIRN.js.map +1 -0
  9. package/dist/chunk-HBOJLP2Z.js +840 -0
  10. package/dist/chunk-HBOJLP2Z.js.map +1 -0
  11. package/dist/{chunk-7Z6MGMXQ.js → chunk-XJ2RSHEU.js} +11 -5
  12. package/dist/chunk-XJ2RSHEU.js.map +1 -0
  13. package/dist/{chunk-ERDL5VAE.js → chunk-YOY5DFQS.js} +2 -2
  14. package/dist/db/env.cjs.map +1 -1
  15. package/dist/db/env.d.cts +31 -14
  16. package/dist/db/env.d.ts +31 -14
  17. package/dist/db/index.cjs +226 -111
  18. package/dist/db/index.cjs.map +1 -1
  19. package/dist/db/index.d.cts +1 -1
  20. package/dist/db/index.d.ts +1 -1
  21. package/dist/db/index.js +11 -1
  22. package/dist/engine/index.cjs +89 -50
  23. package/dist/engine/index.cjs.map +1 -1
  24. package/dist/engine/index.d.cts +2 -2
  25. package/dist/engine/index.d.ts +2 -2
  26. package/dist/engine/index.js +3 -3
  27. package/dist/{index-BTMYod_l.d.ts → index-B4CcpqLb.d.ts} +224 -75
  28. package/dist/{index-DEneI8Mn.d.ts → index-B8v6hVyU.d.ts} +5 -2
  29. package/dist/{index-BLAbr9ZH.d.cts → index-DmVyY6N7.d.cts} +224 -75
  30. package/dist/{index-C-ALG22n.d.cts → index-VsjBQ4Kw.d.cts} +5 -2
  31. package/dist/index.cjs +580 -301
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +125 -22
  34. package/dist/index.d.ts +125 -22
  35. package/dist/index.js +173 -217
  36. package/dist/index.js.map +1 -1
  37. package/dist/openapi/index.cjs +100 -36
  38. package/dist/openapi/index.cjs.map +1 -1
  39. package/dist/openapi/index.js +59 -2
  40. package/dist/openapi/index.js.map +1 -1
  41. package/docs/README.md +64 -31
  42. package/docs/endpoints.md +25 -28
  43. package/docs/llms-full.txt +430 -153
  44. package/docs/schema.md +303 -91
  45. package/docs/services.md +39 -4
  46. package/package.json +1 -1
  47. package/template/AGENTS.md +119 -314
  48. package/template/CLAUDE.md +13 -0
  49. package/template/controllers/notes.controller.ts +6 -13
  50. package/template/db/public.ts +38 -0
  51. package/template/models/notes/create.ts +38 -0
  52. package/template/package.json +6 -3
  53. package/template/services/note.service.test.ts +45 -0
  54. package/template/services/note.service.ts +2 -2
  55. package/dist/chunk-7Z6MGMXQ.js.map +0 -1
  56. package/dist/chunk-D5CQES25.js +0 -556
  57. package/dist/chunk-D5CQES25.js.map +0 -1
  58. package/dist/chunk-EIXCY4SS.js.map +0 -1
  59. package/dist/chunk-UWSYTUGM.js.map +0 -1
  60. package/template/db/schema.ts +0 -35
  61. /package/dist/{chunk-ERDL5VAE.js.map → chunk-YOY5DFQS.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 in `db/schema.ts` with `defineSchema`. This drives
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
- The table NAME comes from the object key under `tables`. Each table value is an
11
- object whose only required field is `columns`; `rls` and `policies` enable
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 default defineSchema({
22
- tables: {
23
- rooms: {
24
- columns: {
25
- id: uuid().primaryKey().defaultRandom(),
26
- name: text().notNull(),
27
- capacity: integer().nullable(),
28
- is_active: boolean().default(true),
29
- created_at: timestamp().defaultNow(),
30
- },
31
- },
32
- sessions: {
33
- columns: {
34
- id: uuid().primaryKey().defaultRandom(),
35
- room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
36
- user_id: uuid().notNull(),
37
- data: jsonb().nullable(),
38
- started_at: timestamp().defaultNow(),
39
- },
40
- },
41
- orders: {
42
- columns: {
43
- id: uuid().primaryKey().defaultRandom(),
44
- status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
45
- amount: integer().notNull(),
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,9 +121,124 @@ 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, column)`,
124
+ `.defaultNow()` (timestamp → `now()`),
125
+ `.references(() => table.column, { as?, reverseAs?, onDelete? })`,
126
+ `.selfReferences("column", opts?)`,
67
127
  `.onDelete("cascade" | "set null" | "restrict" | "no action")`, `.ignored()`.
68
128
 
129
+ ## Foreign keys
130
+
131
+ The target of `references` is a **thunk**, not a direct reference. The callback is
132
+ invoked inside `defineSchema`, where every binding exists and every table already
133
+ knows its name — which is what makes a cycle expressible at all: in `x → y, y → x`
134
+ the second table does not exist yet when the first is built.
135
+
136
+ ```ts
137
+ list_id: uuid().references(() => lists.id),
138
+ ```
139
+
140
+ **Pointing at this same table** takes no thunk and no annotation — the target
141
+ table is the one being declared, so there is nothing to defer:
142
+
143
+ ```ts
144
+ parent_id: uuid().nullable().selfReferences("id"),
145
+ ```
146
+
147
+ Naming a column the table does not have is refused where you declare it.
148
+
149
+ **Two tables that point at each other** need an explicit return type on ONE side,
150
+ and one is enough — measured. Without it TypeScript chases its own tail (TS7022):
151
+
152
+ ```ts
153
+ import { type AnyColumn } from "@palbase/backend";
154
+
155
+ export const users = defineTable("users", {
156
+ columns: {
157
+ id: uuid().primaryKey().defaultRandom(),
158
+ primary_org_id: uuid().nullable().references((): AnyColumn => orgs.id),
159
+ },
160
+ });
161
+
162
+ export const orgs = defineTable("orgs", {
163
+ columns: {
164
+ id: uuid().primaryKey().defaultRandom(),
165
+ owner_id: uuid().nullable().references(() => users.id),
166
+ },
167
+ });
168
+ ```
169
+
170
+ ### Relation names
171
+
172
+ Every foreign key produces **two** named relations, and the two names are derived
173
+ separately:
174
+
175
+ | Direction | Where it appears | Default name | Option that changes it |
176
+ |---|---|---|---|
177
+ | forward (child → parent) | on the child | the column minus `_id` — `list_id` → `list` | `as` |
178
+ | reverse (parent → children) | on the parent | the **child table's** name — `lists.todos` | `reverseAs` |
179
+
180
+ So `todos.list_id → lists` gives `todos.list` and `lists.todos`, and neither
181
+ needs declaring. `as` renames the forward side only: `author_id` declared
182
+ `{ as: "author" }` gives `posts.author` and still gives `users.posts`. That is
183
+ why the two options exist separately — `posts` and `comments` can both call their
184
+ forward relation `author` without colliding, because what lands on `users` is
185
+ `posts` and `comments`.
186
+
187
+ **Two foreign keys from one table to the same parent** collide on the reverse
188
+ side: both reverse relations want the child table's name. Name them:
189
+
190
+ ```ts
191
+ billing_address_id: uuid().references(() => addresses.id, { reverseAs: "billed_orders" }),
192
+ shipping_address_id: uuid().references(() => addresses.id, { reverseAs: "shipped_orders" }),
193
+ ```
194
+
195
+ The forward names here (`billing_address`, `shipping_address`) are already
196
+ distinct, so no `as` is needed. Add one when two columns WOULD derive the same
197
+ forward name.
198
+
199
+ Any two relations resolving to one name on one table are refused at push, with
200
+ both relations named and the option that separates them.
201
+
202
+ ## Rows that belong to a user
203
+
204
+ There is no `public.users` table: auth users live in the `auth` schema of the same
205
+ Postgres. Three column factories declare a real foreign key onto it. They are
206
+ factories rather than chain methods because the column type, its nullability and
207
+ its `ON DELETE` are part of what each one MEANS — so they cannot be written wrong.
208
+
209
+ ```ts
210
+ user_id: ownedByUser(), // text, NOT NULL, ON DELETE CASCADE
211
+ edited_by: userRef({ onDelete: "set null" }).nullable(),
212
+ device_id: installationRef({ onDelete: "cascade" }),
213
+ ```
214
+
215
+ | | `ownedByUser()` | `userRef({ onDelete })` | `installationRef({ onDelete })` |
216
+ |---|---|---|---|
217
+ | References | `auth.users(id)` | `auth.users(id)` | `auth.installations(id)` |
218
+ | Means | the row **belongs to** that user | the row **points at** a user | the row is scoped to an app install |
219
+ | `ON DELETE` | `cascade`, no argument | required: `cascade` / `set null` | required: `cascade` / `set null` |
220
+ | Account erasure follows it | yes | no | no |
221
+ | Per table | **at most one** | unlimited | unlimited |
222
+
223
+ Several `userRef` columns on one table are fine — they are ordinary foreign keys
224
+ and each takes its own relation name from its column (`created_by`, `edited_by`).
225
+ Only two of them resolving to the SAME name is refused, and `userRef({ onDelete,
226
+ as })` is how you separate them. `auth.users` is not a table of your schema, so
227
+ none of these produce a reverse relation to collide over.
228
+
229
+ `ownedByUser()` takes no `onDelete` because there is only one correct answer:
230
+ ownership is what account erasure walks, so a row owned by an account has to go
231
+ when the account does. `"set null"` on a `userRef` needs a `.nullable()` column.
232
+
233
+ **One `ownedByUser()` per table, enforced.** Two on one table are refused at push
234
+ with both column names in the error. The rule exists because the alternative was
235
+ worse than a refusal: when several columns could reference `auth.users`, the owner
236
+ was whichever came FIRST IN DECLARATION ORDER — so moving a `created_by` above a
237
+ `user_id` silently changed which rows an account deletion took with it.
238
+
239
+ An installation reference is **not** ownership. A user-owned row still needs its
240
+ own `ownedByUser()`, or erasing the account leaves it behind.
241
+
69
242
  ## Removing a column
70
243
 
71
244
  A deploy applies the schema while the PREVIOUS release is still answering requests, so
@@ -82,23 +255,47 @@ Removing a column is therefore two deploys:
82
255
 
83
256
  ```ts
84
257
  // 1. Mark it. The column stays; nothing breaks; no DDL is produced.
85
- export const schema = defineSchema({
86
- tables: { notes: { columns: { id: uuid().primaryKey(), old_body: text().ignored() } } },
258
+ const notes = defineTable("notes", {
259
+ columns: { id: uuid().primaryKey(), old_body: text().ignored() },
87
260
  });
261
+ export default defineSchema("public", { tables: [notes] });
88
262
  ```
89
263
 
90
264
  ```ts
91
265
  // 2. Ship that. Then delete the column and ship again — this time the gate passes,
92
266
  // because the release now serving promised it does not name the column.
93
- export const schema = defineSchema({
94
- tables: { notes: { columns: { id: uuid().primaryKey() } } } },
95
- });
267
+ const notes = defineTable("notes", { columns: { id: uuid().primaryKey() } });
268
+ export default defineSchema("public", { tables: [notes] });
96
269
  ```
97
270
 
98
271
  `palbase db plan` tells you which step you are on before you push. Locally,
99
272
  `palbase db apply` is not restricted — local is where you experiment, and there is no
100
273
  traffic to protect.
101
274
 
275
+ ### When you cannot wait two deploys
276
+
277
+ There is an escape, and it is deliberately loud:
278
+
279
+ ```
280
+ palbase push --accept-breaking
281
+ ```
282
+
283
+ It opens the gate for one push. Use it when the running release is ALREADY broken and
284
+ the fix is the very change the gate refuses — an incident, not an inconvenience. Outside
285
+ that, two deploys cost less than the one this can break.
286
+
287
+ It is not silent, and that is the whole design: the push prints the consents it is
288
+ sending, and the server records a `BREAK-GLASS` line naming the digest that was serving
289
+ and every object the gate had refused. So the decision has an author and a time, and
290
+ whoever asks later why a column disappeared finds the answer instead of a normal-looking
291
+ push.
292
+
293
+ Two things it will not do. It does not apply to a cloud push — `--accept-breaking` there
294
+ is refused by name rather than ignored, because the gate needs to know what is serving
295
+ and only a linked checkout can tell it. And it does not skip the data-loss consent:
296
+ `--approve` is a separate question about erasing rows, and answering one does not answer
297
+ the other.
298
+
102
299
  The word is `ignored` and not `deprecated` on purpose: deprecation is defined, in
103
300
  RFC 9745 and in the GraphQL spec alike, as changing NO behaviour. This changes what a
104
301
  deploy will accept.
@@ -121,7 +318,7 @@ Add a value instead, and let the old one die:
121
318
 
122
319
  ```sql
123
320
  -- 1. Add the new label. This IS safe while the previous release serves.
124
- -- (Declare it in db/schema.ts; the rail emits ALTER TYPE … ADD VALUE.)
321
+ -- (Declare it in db/public.ts; the rail emits ALTER TYPE … ADD VALUE.)
125
322
  -- 2. Move the data:
126
323
  UPDATE posts SET status = 'review' WHERE status = 'onay';
127
324
  -- 3. Stop naming the old value in the next release.
@@ -142,19 +339,15 @@ used to reject passed after the rename.
142
339
  `indexes` declares plain (non-unique) btree indexes over an ordered column list:
143
340
 
144
341
  ```ts
145
- export default defineSchema({
146
- tables: {
147
- sessions: {
148
- columns: {
149
- id: uuid().primaryKey().defaultRandom(),
150
- room_id: uuid().notNull().references("rooms", "id"),
151
- started_at: timestamp().defaultNow(),
152
- },
153
- indexes: [
154
- { name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
155
- ],
156
- },
342
+ export const sessions = defineTable("sessions", {
343
+ columns: {
344
+ id: uuid().primaryKey().defaultRandom(),
345
+ room_id: uuid().references(() => rooms.id),
346
+ started_at: timestamp().defaultNow(),
157
347
  },
348
+ indexes: [
349
+ { name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
350
+ ],
158
351
  });
159
352
  ```
160
353
 
@@ -166,7 +359,7 @@ name and every column are identifier-validated before any SQL is built.
166
359
  knowing before you name an index:
167
360
 
168
361
  - An index that exists in the database but is not in `indexes` is never dropped.
169
- `db/schema.ts` does not own the database's indexes; it only adds the ones it
362
+ Your schema file does not own the database's indexes; it only adds the ones it
170
363
  names.
171
364
  - Removing an entry from `indexes` therefore does **not** drop the index. Drop it
172
365
  in an explicit [migration](./migrations.md).
@@ -192,10 +385,9 @@ nothing. Rather than ship a half-working partial-index path, the typed field
192
385
  stays columns-only and `raw()` carries the rest:
193
386
 
194
387
  ```ts
195
- import { defineSchema, raw, uuid, text, timestamp } from "@palbase/backend";
388
+ import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
196
389
 
197
- //
198
- orders: {
390
+ export const orders = defineTable("orders", {
199
391
  columns: {
200
392
  id: uuid().primaryKey().defaultRandom(),
201
393
  status: text().notNull(),
@@ -208,7 +400,7 @@ orders: {
208
400
  { down: "DROP INDEX IF EXISTS orders_pending_idx" },
209
401
  ),
210
402
  ],
211
- },
403
+ });
212
404
  ```
213
405
 
214
406
  `raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
@@ -218,12 +410,34 @@ and an index is not one.
218
410
 
219
411
  ## Typed DB access — by default
220
412
 
221
- You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
413
+ You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
222
414
  `palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
223
415
  of the schema, no generic, no cast:
224
416
 
225
417
  ```ts
226
- import { Controller, Post, Body, Database, z } from "@palbase/backend";
418
+ // services/room.service.ts the layer that touches the database.
419
+ import { Database } from "@palbase/backend";
420
+
421
+ type RoomsTable = typeof Database.tables.rooms; // typed from db/schema.ts
422
+
423
+ export class RoomService {
424
+ private readonly rooms: RoomsTable;
425
+ constructor(rooms: RoomsTable) { this.rooms = rooms; }
426
+
427
+ async create(name: string) {
428
+ const room = await this.rooms.insert({ name });
429
+ return { id: room.id, name: room.name }; // room.id: string ✓
430
+ // room.nope ← compile error
431
+ }
432
+ }
433
+
434
+ export const roomService = new RoomService(Database.tables.rooms);
435
+ ```
436
+
437
+ ```ts
438
+ // controllers/rooms.controller.ts — HTTP only; no `Database` import here.
439
+ import { Controller, Post, Body, z } from "@palbase/backend";
440
+ import { roomService } from "../services/room.service.js";
227
441
 
228
442
  const CreateRoomBody = z.object({ name: z.string() });
229
443
  const RoomOut = z.object({ id: z.string(), name: z.string() });
@@ -233,10 +447,8 @@ export default class RoomsController {
233
447
  @Post("")
234
448
  // The return type names the 200 schema — `z.infer<typeof RoomOut>` works
235
449
  // inline, no separate `export type` needed.
236
- async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
237
- const room = await Database.tables.rooms.insert({ name: body.name });
238
- return { id: room.id, name: room.name }; // room.id: string ✓
239
- // room.nope ← compile error
450
+ create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
451
+ return roomService.create(body.name);
240
452
  }
241
453
  }
242
454
  ```
@@ -334,36 +546,37 @@ rows.
334
546
  ### Owner-scoped `todos` example
335
547
 
336
548
  ```ts
337
- import { defineSchema, policy, uuid, text, boolean, timestamp } from "@palbase/backend";
338
-
339
- export default defineSchema({
340
- tables: {
341
- todos: {
342
- columns: {
343
- id: uuid().primaryKey().defaultRandom(),
344
- owner: text().notNull(), // palauth user id (TEXT)
345
- title: text().notNull(),
346
- done: boolean().default(false),
347
- created_at: timestamp().defaultNow(),
348
- },
349
- // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
350
- policies: [
351
- // Read: a user sees only their own todos.
352
- policy("pb_todos_owner_select")
353
- .for("select")
354
- .to("authenticated")
355
- .using("owner = (select auth.uid())"),
356
-
357
- // Write: a user can insert/update/delete only rows they own.
358
- policy("pb_todos_owner_write")
359
- .for("all")
360
- .to("authenticated")
361
- .using("owner = (select auth.uid())")
362
- .withCheck("owner = (select auth.uid())"),
363
- ],
364
- },
549
+ import {
550
+ defineSchema, defineTable, policy, ownedByUser,
551
+ uuid, text, boolean, timestamp,
552
+ } from "@palbase/backend";
553
+
554
+ export const todos = defineTable("todos", {
555
+ columns: {
556
+ id: uuid().primaryKey().defaultRandom(),
557
+ owner: ownedByUser(), // text FK onto auth.users(id), NOT NULL, CASCADE
558
+ title: text().notNull(),
559
+ done: boolean().default(false),
560
+ created_at: timestamp().defaultNow(),
365
561
  },
562
+ // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
563
+ policies: [
564
+ // Read: a user sees only their own todos.
565
+ policy("pb_todos_owner_select")
566
+ .for("select")
567
+ .to("authenticated")
568
+ .using("owner = (select auth.uid())"),
569
+
570
+ // Write: a user can insert/update/delete only rows they own.
571
+ policy("pb_todos_owner_write")
572
+ .for("all")
573
+ .to("authenticated")
574
+ .using("owner = (select auth.uid())")
575
+ .withCheck("owner = (select auth.uid())"),
576
+ ],
366
577
  });
578
+
579
+ export default defineSchema("public", { tables: [todos] });
367
580
  ```
368
581
 
369
582
  With this in place, `await Database.tables.todos.findMany({})` returns only the
@@ -382,4 +595,3 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
382
595
  > Changing a policy's body (its `USING`/`WITH CHECK` SQL) in place is not yet
383
596
  > auto-applied — rename the policy (new `(table, name)`) or drop the old one in
384
597
  > a hand-written migration. Policy DROP/rewrite churn is a documented TODO.
385
-
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
- test("list filters by owner", async () => {
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 fake = { findMany: async (q: unknown) => (seen.push(q), []) };
283
- await new NoteService(fake as never).list("u_1");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "24.3.0",
3
+ "version": "25.0.1",
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": {