@palbase/backend 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-B7EUJP5W.js → chunk-EG7TTYHY.js} +113 -3
- package/dist/chunk-EG7TTYHY.js.map +1 -0
- package/dist/{chunk-PHAFZGHN.js → chunk-WUQO76NW.js} +26 -19
- package/dist/chunk-WUQO76NW.js.map +1 -0
- package/dist/db/index.cjs +117 -2
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +11 -1
- package/dist/{endpoint-DJ98tQd6.d.cts → endpoint-2d_DpASt.d.cts} +92 -56
- package/dist/{endpoint-DJ98tQd6.d.ts → endpoint-2d_DpASt.d.ts} +92 -56
- package/dist/{index-CXUs9iTQ.d.ts → index-DZW9CjiY.d.ts} +210 -41
- package/dist/{index-CZAwpQE1.d.cts → index-DzRFS3Tl.d.cts} +210 -41
- package/dist/index.cjs +371 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +88 -215
- package/dist/index.d.ts +88 -215
- package/dist/index.js +217 -21
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +34 -19
- 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 +10 -2
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +11 -11
- package/docs/database.md +40 -0
- package/docs/endpoints.md +98 -92
- package/docs/errors.md +37 -30
- package/docs/getting-started.md +24 -20
- package/docs/llms-full.txt +401 -235
- package/docs/routing.md +39 -45
- package/docs/schema.md +134 -23
- package/docs/services.md +14 -10
- package/package.json +2 -2
- package/dist/chunk-B7EUJP5W.js.map +0 -1
- package/dist/chunk-PHAFZGHN.js.map +0 -1
package/docs/endpoints.md
CHANGED
|
@@ -1,105 +1,126 @@
|
|
|
1
1
|
# Endpoints
|
|
2
2
|
|
|
3
|
-
An endpoint is a **
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
An endpoint is a **method on a class controller** — a class decorated with
|
|
4
|
+
`@Controller(basePath)` whose methods are decorated with `@Get`/`@Post`/`@Put`/
|
|
5
|
+
`@Patch`/`@Delete`. Controller files live under `controllers/` and are mounted
|
|
6
|
+
automatically (see [routing.md](./routing.md)). Request input + context are
|
|
7
|
+
injected into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/
|
|
8
|
+
`@User`/…), each piece direct — no `req` god-object. Services are reached via the
|
|
9
|
+
imported singletons (see [services.md](./services.md)).
|
|
8
10
|
|
|
9
|
-
##
|
|
11
|
+
## A controller
|
|
10
12
|
|
|
11
13
|
```ts
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
// controllers/rooms.controller.ts
|
|
15
|
+
import { Controller, Get, Post, Returns, Body, Param, User, NotFound, Database } from "@palbase/backend";
|
|
16
|
+
import type { UserT } from "@palbase/backend";
|
|
17
|
+
import { CreateRoomBody } from "../models/rooms/create.js";
|
|
18
|
+
import { RoomSchema } from "../models/rooms/shared.js";
|
|
19
|
+
|
|
20
|
+
@Controller("/rooms")
|
|
21
|
+
export default class RoomsController {
|
|
22
|
+
@Post("")
|
|
23
|
+
@Returns(RoomSchema)
|
|
24
|
+
async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
|
|
25
|
+
const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
|
|
26
|
+
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Get("/{id}")
|
|
30
|
+
@Returns(RoomSchema)
|
|
31
|
+
async getOne(@Param("id") id: string): Promise<RoomSchema> {
|
|
32
|
+
const room = await Database.findById("rooms", id);
|
|
33
|
+
if (!room) throw new NotFound("Room does not exist", "room_not_found");
|
|
34
|
+
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
21
37
|
```
|
|
22
38
|
|
|
23
|
-
|
|
24
|
-
|
|
39
|
+
## Method decorators
|
|
40
|
+
|
|
41
|
+
`@Get`/`@Post`/`@Put`/`@Patch`/`@Delete` take `(subpath, options?)`:
|
|
25
42
|
|
|
26
|
-
|
|
43
|
+
```ts
|
|
44
|
+
interface RouteOptions {
|
|
45
|
+
auth?: boolean | { required?: boolean; role?: string }; // overrides controller default
|
|
46
|
+
rateLimit?: { max: number; window: number }; // optional, per route
|
|
47
|
+
}
|
|
48
|
+
```
|
|
27
49
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
| `req.params` | `Record<string,string>` | route params, e.g. `req.params.id` |
|
|
32
|
-
| `req.query` | `Record<string,string>` | parsed query string |
|
|
33
|
-
| `req.headers` | `Record<string,string>` | lowercase keys |
|
|
34
|
-
| `req.user` | `User` when authenticated; `User \| null` when `auth` is omitted or `required: false` | see Auth below |
|
|
35
|
-
| `req.client` | `ClientInfo` | calling SDK/app/platform/os version (all nullable) |
|
|
36
|
-
| `req.file` | `FileContext \| null` | uploaded file, if any |
|
|
37
|
-
| `req.method` | `string` | the HTTP method |
|
|
38
|
-
| `req.requestId` / `req.traceId` / `req.spanId` | `string` | correlation ids |
|
|
39
|
-
| `req.errors` | typed throwers | present when `errors` is declared (see errors.md) |
|
|
50
|
+
Input and output are NOT method-decorator options: input comes from the
|
|
51
|
+
parameter decorators, output from the method's return type. The method decorator
|
|
52
|
+
only carries route concerns (path, auth, rateLimit).
|
|
40
53
|
|
|
41
|
-
|
|
54
|
+
## Parameter decorators
|
|
42
55
|
|
|
43
|
-
|
|
56
|
+
| Decorator | Injects | Typed by | OpenAPI |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `@Body(schema)` | request body | `: T` (`z.infer<schema>`, same name) | requestBody |
|
|
59
|
+
| `@Query(schema)` | query params | `: T` | query parameters |
|
|
60
|
+
| `@Param("id")` | one path param | `: string` | path parameter |
|
|
61
|
+
| `@Headers(schema?)` | request headers | `Record<string,string>` (+ typed with schema) | header parameters |
|
|
62
|
+
| `@User()` | authenticated user | `: UserT` | — (from token) |
|
|
63
|
+
| `@OptionalUser()` | user, possibly anon | `: UserT \| null` | — |
|
|
64
|
+
| `@Client()` | parsed client info | `: ClientInfo` | — |
|
|
65
|
+
| `@RequestId()` / `@TraceId()` | correlation ids | `: string` | — |
|
|
66
|
+
| `@Req()` | raw request (escape hatch) | `: PBRequest` | — |
|
|
44
67
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
68
|
+
`@Body`/`@Query`/`@Headers` take a zod schema (validation + codegen source); the
|
|
69
|
+
developer writes the matching type annotation (same name). `UserT` is the
|
|
70
|
+
authenticated-user type (`{ id: string; email?: string; role: string; metadata:
|
|
71
|
+
Record<string, unknown> }`) — exported under the alias `UserT` because the value
|
|
72
|
+
name `User` is the `@User()` decorator.
|
|
48
73
|
|
|
49
|
-
|
|
50
|
-
// auth omitted entirely // AUTH REQUIRED → req.user is non-null User
|
|
51
|
-
auth: { required: true } // same: require any authenticated user
|
|
52
|
-
auth: { required: true, role: "admin" } // require a specific role
|
|
53
|
-
auth: { required: false } // PUBLIC → req.user may be null (anon key only)
|
|
54
|
-
```
|
|
74
|
+
## Output = return type
|
|
55
75
|
|
|
56
|
-
|
|
57
|
-
|
|
76
|
+
The success response is the method's RETURN TYPE. Pair it with `@Returns(schema)`
|
|
77
|
+
so the zod value drives the OpenAPI 200 response (the type annotation drives
|
|
78
|
+
compile-time checking; the zod value is lossless for codegen). A method that
|
|
79
|
+
returns nothing annotates `: void`.
|
|
58
80
|
|
|
59
|
-
|
|
60
|
-
|--------------|-----------------|
|
|
61
|
-
| omitted | `User` (secure-by-default) |
|
|
62
|
-
| `true` | `User` |
|
|
63
|
-
| `false` | `User \| null` (public) |
|
|
64
|
-
| `{ required: true }` | `User` |
|
|
65
|
-
| `{ required: false }` | `User \| null` (public) |
|
|
66
|
-
| `{ role: "admin" }` (object, no `required`) | `User` |
|
|
81
|
+
## Auth — controller default + route override cascade
|
|
67
82
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
`auth` is omitted, the endpoint is public and `req.user` may be null.
|
|
83
|
+
**Secure by default:** a route requires authentication UNLESS it explicitly opts
|
|
84
|
+
out. Resolution order (most specific wins):
|
|
71
85
|
|
|
72
|
-
|
|
86
|
+
1. Route-level `@Post("", { auth })` — wins if present.
|
|
87
|
+
2. Controller-level `@Controller("/x", { auth })` — applies to routes without
|
|
88
|
+
their own `auth`.
|
|
89
|
+
3. Default `true` (secure-by-default).
|
|
73
90
|
|
|
74
91
|
```ts
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
handler: async (req) => {
|
|
82
|
-
const room = await Database.insert("rooms", {
|
|
83
|
-
name: req.input.name,
|
|
84
|
-
capacity: req.input.capacity ?? null,
|
|
85
|
-
});
|
|
86
|
-
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
87
|
-
},
|
|
88
|
-
});
|
|
92
|
+
@Controller("/public", { auth: false }) // all routes default public
|
|
93
|
+
export default class PublicController {
|
|
94
|
+
@Get("/open") open(): Info { ... } // inherits → public
|
|
95
|
+
@Get("/secret", { auth: true }) // OVERRIDES → authed
|
|
96
|
+
secret(@User() u: UserT): Secret { ... }
|
|
97
|
+
}
|
|
89
98
|
```
|
|
90
99
|
|
|
91
|
-
|
|
100
|
+
| effective `auth` | `@User()` type to annotate |
|
|
101
|
+
|------------------|----------------------------|
|
|
102
|
+
| required (omitted / `true` / `{ required: true }` / role-only) | `@User() u: UserT` |
|
|
103
|
+
| public (`false` / `{ required: false }`) | `@OptionalUser() u: UserT \| null` |
|
|
104
|
+
|
|
105
|
+
The runtime always applies the resolved effective auth; the static type you
|
|
106
|
+
annotate is best-effort (a parameter decorator cannot see the controller default
|
|
107
|
+
at the type level).
|
|
108
|
+
|
|
109
|
+
## Errors — global throw classes
|
|
110
|
+
|
|
111
|
+
Throw an error class anywhere (controller OR service) — no `req`, no per-route
|
|
112
|
+
map. The runtime catches it and emits the standard envelope:
|
|
92
113
|
|
|
93
114
|
```ts
|
|
94
|
-
|
|
95
|
-
import { defineController, route } from "@palbase/backend";
|
|
96
|
-
import create from "../handlers/rooms/create.js";
|
|
115
|
+
import { Conflict, NotFound, BadRequest, Unauthorized, Forbidden, TooManyRequests, PalError } from "@palbase/backend";
|
|
97
116
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
117
|
+
throw new Conflict("title taken"); // → 409
|
|
118
|
+
throw new NotFound(); // → 404 ("Not found")
|
|
119
|
+
throw new PalError(418, "teapot", "custom"); // → custom status/code
|
|
101
120
|
```
|
|
102
121
|
|
|
122
|
+
See [errors.md](./errors.md) for the full set + the wire envelope shape.
|
|
123
|
+
|
|
103
124
|
## Middleware
|
|
104
125
|
|
|
105
126
|
A middleware wraps a request. Define one in `middleware/<name>.ts`:
|
|
@@ -116,19 +137,4 @@ export default defineMiddleware(async (ctx, next) => {
|
|
|
116
137
|
```
|
|
117
138
|
|
|
118
139
|
The middleware handler receives `(ctx, next)` — call `await next()` to run the
|
|
119
|
-
rest of the chain (other middleware, then the endpoint
|
|
120
|
-
the `ctx` model, not `req`.
|
|
121
|
-
|
|
122
|
-
To attach middleware to a specific handler, import it and list it in the
|
|
123
|
-
handler's `middleware` array:
|
|
124
|
-
|
|
125
|
-
```ts
|
|
126
|
-
import { defineHandler, z } from "@palbase/backend";
|
|
127
|
-
import logger from "../../middleware/logger.js";
|
|
128
|
-
|
|
129
|
-
export default defineHandler({
|
|
130
|
-
middleware: [logger],
|
|
131
|
-
output: z.object({ ok: z.boolean() }),
|
|
132
|
-
handler: async (req) => ({ ok: true }),
|
|
133
|
-
});
|
|
134
|
-
```
|
|
140
|
+
rest of the chain (other middleware, then the endpoint method).
|
package/docs/errors.md
CHANGED
|
@@ -1,47 +1,54 @@
|
|
|
1
1
|
# Errors
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
envelope:
|
|
3
|
+
Throw an error class to fail a request. Every one serializes to the standard
|
|
4
|
+
Palbase error envelope:
|
|
5
5
|
|
|
6
6
|
```json
|
|
7
7
|
{ "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
|
|
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.
|
|
13
|
+
|
|
14
|
+
## Named status classes
|
|
11
15
|
|
|
12
16
|
```ts
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
throw new
|
|
17
|
+
import { BadRequest, Unauthorized, Forbidden, NotFound, Conflict, TooManyRequests } from "@palbase/backend";
|
|
18
|
+
|
|
19
|
+
throw new Conflict("title taken"); // → 409 ("conflict" code by default)
|
|
20
|
+
throw new NotFound(); // → 404 ("not_found", "Not found")
|
|
21
|
+
throw new BadRequest("missing field"); // → 400
|
|
22
|
+
throw new Unauthorized(); // → 401
|
|
23
|
+
throw new Forbidden(); // → 403
|
|
24
|
+
throw new TooManyRequests(); // → 429
|
|
17
25
|
```
|
|
18
26
|
|
|
19
|
-
|
|
27
|
+
Each class fixes its HTTP status. The constructor is
|
|
28
|
+
`new <Class>(message?, code?, data?)`:
|
|
20
29
|
|
|
21
|
-
|
|
30
|
+
- `message` overrides the human-readable `error_description` (defaults to a label
|
|
31
|
+
derived from the class name).
|
|
32
|
+
- `code` overrides the wire `error` code (defaults to the class's snake_case
|
|
33
|
+
code, e.g. `not_found`).
|
|
34
|
+
- `data` rides along under the envelope's `data` field for structured context.
|
|
22
35
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
```ts
|
|
37
|
+
throw new NotFound("Room does not exist", "room_not_found");
|
|
38
|
+
throw new Conflict("locked", "title_locked", { retryAfter: 30 });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## `PalError` / `HttpError` (custom status)
|
|
42
|
+
|
|
43
|
+
For a status/code not covered by a named class:
|
|
26
44
|
|
|
27
45
|
```ts
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
output: z.object({ ok: z.boolean() }),
|
|
33
|
-
errors: {
|
|
34
|
-
notFound: { status: 404, code: "todo_not_found", description: "No such todo" },
|
|
35
|
-
locked: { status: 423, code: "todo_locked", data: z.object({ retryAfter: z.number() }) },
|
|
36
|
-
},
|
|
37
|
-
handler: async (req) => {
|
|
38
|
-
const todo = await Database.findById("todos", req.input.id);
|
|
39
|
-
if (!todo) throw req.errors.notFound(); // no data → no args
|
|
40
|
-
if (todo.locked) throw req.errors.locked({ retryAfter: 30 }); // data schema → required arg
|
|
41
|
-
return { ok: true };
|
|
42
|
-
},
|
|
43
|
-
});
|
|
46
|
+
import { PalError } from "@palbase/backend";
|
|
47
|
+
throw new PalError(418, "teapot", "I'm a teapot");
|
|
48
|
+
// optional structured payload (4th arg) rides along under `data`:
|
|
49
|
+
throw new PalError(423, "todo_locked", "Locked", { retryAfter: 30 });
|
|
44
50
|
```
|
|
45
51
|
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
`PalError` (and its base `HttpError`) take `(status, code, description, data?)`.
|
|
53
|
+
The named classes all extend `HttpError`, so `catch (e) { if (e instanceof
|
|
54
|
+
HttpError) … }` matches any of them.
|
package/docs/getting-started.md
CHANGED
|
@@ -33,33 +33,37 @@ Your project depends on the SDK and uses the Palbase CLI for the dev loop:
|
|
|
33
33
|
|
|
34
34
|
## Your first endpoint
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
`handlers/hello.ts`:
|
|
36
|
+
An endpoint is a method on a class controller. Declare the schemas in `models/`:
|
|
38
37
|
|
|
39
38
|
```ts
|
|
40
|
-
|
|
39
|
+
// models/hello/greet.ts
|
|
40
|
+
import { z } from "@palbase/backend";
|
|
41
41
|
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
user: req.user?.id ?? null,
|
|
48
|
-
}),
|
|
49
|
-
});
|
|
42
|
+
export const GreetQuery = z.object({ name: z.string().optional() });
|
|
43
|
+
export type GreetQuery = z.infer<typeof GreetQuery>;
|
|
44
|
+
|
|
45
|
+
export const HelloResponse = z.object({ message: z.string(), user: z.string().nullable() });
|
|
46
|
+
export type HelloResponse = z.infer<typeof HelloResponse>;
|
|
50
47
|
```
|
|
51
48
|
|
|
52
|
-
Then
|
|
49
|
+
Then write the controller in `controllers/hello.controller.ts`:
|
|
53
50
|
|
|
54
51
|
```ts
|
|
55
|
-
import {
|
|
56
|
-
import
|
|
52
|
+
import { Controller, Get, Returns, Query, OptionalUser } from "@palbase/backend";
|
|
53
|
+
import type { UserT } from "@palbase/backend";
|
|
54
|
+
import { GreetQuery, HelloResponse } from "../models/hello/greet.js";
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
@Controller("/hello", { auth: false })
|
|
57
|
+
export default class HelloController {
|
|
58
|
+
@Get("")
|
|
59
|
+
@Returns(HelloResponse)
|
|
60
|
+
greet(@Query(GreetQuery) q: GreetQuery, @OptionalUser() user: UserT | null): HelloResponse {
|
|
61
|
+
return { message: `hello, ${q.name ?? "world"}!`, user: user?.id ?? null };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
61
64
|
```
|
|
62
65
|
|
|
63
|
-
This is served at `GET /hello`. The
|
|
64
|
-
|
|
65
|
-
[routing.md](./routing.md)
|
|
66
|
+
This is served at `GET /hello`. The `@Query` schema validates the query string;
|
|
67
|
+
the method's return type (paired with `@Returns(HelloResponse)`) validates and
|
|
68
|
+
describes the response. See [routing.md](./routing.md) and
|
|
69
|
+
[endpoints.md](./endpoints.md) for the full class-controller model.
|