@palbase/backend 33.0.0 → 33.0.2
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.map +1 -1
- package/dist/bin/palbase-backend.js +5 -5
- package/dist/{chunk-BRLJOXWS.js → chunk-AAT5G7KY.js} +2 -2
- package/dist/{chunk-BRLJOXWS.js.map → chunk-AAT5G7KY.js.map} +1 -1
- package/dist/{chunk-DCDHAKF3.js → chunk-C6COAB3E.js} +5 -5
- package/dist/{chunk-C4ZA5AT2.js → chunk-SI4KGEM3.js} +1 -1
- package/dist/{chunk-C4ZA5AT2.js.map → chunk-SI4KGEM3.js.map} +1 -1
- package/dist/{chunk-QMFOL2K6.js → chunk-TVCCR6SO.js} +2 -2
- package/dist/{chunk-N54QZER3.js → chunk-WWUG2QXF.js} +2 -2
- package/dist/{chunk-GOPZPM2A.js → chunk-YIQ4RS4F.js} +2 -2
- 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 +2 -2
- 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-Bi74dcOu.d.ts → index-D-3duy8Y.d.ts} +2 -2
- package/dist/{index-BWgnGj68.d.cts → index-DB_nW-AV.d.cts} +19 -10
- package/dist/{index-g6iQyYci.d.cts → index-DLveQoOf.d.cts} +2 -2
- package/dist/{index-Dg10RnZO.d.ts → index-DMZG3kpo.d.ts} +19 -10
- 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.cjs +1 -1
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +2 -2
- package/dist/openapi/index.d.ts +2 -2
- package/dist/openapi/index.js +1 -1
- package/dist/{registry-6VT5RPeO.d.cts → registry-DSTThhKf.d.cts} +1 -1
- package/dist/{registry-B-sxJJN0.d.ts → registry-JjF5lcj4.d.ts} +1 -1
- package/dist/test/index.cjs +1120 -4
- 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 +1121 -5
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +123 -79
- package/docs/auth.md +11 -7
- 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 +399 -232
- package/docs/migrations.md +6 -5
- package/docs/routing.md +34 -11
- package/docs/schema.md +61 -36
- package/docs/services.md +30 -13
- package/package.json +2 -1
- package/stack-images.json +24 -0
- package/template/AGENTS.md +7 -7
- package/template/modules/health/health.controller.ts +7 -4
- package/template/modules/notes/dto/create.ts +4 -3
- package/template/modules/notes/notes.controller.ts +8 -6
- /package/dist/{chunk-DCDHAKF3.js.map → chunk-C6COAB3E.js.map} +0 -0
- /package/dist/{chunk-QMFOL2K6.js.map → chunk-TVCCR6SO.js.map} +0 -0
- /package/dist/{chunk-N54QZER3.js.map → chunk-WWUG2QXF.js.map} +0 -0
- /package/dist/{chunk-GOPZPM2A.js.map → chunk-YIQ4RS4F.js.map} +0 -0
package/docs/llms-full.txt
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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 `
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
116
|
+
// modules/todos/todo.service.ts — @Injectable() classes. The real work.
|
|
108
117
|
import { Database, Injectable, NotFound } from "@palbase/backend";
|
|
109
|
-
import type {
|
|
118
|
+
import type { Tables } from "@palbase/backend/env";
|
|
110
119
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
117
|
-
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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 "./
|
|
151
|
-
import { TodoService } from "./
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
//
|
|
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 {
|
|
170
|
-
import { TodoSchema } from "
|
|
171
|
-
import { CreateTodoBody } from "
|
|
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
|
|
175
|
-
// The service arrives
|
|
176
|
-
//
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
return
|
|
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
|
-
|
|
186
|
-
return
|
|
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
|
-
|
|
191
|
-
return
|
|
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
|
-
|
|
196
|
-
|
|
230
|
+
remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
|
|
231
|
+
return this.todos.remove(user.id, id);
|
|
197
232
|
}
|
|
198
233
|
}
|
|
199
|
-
//
|
|
200
|
-
//
|
|
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
|
-
|
|
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
|
|
310
|
+
Every handler type reaches the PLATFORM services through the same import:
|
|
274
311
|
|
|
275
312
|
```ts
|
|
276
|
-
import { Database, Log, Cache
|
|
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** (`
|
|
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 —
|
|
291
|
-
|
|
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`.
|
|
295
|
-
the
|
|
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
|
|
302
|
-
├── tsconfig.json
|
|
303
|
-
├──
|
|
304
|
-
│
|
|
305
|
-
├──
|
|
306
|
-
│ └──
|
|
307
|
-
|
|
308
|
-
├──
|
|
309
|
-
├──
|
|
310
|
-
├──
|
|
311
|
-
|
|
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
|
|
319
|
-
(`@Controller("/base")` with `@Get`/`@Post`/… methods)
|
|
320
|
-
`controllers
|
|
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": ["
|
|
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 `
|
|
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.
|
|
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
|
-
//
|
|
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 `
|
|
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 "
|
|
424
|
-
import type { HelloResponse } from "
|
|
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`/…).
|
|
465
|
-
|
|
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
|
|
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
|
-
//
|
|
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 {
|
|
482
|
-
import { ImportNearbyBody } from "
|
|
483
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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`.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
//
|
|
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 {
|
|
549
|
-
import { CreateRoomBody } from "
|
|
550
|
-
import
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
608
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
979
|
-
|
|
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
|
-
```
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 — `
|
|
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().
|
|
1641
|
-
const rows = await Database.$asService()
|
|
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()
|
|
1645
|
-
tx.
|
|
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
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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`; `
|
|
1700
|
-
[Row-Level Security](#row-level-security-rls),
|
|
1701
|
-
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
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 "./
|
|
2149
|
-
import { RoomService } from "./
|
|
2244
|
+
import { RoomsController } from "./rooms.controller.ts";
|
|
2245
|
+
import { DbRoomRepo, RoomService } from "./room.service.ts";
|
|
2150
2246
|
|
|
2151
|
-
@Module({
|
|
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
|
-
//
|
|
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 "
|
|
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
|
|
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
|
|
2272
|
+
return this.rooms.create(body.name);
|
|
2170
2273
|
}
|
|
2171
2274
|
}
|
|
2172
2275
|
```
|
|
2173
2276
|
|
|
2174
|
-
`Database.public.<name>` exposes `insert
|
|
2175
|
-
`
|
|
2176
|
-
|
|
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`
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
deliberate deny-all
|
|
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
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
-
|
|
2275
|
-
policy("notes_delete_any").for("delete")
|
|
2276
|
-
|
|
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`
|
|
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
|
-
|
|
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 `
|
|
2515
|
-
generated migration emits the `ENABLE ROW LEVEL SECURITY` +
|
|
2516
|
-
|
|
2517
|
-
|
|
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** — `
|
|
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
|
|
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
|
|
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
|
|
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 — `
|
|
2776
|
-
example of (`
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 "./
|
|
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
|
-
//
|
|
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
|
|
2984
|
-
|
|
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
|
|
3045
|
-
|
|
3046
|
-
"@palbase/backend"`). It does **not** receive
|
|
3047
|
-
carries the non-service data (`env`, correlation
|
|
3168
|
+
`jobs/` is the background rail, and it stays at the ROOT — a 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.
|
|
3061
|
-
|
|
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
|
-
|
|
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
|
|
3087
|
-
|
|
3088
|
-
"@palbase/backend"`). They do
|
|
3089
|
-
carries the non-service data
|
|
3090
|
-
get `requestId`).
|
|
3219
|
+
Like jobs, `hooks/` and `webhooks/` stay at the ROOT — neither 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,
|
|
3095
|
-
|
|
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 {
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
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
|
-
|
|
3110
|
-
|
|
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
|
-
`
|
|
3115
|
-
|
|
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
|
-
|
|
3118
|
-
`
|
|
3119
|
-
|
|
3120
|
-
|
|
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
|
|
3126
|
-
|
|
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
|
-
|
|
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) {
|