@palbase/backend 32.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 (63) hide show
  1. package/dist/bin/palbase-backend.cjs +79 -26
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +5 -5
  4. package/dist/{chunk-AZJIKCOR.js → chunk-IKDONZ5D.js} +39 -23
  5. package/dist/{chunk-AZJIKCOR.js.map → chunk-IKDONZ5D.js.map} +1 -1
  6. package/dist/{chunk-IXAX5CON.js → chunk-SI4KGEM3.js} +1 -1
  7. package/dist/{chunk-IXAX5CON.js.map → chunk-SI4KGEM3.js.map} +1 -1
  8. package/dist/{chunk-XEGZ3S2Q.js → chunk-TVCCR6SO.js} +32 -7
  9. package/dist/chunk-TVCCR6SO.js.map +1 -0
  10. package/dist/{chunk-5C5UCILO.js → chunk-WWUG2QXF.js} +3 -3
  11. package/dist/{chunk-KGP6ALIU.js → chunk-XOX6RFPZ.js} +30 -8
  12. package/dist/chunk-XOX6RFPZ.js.map +1 -0
  13. package/dist/{chunk-Z4CZRMNF.js → chunk-YIQ4RS4F.js} +2 -2
  14. package/dist/db/index.cjs +14 -4
  15. package/dist/db/index.cjs.map +1 -1
  16. package/dist/db/index.d.cts +1 -1
  17. package/dist/db/index.d.ts +1 -1
  18. package/dist/db/index.js +3 -3
  19. package/dist/engine/index.cjs +79 -26
  20. package/dist/engine/index.cjs.map +1 -1
  21. package/dist/engine/index.d.cts +3 -3
  22. package/dist/engine/index.d.ts +3 -3
  23. package/dist/engine/index.js +5 -5
  24. package/dist/{index-BILC9WwS.d.ts → index-D-3duy8Y.d.ts} +2 -2
  25. package/dist/{index-DgYkdNT9.d.cts → index-DB_nW-AV.d.cts} +87 -30
  26. package/dist/{index-CWGiJ2Up.d.cts → index-DLveQoOf.d.cts} +2 -2
  27. package/dist/{index-BgWnP07a.d.ts → index-DMZG3kpo.d.ts} +87 -30
  28. package/dist/index.cjs +59 -12
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +6 -6
  31. package/dist/index.d.ts +6 -6
  32. package/dist/index.js +5 -5
  33. package/dist/openapi/index.d.cts +2 -2
  34. package/dist/openapi/index.d.ts +2 -2
  35. package/dist/{registry-BcRYIQ-R.d.cts → registry-DSTThhKf.d.cts} +1 -1
  36. package/dist/{registry-ClzjxIWy.d.ts → registry-JjF5lcj4.d.ts} +1 -1
  37. package/dist/test/index.cjs +1155 -12
  38. package/dist/test/index.cjs.map +1 -1
  39. package/dist/test/index.d.cts +1 -1
  40. package/dist/test/index.d.ts +1 -1
  41. package/dist/test/index.js +1126 -8
  42. package/dist/test/index.js.map +1 -1
  43. package/docs/README.md +124 -80
  44. package/docs/auth.md +112 -26
  45. package/docs/background.md +16 -7
  46. package/docs/database.md +22 -18
  47. package/docs/endpoints.md +26 -20
  48. package/docs/errors.md +2 -3
  49. package/docs/events.md +63 -29
  50. package/docs/getting-started.md +8 -7
  51. package/docs/llms-full.txt +519 -244
  52. package/docs/migrations.md +6 -5
  53. package/docs/routing.md +34 -11
  54. package/docs/schema.md +78 -27
  55. package/docs/services.md +30 -13
  56. package/package.json +2 -1
  57. package/stack-images.json +24 -0
  58. package/template/db/public.ts +23 -0
  59. package/template/package.json +1 -1
  60. package/dist/chunk-KGP6ALIU.js.map +0 -1
  61. package/dist/chunk-XEGZ3S2Q.js.map +0 -1
  62. /package/dist/{chunk-5C5UCILO.js.map → chunk-WWUG2QXF.js.map} +0 -0
  63. /package/dist/{chunk-Z4CZRMNF.js.map → chunk-YIQ4RS4F.js.map} +0 -0
