@palbase/backend 24.3.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.
Files changed (61) hide show
  1. package/dist/bin/palbase-backend.cjs +101 -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-43A3KGWL.js} +80 -49
  6. package/dist/chunk-43A3KGWL.js.map +1 -0
  7. package/dist/{chunk-ERDL5VAE.js → chunk-5CMLOAEF.js} +2 -2
  8. package/dist/chunk-OEQBHE2Z.js +825 -0
  9. package/dist/chunk-OEQBHE2Z.js.map +1 -0
  10. package/dist/{chunk-7Z6MGMXQ.js → chunk-XJ2RSHEU.js} +11 -5
  11. package/dist/chunk-XJ2RSHEU.js.map +1 -0
  12. package/dist/{chunk-UWSYTUGM.js → chunk-ZQRWW37O.js} +44 -1
  13. package/dist/chunk-ZQRWW37O.js.map +1 -0
  14. package/dist/db/env.cjs.map +1 -1
  15. package/dist/db/env.d.cts +29 -13
  16. package/dist/db/env.d.ts +29 -13
  17. package/dist/db/index.cjs +212 -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 +87 -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-DEneI8Mn.d.ts → index-BF1f0DfA.d.ts} +5 -2
  28. package/dist/{index-C-ALG22n.d.cts → index-CoaDN9dL.d.cts} +5 -2
  29. package/dist/{index-BTMYod_l.d.ts → index-Ct1iiB4N.d.ts} +203 -61
  30. package/dist/{index-BLAbr9ZH.d.cts → index-CwaWRhyc.d.cts} +203 -61
  31. package/dist/index.cjs +550 -297
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +122 -20
  34. package/dist/index.d.ts +122 -20
  35. package/dist/index.js +164 -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 +399 -153
  44. package/docs/schema.md +272 -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-5CMLOAEF.js.map} +0 -0
@@ -31,17 +31,26 @@ services/<name>.service.ts # plain class + singleton — the real logic
31
31
  db/schema.ts # config-as-code Postgres schema (tables, columns, RLS) — auto-migrated on deploy
32
32
  ```
33
33
 
34
- The four folders above are the daily surface. These also exist (own docs, linked
35
- below): `resources/` (plain modules a controller imports a client, a
36
- formatter, anything shared), `seeds/` (seed data), `jobs/` (background [background.md](./background.md)),
37
- `webhooks/` + `hooks/` (events — [events.md](./events.md)), `middleware/`.
34
+ The four folders above are the daily surface. Three more are discovered by name,
35
+ one class per file, `export default` required: `jobs/` (background
36
+ [background.md](./background.md)), `webhooks/` and `hooks/` (events
37
+ [events.md](./events.md)).
38
+
39
+ There is no `resources/`, no `seeds/` and no working `middleware/`. `middleware/`
40
+ is discovered by nothing and the engine has no middleware pipeline: code written
41
+ against it deploys, never runs, and nothing says so. Put cross-cutting work in a
42
+ service the controllers call.
38
43
 
39
44
  ### The 7 rules (checklist)
40
45
 
41
- 1. **`export default <ControllerClass>` is REQUIRED.** Every `controllers/*.controller.ts`
42
- must default-export its `@Controller` class, or the deploy FAILS with a
43
- "not a @Controller / non-controller export" error. Either `export default class X {}`
44
- or a trailing `export default X;` both work.
46
+ 1. **A controller needs NO export at all.** `@Controller` records the class into a
47
+ `globalThis` registry as it decorates it, and the bundler imports the file for
48
+ that side effect alone the shipped scaffold's own `HealthController` and
49
+ `NotesController` are not exported. Exporting is harmless and reads well, so
50
+ these examples do it; it is not a requirement. (`export default` **is**
51
+ required for `jobs/`, `webhooks/`, `hooks/` and `db/public.ts`, one class per
52
+ file.) What IS fatal is a `@Controller` class that collected zero routes —
53
+ usually `experimentalDecorators` missing from `tsconfig.json`.
45
54
  2. **Methods that call a service are `async` and return `Promise<T>`.** Services
46
55
  `await Database`, so they return promises; a sync return type on an async body
47
56
  is a tsc error. Annotate `: Promise<TodoSchema>`, not `: TodoSchema`, whenever
@@ -70,7 +79,7 @@ formatter, anything shared), `seeds/` (seed data), `jobs/` (background — [back
70
79
 
71
80
  > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
72
81
  > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
73
- > and will not compile against `@palbase/backend` 4.
82
+ > and will not compile against `@palbase/backend` 25.
74
83
 
75
84
  ### Complete CRUD example (copy-pasteable, compiles)
76
85
 
@@ -97,25 +106,44 @@ export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
97
106
  import { Database, NotFound } from "@palbase/backend";
98
107
  import type { TodoSchema } from "../models/todos/shared.js";
99
108
 
109
+ /** The typed surface of ONE table. Naming it keeps the seam one table wide:
110
+ * a test fake implements five methods, not the whole `Database`. */
111
+ type TodosTable = typeof Database.tables.todos;
112
+
100
113
  export class TodoService {
114
+ private readonly todos: TodosTable;
115
+
116
+ // THE SEAM IS THE CONSTRUCTOR: the class is handed the table rather than
117
+ // reaching for the singleton, so a test constructs it with a stand-in and
118
+ // never needs a database. Assign in the BODY — a parameter property
119
+ // (`constructor(private todos: …)`) is refused by Node's type-stripping test
120
+ // runner, and refused for the whole FILE.
121
+ constructor(todos: TodosTable) {
122
+ this.todos = todos;
123
+ }
124
+
101
125
  list(userId: string): Promise<TodoSchema[]> {
102
- return Database.tables.todos.findMany({ user_id: userId });
126
+ return this.todos.findMany({ user_id: userId });
103
127
  }
104
128
  create(userId: string, title: string): Promise<TodoSchema> {
105
- return Database.tables.todos.insert({ user_id: userId, title });
129
+ return this.todos.insert({ user_id: userId, title });
106
130
  }
107
131
  async get(userId: string, id: string): Promise<TodoSchema> {
108
- const t = await Database.tables.todos.findById(id);
132
+ const t = await this.todos.findById(id);
109
133
  if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
110
134
  return t;
111
135
  }
112
136
  async remove(userId: string, id: string): Promise<void> {
113
- const t = await Database.tables.todos.findById(id);
114
- if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
115
- await Database.tables.todos.delete(id);
137
+ await this.get(userId, id);
138
+ await this.todos.delete(id);
116
139
  }
117
140
  }
