@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
@@ -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,124 @@ 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()`),
1327
+ `.references(() => table.column, { as?, reverseAs?, onDelete? })`,
1328
+ `.selfReferences("column", opts?)`,
1239
1329
  `.onDelete("cascade" | "set null" | "restrict" | "no action")`, `.ignored()`.
1240
1330
 
1331
+ ## Foreign keys
1332
+
1333
+ The target of `references` is a **thunk**, not a direct reference. The callback is
1334
+ invoked inside `defineSchema`, where every binding exists and every table already
1335
+ knows its name — which is what makes a cycle expressible at all: in `x → y, y → x`
1336
+ the second table does not exist yet when the first is built.
1337
+
1338
+ ```ts
1339
+ list_id: uuid().references(() => lists.id),
1340
+ ```
1341
+
1342
+ **Pointing at this same table** takes no thunk and no annotation — the target
1343
+ table is the one being declared, so there is nothing to defer:
1344
+
1345
+ ```ts
1346
+ parent_id: uuid().nullable().selfReferences("id"),
1347
+ ```
1348
+
1349
+ Naming a column the table does not have is refused where you declare it.
1350
+
1351
+ **Two tables that point at each other** need an explicit return type on ONE side,
1352
+ and one is enough — measured. Without it TypeScript chases its own tail (TS7022):
1353
+
1354
+ ```ts
1355
+ import { type AnyColumn } from "@palbase/backend";
1356
+
1357
+ export const users = defineTable("users", {
1358
+ columns: {
1359
+ id: uuid().primaryKey().defaultRandom(),
1360
+ primary_org_id: uuid().nullable().references((): AnyColumn => orgs.id),
1361
+ },
1362
+ });
1363
+
1364
+ export const orgs = defineTable("orgs", {
1365
+ columns: {
1366
+ id: uuid().primaryKey().defaultRandom(),
1367
+ owner_id: uuid().nullable().references(() => users.id),
1368
+ },
1369
+ });
1370
+ ```
1371
+
1372
+ ### Relation names
1373
+
1374
+ Every foreign key produces **two** named relations, and the two names are derived
1375
+ separately:
1376
+
1377
+ | Direction | Where it appears | Default name | Option that changes it |
1378
+ |---|---|---|---|
1379
+ | forward (child → parent) | on the child | the column minus `_id` — `list_id` → `list` | `as` |
1380
+ | reverse (parent → children) | on the parent | the **child table's** name — `lists.todos` | `reverseAs` |
1381
+
1382
+ So `todos.list_id → lists` gives `todos.list` and `lists.todos`, and neither
1383
+ needs declaring. `as` renames the forward side only: `author_id` declared
1384
+ `{ as: "author" }` gives `posts.author` and still gives `users.posts`. That is
1385
+ why the two options exist separately — `posts` and `comments` can both call their
1386
+ forward relation `author` without colliding, because what lands on `users` is
1387
+ `posts` and `comments`.
1388
+
1389
+ **Two foreign keys from one table to the same parent** collide on the reverse
1390
+ side: both reverse relations want the child table's name. Name them:
1391
+
1392
+ ```ts
1393
+ billing_address_id: uuid().references(() => addresses.id, { reverseAs: "billed_orders" }),
1394
+ shipping_address_id: uuid().references(() => addresses.id, { reverseAs: "shipped_orders" }),
1395
+ ```
1396
+
1397
+ The forward names here (`billing_address`, `shipping_address`) are already
1398
+ distinct, so no `as` is needed. Add one when two columns WOULD derive the same
1399
+ forward name.
1400
+
1401
+ Any two relations resolving to one name on one table are refused at push, with
1402
+ both relations named and the option that separates them.
1403
+
1404
+ ## Rows that belong to a user
1405
+
1406
+ There is no `public.users` table: auth users live in the `auth` schema of the same
1407
+ Postgres. Three column factories declare a real foreign key onto it. They are
1408
+ factories rather than chain methods because the column type, its nullability and
1409
+ its `ON DELETE` are part of what each one MEANS — so they cannot be written wrong.
1410
+
1411
+ ```ts
1412
+ user_id: ownedByUser(), // text, NOT NULL, ON DELETE CASCADE
1413
+ edited_by: userRef({ onDelete: "set null" }).nullable(),
1414
+ device_id: installationRef({ onDelete: "cascade" }),
1415
+ ```
1416
+
1417
+ | | `ownedByUser()` | `userRef({ onDelete })` | `installationRef({ onDelete })` |
1418
+ |---|---|---|---|
1419
+ | References | `auth.users(id)` | `auth.users(id)` | `auth.installations(id)` |
1420
+ | Means | the row **belongs to** that user | the row **points at** a user | the row is scoped to an app install |
1421
+ | `ON DELETE` | `cascade`, no argument | required: `cascade` / `set null` | required: `cascade` / `set null` |
1422
+ | Account erasure follows it | yes | no | no |
1423
+ | Per table | **at most one** | unlimited | unlimited |
1424
+
1425
+ Several `userRef` columns on one table are fine — they are ordinary foreign keys
1426
+ and each takes its own relation name from its column (`created_by`, `edited_by`).
1427
+ Only two of them resolving to the SAME name is refused, and `userRef({ onDelete,
1428
+ as })` is how you separate them. `auth.users` is not a table of your schema, so
1429
+ none of these produce a reverse relation to collide over.
1430
+
1431
+ `ownedByUser()` takes no `onDelete` because there is only one correct answer:
1432
+ ownership is what account erasure walks, so a row owned by an account has to go
1433
+ when the account does. `"set null"` on a `userRef` needs a `.nullable()` column.
1434
+
1435
+ **One `ownedByUser()` per table, enforced.** Two on one table are refused at push
1436
+ with both column names in the error. The rule exists because the alternative was
1437
+ worse than a refusal: when several columns could reference `auth.users`, the owner
1438
+ was whichever came FIRST IN DECLARATION ORDER — so moving a `created_by` above a
1439
+ `user_id` silently changed which rows an account deletion took with it.
1440
+
1441
+ An installation reference is **not** ownership. A user-owned row still needs its
1442
+ own `ownedByUser()`, or erasing the account leaves it behind.
1443
+
1241
1444
  ## Removing a column
1242
1445
 
1243
1446
  A deploy applies the schema while the PREVIOUS release is still answering requests, so
@@ -1254,23 +1457,47 @@ Removing a column is therefore two deploys:
1254
1457
 
1255
1458
  ```ts