package/docs/database.md CHANGED
@@ -7,8 +7,9 @@ jobs, hooks, and webhooks all use the same import:
7
7
  import { Database } from "@palbase/backend";
8
8
  ```
9
9
 
10
- Only **middleware** still uses `ctx.db` (see [background.md](./background.md)
11
- and [events.md](./events.md) for worker/job/hook/webhook examples).
10
+ There is no `ctx` and no `ctx.db` anywhere on a path that runs — every handler
11
+ imports `Database` (see [background.md](./background.md) and
12
+ [events.md](./events.md) for the job/hook/webhook examples).
12
13
 
13
14
  ## Typed by default — `Database.<şema>.<tablo>`
14
15
 
@@ -76,7 +77,9 @@ await Database.public.crew_presence.findMany({
76
77
  AND: [sqlFragment`expires_at > now()`],
77
78
  },
78
79
  });
79
- ``` The same filter language works for `updateMany`, `deleteMany`,
80
+ ```
81
+
82
+ The same filter language works for `updateMany`, `deleteMany`,
80
83
  `count` and `search` — one language, so two spellings cannot drift apart. An
81
84
  empty `in` list means NO rows (written into the SQL as `false`), never a silent
82
85
  full scan.
@@ -372,7 +375,7 @@ filter object built from request input that happened to come back empty — is
372
375
  exactly the shape that must not silently succeed. `count()` with no filter IS
373
376
  allowed: counting is a read.
374
377
 
375
- ### `Database.query` — raw SQL
378
+ ### `Database.$query` — raw SQL
376
379
 
377
380
  Use it for shapes the helpers do not cover (joins, aggregates, window functions).
378
381
  It runs in **the request's own transaction**, not a read-only one: a write inside
@@ -397,7 +400,7 @@ per row with a `23505` caught around each:
397
400
 
398
401
  ```ts
399
402
  await Database.$transaction((tx) => {
400
- tx.tables.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
403
+ tx.public.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
401
404
  return null;
402
405
  });
403
406
  ```
@@ -454,11 +457,11 @@ middle, so nothing holds a database connection open while your code thinks.
454
457
  import { Database, NotFound } from "@palbase/backend";
455
458
 
456
459
  const { orderId } = await Database.$transaction((tx) => {
457
- const order = tx.tables.orders
460
+ const order = tx.public.orders
458
461
  .insert({ amount: 1000, status: "pending" })
459
462
  .expectOne(new NotFound("order could not be created"));
460
463
 
461
- tx.tables.order_items.insertMany(
464
+ tx.public.order_items.insertMany(
462
465
  cart.map((line) => ({ order_id: order.id, sku: line.sku })),
463
466
  );
464
467
 
@@ -514,7 +517,7 @@ filter plus an expectation — which is also stronger, because the check and the
514
517
  write are now the same statement and nothing can slip between them:
515
518
 
516
519
  ```ts
517
- tx.tables.invites
520
+ tx.public.invites
518
521
  .updateWhere({ token, accepted_at: null }, { accepted_at: now() })
519
522
  .expectOne(new Conflict("invite already used", "invite_used"));
520
523
  ```
@@ -554,7 +557,7 @@ await Database.public.accounts.updateMany({
554
557
 
555
558
  // Inside a plan: the same expression, the same meaning.
556
559
  await Database.$transaction((tx) => {
557
- tx.tables.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
560
+ tx.public.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
558
561
  return null;
559
562
  });
560
563
  ```
@@ -660,7 +663,8 @@ Sometimes you need to read or write **across all users** — an admin endpoint,
660
663
  background job that fans out notifications, a cleanup task. For that, call
661
664
  `Database.$asService()`. It returns a sibling client that runs as
662
665
  **`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
663
- exact same surface — `tables`, the raw string ops, and `transaction`:
666
+ exact same surface — `public` (and every other schema), the `$`-prefixed raw ops,
667
+ and `$transaction`:
664
668
 
665
669
  ```ts
666
670
  import { Database } from "@palbase/backend";
@@ -669,32 +673,32 @@ import { Database } from "@palbase/backend";
669
673
  const mine = await Database.public.todos.findMany({});
670
674
 
671
675
  // Service-role bypass: every user's rows. Explicit and greppable.