118
- export const todoService = new TodoService();
141
+
142
+ /** The wired instance. Controllers import THIS, never the class. It is also the
143
+ * ONLY supported way to hold a dependency: a controller, job, hook or webhook is
144
+ * constructed with no arguments, and one that declares a constructor parameter
145
+ * is refused at build with the class named. */
146
+ export const todoService = new TodoService(Database.tables.todos);
119
147
  ```
120
148
 
121
149
  ```ts
@@ -127,31 +155,33 @@ import { TodoSchema } from "../models/todos/shared.js";
127
155
  import { CreateTodoBody } from "../models/todos/create.js";
128
156
 
129
157
  @Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
130
- export class TodosController {
131
- private todos = todoService;
158
+ export default class TodosController {
159
+ // The service arrives as an IMPORTED SINGLETON, not a constructor parameter:
160
+ // the runtime constructs this class with no arguments, and one that declares a
161
+ // parameter is refused at build with the class named.
132
162
 
133
163
  @Get("") // GET /todos → operationId todos.list
134
164
  async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
135
- return this.todos.list(user.id);
165
+ return todoService.list(user.id);
136
166
  }
137
167
 
138
168
  @Post("") // POST /todos → todos.create
139
169
  async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
140
- return this.todos.create(user.id, body.title);
170
+ return todoService.create(user.id, body.title);
141
171
  }
142
172
 
143
173
  @Get("/{id}") // GET /todos/{id} → todos.get
144
174
  async get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
145
- return this.todos.get(user.id, id);
175
+ return todoService.get(user.id, id);
146
176
  }
147
177
 
148
178
  @Delete("/{id}") // DELETE /todos/{id} → todos.remove; no body → : Promise<void>
149
179
  async remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
150
- await this.todos.remove(user.id, id);
180
+ await todoService.remove(user.id, id);
151
181
  }
152
182
  }
153
-
154
- export default TodosController; // REQUIRED the runtime loads the default export
183
+ // No export is needed at all — @Controller registered the class as it decorated
184
+ // it. `export default` here is style, not a requirement.
155
185
  ```
156
186
 
157
187
  ```ts
@@ -238,7 +268,10 @@ The **only difference** is the trigger argument:
238
268
  | **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
239
269
  | **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
240
270
  | **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
241
- | **Middleware** (`middleware/**`) | `(ctx, next)` | `MiddlewareContext` — the **one exception** |
271
+
272
+ `defineMiddleware` is still exported and takes `(ctx, next)`, but **no bundler
273
+ reads a `middleware/` directory and the engine never calls one** — there is no
274
+ `ctx` anywhere on a path that runs. Every handler above imports its services.
242
275
 
243
276
  `meta` carries non-service data: `env` (Environment variables),
244
277
  `environmentId`, and for webhooks `requestId`. Services always come from
@@ -255,16 +288,16 @@ my-backend/
255
288
  ├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
256
289
  │ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
257
290
  ├── services/ # plain classes/singletons your controllers call
258
- ├── db/schema.ts # table definitions (optional, enables typed DB)
259
- ├── db/migrations/ # explicit SQL migrations for type changes (optional)
260
- ├── resources/ # external connections, set up once at boot (optional)
261
- ├── seeds/ # seed data (optional)
291
+ ├── db/public.ts # the database itself: tables, columns, RLS policies
262
292
  ├── jobs/ # cron-scheduled jobs (optional)
