@palbase/backend 33.0.0 → 33.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 (53) hide show
  1. package/dist/bin/palbase-backend.cjs.map +1 -1
  2. package/dist/bin/palbase-backend.js +4 -4
  3. package/dist/{chunk-DCDHAKF3.js → chunk-IKDONZ5D.js} +4 -4
  4. package/dist/{chunk-C4ZA5AT2.js → chunk-SI4KGEM3.js} +1 -1
  5. package/dist/{chunk-C4ZA5AT2.js.map → chunk-SI4KGEM3.js.map} +1 -1
  6. package/dist/{chunk-QMFOL2K6.js → chunk-TVCCR6SO.js} +2 -2
  7. package/dist/{chunk-N54QZER3.js → chunk-WWUG2QXF.js} +2 -2
  8. package/dist/{chunk-GOPZPM2A.js → chunk-YIQ4RS4F.js} +2 -2
  9. package/dist/db/index.cjs.map +1 -1
  10. package/dist/db/index.d.cts +1 -1
  11. package/dist/db/index.d.ts +1 -1
  12. package/dist/db/index.js +2 -2
  13. package/dist/engine/index.cjs.map +1 -1
  14. package/dist/engine/index.d.cts +3 -3
  15. package/dist/engine/index.d.ts +3 -3
  16. package/dist/engine/index.js +4 -4
  17. package/dist/{index-Bi74dcOu.d.ts → index-D-3duy8Y.d.ts} +2 -2
  18. package/dist/{index-BWgnGj68.d.cts → index-DB_nW-AV.d.cts} +19 -10
  19. package/dist/{index-g6iQyYci.d.cts → index-DLveQoOf.d.cts} +2 -2
  20. package/dist/{index-Dg10RnZO.d.ts → index-DMZG3kpo.d.ts} +19 -10
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +6 -6
  23. package/dist/index.d.ts +6 -6
  24. package/dist/index.js +4 -4
  25. package/dist/openapi/index.d.cts +2 -2
  26. package/dist/openapi/index.d.ts +2 -2
  27. package/dist/{registry-6VT5RPeO.d.cts → registry-DSTThhKf.d.cts} +1 -1
  28. package/dist/{registry-B-sxJJN0.d.ts → registry-JjF5lcj4.d.ts} +1 -1
  29. package/dist/test/index.cjs +1120 -4
  30. package/dist/test/index.cjs.map +1 -1
  31. package/dist/test/index.d.cts +1 -1
  32. package/dist/test/index.d.ts +1 -1
  33. package/dist/test/index.js +1121 -5
  34. package/dist/test/index.js.map +1 -1
  35. package/docs/README.md +123 -79
  36. package/docs/auth.md +11 -7
  37. package/docs/background.md +16 -7
  38. package/docs/database.md +22 -18
  39. package/docs/endpoints.md +26 -20
  40. package/docs/errors.md +2 -3
  41. package/docs/events.md +63 -29
  42. package/docs/getting-started.md +8 -7
  43. package/docs/llms-full.txt +399 -232
  44. package/docs/migrations.md +6 -5
  45. package/docs/routing.md +34 -11
  46. package/docs/schema.md +61 -36
  47. package/docs/services.md +30 -13
  48. package/package.json +2 -1
  49. package/stack-images.json +24 -0
  50. /package/dist/{chunk-DCDHAKF3.js.map → chunk-IKDONZ5D.js.map} +0 -0
  51. /package/dist/{chunk-QMFOL2K6.js.map → chunk-TVCCR6SO.js.map} +0 -0
  52. /package/dist/{chunk-N54QZER3.js.map → chunk-WWUG2QXF.js.map} +0 -0
  53. /package/dist/{chunk-GOPZPM2A.js.map → chunk-YIQ4RS4F.js.map} +0 -0
@@ -24,15 +24,24 @@ deploy gate or the TypeScript compile.
24
24
 
25
25
  ### Folder layout
26
26
 
27
+ Every domain is a FOLDER under `modules/`, carrying its own controller, service,
28
+ schemas, and the one module that lists them:
29
+
27
30
  ```
28
- controllers/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
29
- models/<controller>/<endpoint>.ts # zod schemas one folder per controller, one file per endpoint (+ shared.ts)
30
- services/<name>.service.ts # plain class + singleton — the real logic (controllers stay thin)
31
- db/<schema>.ts # one file per Postgres schema (db/public.ts in a fresh scaffold)
31
+ modules/<domain>/<domain>.module.ts # @Module: controllers · providers · exports · imports
32
+ modules/<domain>/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
33
+ modules/<domain>/<name>.service.ts # @Injectable() class — the real logic (controllers stay thin)
34
+ modules/<domain>/dto/<endpoint>.ts # zod schemas — one file per endpoint
35
+ db/<schema>.ts # one file per Postgres schema (db/public.ts in a fresh scaffold)
32
36
  ```
33
37
 
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
38
+ There is no root `controllers/`, `services/` or `models/` directory, and no
39
+ `app.module.ts` to mount anything into: a `*.module.ts` is found wherever it
40
+ lives, so adding a domain means adding a folder — never a line in a file every
41
+ branch edits.
42
+
43
+ The schema stays at the root, in `db/`. Three more directories are discovered by
44
+ name, one class per file, `export default` required: `jobs/` (background —
36
45
  [background.md](./background.md)), `webhooks/` and `hooks/` (events —
37
46
  [events.md](./events.md)).
38
47
 
@@ -66,7 +75,7 @@ service the controllers call.
66
75
  + the runtime read that named type to bind the 200 response zod schema. There
67
76
  is no `@Returns` decorator. A body route with no named return type is a build
68
77
  error; a method with no response body annotates `: Promise<void>` / `: void`.
69
- 5. **Schemas live in `models/<controller>/<endpoint>.ts`,** exported as a zod
78
+ 5. **Schemas live in `modules/<domain>/dto/<endpoint>.ts`,** exported as a zod
70
79
  VALUE and a same-named `z.infer` TYPE. The controller imports them and writes
71
80
  `@Body(CreateTodoBody) body: CreateTodoBody` (value in the decorator, same name
72
81
  as the type annotation).
@@ -86,7 +95,7 @@ service the controllers call.
86
95
  ### Complete CRUD example (copy-pasteable, compiles)
87
96
 
88
97
  ```ts
89
- // models/todos/shared.ts — the response shape, reused across endpoints.
98
+ // modules/todos/dto/shared.ts — the response shape, reused across endpoints.
90
99
  import { z } from "@palbase/backend";
91
100
  export const TodoSchema = z.object({
92
101
  id: z.string(),
@@ -97,41 +106,63 @@ export type TodoSchema = z.infer<typeof TodoSchema>;
97
106
  ```
98
107
 
99
108
  ```ts
100
- // models/todos/create.ts — the POST body.
109
+ // modules/todos/dto/create.ts — the POST body.
101
110
  import { z } from "@palbase/backend";
102
111
  export const CreateTodoBody = z.object({ title: z.string().min(1) });
103
112
  export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
104
113
  ```
105
114
 
