@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.
- package/dist/bin/palbase-backend.cjs +79 -26
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +5 -5
- package/dist/{chunk-AZJIKCOR.js → chunk-IKDONZ5D.js} +39 -23
- package/dist/{chunk-AZJIKCOR.js.map → chunk-IKDONZ5D.js.map} +1 -1
- package/dist/{chunk-IXAX5CON.js → chunk-SI4KGEM3.js} +1 -1
- package/dist/{chunk-IXAX5CON.js.map → chunk-SI4KGEM3.js.map} +1 -1
- package/dist/{chunk-XEGZ3S2Q.js → chunk-TVCCR6SO.js} +32 -7
- package/dist/chunk-TVCCR6SO.js.map +1 -0
- package/dist/{chunk-5C5UCILO.js → chunk-WWUG2QXF.js} +3 -3
- package/dist/{chunk-KGP6ALIU.js → chunk-XOX6RFPZ.js} +30 -8
- package/dist/chunk-XOX6RFPZ.js.map +1 -0
- package/dist/{chunk-Z4CZRMNF.js → chunk-YIQ4RS4F.js} +2 -2
- package/dist/db/index.cjs +14 -4
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +3 -3
- package/dist/engine/index.cjs +79 -26
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +3 -3
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +5 -5
- package/dist/{index-BILC9WwS.d.ts → index-D-3duy8Y.d.ts} +2 -2
- package/dist/{index-DgYkdNT9.d.cts → index-DB_nW-AV.d.cts} +87 -30
- package/dist/{index-CWGiJ2Up.d.cts → index-DLveQoOf.d.cts} +2 -2
- package/dist/{index-BgWnP07a.d.ts → index-DMZG3kpo.d.ts} +87 -30
- package/dist/index.cjs +59 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +5 -5
- package/dist/openapi/index.d.cts +2 -2
- package/dist/openapi/index.d.ts +2 -2
- package/dist/{registry-BcRYIQ-R.d.cts → registry-DSTThhKf.d.cts} +1 -1
- package/dist/{registry-ClzjxIWy.d.ts → registry-JjF5lcj4.d.ts} +1 -1
- package/dist/test/index.cjs +1155 -12
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/dist/test/index.js +1126 -8
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +124 -80
- package/docs/auth.md +112 -26
- package/docs/background.md +16 -7
- package/docs/database.md +22 -18
- package/docs/endpoints.md +26 -20
- package/docs/errors.md +2 -3
- package/docs/events.md +63 -29
- package/docs/getting-started.md +8 -7
- package/docs/llms-full.txt +519 -244
- package/docs/migrations.md +6 -5
- package/docs/routing.md +34 -11
- package/docs/schema.md +78 -27
- package/docs/services.md +30 -13
- package/package.json +2 -1
- package/stack-images.json +24 -0
- package/template/db/public.ts +23 -0
- package/template/package.json +1 -1
- package/dist/chunk-KGP6ALIU.js.map +0 -1
- package/dist/chunk-XEGZ3S2Q.js.map +0 -1
- /package/dist/{chunk-5C5UCILO.js.map → chunk-WWUG2QXF.js.map} +0 -0
- /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
|
-
|
|
11
|
-
|
|
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
|
-
```
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 — `
|
|
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().
|
|
673
|
-
const rows = await Database.$asService()
|
|
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()
|
|
677
|
-
tx.
|
|
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
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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`.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
//
|
|
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 {
|
|
18
|
-
import { CreateRoomBody } from "
|
|
19
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
77
|
-
return
|
|
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
|
|
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
|
|
11
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
"@palbase/backend"`). They do
|
|
6
|
-
carries the non-service data
|
|
7
|
-
get `requestId`).
|
|
3
|
+
Like jobs, `hooks/` and `webhooks/` stay at the ROOT — neither 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,
|
|
12
|
-
|
|
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 {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
`
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
`
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
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) {
|
package/docs/getting-started.md
CHANGED
|
@@ -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": ["
|
|
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 `
|
|
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.
|
|
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
|
-
//
|
|
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 `
|
|
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 "
|
|
79
|
-
import type { HelloResponse } from "
|
|
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 {
|