263
293
  ├── hooks/ # auth/storage/document event hooks (optional)
264
- ├── webhooks/ # inbound provider webhooks (optional)
265
- └── middleware/ # cross-cutting request middleware (optional)
294
+ └── webhooks/ # inbound provider webhooks (optional)
266
295
  ```
267
296
 
297
+ There is **no `db/migrations/`**. Nothing generates a migration file, nothing
298
+ commits one and nothing replays one: `db/schema.ts` is diffed against the live
299
+ database and applied — see [migrations.md](./migrations.md).
300
+
268
301
  HTTP endpoints are **not** file-path routed. You author a class controller
269
302
  (`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
270
303
  `controllers/` mounts it. See [routing.md](./routing.md).
@@ -493,35 +526,38 @@ imported singletons (see [services.md](./services.md)).
493
526
 
494
527
  ```ts
495
528
  // controllers/rooms.controller.ts
496
- import { Controller, Get, Post, Body, Param, User, NotFound, Database } from "@palbase/backend";
529
+ import { Controller, Get, Post, Body, Param, User } from "@palbase/backend";
497
530
  import type { UserT } from "@palbase/backend";
531
+ import { roomService } from "../services/room.service.js";
498
532
  import { CreateRoomBody } from "../models/rooms/create.js";
499
533
  import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
500
534
 
535
+ // A controller does not import `Database`. Everything here is HTTP: validate the
536
+ // body through a named schema, name the 200 shape as the return type, delegate.
537
+ // Which rows, whose, in what order is the service's job — and the service is the
538
+ // thing worth testing, because it is the thing that can be wrong.
501
539
  @Controller("/rooms")
502
540
  export default class RoomsController {
503
541
  @Post("")
504
- async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
505
- const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
506
- return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
542
+ create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
543
+ return roomService.create(user.id, body);
507
544
  }
508
545
 
509
546
  @Get("/{id}")
510
- async getOne(@Param("id") id: string): Promise<RoomSchema> {
511
- const room = await Database.findById("rooms", id);
512
- if (!room) throw new NotFound("Room does not exist", "room_not_found");
513
- return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
547
+ getOne(@Param("id") id: string): Promise<RoomSchema> {
548
+ return roomService.get(id);
514
549
  }
515
550
  }
516
551
  ```
517
552
 
518
553
  **Two non-negotiables** (the most common codegen mistakes):
519
554
 
520
- 1. **`export default <Controller>` is REQUIRED.** Above it is
521
- `export default class RoomsController`; the trailing form `export class
522
- RoomsController {…}` + `export default RoomsController;` is equivalent. Without
523
- a default export the deploy aborts with a "not a @Controller / non-controller
524
- export" error — the runtime loads the file's default export.
555
+ 1. **The controller does not touch the database.** It delegates to a service, as
556
+ above. A method here that reaches for `Database` has moved the logic into the
557
+ layer that is hardest to test. (An export is not required at all: `@Controller`
558
+ records the class as it decorates it, and importing the file IS the
559
+ registration — the shipped scaffold's own controllers are not exported.
560
+ `export default` here is style.)
525
561
  2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
526
562
  returns promises, so a body that `await`s it cannot have a sync return type