106
115
  ```ts
107
- // services/todo.service.ts — plain class + singleton. The real work.
116
+ // modules/todos/todo.service.ts — @Injectable() classes. The real work.
108
117
  import { Database, Injectable, NotFound } from "@palbase/backend";
109
- import type { TodoSchema } from "../models/todos/shared.js";
118
+ import type { Tables } from "@palbase/backend/env";
110
119
 
111
- /** The typed surface of ONE table. Naming it keeps the seam one table wide:
112
- * a test fake implements five methods, not the whole `Database`. */
113
- type TodosTable = typeof Database.public.todos;
120
+ /** One row of `todos`, exactly as `db/public.ts` declares it. */
121
+ export type Todo = Tables["todos"]["row"];
122
+
123
+ /** The one table this vertical touches, behind a class the container can build.
124
+ * `Database.public.todos` is a VALUE, and a dependency is named by its
125
+ * parameter's TYPE — so the seam is an `abstract class`, and it stays ONE table
126
+ * wide: a test fake implements four methods, not the whole `Database`. */
127
+ export abstract class TodoRepo {
128
+ // Mirror the table's own call shape: `findMany` takes `{ where, orderBy, … }`,
129
+ // not a bare filter.
130
+ abstract findMany(q: { where: { user_id: string } }): Promise<Todo[]>;
131
+ abstract insert(row: { user_id: string; title: string }): Promise<Todo>;
132
+ abstract findById(id: string): Promise<Todo | null>;
133
+ abstract delete(id: string): Promise<void>;
134
+ }
114
135
 
115
136
  @Injectable()
116
- export class TodoService {
117
- private readonly todos: TodosTable;
118
-
119
- // THE SEAM IS THE CONSTRUCTOR: the class is handed the table rather than
120
- // reaching for the singleton, so a test constructs it with a stand-in and
121
- // never needs a database. Assign in the BODY — a parameter property
122
- // (`constructor(private todos: …)`) is refused by Node's type-stripping test
123
- // runner, and refused for the whole FILE.
124
- constructor(todos: TodosTable) {
125
- this.todos = todos;
137
+ export class DbTodoRepo extends TodoRepo {
138
+ findMany(q: { where: { user_id: string } }): Promise<Todo[]> {
139
+ return Database.public.todos.findMany(q);
126
140
  }
141
+ insert(row: { user_id: string; title: string }): Promise<Todo> {
142
+ return Database.public.todos.insert(row);
143
+ }
144
+ findById(id: string): Promise<Todo | null> {
145
+ return Database.public.todos.findById(id);
146
+ }
147
+ delete(id: string): Promise<void> {
148
+ return Database.public.todos.delete(id);
149
+ }
150
+ }
127
151
 
128
- list(userId: string): Promise<TodoSchema[]> {
152
+ @Injectable()
153
+ export class TodoService {
154
+ // THE SEAM IS THE CONSTRUCTOR: the container supplies this, so a test hands in
155
+ // a stand-in and never needs a database. Nothing registers it and nothing
156
+ // wires it — `todos.module.ts` lists the class, and that list is the ownership.
157
+ constructor(private readonly todos: TodoRepo) {}
158
+
159
+ list(userId: string): Promise<Todo[]> {
129
160
  return this.todos.findMany({ where: { user_id: userId } });
130
161
  }
131
- create(userId: string, title: string): Promise<TodoSchema> {
162
+ create(userId: string, title: string): Promise<Todo> {
132
163
  return this.todos.insert({ user_id: userId, title });
133
164
  }
134
- async get(userId: string, id: string): Promise<TodoSchema> {
165
+ async get(userId: string, id: string): Promise<Todo> {
135
166
  const t = await this.todos.findById(id);
136
167
  if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
137
168
  return t;
@@ -141,68 +172,74 @@ export class TodoService {
141
172
  await this.todos.delete(id);
142
173
  }
143
174
  }
144
-
145
175
  ```
146
176
 
147
177
  ```ts
148
- // todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
178
+ // modules/todos/todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
149
179
  import { Module, type Token } from "@palbase/backend";
150
- import { TodosController } from "./controllers/todos.controller.ts";
151
- import { TodoService } from "./services/todo.service.ts";
180
+ import { TodosController } from "./todos.controller.ts";
181
+ import { DbTodoRepo, TodoService } from "./todo.service.ts";
152
182
 
153
183
  @Module({
154
184
  controllers: [TodosController as Token],
155
- providers: [TodoService as Token],
185
+ providers: [TodoService as Token, DbTodoRepo as Token],
186
+ exports: [], // which of its own classes ANOTHER module may reach
187
+ imports: [], // whose exports THIS module may reach
156
188
  })
157
189
  export class TodosModule {}
158
190
  ```
159
191
 