1256
1459
  // 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() } } },
1460
+ const notes = defineTable("notes", {
1461
+ columns: { id: uuid().primaryKey(), old_body: text().ignored() },
1259
1462
  });
1463
+ export default defineSchema("public", { tables: [notes] });
1260
1464
  ```
1261
1465
 
1262
1466
  ```ts
1263
1467
  // 2. Ship that. Then delete the column and ship again — this time the gate passes,
1264
1468
  // 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
- });
1469
+ const notes = defineTable("notes", { columns: { id: uuid().primaryKey() } });
1470
+ export default defineSchema("public", { tables: [notes] });
1268
1471
  ```
1269
1472
 
1270
1473
  `palbase db plan` tells you which step you are on before you push. Locally,
1271
1474
  `palbase db apply` is not restricted — local is where you experiment, and there is no
1272
1475
  traffic to protect.
1273
1476
 
1477
+ ### When you cannot wait two deploys
1478
+
1479
+ There is an escape, and it is deliberately loud:
1480
+
1481
+ ```
1482
+ palbase push --accept-breaking
1483
+ ```
1484
+
1485
+ It opens the gate for one push. Use it when the running release is ALREADY broken and
1486
+ the fix is the very change the gate refuses — an incident, not an inconvenience. Outside
1487
+ that, two deploys cost less than the one this can break.
1488
+
1489
+ It is not silent, and that is the whole design: the push prints the consents it is
1490
+ sending, and the server records a `BREAK-GLASS` line naming the digest that was serving
1491
+ and every object the gate had refused. So the decision has an author and a time, and
1492
+ whoever asks later why a column disappeared finds the answer instead of a normal-looking
1493
+ push.
1494
+
1495
+ Two things it will not do. It does not apply to a cloud push — `--accept-breaking` there
1496
+ is refused by name rather than ignored, because the gate needs to know what is serving
1497
+ and only a linked checkout can tell it. And it does not skip the data-loss consent:
1498
+ `--approve` is a separate question about erasing rows, and answering one does not answer
1499
+ the other.
1500
+
1274
1501
  The word is `ignored` and not `deprecated` on purpose: deprecation is defined, in
1275
1502
  RFC 9745 and in the GraphQL spec alike, as changing NO behaviour. This changes what a
1276
1503
  deploy will accept.
@@ -1293,7 +1520,7 @@ Add a value instead, and let the old one die:
1293
1520
 
1294
1521
  ```sql