672
- const all = await Database.$asService().tables.todos.findMany({});
673
- const rows = await Database.$asService().query("SELECT count(*) FROM todos");
676
+ const all = await Database.$asService().public.todos.findMany({});
677
+ const rows = await Database.$asService().$query("SELECT count(*) FROM todos");
674
678
 
675
679
  // A service-role transaction (the role is fixed for the whole plan):
676
- await Database.$asService().transaction((tx) => {
677
- tx.tables.todos.updateWhere({ id }, { done: true });
680
+ await Database.$asService().$transaction((tx) => {
681
+ tx.public.todos.updateWhere({ id }, { done: true });
678
682
  return null;
679
683
  });
680
684
  ```
681
685
 
682
686
  Guidelines:
683
687
 
684
- - **Be explicit.** Prefer the default `Database.*` and reach for `asService()`
688
+ - **Be explicit.** Prefer the default `Database.*` and reach for `$asService()`
685
689
  only where you genuinely need cross-user access. It is intentionally easy to
686
690
  grep for in review.
687
691
  - **No double-bypass / no nesting.** The sibling does not re-expose
688
- `asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
692
+ `$asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
689
693
  transaction. Use `Database.$transaction(...)` for an authenticated one and
690
- `Database.$asService().transaction(...)` for a service-role one; you cannot mix
694
+ `Database.$asService().$transaction(...)` for a service-role one; you cannot mix
691
695
  enforced and bypassed operations inside a single plan.
692
696
  - **It is a second transaction, on a second connection.** It has to be: the
693
697
  Postgres role is bound once, when the transaction opens, so a sibling sharing
694
698
  the request's transaction would run as `backend_authenticated` and silently
695
699
  keep filtering. Three consequences worth knowing:
696
700
  - The request opens it at most once, however many times you call
697
- `asService()`, and never at all if you don't use it.
701
+ `$asService()`, and never at all if you don't use it.
698
702
  - Both settle with the request — commit when your handler returns, roll back
699
703
  when it throws — but they settle as two transactions. A row written through
700
704
  one surface is **not visible** to the other until the request commits.
package/docs/endpoints.md CHANGED
@@ -2,36 +2,41 @@
2
2
 
3
3
  An endpoint is a **method on a class controller** — a class decorated with
4
4
  `@Controller(basePath)` whose methods are decorated with `@Get`/`@Post`/`@Put`/
5
- `@Patch`/`@Delete`/`@Query`. Controller files live under `controllers/` and are
6
- mounted automatically (see [routing.md](./routing.md)). Request input + context
7
- are injected into the method via **parameter decorators** (`@Body`/`@QueryParams`/
8
- `@Param`/`@User`/…), each piece direct no `req` god-object. Services are reached via the
9
- imported singletons (see [services.md](./services.md)).
5
+ `@Patch`/`@Delete`/`@Query`. A controller lives in its domain's folder
6
+ (`modules/<domain>/<name>.controller.ts`) and a `@Module` lists it in
7
+ `controllers` that list is the registration, not the directory (see
8
+ [routing.md](./routing.md)). Request input + context are injected into the method
9
+ via **parameter decorators** (`@Body`/`@QueryParams`/`@Param`/`@User`/…), each
10
+ piece direct — no `req` god-object. Your own services arrive through the
11
+ CONSTRUCTOR and the container supplies them (see [services.md](./services.md)).
10
12
 
11
13
  ## A controller
12
14
 
13
15
  ```ts
14
- // controllers/rooms.controller.ts
16
+ // modules/rooms/rooms.controller.ts
15
17
  import { Controller, Get, Post, Body, Param, User } from "@palbase/backend";
16
18
  import type { UserT } from "@palbase/backend";
17
- import { roomService } from "../services/room.service.js";
18
- import { CreateRoomBody } from "../models/rooms/create.js";
19
- import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
19
+ import { RoomService } from "./room.service";
20
+ import { CreateRoomBody } from "./dto/create";
21
+ import { RoomSchema } from "./dto/shared"; // the return TYPE names the 200 schema
20
22
 
21
23
  // A controller does not import `Database`. Everything here is HTTP: validate the
22
24
  // body through a named schema, name the 200 shape as the return type, delegate.
23
25
  // Which rows, whose, in what order is the service's job — and the service is the
24
26
  // thing worth testing, because it is the thing that can be wrong.
25
27
  @Controller("/rooms")
26
- export default class RoomsController {
28
+ export class RoomsController {
29
+ // The service arrives through the constructor; `rooms.module.ts` lists both.
30
+ constructor(private readonly rooms: RoomService) {}
31
+
27
32
  @Post("")
28
33
  create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
29
- return roomService.create(user.id, body);
34
+ return this.rooms.create(user.id, body);
30
35
  }
31
36
 
32
37
  @Get("/{id}")
33
38
  getOne(@Param("id") id: string): Promise<RoomSchema> {
34
- return roomService.get(id);
39
+ return this.rooms.get(id);
35
40
  }
36
41
  }
37
42
  ```
@@ -41,13 +46,14 @@ export default class RoomsController {
41
46
  1. **The controller does not touch the database.** It delegates to a service, as
42
47
  above. A method here that reaches for `Database` has moved the logic into the
43
48
  layer that is hardest to test. (Export the class by NAME so its module can
44
- list it in `controllers`; a class no module lists is refused at build with
45
- `unowned class`. Nothing here is default-exported.)
49
+ import it and list it in `controllers`; a class no module lists is refused at
50
+ build with `unowned class`. Nothing here is default-exported.)
46
51
  2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
47
52
  returns promises, so a body that `await`s it cannot have a sync return type
48
- (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
49
- `async` and return `Promise<RoomSchema>`. (A pure method that returns a literal
50
- with no `await` may stay synchronous.)
53
+ (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above hand
54
+ the service's promise straight back, so they need no `await` — but they still
55
+ return `Promise<RoomSchema>`. (A pure method that returns a literal and touches
56
+ no promise may stay synchronous.)
51
57
 
52
58
  ## Method decorators
53
59
 
@@ -73,8 +79,8 @@ a query string — complex search/filter endpoints that would otherwise be a
73
79
 
74
80
  ```ts
75
81
  @Query("/search")
76
- async search(@Body(SearchTodosBody) body: SearchTodosBody): Promise<TodoSchema[]> {
77
- return todoService.search(body);
82
+ search(@Body(SearchTodosBody) body: SearchTodosBody): Promise<TodoSchema[]> {
83
+ return this.todos.search(body);
78
84
  }
79
85
  ```
80
86
 
@@ -121,7 +127,7 @@ out. Resolution order (most specific wins):
121
127
 
122
128
  ```ts
123
129
  @Controller("/public", { auth: false }) // all routes default public
124
- export default class PublicController {
130
+ export class PublicController {
125
131
  @Get("/open") open(): Info { ... } // inherits → public
126
132
  @Get("/secret", { auth: true }) // OVERRIDES → authed
127
133
  secret(@User() u: UserT): Secret { ... }
package/docs/errors.md CHANGED
@@ -7,9 +7,8 @@ Palbase error envelope:
7
7
  { "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
8
8
  ```
9
9
 
10
- Throw anywhere — in a controller method OR in a `services/` class. No `req`, no
11
- per-route error map: the runtime catches any thrown error class and emits the
12
- envelope.
10
+ Throw anywhere — in a controller method OR in a service. No `req`, no per-route
11
+ error map: the runtime catches any thrown error class and emits the envelope.
13
12
 
14
13
  The runtime refuses some requests before a handler runs, and those refusals use
15
14
  the same envelope and the same codes as the classes below — so a client decodes
package/docs/events.md CHANGED
@@ -1,52 +1,86 @@
1
1
  # Hooks & Webhooks
2
2
 
3
- Like jobs, hooks and webhooks use the **singleton model** the same
4
- imported service singletons as endpoints (`import { Database, Log } from
5
- "@palbase/backend"`). They do **not** receive a `req`. A second `meta` argument
6
- carries the non-service data (`env`, `environmentId`; webhooks also
7
- get `requestId`).
3
+ Like jobs, `hooks/` and `webhooks/` stay at the ROOTneither is part of a
4
+ module and both reach the platform services through the same import as
5
+ everything else (`import { Database, Log } from "@palbase/backend"`). They do
6
+ **not** receive a `req`. A second `meta` argument carries the non-service data
7
+ (`env`, `environmentId`; webhooks also get `requestId`).
8
8
 
9
9
  ## Hooks (platform events)
10
10
 
11
- React to auth, storage, and document events. Files live under `hooks/`. Builders
12
- are imported from `@palbase/backend`: `auth`, `storage`, `documents`.
11
+ React to events this stack raises — auth, storage, documents. Files live under
12
+ `hooks/`, one **default-exported class** per file, and the handlers are methods
13
+ carrying a decorator. There are no builder functions to import.
14
+
15
+ Two decorators, and the difference is whether the handler can stop the event:
16
+
17
+ | | Where the event comes from | Can it block? |
18
+ | --- | --- | --- |
19
+ | `@Hook("before.…")` | this stack | **Yes** — `throw` cancels the operation |
20
+ | `@On("after.…")` | this stack | No — a monitor; a throw reaches only the log |
21
+ | `@Webhook` + `@On` | an OUTSIDE service | No — see the next section |
13
22
 
14
23
  ```ts
15
24
  // hooks/auth.ts
16
- import { auth, Database, Log } from "@palbase/backend";
17
-
18
- export const onUserCreated = auth.onUserCreated(async (event, meta) => {
19
- Log.info(`new user: ${event.user.email}`);
20
- await Database.$insert("profiles", {
21
- user_id: event.user.id,
22
- email: event.user.email,
23
- });
24
- });
25
-
26
- export const onSignIn = auth.onSignIn(async (event, meta) => {
27
- Log.info(`sign in: ${event.user.email} via ${event.provider}`);
28
- });
25
+ import { Database, Deny, Hook, Log, On } from "@palbase/backend";
26
+ import type { AuthHookEvent, HookMeta } from "@palbase/backend";
27
+
28
+ export default class AuthHooks {
29
+ // The gate. A throw CANCELS the signup and the reason reaches the caller.
30
+ @Hook("before.user.create")
31
+ async gate(event: AuthHookEvent, _meta: HookMeta): Promise<void> {
32
+ if ((event.user?.email ?? "").endsWith("@blocked.test")) {
33
+ throw new Deny("this address is not accepted");
34
+ }
35
+ }
36
+
37
+ // The monitor. It runs after the fact, so a throw here is logged and the
38
+ // session is not un-created.
39
+ @On("after.login.failed")
40
+ async record(event: AuthHookEvent, meta: HookMeta): Promise<void> {
41
+ Log.info(`failed login for ${event.user?.email ?? "(no email)"} req=${meta.requestId}`);
42
+ await Database.$insert("login_failures", { email: event.user?.email ?? null });
43
+ }
44
+ }
29
45
  ```
30
46
 
31
- `meta` shape: `{ env, environmentId }`. Environment variables are in
32
- `meta.env`; services come from the imported singletons.
47
+ `Deny` is exported for exactly this: any throw denies, but `Deny` carries the
48
+ reason deliberately instead of surfacing whatever a stray `TypeError` said. The
49
+ engine is fail-closed — an unreachable hook denies — so keep a blocking handler
50
+ narrow.
33
51
 
34
- Available hook builders: `auth.onUserCreated`, `auth.onSignIn`, `auth.onSignOut`,
35
- `auth.onPasswordReset`, `storage.onFileUploaded`, `storage.onFileDeleted`,
36
- `documents.onDocumentCreated`, `documents.onDocumentUpdated`,
37
- `documents.onDocumentDeleted`.
52
+ `meta` shape: `{ env, environmentId, requestId? }`. Environment variables are in
53
+ `meta.env`; the platform services (`Database`, `Log`, …) are imported as
54
+ singletons, never passed in.
55
+
56
+ > **Warning:** `auth`, `storage` and `documents` builder objects —
57
+ > `auth.onUserCreated(handler)`, `storage.onFileUploaded(handler)` and friends —
58
+ > **were removed in 26.0.0** and this page taught them until 33.0.0. They were
59
+ > not merely renamed: nothing in the runtime or the bundler ever read the record
60
+ > they returned, so a project that wrote one got a handler that was **never
61
+ > called and no error said so**. The types went with them for the same reason —
62
+ > `UserCreatedEvent` promised `user.role`, `user.metadata` and `user.createdAt`
63
+ > and the auth engine sends none of the three. If you have such a file, the
64
+ > import now fails to build, which is the point.
38
65
 
39
66
  ## Webhooks (inbound provider events)
40
67
 
41
68
  Receive and verify webhooks from third-party providers. Files live under
42
- `webhooks/` the URL is `POST /webhooks/<file-name>` (e.g. `webhooks/stripe.ts`
43
- → `POST /webhooks/stripe`); there is no `path` option.
69
+ `webhooks/`, one class per file, `export default` required.
70
+
71
+ **`name` is required and DECLARED.** It is the path segment the webhook is served
72
+ at — `POST /webhooks/<name>` — so `name: "stripe"` answers at
73
+ `POST /webhooks/stripe`. It is not taken from the file name any more: a PUBLIC
74
+ URL, the one a sender is configured with, belongs beside the provider and the
75
+ secret rather than in the file system. Lowercase letters, digits and dashes.
44
76
 
45
77
  ```ts
46
78
  // webhooks/stripe.ts
47
79
  import { Database, Log, On, Webhook, type WebhookMeta } from "@palbase/backend";
48
80
 
49
- @Webhook({ provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } }) // signing secret resolved from env
81
+ // name: the path segment (POST /webhooks/stripe); secret: the signing secret,
82
+ // resolved by the runtime from the named env var.
83
+ @Webhook({ name: "stripe", provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } })
50
84
  export default class StripeWebhook {
51
85
  @On("checkout.session.completed")
52
86
  async checkoutCompleted(event: unknown, meta: WebhookMeta) {
@@ -33,7 +33,8 @@ The controllers use **decorators**, so the `tsconfig.json` must set
33
33
  "experimentalDecorators": true,
34
34
  "noEmit": true
35
35
  },
36
- "include": ["controllers/**/*.ts", "models/**/*.ts", "services/**/*.ts", "db/**/*.ts", "*.d.ts"]
36
+ "include": ["**/*.ts"],
37
+ "exclude": ["node_modules", ".palbase"]
37
38
  }
38
39
  ```
39
40
 
@@ -43,7 +44,7 @@ Your backend runs on Palbase, not on your laptop — there is no local runtime t
43
44
  start. The loop is: edit, validate, push to a dev Environment.
44
45
 
45
46
  - `palbase build` — validate the tree the way the deploy will. It stages and
46
- bundles your `controllers/` exactly as the deploy does and runs the deploy's
47
+ bundles your `modules/` exactly as the deploy does and runs the deploy's
47
48
  own metadata extractor over the result, so a bad decorator, an illegal return
48
49
  type or an SDK major skew fails here rather than shipping a deploy that
49
50
  serves zero endpoints. It is wired into a `pre-push` git hook for you. It also
@@ -57,10 +58,10 @@ start. The loop is: edit, validate, push to a dev Environment.
57
58
 
58
59
  ## Your first endpoint
59
60
 
60
- An endpoint is a method on a class controller. Declare the schemas in `models/`:
61
+ An endpoint is a method on a class controller. Everything for one domain lives in `modules/<domain>/`; declare the schemas in its `dto/`:
61
62
 
62
63
  ```ts
63
- // models/hello/greet.ts
64
+ // modules/hello/dto/greet.ts
64
65
  import { z } from "@palbase/backend";
65
66
 
66
67
  export const GreetQuery = z.object({ name: z.string().optional() });
@@ -70,13 +71,13 @@ export const HelloResponse = z.object({ message: z.string(), user: z.string().nu
70
71
  export type HelloResponse = z.infer<typeof HelloResponse>;
71
72
  ```
72
73
 
73
- Then write the controller in `controllers/hello.controller.ts`:
74
+ Then write the controller in `modules/hello/hello.controller.ts`:
74
75
 
75
76
  ```ts
76
77
  import { Controller, Get, QueryParams, OptionalUser } from "@palbase/backend";
77
78
  import type { UserT } from "@palbase/backend";
78
- import { GreetQuery } from "../models/hello/greet.js";
79
- import type { HelloResponse } from "../models/hello/greet.js"; // the return TYPE names the 200 schema
79
+ import { GreetQuery } from "./dto/greet.js";
80
+ import type { HelloResponse } from "./dto/greet.js"; // the return TYPE names the 200 schema
80
81
 
81
82
  @Controller("/hello", { auth: false })
82
83
  export default class HelloController {