160
- A class no module lists does not exist: it is refused at build, by name, and it
161
- never reaches the route table or the OpenAPI document. Nothing is wired by hand
162
- a controller names `TodoService` as a constructor parameter and the container
163
- supplies it. See [services.md](./services.md#your-own-service-layer).
192
+ The abstraction is the TOKEN and the implementation is the PROVIDER: `DbTodoRepo`
193
+ is listed, `TodoRepo` is not the container resolves an abstraction to the single
194
+ class that `extends` it. A class no module lists does not exist: it is refused at
195
+ build, by name, and it never reaches the route table or the OpenAPI document.
196
+ Nothing is wired by hand — a controller names `TodoService` as a constructor
197
+ parameter and the container supplies it. See
198
+ [services.md](./services.md#your-own-service-layer).
164
199
 
165
200
  ```ts
166
- // controllers/todos.controller.ts — class controller. Thin: delegates to the service.
201
+ // modules/todos/todos.controller.ts — class controller. Thin: delegates to the service.
167
202
  import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
168
203
  import type { UserT } from "@palbase/backend"; // the user TYPE (@User is the value)
169
- import { todoService } from "../services/todo.service.js";
170
- import { TodoSchema } from "../models/todos/shared.js";
171
- import { CreateTodoBody } from "../models/todos/create.js";
204
+ import { TodoService } from "./todo.service";
205
+ import { TodoSchema } from "./dto/shared";
206
+ import { CreateTodoBody } from "./dto/create";
172
207
 
173
208
  @Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
174
- export default class TodosController {
175
- // The service arrives as an IMPORTED SINGLETON, not a constructor parameter:
176
- // the runtime constructs this class with no arguments, and one that declares a
177
- // parameter is refused when the route table is built at boot, with the class named.
209
+ export class TodosController {
210
+ // The service arrives through the CONSTRUCTOR and the container supplies it.
211
+ // Nothing is imported as an instance and nothing is wired by hand.
212
+ constructor(private readonly todos: TodoService) {}
178
213
 
179
214
  @Get("") // GET /todos → operationId todos.list
180
- async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
181
- return todoService.list(user.id);
215
+ list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
216
+ return this.todos.list(user.id);
182
217
  }
183
218
 
184
219
  @Post("") // POST /todos → todos.create
185
- async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
186
- return todoService.create(user.id, body.title);
220
+ create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
221
+ return this.todos.create(user.id, body.title);
187
222
  }
188
223
 
189
224
  @Get("/{id}") // GET /todos/{id} → todos.get
190
- async get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
191
- return todoService.get(user.id, id);
225
+ get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
226
+ return this.todos.get(user.id, id);
192
227
  }
193
228
 
194
229
  @Delete("/{id}") // DELETE /todos/{id} → todos.remove; no body → : Promise<void>
195
- async remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
196
- await todoService.remove(user.id, id);
230
+ remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
231
+ return this.todos.remove(user.id, id);
197
232
  }
198
233
  }
199
- // No export is needed at all @Controller registered the class as it decorated
200
- // it. `export default` here is style, not a requirement.
234
+ // Exported by NAME, because `todos.module.ts` imports it to list it in
235
+ // `controllers` that list IS the registration. Nothing here is
236
+ // default-exported.
201
237
  ```
202
238
 
203
239
  ```ts
204
240
  // db/public.ts — config-as-code; the deploy auto-migrates additive changes.
205
241
  // ONE SCHEMA PER FILE: this is `public`; a sibling `db/billing.ts` declares its own.
242
+ // The schema stays at the ROOT — it is not part of any module.
206
243
  import { defineSchema, defineTable, uuid, text, boolean, timestamp, policy, ownedByUser } from "@palbase/backend";
207
244
 
208
245
  export const todos = defineTable("todos", {
@@ -213,8 +250,8 @@ export const todos = defineTable("todos", {
213
250
  completed: boolean().default(false),
214
251
  created_at: timestamp().defaultNow(),
215
252
  },
216
- rls: true, // every Database.* query runs as the request user
217
- policies: [
253
+ // RLS is ON by default, and `policies` is a CALLBACK not an array.
254
+ policies: () => [
218
255
  policy("pb_owner_all")
219
256
  .for("all")
220
257
  .to("authenticated")
@@ -270,54 +307,63 @@ generated client surface) changes; the verb/path do not affect it.
270
307
 
271
308
  ## Mental model
272
309
 
273
- Every handler type imports service singletons the same way:
310
+ Every handler type reaches the PLATFORM services through the same import:
274
311
 
275
312
  ```ts
276
- import { Database, Log, Cache, Queue } from "@palbase/backend";
313
+ import { Database, Log, Cache } from "@palbase/backend";
277
314
  ```
278
315
 
316
+ (Your OWN services are different: a controller names one as a constructor
317
+ parameter and the container supplies it — see the example above.)
318
+
279
319
  The **only difference** is the trigger argument:
280
320
 
281
321
  | You are writing… | Handler signature | Trigger arg |
282
322
  |------------------|-------------------|-------------|
283
- | **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@QueryParams`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
323
+ | **Endpoints** (`modules/<domain>/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@QueryParams`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
284
324
  | **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
285
325
  | **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
286
326
  | **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
287
327
 
288
328
  `defineMiddleware(fn)` **throws when called**: no bundler reads a `middleware/`
289
329
  directory and the engine never calls one, so a handler written against it would
290
- deploy and never run. There is no `ctx` anywhere on a path that runs — every
291
- handler above imports its services.
330
+ deploy and never run. There is no `ctx` anywhere on a path that runs — a handler
331
+ imports the platform services it needs.
292
332
 
293
333
  `meta` carries non-service data: `env` (Environment variables),
294
- `environmentId`, and for webhooks `requestId`. Services always come from
295
- the imported singletons — not from `ctx` or any argument.
334
+ `environmentId`, and for webhooks `requestId`. Platform services always come
335
+ from the import — not from `ctx` or any argument.
296
336
 
297
337
  ## Project shape
298
338
 
299
339
  ```
300
340
  my-backend/
301
- ├── package.json # depends on @palbase/backend
302
- ├── tsconfig.json # experimentalDecorators: true (required for the decorators)
303
- ├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
304
- └── hello.controller.ts # @Controller("/hello") + @Get example
305
- ├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
306
- │ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
307
- ├── services/ # plain classes/singletons your controllers call
308
- ├── db/public.ts # the database itself: tables, columns, RLS policies
309
- ├── jobs/ # cron-scheduled jobs (optional)
310
- ├── hooks/ # auth/storage/document event hooks (optional)
311
- └── webhooks/ # inbound provider webhooks (optional)
341
+ ├── package.json # depends on @palbase/backend
342
+ ├── tsconfig.json # experimentalDecorators + emitDecoratorMetadata (both required)
343
+ ├── modules/ # ONE FOLDER PER DOMAIN there is no root module
344
+ ├── health/
345
+ │ │ ├── health.controller.ts # @Controller("/health", { auth: false }) + @Get
346
+ └── health.module.ts # the four lists that make the class exist
347
+ │ └── notes/
348
+ ├── notes.controller.ts # HTTP only never imports `Database`
349
+ ├── note.service.ts # @Injectable(); the logic worth testing
350
+ ├── note.service.test.ts # the service, through the container, no database
351
+ │ ├── notes.module.ts # controllers · providers · exports · imports
352
+ │ └── dto/create.ts # zod schemas (value + same-named z.infer type)
353
+ ├── db/public.ts # the database itself: tables, columns, RLS policies
354
+ ├── jobs/ # cron-scheduled jobs (optional)
355
+ ├── hooks/ # auth/storage/document event hooks (optional)
356
+ └── webhooks/ # inbound provider webhooks (optional)
312
357
  ```
313
358
 
314
359
  There is **no `db/migrations/`**. Nothing generates a migration file, nothing
315
360
  commits one and nothing replays one: `db/*.ts` is diffed against the live
316
361
  database and applied — see [migrations.md](./migrations.md).
317
362
 
318
- HTTP endpoints are **not** file-path routed. You author a class controller
319
- (`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
320
- `controllers/` mounts it. See [routing.md](./routing.md).
363
+ HTTP endpoints are **not** file-path routed, and no directory mounts them. You
364
+ author a class controller (`@Controller("/base")` with `@Get`/`@Post`/… methods)
365
+ and a `@Module` lists it in `controllers` that list is the registration. See
366
+ [routing.md](./routing.md).
321
367
 
322
368
  ## Documentation
323
369
 
@@ -336,8 +382,6 @@ HTTP endpoints are **not** file-path routed. You author a class controller
336
382
 
337
383
  For AI coding tools: a single concatenated corpus is generated at
338
384
  [`llms-full.txt`](./llms-full.txt) (and an index at [`llms.txt`](./llms.txt)).
339
- </content>
340
- </invoke>
341
385
 
342
386
 
343
387
 
@@ -378,7 +422,8 @@ The controllers use **decorators**, so the `tsconfig.json` must set
378
422
  "experimentalDecorators": true,
379
423
  "noEmit": true
380
424
  },
381
- "include": ["controllers/**/*.ts", "models/**/*.ts", "services/**/*.ts", "db/**/*.ts", "*.d.ts"]
425
+ "include": ["**/*.ts"],
426
+ "exclude": ["node_modules", ".palbase"]
382
427
  }
383
428
  ```
384
429
 
@@ -388,7 +433,7 @@ Your backend runs on Palbase, not on your laptop — there is no local runtime t
388
433
  start. The loop is: edit, validate, push to a dev Environment.
389
434
 
390
435
  - `palbase build` — validate the tree the way the deploy will. It stages and
391
- bundles your `controllers/` exactly as the deploy does and runs the deploy's
436
+ bundles your `modules/` exactly as the deploy does and runs the deploy's
392
437
  own metadata extractor over the result, so a bad decorator, an illegal return
393
438
  type or an SDK major skew fails here rather than shipping a deploy that
394
439
  serves zero endpoints. It is wired into a `pre-push` git hook for you. It also
@@ -402,10 +447,10 @@ start. The loop is: edit, validate, push to a dev Environment.
402
447
 
403
448
  ## Your first endpoint
404
449
 
405
- An endpoint is a method on a class controller. Declare the schemas in `models/`:
450
+ An endpoint is a method on a class controller. Everything for one domain lives in `modules/<domain>/`; declare the schemas in its `dto/`:
406
451
 
407
452
  ```ts
408
- // models/hello/greet.ts
453
+ // modules/hello/dto/greet.ts
409
454
  import { z } from "@palbase/backend";
410
455
 
411
456
  export const GreetQuery = z.object({ name: z.string().optional() });
@@ -415,13 +460,13 @@ export const HelloResponse = z.object({ message: z.string(), user: z.string().nu
415
460
  export type HelloResponse = z.infer<typeof HelloResponse>;
416
461
  ```
417
462
 
418
- Then write the controller in `controllers/hello.controller.ts`:
463
+ Then write the controller in `modules/hello/hello.controller.ts`:
419
464
 
420
465
  ```ts
421
466
  import { Controller, Get, QueryParams, OptionalUser } from "@palbase/backend";
422
467
  import type { UserT } from "@palbase/backend";
423
- import { GreetQuery } from "../models/hello/greet.js";
424
- import type { HelloResponse } from "../models/hello/greet.js"; // the return TYPE names the 200 schema
468
+ import { GreetQuery } from "./dto/greet.js";
469
+ import type { HelloResponse } from "./dto/greet.js"; // the return TYPE names the 200 schema
425
470
 
426
471
  @Controller("/hello", { auth: false })
427
472
  export default class HelloController {
@@ -461,8 +506,11 @@ Routes are declared in code with **class controllers**. A controller is a class
461
506
  decorated with `@Controller(basePath)`; each route is a method decorated with
462
507
  `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`/`@Query`. Request input + context are
463
508
  injected into the method via **parameter decorators** (`@Body`/`@QueryParams`/
464
- `@Param`/`@User`/…). Putting a controller file under `controllers/` mounts it
465
- there is no central router and no manual registration.
509
+ `@Param`/`@User`/…). A controller is mounted by being LISTED: its module names
510
+ it in `controllers`, and that list is the only place ownership is decided. A
511
+ class no module lists does not exist — the build refuses it by name, and it
512
+ never reaches the route table or the OpenAPI document. Being in a particular
513
+ folder grants nothing.
466
514
 
467
515
  ```ts
468
516
  import { Controller, Get, Post, Body, QueryParams, Param, User } from "@palbase/backend";
@@ -471,31 +519,51 @@ import { Controller, Get, Post, Body, QueryParams, Param, User } from "@palbase/
471
519
  ## Controllers — class + method decorators
472
520
 
473
521
  `@Controller(basePath)` marks the class and sets the mount path. Each route
474
- method declares its verb + subpath; the real work lives in a `services/` class
475
- (the controller method is thin).
522
+ method declares its verb + subpath; the real work lives in an `@Injectable()`
523
+ service that arrives through the CONSTRUCTOR (the controller method is thin).
476
524
 
477
525
  ```ts
478
- // controllers/places.controller.ts
526
+ // modules/places/places.controller.ts
479
527
  import { Controller, Get, Post, Body, User } from "@palbase/backend";
480
528
  import type { UserT } from "@palbase/backend";
481
- import { placeService } from "../services/place.service.js";
482
- import { ImportNearbyBody } from "../models/places/import.js";
483
- import type { PlaceSchema } from "../models/places/shared.js"; // the return TYPE names the 200 schema
529
+ import { PlaceService } from "./place.service.js";
530
+ import { ImportNearbyBody } from "./dto/import.js";
531
+ import { PlaceSchema } from "./dto/shared.js"; // the return TYPE names the 200 schema
484
532
 
485
533
  @Controller("/places")
486
- export default class PlacesController {
534
+ export class PlacesController {
535
+ constructor(private readonly places: PlaceService) {}
536
+
487
537
  @Post("/import")
488
538
  importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
489
- return placeService.importNearby(body.lat, body.lng, user.id);
539
+ return this.places.importNearby(body.lat, body.lng, user.id);
490
540
  }
491
541
 
492
542
  @Get("/favorites", { auth: false })
493
543
  listFavorites(): PlaceSchema[] {
494
- return placeService.listFavorites();
544
+ return this.places.listFavorites();
495
545
  }
496
546
  }
497
547
  ```
498
548
 
549
+ The class is mounted by being LISTED, and nothing else mounts it:
550
+
551
+ ```ts
552
+ // modules/places/places.module.ts
553
+ import { Module, type Token } from "@palbase/backend";
554
+
555
+ import { PlacesController } from "./places.controller.js";
556
+ import { PlaceService } from "./place.service.js";
557
+
558
+ @Module({
559
+ controllers: [PlacesController as Token],
560
+ providers: [PlaceService as Token],
561
+ exports: [],
562
+ imports: [],
563
+ })
564
+ export class PlacesModule {}
565
+ ```
566
+
499
567
  | Method name | Verb | Full path | operationId (dotted) |
500
568
  |---|---|---|---|
501
569
  | `importNearby` | POST | `/places/import` | `places.importNearby` |
@@ -533,36 +601,41 @@ options, the parameter decorators, auth cascade, and error classes).
533
601
 
534
602
  An endpoint is a **method on a class controller** — a class decorated with
535
603
  `@Controller(basePath)` whose methods are decorated with `@Get`/`@Post`/`@Put`/
536
- `@Patch`/`@Delete`/`@Query`. Controller files live under `controllers/` and are
537
- mounted automatically (see [routing.md](./routing.md)). Request input + context
538
- are injected into the method via **parameter decorators** (`@Body`/`@QueryParams`/
539
- `@Param`/`@User`/…), each piece direct no `req` god-object. Services are reached via the
540
- imported singletons (see [services.md](./services.md)).
604
+ `@Patch`/`@Delete`/`@Query`. A controller lives in its domain's folder
605
+ (`modules/<domain>/<name>.controller.ts`) and a `@Module` lists it in
606
+ `controllers` that list is the registration, not the directory (see
607
+ [routing.md](./routing.md)). Request input + context are injected into the method
608
+ via **parameter decorators** (`@Body`/`@QueryParams`/`@Param`/`@User`/…), each
609
+ piece direct — no `req` god-object. Your own services arrive through the
610
+ CONSTRUCTOR and the container supplies them (see [services.md](./services.md)).
541
611
 
542
612
  ## A controller
543
613
 
544
614
  ```ts
545
- // controllers/rooms.controller.ts
615
+ // modules/rooms/rooms.controller.ts
546
616
  import { Controller, Get, Post, Body, Param, User } from "@palbase/backend";
547
617
  import type { UserT } from "@palbase/backend";
548
- import { roomService } from "../services/room.service.js";
549
- import { CreateRoomBody } from "../models/rooms/create.js";
550
- import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
618
+ import { RoomService } from "./room.service";
619
+ import { CreateRoomBody } from "./dto/create";
620
+ import { RoomSchema } from "./dto/shared"; // the return TYPE names the 200 schema
551
621
 
552
622
  // A controller does not import `Database`. Everything here is HTTP: validate the
553
623
  // body through a named schema, name the 200 shape as the return type, delegate.
554
624
  // Which rows, whose, in what order is the service's job — and the service is the
555
625
  // thing worth testing, because it is the thing that can be wrong.
556
626
  @Controller("/rooms")
557
- export default class RoomsController {
627
+ export class RoomsController {
628
+ // The service arrives through the constructor; `rooms.module.ts` lists both.
629
+ constructor(private readonly rooms: RoomService) {}
630
+
558
631
  @Post("")
559
632
  create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
560
- return roomService.create(user.id, body);
633
+ return this.rooms.create(user.id, body);
561
634
  }
562
635
 
563
636
  @Get("/{id}")
564
637
  getOne(@Param("id") id: string): Promise<RoomSchema> {
565
- return roomService.get(id);
638
+ return this.rooms.get(id);
566
639
  }
567
640
  }
568
641
  ```
@@ -572,13 +645,14 @@ export default class RoomsController {
572
645
  1. **The controller does not touch the database.** It delegates to a service, as
573
646
  above. A method here that reaches for `Database` has moved the logic into the
574
647
  layer that is hardest to test. (Export the class by NAME so its module can
575
- list it in `controllers`; a class no module lists is refused at build with
576
- `unowned class`. Nothing here is default-exported.)
648
+ import it and list it in `controllers`; a class no module lists is refused at
649
+ build with `unowned class`. Nothing here is default-exported.)
577
650
  2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
578
651
  returns promises, so a body that `await`s it cannot have a sync return type
579
- (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
580
- `async` and return `Promise<RoomSchema>`. (A pure method that returns a literal
581
- with no `await` may stay synchronous.)
652
+ (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above hand
653
+ the service's promise straight back, so they need no `await` — but they still
654
+ return `Promise<RoomSchema>`. (A pure method that returns a literal and touches
655
+ no promise may stay synchronous.)
582
656
 
583
657
  ## Method decorators
584
658
 
@@ -604,8 +678,8 @@ a query string — complex search/filter endpoints that would otherwise be a
604
678
 
605
679
  ```ts
606
680
  @Query("/search")
607
- async search(@Body(SearchTodosBody) body: SearchTodosBody): Promise<TodoSchema[]> {
608
- return todoService.search(body);
681
+ search(@Body(SearchTodosBody) body: SearchTodosBody): Promise<TodoSchema[]> {
682
+ return this.todos.search(body);
609
683
  }
610
684
  ```
611
685
 
@@ -652,7 +726,7 @@ out. Resolution order (most specific wins):
652
726
 
653
727
  ```ts
654
728
  @Controller("/public", { auth: false }) // all routes default public
655
- export default class PublicController {
729
+ export class PublicController {
656
730
  @Get("/open") open(): Info { ... } // inherits → public
657
731
  @Get("/secret", { auth: true }) // OVERRIDES → authed
658
732
  secret(@User() u: UserT): Secret { ... }
@@ -710,17 +784,20 @@ user.
710
784
  ```ts
711
785
  import { Controller, Get, Post, Body, User, OptionalUser } from "@palbase/backend";
712
786
  import type { UserT } from "@palbase/backend";
787
+ import { TodoService } from "./todo.service";
713
788
 
714
789
  @Controller("/todos") // no auth option → every route needs a user
715
- export default class TodosController {
790
+ export class TodosController {
791
+ constructor(private readonly todos: TodoService) {}
792
+
716
793
  @Post("")
717
794
  create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT) {
718
- return todoService.create(user.id, body.title); // user is non-null — guaranteed
795
+ return this.todos.create(user.id, body.title); // user is non-null — guaranteed
719
796
  }
720
797
 
721
798
  @Get("/featured", { auth: false }) // one public route
722
799
  featured(@OptionalUser() user: UserT | null) {
723
- return todoService.featured(user?.id ?? null); // may be null — handle it
800
+ return this.todos.featured(user?.id ?? null); // may be null — handle it
724
801
  }
725
802
  }
726
803
  ```
@@ -728,11 +805,12 @@ export default class TodosController {
728
805
  ## What `@User()` gives you
729
806
 
730
807
  ```ts
731
- interface User {
808
+ interface UserT { // exported as `UserT`; the value name `User` is the decorator
732
809
  id: string;
733
810
  email?: string; // absent for phone-only users
734
811
  emailVerified: boolean;
735
- role: string;
812
+ role: string; // the DATABASE role — always "authenticated" when signed in
813
+ roles: string[]; // your APPLICATION roles, read per request
736
814
  metadata: Record<string, unknown>;
737
815
  device: VerifiedDevice | null;
738
816
  }
@@ -781,7 +859,7 @@ transaction as the user row.
781
859
 
782
860
  ```ts
783
861
  @Controller("/notes")
784
- export default class NotesController {
862
+ export class NotesController {
785
863
  // Anyone signed in.
786
864
  @Get("") list(@User() user: UserT) { … }
787
865
 
@@ -899,7 +977,7 @@ publish(@User() user: UserT) {
899
977
  if (!user.emailVerified) {
900
978
  throw new Forbidden("Confirm your email address before publishing.");
901
979
  }
902
- return postService.publish(user.id);
980
+ return this.posts.publish(user.id);
903
981
  }
904
982
  ```
905
983
 
@@ -975,8 +1053,9 @@ jobs, hooks, and webhooks all use the same import:
975
1053
  import { Database } from "@palbase/backend";
976
1054
  ```
977
1055
 
978
- Only **middleware** still uses `ctx.db` (see [background.md](./background.md)
979
- and [events.md](./events.md) for worker/job/hook/webhook examples).
1056
+ There is no `ctx` and no `ctx.db` anywhere on a path that runs — every handler
1057
+ imports `Database` (see [background.md](./background.md) and
1058
+ [events.md](./events.md) for the job/hook/webhook examples).
980
1059
 
981
1060
  ## Typed by default — `Database.<şema>.<tablo>`
982
1061
 
@@ -1044,7 +1123,9 @@ await Database.public.crew_presence.findMany({
1044
1123
  AND: [sqlFragment`expires_at > now()`],
1045
1124
  },
1046
1125
  });
1047
- ``` The same filter language works for `updateMany`, `deleteMany`,
1126
+ ```
1127
+
1128
+ The same filter language works for `updateMany`, `deleteMany`,
1048
1129
  `count` and `search` — one language, so two spellings cannot drift apart. An
1049
1130
  empty `in` list means NO rows (written into the SQL as `false`), never a silent
1050
1131
  full scan.
@@ -1340,7 +1421,7 @@ filter object built from request input that happened to come back empty — is
1340
1421
  exactly the shape that must not silently succeed. `count()` with no filter IS
1341
1422
  allowed: counting is a read.
1342
1423
 
1343
- ### `Database.query` — raw SQL
1424
+ ### `Database.$query` — raw SQL
1344
1425
 
1345
1426
  Use it for shapes the helpers do not cover (joins, aggregates, window functions).
1346
1427
  It runs in **the request's own transaction**, not a read-only one: a write inside
@@ -1365,7 +1446,7 @@ per row with a `23505` caught around each:
1365
1446
 
1366
1447
  ```ts
1367
1448
  await Database.$transaction((tx) => {
1368
- tx.tables.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
1449
+ tx.public.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
1369
1450
  return null;
1370
1451
  });
1371
1452
  ```
@@ -1422,11 +1503,11 @@ middle, so nothing holds a database connection open while your code thinks.
1422
1503
  import { Database, NotFound } from "@palbase/backend";
1423
1504
 
1424
1505
  const { orderId } = await Database.$transaction((tx) => {
1425
- const order = tx.tables.orders
1506
+ const order = tx.public.orders
1426
1507
  .insert({ amount: 1000, status: "pending" })
1427
1508
  .expectOne(new NotFound("order could not be created"));
1428
1509
 
1429
- tx.tables.order_items.insertMany(
1510
+ tx.public.order_items.insertMany(
1430
1511
  cart.map((line) => ({ order_id: order.id, sku: line.sku })),
1431
1512
  );
1432
1513
 
@@ -1482,7 +1563,7 @@ filter plus an expectation — which is also stronger, because the check and the
1482
1563
  write are now the same statement and nothing can slip between them:
1483
1564
 
1484
1565
  ```ts
1485
- tx.tables.invites
1566
+ tx.public.invites
1486
1567
  .updateWhere({ token, accepted_at: null }, { accepted_at: now() })
1487
1568
  .expectOne(new Conflict("invite already used", "invite_used"));
1488
1569
  ```
@@ -1522,7 +1603,7 @@ await Database.public.accounts.updateMany({
1522
1603
 
1523
1604
  // Inside a plan: the same expression, the same meaning.
1524
1605
  await Database.$transaction((tx) => {
1525
- tx.tables.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
1606
+ tx.public.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
1526
1607
  return null;
1527
1608
  });
1528
1609
  ```
@@ -1628,7 +1709,8 @@ Sometimes you need to read or write **across all users** — an admin endpoint,
1628
1709
  background job that fans out notifications, a cleanup task. For that, call
1629
1710
  `Database.$asService()`. It returns a sibling client that runs as
1630
1711
  **`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
1631
- exact same surface — `tables`, the raw string ops, and `transaction`:
1712
+ exact same surface — `public` (and every other schema), the `$`-prefixed raw ops,
1713
+ and `$transaction`:
1632
1714
 
1633
1715
  ```ts
1634
1716
  import { Database } from "@palbase/backend";
@@ -1637,32 +1719,32 @@ import { Database } from "@palbase/backend";
1637
1719
  const mine = await Database.public.todos.findMany({});
1638
1720
 
1639
1721
  // Service-role bypass: every user's rows. Explicit and greppable.
1640
- const all = await Database.$asService().tables.todos.findMany({});
1641
- const rows = await Database.$asService().query("SELECT count(*) FROM todos");
1722
+ const all = await Database.$asService().public.todos.findMany({});
1723
+ const rows = await Database.$asService().$query("SELECT count(*) FROM todos");
1642
1724
 
1643
1725
  // A service-role transaction (the role is fixed for the whole plan):
1644
- await Database.$asService().transaction((tx) => {
1645
- tx.tables.todos.updateWhere({ id }, { done: true });
1726
+ await Database.$asService().$transaction((tx) => {
1727
+ tx.public.todos.updateWhere({ id }, { done: true });
1646
1728
  return null;
1647
1729
  });
1648
1730
  ```
1649
1731
 
1650
1732
  Guidelines:
1651
1733
 
1652
- - **Be explicit.** Prefer the default `Database.*` and reach for `asService()`
1734
+ - **Be explicit.** Prefer the default `Database.*` and reach for `$asService()`
1653
1735
  only where you genuinely need cross-user access. It is intentionally easy to
1654
1736
  grep for in review.
1655
1737
  - **No double-bypass / no nesting.** The sibling does not re-expose
1656
- `asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
1738
+ `$asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
1657
1739
  transaction. Use `Database.$transaction(...)` for an authenticated one and
1658
- `Database.$asService().transaction(...)` for a service-role one; you cannot mix
1740
+ `Database.$asService().$transaction(...)` for a service-role one; you cannot mix
1659
1741
  enforced and bypassed operations inside a single plan.
1660
1742
  - **It is a second transaction, on a second connection.** It has to be: the
1661
1743
  Postgres role is bound once, when the transaction opens, so a sibling sharing
1662
1744
  the request's transaction would run as `backend_authenticated` and silently
1663
1745
  keep filtering. Three consequences worth knowing:
1664
1746
  - The request opens it at most once, however many times you call
1665
- `asService()`, and never at all if you don't use it.
1747
+ `$asService()`, and never at all if you don't use it.
1666
1748
  - Both settle with the request — commit when your handler returns, roll back
1667
1749
  when it throws — but they settle as two transactions. A row written through
1668
1750
  one surface is **not visible** to the other until the request commits.
@@ -1696,9 +1778,10 @@ those values — never a dictionary, because a name in a dictionary key is a sec
1696
1778
  place the name is written, and a table built under a key does not yet know what
1697
1779
  to call itself when a sibling references it.
1698
1780
 
1699
- Each table's only required field is `columns`; `rls` and `policies` enable
1700
- [Row-Level Security](#row-level-security-rls), and `indexes` declares plain
1701
- btree [indexes](#indexes).
1781
+ Each table's only required field is `columns`; `policies` (a callback — see
1782
+ [Row-Level Security](#row-level-security-rls)) declares the RLS rules, `rls`
1783
+ toggles enforcement (default `true`), and `indexes` declares plain btree
1784
+ [indexes](#indexes).
1702
1785
 
1703
1786
  ```ts
1704
1787
  import {
@@ -2124,14 +2207,28 @@ You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
2124
2207
  of the schema, no generic, no cast:
2125
2208
 
2126
2209
  ```ts
2127
- // services/room.service.ts — the layer that touches the database.
2128
- import { Database } from "@palbase/backend";
2210
+ // modules/rooms/room.service.ts — the layer that touches the database.
2211
+ import { Database, Injectable } from "@palbase/backend";
2212
+ import type { Tables } from "@palbase/backend/env";
2129
2213
 
2130
- type RoomsTable = typeof Database.public.rooms; // typed from your db/*.ts
2214
+ type Room = Tables["rooms"]["row"]; // typed from your db/*.ts
2131
2215
 
2216
+ /** `Database.public.rooms` is a VALUE, and a dependency is named by its
2217
+ * parameter's TYPE — so the seam is an `abstract class`, one table wide. */
2218
+ export abstract class RoomRepo {
2219
+ abstract insert(row: { name: string }): Promise<Room>;
2220
+ }
2221
+
2222
+ @Injectable()
2223
+ export class DbRoomRepo extends RoomRepo {
2224
+ insert(row: { name: string }): Promise<Room> {
2225
+ return Database.public.rooms.insert(row); // typed: rooms.nope ← compile error
2226
+ }
2227
+ }
2228
+
2229
+ @Injectable()
2132
2230
  export class RoomService {
2133
- private readonly rooms: RoomsTable;
2134
- constructor(rooms: RoomsTable) { this.rooms = rooms; }
2231
+ constructor(private readonly rooms: RoomRepo) {}
2135
2232
 
2136
2233
  async create(name: string) {
2137
2234
  const room = await this.rooms.insert({ name });
@@ -2139,41 +2236,48 @@ export class RoomService {
2139
2236
  // room.nope ← compile error
2140
2237
  }
2141
2238
  }
2142
-
2143
2239
  ```
2144
2240
 
2145
2241
  ```ts
2146
- // rooms.module.ts
2242
+ // modules/rooms/rooms.module.ts — the four lists that make these classes exist.
2147
2243
  import { Module, type Token } from "@palbase/backend";
2148
- import { RoomsController } from "./controllers/rooms.controller.ts";
2149
- import { RoomService } from "./services/room.service.ts";
2244
+ import { RoomsController } from "./rooms.controller.ts";
2245
+ import { DbRoomRepo, RoomService } from "./room.service.ts";
2150
2246
 
2151
- @Module({ controllers: [RoomsController as Token], providers: [RoomService as Token] })
2247
+ @Module({
2248
+ controllers: [RoomsController as Token],
2249
+ providers: [RoomService as Token, DbRoomRepo as Token],
2250
+ exports: [],
2251
+ imports: [],
2252
+ })
2152
2253
  export class RoomsModule {}
2153
2254
  ```
2154
2255
 
2155
2256
  ```ts
2156
- // controllers/rooms.controller.ts — HTTP only; no `Database` import here.
2257
+ // modules/rooms/rooms.controller.ts — HTTP only; no `Database` import here.
2157
2258
  import { Controller, Post, Body, z } from "@palbase/backend";
2158
- import { RoomService } from "../services/room.service.js";
2259
+ import { RoomService } from "./room.service";
2159
2260
 
2160
2261
  const CreateRoomBody = z.object({ name: z.string() });
2161
2262
  const RoomOut = z.object({ id: z.string(), name: z.string() });
2162
2263
 
2163
2264
  @Controller("/rooms")
2164
- export default class RoomsController {
2265
+ export class RoomsController {
2266
+ constructor(private readonly rooms: RoomService) {}
2267
+
2165
2268
  @Post("")
2166
2269
  // The return type names the 200 schema — `z.infer<typeof RoomOut>` works
2167
2270
  // inline, no separate `export type` needed.
2168
2271
  create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
2169
- return roomService.create(body.name);
2272
+ return this.rooms.create(body.name);
2170
2273
  }
2171
2274
  }
2172
2275
  ```
2173
2276
 
2174
- `Database.public.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
2175
- `findById(id)`, `findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
2176
- whose `tx.tables.<name>` is typed from the same schema, but carries plan
2277
+ `Database.public.<name>` exposes `insert(values)`,
2278
+ `update({ where: { id }, set })`, `delete(id)`, `findById(id)`,
2279
+ `findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
2280
+ whose `tx.public.<name>` is typed from the same schema, but carries plan
2177
2281
  operations (`insert`/`insertMany`/`updateWhere`/`deleteWhere`/`select`) rather
2178
2282
  than awaited calls — see [database.md](./database.md#transactions). The raw
2179
2283
  string-keyed ops (`Database.$insert("rooms", …)`, `Database.$query(…)`) are still
@@ -2195,10 +2299,11 @@ missing `WHERE user_id = …` in your handler can no longer leak another user's
2195
2299
  rows — the policy enforces it. This is the recommended way to scope data per
2196
2300
  user.
2197
2301
 
2198
- Add `policies` (and optionally `rls`) to a table. `policies` being non-empty
2199
- implies `rls: true` automatically (a table with policies must have RLS enabled
2200
- or the policies are inert). Set `rls: true` with no policies only as a
2201
- deliberate deny-all intermediate step.
2302
+ Add `policies` to a table a **callback** returning the list, `policies: () =>
2303
+ […]`, not a bare array. RLS defaults to `true`, and a non-empty `policies` forces
2304
+ it on regardless (a table with policies must have RLS enabled or the policies are
2305
+ inert). Write `rls: true` with no policies only as a deliberate deny-all
2306
+ intermediate step.
2202
2307
 
2203
2308
  ### The `policy()` builder
2204
2309
 
@@ -2265,15 +2370,17 @@ from `auth.uid()`, so a policy cannot ask about somebody else. A revoked role is
2265
2370
  gone from the very next statement; there is nothing cached.
2266
2371
 
2267
2372
  ```ts
2268
- policy("notes_owner").for("all")
2269
- .using("user_id = (select auth.uid())")
2270
- .withCheck("user_id = (select auth.uid())"),
2373
+ policies: () => [
2374
+ policy("notes_owner").for("all")
2375
+ .using("user_id = (select auth.uid())")
2376
+ .withCheck("user_id = (select auth.uid())"),
2271
2377
 
2272
- // Moderation. Permissive policies are OR'd, so these widen only.
2273
- policy("notes_moderate_read").for("select")
2274
- .using("(select auth.has_permission('notes.delete_any'))"),
2275
- policy("notes_delete_any").for("delete")
2276
- .using("(select auth.has_permission('notes.delete_any'))"),
2378
+ // Moderation. Permissive policies are OR'd, so these widen only.
2379
+ policy("notes_moderate_read").for("select")
2380
+ .using("(select auth.has_permission('notes.delete_any'))"),
2381
+ policy("notes_delete_any").for("delete")
2382
+ .using("(select auth.has_permission('notes.delete_any'))"),
2383
+ ],
2277
2384
  ```
2278
2385
 
2279
2386
  > **The read policy is not optional.** `DELETE … WHERE id = $1` has to FIND the
@@ -2303,8 +2410,8 @@ export const todos = defineTable("todos", {
2303
2410
  done: boolean().default(false),
2304
2411
  created_at: timestamp().defaultNow(),
2305
2412
  },
2306
- // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
2307
- policies: [
2413
+ // `policies` is a CALLBACK. Non-empty ⇒ RLS is enabled + FORCEd automatically.
2414
+ policies: () => [
2308
2415
  // Read: a user sees only their own todos.
2309
2416
  policy("pb_todos_owner_select")
2310
2417
  .for("select")
@@ -2436,7 +2543,7 @@ Code and schema move at different speeds, so three things keep them together:
2436
2543
  ## Your schema change is not live anywhere you have not applied it
2437
2544
 
2438
2545
  The local stack and every Environment hold their own database, and editing
2439
- Editing `db/*.ts` changes none of them: `palbase db apply` is what moves the local
2546
+ `db/*.ts` changes none of them: `palbase db apply` is what moves the local
2440
2547
  one, `palbase push` is what moves an Environment's. Until then the declaration is
2441
2548
  ahead of the tables.
2442
2549
 
@@ -2511,10 +2618,11 @@ existing data gets there.
2511
2618
 
2512
2619
  ## Row-Level Security
2513
2620
 
2514
- Add `rls: true` + `policies: [policy(...)]` to a table in `db/*.ts`; the
2515
- generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
2516
- See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
2517
- `Database.public.*` access.
2621
+ Add `policies: () => [policy(...)]` to a table in `db/*.ts` — a CALLBACK, not an
2622
+ array — and the generated migration emits the `ENABLE ROW LEVEL SECURITY` +
2623
+ `CREATE POLICY` DDL. RLS is on by default, so `rls: true` is only worth writing
2624
+ as a deliberate deny-all with no policies. See [schema.md](./schema.md) for the
2625
+ column builders, the policy DSL, and typed `Database.public.*` access.
2518
2626
 
2519
2627
  ### Hand-writing a policy
2520
2628
 
@@ -2556,7 +2664,7 @@ no such safety net — write it the first time.
2556
2664
  **Two different things are called "services" here, and this page is the second
2557
2665
  one.** Read the first paragraph before searching this page for a layout rule.
2558
2666
 
2559
- - **YOUR service layer** — `services/<name>.service.ts`, the plain classes your
2667
+ - **YOUR service layer** — `modules/<domain>/<name>.service.ts`, the `@Injectable()` classes your
2560
2668
  own business logic lives in. The scaffold ships one, and the contract is at
2561
2669
  the bottom of this page: [Your own service layer](#your-own-service-layer).
2562
2670
  - **THE service singletons** — the platform objects you import from
@@ -2579,9 +2687,26 @@ turns out to be empty — it fails at the import.
2579
2687
  a channel, but cannot subscribe (a stateless request can't hold a socket).
2580
2688
  Subscription lives on the client SDK (`pb.realtime`).
2581
2689
 
2690
+ `Auth` is available here for exactly one thing: **granting and revoking roles**.
2691
+ Signing in, signing up and session handling run on the client SDK — a backend
2692
+ handler never holds a user's credentials — but assignment is server work, so
2693
+ `assignRole`, `revokeRole` and `rolesOf` are on this side:
2694
+
2695
+ ```ts
2696
+ import { Auth } from "@palbase/backend";
2697
+
2698
+ await Auth.assignRole(userId, "moderator"); // takes effect on the next request
2699
+ await Auth.revokeRole(userId, "moderator"); // and so does this
2700
+ const held = await Auth.rolesOf(userId); // string[]
2701
+ ```
2702
+
2703
+ A role that does not exist throws `RoleNotDefined` naming it — declare it first
2704
+ with `palbase roles create`. A failed read throws rather than answering `[]`:
2705
+ "nobody could say" and "holds nothing" are different claims, and a handler that
2706
+ confuses them denies a user everything they hold.
2707
+
2582
2708
  **Not available to backend handlers** (do not import them here): Functions, CMS,
2583
- Links, Analytics, and Auth. Auth runs on the client SDK; the others are out of
2584
- scope for backend endpoints.
2709
+ Links and Analytics out of scope for backend endpoints.
2585
2710
 
2586
2711
  ## Cache
2587
2712
 
@@ -2711,7 +2836,7 @@ Writes mirror the `Database` / `Database.$asService()` model:
2711
2836
  - `Flags.setOverride(key, value)` (default) writes an override for the **current
2712
2837
  request user** — no `userId` argument, no admin power. It errors on an
2713
2838
  anonymous request (no signed-in user).
2714
- - `Flags.asService()` returns the cross-user admin surface
2839
+ - `Flags.$asService()` returns the cross-user admin surface
2715
2840
  (`setOverrideForUser`, `setOverridesForUser`, `clearOverrideForUser`,
2716
2841
  `clearAllOverridesForUser`, `batchSetOverrides`) for writing overrides for an
2717
2842
  **arbitrary** user. Explicit and greppable, just like `Database.$asService()`.
@@ -2720,8 +2845,8 @@ Writes mirror the `Database` / `Database.$asService()` model:
2720
2845
  // Current request user — no userId needed:
2721
2846
  await Flags.setOverride("new-checkout", true);
2722
2847
 
2723
- // Cross-user admin write — explicit target, via asService():
2724
- await Flags.asService().setOverrideForUser("user_123", "new-checkout", true);
2848
+ // Cross-user admin write — explicit target, via $asService():
2849
+ await Flags.$asService().setOverrideForUser("user_123", "new-checkout", true);
2725
2850
  ```
2726
2851
 
2727
2852
  ## Realtime
@@ -2772,8 +2897,8 @@ to drive live chat, presence, dashboards, and other push features.
2772
2897
  ## Your own service layer
2773
2898
 
2774
2899
  The singletons above are what a service CALLS. This section is the layer that
2775
- calls them — `services/<name>.service.ts`, which the scaffold ships a working
2776
- example of (`services/note.service.ts` and the controller that uses it).
2900
+ calls them — `modules/<domain>/<name>.service.ts`, which the scaffold ships a working
2901
+ example of (`modules/notes/note.service.ts` and the controller that uses it).
2777
2902
 
2778
2903
  The contract is three rules, and the scaffold's own test enforces all three:
2779
2904
 
@@ -2788,7 +2913,7 @@ it.** Mark the class `@Injectable()` and name what it needs as ordinary
2788
2913
  constructor parameters:
2789
2914
 
2790
2915
  ```ts
2791
- // services/note.service.ts
2916
+ // modules/notes/note.service.ts
2792
2917
  import { Database, Injectable } from "@palbase/backend";
2793
2918
 
2794
2919
  type NotesTable = typeof Database.public.notes;
@@ -2804,7 +2929,7 @@ export class NoteService {
2804
2929
  ```
2805
2930
 
2806
2931
  ```ts
2807
- // services/report.service.ts — a service that depends on another service
2932
+ // modules/reports/report.service.ts — a service that depends on another service
2808
2933
  import { Injectable } from "@palbase/backend";
2809
2934
  import { NoteService } from "./note.service.ts";
2810
2935
 
@@ -2821,7 +2946,7 @@ export class ReportService {
2821
2946
  A controller asks the same way, and nothing wires it by hand:
2822
2947
 
2823
2948
  ```ts
2824
- // controllers/notes.controller.ts
2949
+ // modules/notes/notes.controller.ts
2825
2950
  import { Controller, Get } from "@palbase/backend";
2826
2951
  import { NoteService } from "../services/note.service.ts";
2827
2952
 
@@ -2863,7 +2988,7 @@ never reaches the route table, the dispatcher or the OpenAPI document.
2863
2988
  ```ts
2864
2989
  // notes.module.ts — beside the domain it owns, not in a directory we name
2865
2990
  import { Module, type Token } from "@palbase/backend";
2866
- import { NotesController } from "./controllers/notes.controller.ts";
2991
+ import { NotesController } from "./modules/notes/notes.controller.ts";
2867
2992
  import { NoteService } from "./services/note.service.ts";
2868
2993
  import { ReportService } from "./services/report.service.ts";
2869
2994
 
@@ -2916,7 +3041,7 @@ NoteService())` is ordinary TypeScript, and the container is not required for it
2916
3041
  to work.
2917
3042
 
2918
3043
  ```ts
2919
- // services/note.service.test.ts — `npm test`, no database
3044
+ // modules/notes/note.service.test.ts — `npm test`, no database
2920
3045
  import { test } from "node:test";
2921
3046
  import assert from "node:assert/strict";
2922
3047
 
@@ -2980,9 +3105,8 @@ Palbase error envelope:
2980
3105
  { "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
2981
3106
  ```
2982
3107
 
2983
- Throw anywhere — in a controller method OR in a `services/` class. No `req`, no
2984
- per-route error map: the runtime catches any thrown error class and emits the
2985
- envelope.
3108
+ Throw anywhere — in a controller method OR in a service. No `req`, no per-route
3109
+ error map: the runtime catches any thrown error class and emits the envelope.
2986
3110
 
2987
3111
  The runtime refuses some requests before a handler runs, and those refusals use
2988
3112
  the same envelope and the same codes as the classes below — so a client decodes
@@ -3041,10 +3165,11 @@ HttpError) … }` matches any of them.
3041
3165
 
3042
3166
  # Background Jobs
3043
3167
 
3044
- `jobs/` is the background rail. A job uses the **singleton model** the same
3045
- imported service singletons as endpoints (`import { Database, Log } from
3046
- "@palbase/backend"`). It does **not** receive a `req`; a small `meta` argument
3047
- carries the non-service data (`env`, correlation ids).
3168
+ `jobs/` is the background rail, and it stays at the ROOTa job is not part of
3169
+ any module. It reaches the platform services through the same import as everything
3170
+ else (`import { Database, Log } from "@palbase/backend"`). It does **not** receive
3171
+ a `req`; a small `meta` argument carries the non-service data (`env`, correlation
3172
+ ids).
3048
3173
 
3049
3174
  > **There is no queue.** `Queue.push` and `defineWorker` existed in earlier
3050
3175
  > versions and never ran: nothing consumed the queue, so a push returned a job id
@@ -3057,14 +3182,22 @@ carries the non-service data (`env`, correlation ids).
3057
3182
 
3058
3183
  ## Jobs (cron-scheduled)
3059
3184
 
3060
- A job runs on a cron schedule. File lives under `jobs/` the job's name is
3061
- the file name, there is no `name` option.
3185
+ A job runs on a cron schedule. The file lives under `jobs/`, one class per file,
3186
+ `export default` required.
3187
+
3188
+ **`name` is required and DECLARED.** It is the job's identity — the row the
3189
+ scheduler holds it under — so it is not taken from the file name any more:
3190
+ renaming the file would otherwise start a different job. Lowercase letters,
3191
+ digits and dashes.
3062
3192
 
3063
3193
  ```ts
3064
3194
  // jobs/cleanup.ts
3065
3195
  import { Database, Job, Log, type JobMeta } from "@palbase/backend";
3066
3196
 
3067
- @Job({ schedule: "0 3 * * *", timeout: 120 }) // schedule: standard cron; timeout: optional, seconds
3197
+ // name: the scheduler's identity; schedule: standard 5-field cron;
3198
+ // timeout: optional seconds (default 30, ceiling 300);
3199
+ // retry: optional, how many times a FAILED run is retried (default 5, ceiling 10).
3200
+ @Job({ name: "cleanup", schedule: "0 3 * * *", timeout: 120 })
3068
3201
  export default class CleanupJob {
3069
3202
  async run(meta: JobMeta) {
3070
3203
  const expired = await Database.$findMany("sessions", { expired: true });
@@ -3083,53 +3216,87 @@ system-initiated).
3083
3216
 
3084
3217
  # Hooks & Webhooks
3085
3218
 
3086
- Like jobs, hooks and webhooks use the **singleton model** the same
3087
- imported service singletons as endpoints (`import { Database, Log } from
3088
- "@palbase/backend"`). They do **not** receive a `req`. A second `meta` argument
3089
- carries the non-service data (`env`, `environmentId`; webhooks also
3090
- get `requestId`).
3219
+ Like jobs, `hooks/` and `webhooks/` stay at the ROOTneither is part of a
3220
+ module and both reach the platform services through the same import as
3221
+ everything else (`import { Database, Log } from "@palbase/backend"`). They do
3222
+ **not** receive a `req`. A second `meta` argument carries the non-service data
3223
+ (`env`, `environmentId`; webhooks also get `requestId`).
3091
3224
 
3092
3225
  ## Hooks (platform events)
3093
3226
 
3094
- React to auth, storage, and document events. Files live under `hooks/`. Builders
3095
- are imported from `@palbase/backend`: `auth`, `storage`, `documents`.
3227
+ React to events this stack raises — auth, storage, documents. Files live under
3228
+ `hooks/`, one **default-exported class** per file, and the handlers are methods
3229
+ carrying a decorator. There are no builder functions to import.
3230
+
3231
+ Two decorators, and the difference is whether the handler can stop the event:
3232
+
3233
+ | | Where the event comes from | Can it block? |
3234
+ | --- | --- | --- |
3235
+ | `@Hook("before.…")` | this stack | **Yes** — `throw` cancels the operation |
3236
+ | `@On("after.…")` | this stack | No — a monitor; a throw reaches only the log |
3237
+ | `@Webhook` + `@On` | an OUTSIDE service | No — see the next section |
3096
3238
 
3097
3239
  ```ts
3098
3240
  // hooks/auth.ts
3099
- import { auth, Database, Log } from "@palbase/backend";
3100
-
3101
- export const onUserCreated = auth.onUserCreated(async (event, meta) => {
3102
- Log.info(`new user: ${event.user.email}`);
3103
- await Database.$insert("profiles", {
3104
- user_id: event.user.id,
3105
- email: event.user.email,
3106
- });
3107
- });
3241
+ import { Database, Deny, Hook, Log, On } from "@palbase/backend";
3242
+ import type { AuthHookEvent, HookMeta } from "@palbase/backend";
3243
+
3244
+ export default class AuthHooks {
3245
+ // The gate. A throw CANCELS the signup and the reason reaches the caller.
3246
+ @Hook("before.user.create")
3247
+ async gate(event: AuthHookEvent, _meta: HookMeta): Promise<void> {
3248
+ if ((event.user?.email ?? "").endsWith("@blocked.test")) {
3249
+ throw new Deny("this address is not accepted");
3250
+ }
3251
+ }
3108
3252
 
3109
- export const onSignIn = auth.onSignIn(async (event, meta) => {
3110
- Log.info(`sign in: ${event.user.email} via ${event.provider}`);
3111
- });
3253
+ // The monitor. It runs after the fact, so a throw here is logged and the
3254
+ // session is not un-created.
3255
+ @On("after.login.failed")
3256
+ async record(event: AuthHookEvent, meta: HookMeta): Promise<void> {
3257
+ Log.info(`failed login for ${event.user?.email ?? "(no email)"} req=${meta.requestId}`);
3258
+ await Database.$insert("login_failures", { email: event.user?.email ?? null });
3259
+ }
3260
+ }
3112
3261
  ```
3113
3262
 
3114
- `meta` shape: `{ env, environmentId }`. Environment variables are in
3115
- `meta.env`; services come from the imported singletons.
3263
+ `Deny` is exported for exactly this: any throw denies, but `Deny` carries the
3264
+ reason deliberately instead of surfacing whatever a stray `TypeError` said. The
3265
+ engine is fail-closed — an unreachable hook denies — so keep a blocking handler
3266
+ narrow.
3116
3267
 
3117
- Available hook builders: `auth.onUserCreated`, `auth.onSignIn`, `auth.onSignOut`,
3118
- `auth.onPasswordReset`, `storage.onFileUploaded`, `storage.onFileDeleted`,
3119
- `documents.onDocumentCreated`, `documents.onDocumentUpdated`,
3120
- `documents.onDocumentDeleted`.
3268
+ `meta` shape: `{ env, environmentId, requestId? }`. Environment variables are in
3269
+ `meta.env`; the platform services (`Database`, `Log`, …) are imported as
3270
+ singletons, never passed in.
3271
+
3272
+ > **Warning:** `auth`, `storage` and `documents` builder objects —
3273
+ > `auth.onUserCreated(handler)`, `storage.onFileUploaded(handler)` and friends —
3274
+ > **were removed in 26.0.0** and this page taught them until 33.0.0. They were
3275
+ > not merely renamed: nothing in the runtime or the bundler ever read the record
3276
+ > they returned, so a project that wrote one got a handler that was **never
3277
+ > called and no error said so**. The types went with them for the same reason —
3278
+ > `UserCreatedEvent` promised `user.role`, `user.metadata` and `user.createdAt`
3279
+ > and the auth engine sends none of the three. If you have such a file, the
3280
+ > import now fails to build, which is the point.
3121
3281
 
3122
3282
  ## Webhooks (inbound provider events)
3123
3283
 
3124
3284
  Receive and verify webhooks from third-party providers. Files live under
3125
- `webhooks/` the URL is `POST /webhooks/<file-name>` (e.g. `webhooks/stripe.ts`
3126
- → `POST /webhooks/stripe`); there is no `path` option.
3285
+ `webhooks/`, one class per file, `export default` required.
3286
+
3287
+ **`name` is required and DECLARED.** It is the path segment the webhook is served
3288
+ at — `POST /webhooks/<name>` — so `name: "stripe"` answers at
3289
+ `POST /webhooks/stripe`. It is not taken from the file name any more: a PUBLIC
3290
+ URL, the one a sender is configured with, belongs beside the provider and the
3291
+ secret rather than in the file system. Lowercase letters, digits and dashes.
3127
3292
 
3128
3293
  ```ts
3129
3294
  // webhooks/stripe.ts
3130
3295
  import { Database, Log, On, Webhook, type WebhookMeta } from "@palbase/backend";
3131
3296
 
3132
- @Webhook({ provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } }) // signing secret resolved from env
3297
+ // name: the path segment (POST /webhooks/stripe); secret: the signing secret,
3298
+ // resolved by the runtime from the named env var.
3299
+ @Webhook({ name: "stripe", provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } })
3133
3300
  export default class StripeWebhook {
3134
3301
  @On("checkout.session.completed")
3135
3302
  async checkoutCompleted(event: unknown, meta: WebhookMeta) {