1295
1522
  -- 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.)
1523
+ -- (Declare it in db/public.ts; the rail emits ALTER TYPE … ADD VALUE.)
1297
1524
  -- 2. Move the data:
1298
1525
  UPDATE posts SET status = 'review' WHERE status = 'onay';
1299
1526
  -- 3. Stop naming the old value in the next release.
@@ -1314,19 +1541,15 @@ used to reject passed after the rename.
1314
1541
  `indexes` declares plain (non-unique) btree indexes over an ordered column list:
1315
1542
 
1316
1543
  ```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
- },
1544
+ export const sessions = defineTable("sessions", {
1545
+ columns: {
1546
+ id: uuid().primaryKey().defaultRandom(),
1547
+ room_id: uuid().references(() => rooms.id),
1548
+ started_at: timestamp().defaultNow(),
1329
1549
  },
1550
+ indexes: [
1551
+ { name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
1552
+ ],
1330
1553
  });
1331
1554
  ```
1332
1555
 
@@ -1338,7 +1561,7 @@ name and every column are identifier-validated before any SQL is built.
1338
1561
  knowing before you name an index:
1339
1562
 
1340
1563
  - 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
1564
+ Your schema file does not own the database's indexes; it only adds the ones it
1342
1565
  names.
1343
1566
  - Removing an entry from `indexes` therefore does **not** drop the index. Drop it
1344
1567
  in an explicit [migration](./migrations.md).
@@ -1364,10 +1587,9 @@ nothing. Rather than ship a half-working partial-index path, the typed field
1364
1587
  stays columns-only and `raw()` carries the rest:
1365
1588
 
1366
1589
  ```ts
1367
- import { defineSchema, raw, uuid, text, timestamp } from "@palbase/backend";
1590
+ import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
1368
1591
 
1369
- //
1370
- orders: {
1592
+ export const orders = defineTable("orders", {
1371
1593
  columns: {
1372
1594
  id: uuid().primaryKey().defaultRandom(),
1373
1595
  status: text().notNull(),
@@ -1380,7 +1602,7 @@ orders: {
1380
1602
  { down: "DROP INDEX IF EXISTS orders_pending_idx" },
1381
1603
  ),
1382
1604
  ],
1383
- },
1605
+ });
1384
1606
  ```
1385
1607
 
1386
1608
  `raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
@@ -1390,12 +1612,34 @@ and an index is not one.
1390
1612
 
1391
1613
  ## Typed DB access — by default
1392
1614
 
1393
- You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
1615
+ You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
1394
1616
  `palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
1395
1617
  of the schema, no generic, no cast:
1396
1618
 
1397
1619
  ```ts
1398
- import { Controller, Post, Body, Database, z } from "@palbase/backend";
1620
+ // services/room.service.ts the layer that touches the database.
1621
+ import { Database } from "@palbase/backend";
1622
+
1623
+ type RoomsTable = typeof Database.tables.rooms; // typed from db/schema.ts
1624
+
1625
+ export class RoomService {
1626
+ private readonly rooms: RoomsTable;
1627
+ constructor(rooms: RoomsTable) { this.rooms = rooms; }
1628
+
1629
+ async create(name: string) {
1630
+ const room = await this.rooms.insert({ name });
1631
+ return { id: room.id, name: room.name }; // room.id: string ✓
1632
+ // room.nope ← compile error
1633
+ }
1634
+ }
1635
+
1636
+ export const roomService = new RoomService(Database.tables.rooms);
1637
+ ```
1638
+
1639
+ ```ts
1640
+ // controllers/rooms.controller.ts — HTTP only; no `Database` import here.
1641
+ import { Controller, Post, Body, z } from "@palbase/backend";
1642
+ import { roomService } from "../services/room.service.js";
1399
1643
 
1400
1644
  const CreateRoomBody = z.object({ name: z.string() });
1401
1645
  const RoomOut = z.object({ id: z.string(), name: z.string() });
@@ -1405,10 +1649,8 @@ export default class RoomsController {
1405
1649
  @Post("")
1406
1650
  // The return type names the 200 schema — `z.infer<typeof RoomOut>` works
1407
1651
  // 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
1652
+ create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
1653
+ return roomService.create(body.name);
1412
1654
  }
1413
1655
  }
1414
1656
  ```