527
563
  (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
@@ -631,23 +667,17 @@ throw new PalError(418, "teapot", "custom"); // → custom status/code
631
667
 
632
668
  See [errors.md](./errors.md) for the full set + the wire envelope shape.
633
669
 
634
- ## Middleware
670
+ ## There is no middleware
635
671
 
636
- A middleware wraps a request. Define one in `middleware/<name>.ts`:
672
+ `defineMiddleware(async (ctx, next) => { })` is still exported and still
673
+ type-checks, but **nothing mounts a `middleware/` directory and the engine has no
674
+ middleware pipeline** — a handler written against it deploys, never runs, and
675
+ nothing reports it. There is no `ctx` object anywhere on a path that executes, so
676
+ treat the export as a leftover rather than a seam.
637
677
 
638
- ```ts
639
- // middleware/logger.ts
640
- import { defineMiddleware } from "@palbase/backend";
641
-
642
- export default defineMiddleware(async (ctx, next) => {
643
- ctx.log.info(`start ${ctx.requestId}`);
644
- await next();
645
- ctx.log.info(`done ${ctx.requestId}`);
646
- });
647
- ```
648
-
649
- The middleware handler receives `(ctx, next)` — call `await next()` to run the
650
- rest of the chain (other middleware, then the endpoint method).
678
+ Cross-cutting work goes in a service the controllers call, and the route concerns
679
+ that used to live in a wrapper are route options instead: `auth` on `@Controller`
680
+ or the method decorator, and `rateLimit: { max, window }` per route.
651
681
 
652
682
 
653
683
 
@@ -1172,51 +1202,109 @@ Guidelines:
1172
1202
 
1173
1203
  # Schema & typed database access
1174
1204
 
1175
- Declare your tables in `db/schema.ts` with `defineSchema`. This drives
1205
+ Declare your tables under `db/`, **one file per schema**: `db/public.ts` is the
1206
+ schema Palbase expects to find, `db/billing.ts` declares a second one. Each file
1207
+ default-exports a `defineSchema("<name>", { tables })` call. That drives
1176
1208
  [migrations](./migrations.md) (additive changes auto-apply on deploy; type
1177
1209
  changes need an explicit migration) and makes `Database.tables.*` typed
1178
1210
  everywhere — by default, with no import and no generic.
1179
1211
 
1212
+ > Coming from a single `db/schema.ts` with tables declared inline? That layout is
1213
+ > gone, and a push says so by name. The migration guide at
1214
+ > `/docs/backend/schema-migration` walks the four changes with before/after code.
1215
+
1180
1216
  ## Defining a schema
1181
1217
 
1182
- The table NAME comes from the object key under `tables`. Each table value is an
1183
- object whose only required field is `columns`; `rls` and `policies` enable
1218
+ A table is declared with `defineTable("<name>", { })` and is a **value that
1219
+ knows its own name**. `defineSchema` takes the schema's name and an ARRAY of
1220
+ those values — never a dictionary, because a name in a dictionary key is a second
1221
+ place the name is written, and a table built under a key does not yet know what
1222
+ to call itself when a sibling references it.
1223
+
1224
+ Each table's only required field is `columns`; `rls` and `policies` enable
1184
1225
  [Row-Level Security](#row-level-security-rls), and `indexes` declares plain
1185
1226
  btree [indexes](#indexes).
1186
1227
 
1187
1228
  ```ts
1188
1229
  import {
1189
- defineSchema,
1190
- uuid, text, integer, boolean, timestamp, jsonb, enumType,
1230
+ defineSchema, defineTable,
1231
+ uuid, text, integer, boolean, timestamp, jsonb, enumType, ownedByUser,
1191
1232
  } from "@palbase/backend";
1192
1233
 
1193
- export default defineSchema({
1194
- tables: {
1195
- rooms: {
1196
- columns: {
1197
- id: uuid().primaryKey().defaultRandom(),
1198
- name: text().notNull(),
1199
- capacity: integer().nullable(),
1200
- is_active: boolean().default(true),
1201
- created_at: timestamp().defaultNow(),
1202
- },
1203
- },
1204
- sessions: {
1205
- columns: {
1206
- id: uuid().primaryKey().defaultRandom(),
1207
- room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
1208
- user_id: uuid().notNull(),
1209
- data: jsonb().nullable(),
1210
- started_at: timestamp().defaultNow(),
1211
- },
1212
- },
1213
- orders: {
1214
- columns: {
1215
- id: uuid().primaryKey().defaultRandom(),
1216
- status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
1217
- amount: integer().notNull(),
1218
- },
1219
- },
1234
+ export const rooms = defineTable("rooms", {
1235
+ columns: {
1236
+ id: uuid().primaryKey().defaultRandom(),
1237
+ name: text().notNull(),
1238
+ capacity: integer().nullable(),
1239
+ is_active: boolean().default(true),
1240
+ created_at: timestamp().defaultNow(),
1241
+ },
1242
+ });
1243
+
1244
+ export const sessions = defineTable("sessions", {
1245
+ columns: {
1246
+ id: uuid().primaryKey().defaultRandom(),
1247
+ room_id: uuid().references(() => rooms.id).onDelete("cascade"),
1248
+ user_id: ownedByUser(),
1249
+ data: jsonb().nullable(),
1250
+ started_at: timestamp().defaultNow(),
1251
+ },
1252
+ });
1253
+
1254
+ export const orders = defineTable("orders", {
1255
+ columns: {
1256
+ id: uuid().primaryKey().defaultRandom(),
1257
+ status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
1258
+ amount: integer().notNull(),
1259
+ },
1260
+ });
1261
+
1262
+ export default defineSchema("public", {
1263
+ tables: [rooms, sessions, orders],
1264
+ });
1265
+ ```
1266
+
1267
+ A `defineTable` value **is its columns** — `rooms.id` is the `id` builder, which
1268
+ is what makes `references(() => rooms.id)` an ordinary expression. The table's own
1269
+ metadata hangs off a symbol rather than a plain field, so a column may be called
1270
+ `name`, `columns`, `rls` or `indexes` without shadowing the table's identity.
1271
+
1272
+ ### One file per schema, and `exposed`
1273
+
1274
+ The schema name comes from the declaration; the file name must agree with it. A
1275
+ `db/billing.ts` declaring `defineSchema("accounts", …)` is refused at push with
1276
+ both names in the error.
1277
+
1278
+ `exposed` decides whether a schema is served over `/v1/db`, and the default is
1279
+ NOT uniform: **`public` defaults to `true`**, every other schema to `false`. The
1280
+ asymmetry is deliberate — `public` is reachable today and stays reachable, because
1281
+ a uniform default would silently 404 every existing project's `/v1/db` traffic on
1282
+ upgrade, while a schema you add later is not on the internet just because you
1283
+ declared it.
1284
+
1285
+ ```ts
1286
+ // db/public.ts — reachable, and you write nothing to get that
1287
+ export default defineSchema("public", { tables: [rooms, sessions] });
1288
+
1289
+ // db/billing.ts — declared and typed, but not reachable from a client
1290
+ export default defineSchema("billing", { tables: [invoices] });
1291
+ ```
1292
+
1293
+ Write the field only to go against the grain — `exposed: false` closes `public`,
1294
+ `exposed: true` opens a second schema. Server-side `Database.*` ignores it either
1295
+ way: your controllers, jobs and hooks read every schema you declared.
1296
+
1297
+ A foreign key may cross schemas: import the table binding and point at it.
1298
+
1299
+ ```ts
1300
+ // db/billing.ts
1301
+ import { lists } from "./public";
1302
+
1303
+ export const invoices = defineTable("invoices", {
1304
+ columns: {
1305
+ id: uuid().primaryKey().defaultRandom(),
1306
+ list_id: uuid().references(() => lists.id),
1307
+ amount: numeric(),
1220
1308
  },
1221
1309
  });
1222
1310
  ```
@@ -1235,9 +1323,93 @@ export default defineSchema({
1235
1323
 
1236
1324
  Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
1237
1325
  `.default(value)`, `.defaultRandom()` (uuid → `gen_random_uuid()`),
1238
- `.defaultNow()` (timestamp → `now()`), `.references(table, column)`,
1326
+ `.defaultNow()` (timestamp → `now()`), `.references(() => table.column, opts?)`,
1327
+ `.selfReferences("column", opts?)`,
1239
1328
  `.onDelete("cascade" | "set null" | "restrict" | "no action")`, `.ignored()`.
1240
1329
 
1330
+ ## Foreign keys
1331
+
1332
+ The target of `references` is a **thunk**, not a direct reference. The callback is
1333
+ invoked inside `defineSchema`, where every binding exists and every table already
1334
+ knows its name — which is what makes a cycle expressible at all: in `x → y, y → x`
1335
+ the second table does not exist yet when the first is built.
1336
+
1337
+ ```ts
1338
+ list_id: uuid().references(() => lists.id),
1339
+ ```
1340
+
1341
+ **Pointing at this same table** takes no thunk and no annotation — the target
1342
+ table is the one being declared, so there is nothing to defer:
1343
+
1344
+ ```ts
1345
+ parent_id: uuid().nullable().selfReferences("id"),
1346
+ ```
1347
+
1348
+ Naming a column the table does not have is refused where you declare it.
1349
+
1350
+ **Two tables that point at each other** need an explicit return type on ONE side,
1351
+ and one is enough — measured. Without it TypeScript chases its own tail (TS7022):
1352
+
1353
+ ```ts
1354
+ import { type AnyColumn } from "@palbase/backend";
1355
+
1356
+ export const users = defineTable("users", {
1357
+ columns: {
1358
+ id: uuid().primaryKey().defaultRandom(),
1359
+ primary_org_id: uuid().nullable().references((): AnyColumn => orgs.id),
1360
+ },
1361
+ });
1362
+
1363
+ export const orgs = defineTable("orgs", {
1364
+ columns: {
1365
+ id: uuid().primaryKey().defaultRandom(),
1366
+ owner_id: uuid().nullable().references(() => users.id),
1367
+ },
1368
+ });
1369
+ ```
1370
+
1371
+ **Two foreign keys to the same table** would derive the same relation name from
1372
+ their columns, so name one of them:
1373
+
1374
+ ```ts
1375
+ billing_address_id: uuid().references(() => addresses.id, { as: "billing_address" }),
1376
+ shipping_address_id: uuid().references(() => addresses.id, { as: "shipping_address" }),
1377
+ ```
1378
+
1379
+ ## Rows that belong to a user
1380
+
1381
+ There is no `public.users` table: auth users live in the `auth` schema of the same
1382
+ Postgres. Three column factories declare a real foreign key onto it. They are
1383
+ factories rather than chain methods because the column type, its nullability and
1384
+ its `ON DELETE` are part of what each one MEANS — so they cannot be written wrong.
1385
+
1386
+ ```ts
1387
+ user_id: ownedByUser(), // text, NOT NULL, ON DELETE CASCADE
1388
+ edited_by: userRef({ onDelete: "set null" }).nullable(),
1389
+ device_id: installationRef({ onDelete: "cascade" }),
1390
+ ```
1391
+
1392
+ | | `ownedByUser()` | `userRef({ onDelete })` | `installationRef({ onDelete })` |
1393
+ |---|---|---|---|
1394
+ | References | `auth.users(id)` | `auth.users(id)` | `auth.installations(id)` |
1395
+ | Means | the row **belongs to** that user | the row **points at** a user | the row is scoped to an app install |
1396
+ | `ON DELETE` | `cascade`, no argument | required: `cascade` / `set null` | required: `cascade` / `set null` |
1397
+ | Account erasure follows it | yes | no | no |
1398
+ | Per table | **at most one** | unlimited | unlimited |
1399
+
1400
+ `ownedByUser()` takes no `onDelete` because there is only one correct answer:
1401
+ ownership is what account erasure walks, so a row owned by an account has to go
1402
+ when the account does. `"set null"` on a `userRef` needs a `.nullable()` column.
1403
+
1404
+ **One `ownedByUser()` per table, enforced.** Two on one table are refused at push
1405
+ with both column names in the error. The rule exists because the alternative was
1406
+ worse than a refusal: when several columns could reference `auth.users`, the owner
1407
+ was whichever came FIRST IN DECLARATION ORDER — so moving a `created_by` above a
1408
+ `user_id` silently changed which rows an account deletion took with it.
1409
+
1410
+ An installation reference is **not** ownership. A user-owned row still needs its
1411
+ own `ownedByUser()`, or erasing the account leaves it behind.
1412
+
1241
1413
  ## Removing a column
1242
1414
 
1243
1415
  A deploy applies the schema while the PREVIOUS release is still answering requests, so
@@ -1254,23 +1426,47 @@ Removing a column is therefore two deploys:
1254
1426
 
1255
1427
  ```ts
1256
1428
  // 1. Mark it. The column stays; nothing breaks; no DDL is produced.
1257
- export const schema = defineSchema({
1258
- tables: { notes: { columns: { id: uuid().primaryKey(), old_body: text().ignored() } } },
1429
+ const notes = defineTable("notes", {
1430
+ columns: { id: uuid().primaryKey(), old_body: text().ignored() },
1259
1431
  });
1432
+ export default defineSchema("public", { tables: [notes] });
1260
1433
  ```
1261
1434
 
1262
1435
  ```ts
1263
1436
  // 2. Ship that. Then delete the column and ship again — this time the gate passes,
1264
1437
  // because the release now serving promised it does not name the column.
1265
- export const schema = defineSchema({
1266
- tables: { notes: { columns: { id: uuid().primaryKey() } } } },
1267
- });
1438
+ const notes = defineTable("notes", { columns: { id: uuid().primaryKey() } });
1439
+ export default defineSchema("public", { tables: [notes] });
1268
1440
  ```
1269
1441
 
1270
1442
  `palbase db plan` tells you which step you are on before you push. Locally,
1271
1443
  `palbase db apply` is not restricted — local is where you experiment, and there is no
1272
1444
  traffic to protect.
1273
1445
 
1446
+ ### When you cannot wait two deploys
1447
+
1448
+ There is an escape, and it is deliberately loud:
1449
+
1450
+ ```
1451
+ palbase push --accept-breaking
1452
+ ```
1453
+
1454
+ It opens the gate for one push. Use it when the running release is ALREADY broken and
1455
+ the fix is the very change the gate refuses — an incident, not an inconvenience. Outside
1456
+ that, two deploys cost less than the one this can break.
1457
+
1458
+ It is not silent, and that is the whole design: the push prints the consents it is
1459
+ sending, and the server records a `BREAK-GLASS` line naming the digest that was serving
1460
+ and every object the gate had refused. So the decision has an author and a time, and
1461
+ whoever asks later why a column disappeared finds the answer instead of a normal-looking
1462
+ push.
1463
+
1464
+ Two things it will not do. It does not apply to a cloud push — `--accept-breaking` there
1465
+ is refused by name rather than ignored, because the gate needs to know what is serving
1466
+ and only a linked checkout can tell it. And it does not skip the data-loss consent:
1467
+ `--approve` is a separate question about erasing rows, and answering one does not answer
1468
+ the other.
1469
+
1274
1470
  The word is `ignored` and not `deprecated` on purpose: deprecation is defined, in
1275
1471
  RFC 9745 and in the GraphQL spec alike, as changing NO behaviour. This changes what a
1276
1472
  deploy will accept.
@@ -1293,7 +1489,7 @@ Add a value instead, and let the old one die:
1293
1489
 
1294
1490
  ```sql
1295
1491
  -- 1. Add the new label. This IS safe while the previous release serves.
1296
- -- (Declare it in db/schema.ts; the rail emits ALTER TYPE … ADD VALUE.)
1492
+ -- (Declare it in db/public.ts; the rail emits ALTER TYPE … ADD VALUE.)
1297
1493
  -- 2. Move the data:
1298
1494
  UPDATE posts SET status = 'review' WHERE status = 'onay';
1299
1495
  -- 3. Stop naming the old value in the next release.
@@ -1314,19 +1510,15 @@ used to reject passed after the rename.
1314
1510
  `indexes` declares plain (non-unique) btree indexes over an ordered column list:
1315
1511
 
1316
1512
  ```ts
1317
- export default defineSchema({
1318
- tables: {
1319
- sessions: {
1320
- columns: {
1321
- id: uuid().primaryKey().defaultRandom(),
1322
- room_id: uuid().notNull().references("rooms", "id"),
1323
- started_at: timestamp().defaultNow(),
1324
- },
1325
- indexes: [
1326
- { name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
1327
- ],
1328
- },
1513
+ export const sessions = defineTable("sessions", {
1514
+ columns: {
1515
+ id: uuid().primaryKey().defaultRandom(),
1516
+ room_id: uuid().references(() => rooms.id),
1517
+ started_at: timestamp().defaultNow(),
1329
1518
  },
1519
+ indexes: [
1520
+ { name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
1521
+ ],
1330
1522
  });
1331
1523
  ```
1332
1524
 
@@ -1338,7 +1530,7 @@ name and every column are identifier-validated before any SQL is built.
1338
1530
  knowing before you name an index:
1339
1531
 
1340
1532
  - An index that exists in the database but is not in `indexes` is never dropped.
1341
- `db/schema.ts` does not own the database's indexes; it only adds the ones it
1533
+ Your schema file does not own the database's indexes; it only adds the ones it
1342
1534
  names.
1343
1535
  - Removing an entry from `indexes` therefore does **not** drop the index. Drop it
1344
1536
  in an explicit [migration](./migrations.md).
@@ -1364,10 +1556,9 @@ nothing. Rather than ship a half-working partial-index path, the typed field
1364
1556
  stays columns-only and `raw()` carries the rest:
1365
1557
 
1366
1558
  ```ts
1367
- import { defineSchema, raw, uuid, text, timestamp } from "@palbase/backend";
1559
+ import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
1368
1560
 
1369
- //
1370
- orders: {
1561
+ export const orders = defineTable("orders", {
1371
1562
  columns: {
1372
1563
  id: uuid().primaryKey().defaultRandom(),
1373
1564
  status: text().notNull(),
@@ -1380,7 +1571,7 @@ orders: {
1380
1571
  { down: "DROP INDEX IF EXISTS orders_pending_idx" },
1381
1572
  ),
1382
1573
  ],
1383
- },
1574
+ });
1384
1575
  ```
1385
1576
 
1386
1577
  `raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
@@ -1390,12 +1581,34 @@ and an index is not one.
1390
1581
 
1391
1582
  ## Typed DB access — by default
1392
1583
 
1393
- You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
1584
+ You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
1394
1585
  `palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
1395
1586
  of the schema, no generic, no cast:
1396
1587
 
1397
1588
  ```ts
1398
- import { Controller, Post, Body, Database, z } from "@palbase/backend";
1589
+ // services/room.service.ts the layer that touches the database.
1590
+ import { Database } from "@palbase/backend";
1591
+
1592
+ type RoomsTable = typeof Database.tables.rooms; // typed from db/schema.ts
1593
+
1594
+ export class RoomService {
1595
+ private readonly rooms: RoomsTable;
1596
+ constructor(rooms: RoomsTable) { this.rooms = rooms; }
1597
+
1598
+ async create(name: string) {
1599
+ const room = await this.rooms.insert({ name });
1600
+ return { id: room.id, name: room.name }; // room.id: string ✓
1601
+ // room.nope ← compile error
1602
+ }
1603
+ }
1604
+
1605
+ export const roomService = new RoomService(Database.tables.rooms);
1606
+ ```
1607
+
1608
+ ```ts
1609
+ // controllers/rooms.controller.ts — HTTP only; no `Database` import here.
1610
+ import { Controller, Post, Body, z } from "@palbase/backend";
1611
+ import { roomService } from "../services/room.service.js";
1399
1612
 
1400
1613
  const CreateRoomBody = z.object({ name: z.string() });
1401
1614
  const RoomOut = z.object({ id: z.string(), name: z.string() });
@@ -1405,10 +1618,8 @@ export default class RoomsController {
1405
1618
  @Post("")
1406
1619
  // The return type names the 200 schema — `z.infer<typeof RoomOut>` works
1407
1620
  // inline, no separate `export type` needed.
1408
- async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
1409
- const room = await Database.tables.rooms.insert({ name: body.name });
1410
- return { id: room.id, name: room.name }; // room.id: string ✓
1411
- // room.nope ← compile error
1621
+ create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
1622
+ return roomService.create(body.name);
1412
1623
  }
1413
1624
  }
1414
1625
  ```
@@ -1506,36 +1717,37 @@ rows.
1506
1717
  ### Owner-scoped `todos` example
1507
1718
 
1508
1719
  ```ts
1509
- import { defineSchema, policy, uuid, text, boolean, timestamp } from "@palbase/backend";
1510
-
1511
- export default defineSchema({
1512
- tables: {
1513
- todos: {
1514
- columns: {
1515
- id: uuid().primaryKey().defaultRandom(),
1516
- owner: text().notNull(), // palauth user id (TEXT)
1517
- title: text().notNull(),
1518
- done: boolean().default(false),
1519
- created_at: timestamp().defaultNow(),
1520
- },
1521
- // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
1522
- policies: [
1523
- // Read: a user sees only their own todos.
1524
- policy("pb_todos_owner_select")
1525
- .for("select")
1526
- .to("authenticated")
1527
- .using("owner = (select auth.uid())"),
1720
+ import {
1721
+ defineSchema, defineTable, policy, ownedByUser,
1722
+ uuid, text, boolean, timestamp,
1723
+ } from "@palbase/backend";
1528
1724
 
1529
- // Write: a user can insert/update/delete only rows they own.
1530
- policy("pb_todos_owner_write")
1531
- .for("all")
1532
- .to("authenticated")
1533
- .using("owner = (select auth.uid())")
1534
- .withCheck("owner = (select auth.uid())"),
1535
- ],
1536
- },
1725
+ export const todos = defineTable("todos", {
1726
+ columns: {
1727
+ id: uuid().primaryKey().defaultRandom(),
1728
+ owner: ownedByUser(), // text FK onto auth.users(id), NOT NULL, CASCADE
1729
+ title: text().notNull(),
1730
+ done: boolean().default(false),
1731
+ created_at: timestamp().defaultNow(),
1537
1732
  },
1733
+ // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
1734
+ policies: [
1735
+ // Read: a user sees only their own todos.
1736
+ policy("pb_todos_owner_select")
1737
+ .for("select")
1738
+ .to("authenticated")
1739
+ .using("owner = (select auth.uid())"),
1740
+
1741
+ // Write: a user can insert/update/delete only rows they own.
1742
+ policy("pb_todos_owner_write")
1743
+ .for("all")
1744
+ .to("authenticated")
1745
+ .using("owner = (select auth.uid())")
1746
+ .withCheck("owner = (select auth.uid())"),
1747
+ ],
1538
1748
  });
1749
+
1750
+ export default defineSchema("public", { tables: [todos] });
1539
1751
  ```
1540
1752
 
1541
1753
  With this in place, `await Database.tables.todos.findMany({})` returns only the
@@ -1557,7 +1769,6 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
1557
1769
 
1558
1770
 
1559
1771
 
1560
-
1561
1772
  <!-- ===== migrations.md ===== -->
1562
1773
 
1563
1774
  # Migrations
@@ -2040,16 +2251,51 @@ stand-in and never needs a database.
2040
2251
 
2041
2252
  ```ts
2042
2253
  // services/note.service.test.ts — `npm test`, no database
2043
- import assert from "node:assert/strict";
2044
2254
  import { test } from "node:test";
2255
+ import assert from "node:assert/strict";
2256
+
2045
2257
  import { NoteService } from "./note.service.ts";
2046
2258
 
2047
- test("list filters by owner", async () => {
2259
+ // WHY THIS TEST NEEDS NO DATABASE
2260
+ //
2261
+ // `NoteService` is handed the table it works on rather than reaching for the
2262
+ // singleton itself. That constructor is the seam: a stand-in goes in here, and
2263
+ // the logic — which rows, whose, in what order — is exercised without a
2264
+ // database. Test your own services the same way.
2265
+ //
2266
+ // When you want the whole database surface instead of one table, `fakeDatabase()`
2267
+ // from `@palbase/backend/test` is the stand-in.
2268
+ //
2269
+ // Node's ESM resolver wants the extension on a relative import inside a test
2270
+ // (`./note.service.ts`); this scaffold's `tsconfig.json` allows it.
2271
+
2272
+ test("list asks only for the caller's notes", async () => {
2048
2273
  const seen: unknown[] = [];
2049
- const fake = { findMany: async (q: unknown) => (seen.push(q), []) };
2050
- await new NoteService(fake as never).list("u_1");
2274
+ const notes = {
2275
+ findMany: async (where: unknown) => {
2276
+ seen.push(where);
2277
+ return [];
2278
+ },
2279
+ };
2280
+
2281
+ await new NoteService(notes as never).list("u_1");
2282
+
2051
2283
  assert.deepEqual(seen, [{ user_id: "u_1" }]);
2052
2284
  });
2285
+
2286
+ test("create writes ownership from the argument, never from the body", async () => {
2287
+ const written: unknown[] = [];
2288
+ const notes = {
2289
+ insert: async (row: unknown) => {
2290
+ written.push(row);
2291
+ return row;
2292
+ },
2293
+ };
2294
+
2295
+ await new NoteService(notes as never).create("u_1", "hello");
2296
+
2297
+ assert.deepEqual(written, [{ user_id: "u_1", body: "hello" }]);
2298
+ });
2053
2299
  ```
2054
2300
 
2055
2301
  Node's ESM resolver wants the extension on a relative import inside a test