@@ -1506,36 +1748,37 @@ rows.
1506
1748
  ### Owner-scoped `todos` example
1507
1749
 
1508
1750
  ```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())"),
1751
+ import {
1752
+ defineSchema, defineTable, policy, ownedByUser,
1753
+ uuid, text, boolean, timestamp,
1754
+ } from "@palbase/backend";
1528
1755
 
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
- },
1756
+ export const todos = defineTable("todos", {
1757
+ columns: {
1758
+ id: uuid().primaryKey().defaultRandom(),
1759
+ owner: ownedByUser(), // text FK onto auth.users(id), NOT NULL, CASCADE
1760
+ title: text().notNull(),
1761
+ done: boolean().default(false),
1762
+ created_at: timestamp().defaultNow(),
1537
1763
  },
1764
+ // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
1765
+ policies: [
1766
+ // Read: a user sees only their own todos.
1767
+ policy("pb_todos_owner_select")
1768
+ .for("select")
1769
+ .to("authenticated")
1770
+ .using("owner = (select auth.uid())"),
1771
+
1772
+ // Write: a user can insert/update/delete only rows they own.
1773
+ policy("pb_todos_owner_write")
1774
+ .for("all")
1775
+ .to("authenticated")
1776
+ .using("owner = (select auth.uid())")
1777
+ .withCheck("owner = (select auth.uid())"),
1778
+ ],
1538
1779
  });
1780
+
1781
+ export default defineSchema("public", { tables: [todos] });
1539
1782
  ```
1540
1783
 
1541
1784
  With this in place, `await Database.tables.todos.findMany({})` returns only the
@@ -1557,7 +1800,6 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
1557
1800
 
1558
1801
 
1559
1802
 
1560
-
1561
1803
  <!-- ===== migrations.md ===== -->
1562
1804
 
1563
1805
  # Migrations
@@ -2040,16 +2282,51 @@ stand-in and never needs a database.
2040
2282
 
2041
2283
  ```ts
2042
2284
  // services/note.service.test.ts — `npm test`, no database
2043
- import assert from "node:assert/strict";
2044
2285
  import { test } from "node:test";
2286
+ import assert from "node:assert/strict";
2287
+
2045
2288
  import { NoteService } from "./note.service.ts";
2046
2289
 
2047
- test("list filters by owner", async () => {
2290
+ // WHY THIS TEST NEEDS NO DATABASE
2291
+ //
2292
+ // `NoteService` is handed the table it works on rather than reaching for the
2293
+ // singleton itself. That constructor is the seam: a stand-in goes in here, and
2294
+ // the logic — which rows, whose, in what order — is exercised without a
2295
+ // database. Test your own services the same way.
2296
+ //
2297
+ // When you want the whole database surface instead of one table, `fakeDatabase()`
2298
+ // from `@palbase/backend/test` is the stand-in.
2299
+ //
2300
+ // Node's ESM resolver wants the extension on a relative import inside a test
2301
+ // (`./note.service.ts`); this scaffold's `tsconfig.json` allows it.
2302
+
2303
+ test("list asks only for the caller's notes", async () => {
2048
2304
  const seen: unknown[] = [];
2049
- const fake = { findMany: async (q: unknown) => (seen.push(q), []) };
2050
- await new NoteService(fake as never).list("u_1");
2305
+ const notes = {
2306
+ findMany: async (where: unknown) => {
2307
+ seen.push(where);
2308
+ return [];
2309
+ },
2310
+ };
2311
+
2312
+ await new NoteService(notes as never).list("u_1");
2313
+
2051
2314
  assert.deepEqual(seen, [{ user_id: "u_1" }]);
2052
2315
  });
2316
+
2317
+ test("create writes ownership from the argument, never from the body", async () => {
2318
+ const written: unknown[] = [];
2319
+ const notes = {
2320
+ insert: async (row: unknown) => {
2321
+ written.push(row);
2322
+ return row;
2323
+ },
2324
+ };
2325
+
2326
+ await new NoteService(notes as never).create("u_1", "hello");
2327
+
2328
+ assert.deepEqual(written, [{ user_id: "u_1", body: "hello" }]);
2329
+ });
2053
2330
  ```
2054
2331
 
2055
2332
  Node's ESM resolver wants the extension on a relative import inside a test