@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/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).
|
|
@@ -81,12 +90,12 @@ service the controllers call.
|
|
|
81
90
|
|
|
82
91
|
> **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
|
|
83
92
|
> `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
|
|
84
|
-
> and will not compile against `@palbase/backend`
|
|
93
|
+
> and will not compile against `@palbase/backend` 33.
|
|
85
94
|
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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);
|
|
140
|
+
}
|
|
141
|
+
insert(row: { user_id: string; title: string }): Promise<Todo> {
|
|
142
|
+
return Database.public.todos.insert(row);
|
|
126
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
|
+
}
|
|
151
|
+
|
|
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) {}
|
|
127
158
|
|
|
128
|
-
list(userId: string): Promise<
|
|
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
|
}
|
|
@@ -741,35 +819,117 @@ interface User {
|
|
|
741
819
|
Every field is **server-resolved** from the verified profile — nothing here is
|
|
742
820
|
client-settable. `metadata` is your own `auth.users.metadata` (set through the
|
|
743
821
|
admin users API); `role` is the **database** role RLS reads and is always
|
|
744
|
-
`"authenticated"` for a signed-in user, so
|
|
745
|
-
|
|
822
|
+
`"authenticated"` for a signed-in user, so `user.role === "admin"` is always
|
|
823
|
+
false and reads like a working check.
|
|
824
|
+
|
|
825
|
+
Application roles are **`user.roles`** — a `string[]` resolved per request from
|
|
826
|
+
`auth.user_roles`, the table `palbase roles` writes. They were once kept in
|
|
827
|
+
`metadata`; they are not any more, and a role written there gates nothing.
|
|
828
|
+
|
|
829
|
+
`emailVerified`, `email`, `role` and `metadata` come from the **verified token**,
|
|
830
|
+
so they say what was true when it was minted: a user who confirms their address
|
|
831
|
+
mid-session keeps reporting `false` until their next token. `roles` is the
|
|
832
|
+
exception, and deliberately so — it is read from the table on every request, so
|
|
833
|
+
a grant or a revocation lands immediately.
|
|
834
|
+
|
|
835
|
+
## Roles and permissions
|
|
836
|
+
|
|
837
|
+
Roles are **yours**. There is no built-in "admin": you declare the roles your
|
|
838
|
+
app has, and what each one is allowed to do, from the command line —
|
|
746
839
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
840
|
+
```bash
|
|
841
|
+
palbase roles create member --default --permissions notes.create
|
|
842
|
+
palbase roles create moderator --permissions notes.create,notes.delete_any
|
|
843
|
+
palbase roles assign usr_01a0… moderator
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
— and `palbase spec` brings them back as constants, so a misspelled permission
|
|
847
|
+
is a compile error rather than a silent `403`:
|
|
848
|
+
|
|
849
|
+
```ts
|
|
850
|
+
import { Roles, Permissions } from "./palbe.gen";
|
|
851
|
+
Roles.moderator // 'moderator'
|
|
852
|
+
Permissions.notesDeleteAny // 'notes.delete_any'
|
|
853
|
+
```
|
|
752
854
|
|
|
753
|
-
|
|
855
|
+
`--default` marks the one role every new sign-up receives, written in the same
|
|
856
|
+
transaction as the user row.
|
|
754
857
|
|
|
755
|
-
|
|
858
|
+
### The endpoint gate
|
|
756
859
|
|
|
757
860
|
```ts
|
|
758
|
-
@Controller("/
|
|
759
|
-
export
|
|
760
|
-
|
|
761
|
-
|
|
861
|
+
@Controller("/notes")
|
|
862
|
+
export class NotesController {
|
|
863
|
+
// Anyone signed in.
|
|
864
|
+
@Get("") list(@User() user: UserT) { … }
|
|
865
|
+
|
|
866
|
+
// Only a caller holding the permission — the handler never runs otherwise.
|
|
867
|
+
@Delete("/{id}", { auth: { permission: "notes.delete_any" } })
|
|
868
|
+
remove(@Param("id") id: string) { … }
|
|
869
|
+
|
|
870
|
+
// Or by role, when the check really is "is this caller a moderator".
|
|
871
|
+
@Get("/queue", { auth: { role: "moderator" } })
|
|
872
|
+
queue() { … }
|
|
762
873
|
}
|
|
763
874
|
```
|
|
764
875
|
|
|
765
|
-
Not signed in → `401
|
|
766
|
-
|
|
767
|
-
|
|
876
|
+
Not signed in → `401`: declaring a permission or a role implies authentication,
|
|
877
|
+
so the caller is resolved even on a route with no `@User()` parameter. Signed in
|
|
878
|
+
without it → `403`, and the body names what was missing:
|
|
768
879
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
880
|
+
```json
|
|
881
|
+
{ "error": "forbidden",
|
|
882
|
+
"error_description": "This endpoint requires the \"notes.delete_any\" permission" }
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
If you deliberately open a route with `auth: { required: false, permission: … }`,
|
|
886
|
+
an unauthenticated caller reaches the gate and is refused with `403` — no
|
|
887
|
+
identity means no roles, and the gate answers that rather than waving them
|
|
888
|
+
through.
|
|
889
|
+
|
|
890
|
+
**Authority is in no claim.** The gate reads `auth.user_roles` inside the
|
|
891
|
+
transaction your handler is about to use — never a JWT claim — so
|
|
892
|
+
`palbase roles revoke` closes the door on the **very next request**, with the
|
|
893
|
+
same access token, no refresh and no re-login. Nothing is cached, and there is
|
|
894
|
+
no epoch to bump.
|
|
895
|
+
|
|
896
|
+
### The RLS half
|
|
897
|
+
|
|
898
|
+
The gate decides whether the handler runs. It cannot decide which **rows** the
|
|
899
|
+
handler sees — that is Postgres's job, and the same permissions are available
|
|
900
|
+
there:
|
|
901
|
+
|
|
902
|
+
```ts
|
|
903
|
+
policies: () => [
|
|
904
|
+
policy("notes_owner").for("all").to("authenticated")
|
|
905
|
+
.using("user_id = (select auth.uid())")
|
|
906
|
+
.withCheck("user_id = (select auth.uid())"),
|
|
907
|
+
|
|
908
|
+
// Moderation. Permissive policies are OR'd, so these widen only.
|
|
909
|
+
policy("notes_moderate_read").for("select").to("authenticated")
|
|
910
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
911
|
+
policy("notes_delete_any").for("delete").to("authenticated")
|
|
912
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
913
|
+
]
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
**The read policy is not optional, and leaving it out is the trap.**
|
|
917
|
+
`DELETE … WHERE id = $1` has to FIND the row first, and that read is governed by
|
|
918
|
+
the SELECT policies. With only the DELETE policy, a moderator holding the
|
|
919
|
+
permission deletes **nothing**: the row is invisible to them, the `WHERE`
|
|
920
|
+
matches nothing, and the statement answers `DELETE 0` — a silent no-op, not an
|
|
921
|
+
error. The rule: **if a permission writes to somebody else's row, a SELECT
|
|
922
|
+
policy has to show them that row.**
|
|
923
|
+
|
|
924
|
+
Two helpers are available in every policy body:
|
|
925
|
+
|
|
926
|
+
| helper | answers |
|
|
927
|
+
|---|---|
|
|
928
|
+
| `auth.has_permission(text)` | does the caller hold this permission, through any role they carry |
|
|
929
|
+
| `auth.app_roles()` | the caller's roles as `text[]`, sorted, `{}` when none |
|
|
930
|
+
|
|
931
|
+
Both take the caller from `auth.uid()` and accept no user id, so a policy cannot
|
|
932
|
+
ask about somebody else.
|
|
773
933
|
|
|
774
934
|
## Email verification
|
|
775
935
|
|
|
@@ -817,7 +977,7 @@ publish(@User() user: UserT) {
|
|
|
817
977
|
if (!user.emailVerified) {
|
|
818
978
|
throw new Forbidden("Confirm your email address before publishing.");
|
|
819
979
|
}
|
|
820
|
-
return
|
|
980
|
+
return this.posts.publish(user.id);
|
|
821
981
|
}
|
|
822
982
|
```
|
|
823
983
|
|
|
@@ -893,8 +1053,9 @@ jobs, hooks, and webhooks all use the same import:
|
|
|
893
1053
|
import { Database } from "@palbase/backend";
|
|
894
1054
|
```
|
|
895
1055
|
|
|
896
|
-
|
|
897
|
-
|
|
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).
|
|
898
1059
|
|
|
899
1060
|
## Typed by default — `Database.<şema>.<tablo>`
|
|
900
1061
|
|
|
@@ -962,7 +1123,9 @@ await Database.public.crew_presence.findMany({
|
|
|
962
1123
|
AND: [sqlFragment`expires_at > now()`],
|
|
963
1124
|
},
|
|
964
1125
|
});
|
|
965
|
-
```
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
The same filter language works for `updateMany`, `deleteMany`,
|
|
966
1129
|
`count` and `search` — one language, so two spellings cannot drift apart. An
|
|
967
1130
|
empty `in` list means NO rows (written into the SQL as `false`), never a silent
|
|
968
1131
|
full scan.
|
|
@@ -1258,7 +1421,7 @@ filter object built from request input that happened to come back empty — is
|
|
|
1258
1421
|
exactly the shape that must not silently succeed. `count()` with no filter IS
|
|
1259
1422
|
allowed: counting is a read.
|
|
1260
1423
|
|
|
1261
|
-
### `Database
|
|
1424
|
+
### `Database.$query` — raw SQL
|
|
1262
1425
|
|
|
1263
1426
|
Use it for shapes the helpers do not cover (joins, aggregates, window functions).
|
|
1264
1427
|
It runs in **the request's own transaction**, not a read-only one: a write inside
|
|
@@ -1283,7 +1446,7 @@ per row with a `23505` caught around each:
|
|
|
1283
1446
|
|
|
1284
1447
|
```ts
|
|
1285
1448
|
await Database.$transaction((tx) => {
|
|
1286
|
-
tx.
|
|
1449
|
+
tx.public.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
|
|
1287
1450
|
return null;
|
|
1288
1451
|
});
|
|
1289
1452
|
```
|
|
@@ -1340,11 +1503,11 @@ middle, so nothing holds a database connection open while your code thinks.
|
|
|
1340
1503
|
import { Database, NotFound } from "@palbase/backend";
|
|
1341
1504
|
|
|
1342
1505
|
const { orderId } = await Database.$transaction((tx) => {
|
|
1343
|
-
const order = tx.
|
|
1506
|
+
const order = tx.public.orders
|
|
1344
1507
|
.insert({ amount: 1000, status: "pending" })
|
|
1345
1508
|
.expectOne(new NotFound("order could not be created"));
|
|
1346
1509
|
|
|
1347
|
-
tx.
|
|
1510
|
+
tx.public.order_items.insertMany(
|
|
1348
1511
|
cart.map((line) => ({ order_id: order.id, sku: line.sku })),
|
|
1349
1512
|
);
|
|
1350
1513
|
|
|
@@ -1400,7 +1563,7 @@ filter plus an expectation — which is also stronger, because the check and the
|
|
|
1400
1563
|
write are now the same statement and nothing can slip between them:
|
|
1401
1564
|
|
|
1402
1565
|
```ts
|
|
1403
|
-
tx.
|
|
1566
|
+
tx.public.invites
|
|
1404
1567
|
.updateWhere({ token, accepted_at: null }, { accepted_at: now() })
|
|
1405
1568
|
.expectOne(new Conflict("invite already used", "invite_used"));
|
|
1406
1569
|
```
|
|
@@ -1440,7 +1603,7 @@ await Database.public.accounts.updateMany({
|
|
|
1440
1603
|
|
|
1441
1604
|
// Inside a plan: the same expression, the same meaning.
|
|
1442
1605
|
await Database.$transaction((tx) => {
|
|
1443
|
-
tx.
|
|
1606
|
+
tx.public.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
|
|
1444
1607
|
return null;
|
|
1445
1608
|
});
|
|
1446
1609
|
```
|
|
@@ -1546,7 +1709,8 @@ Sometimes you need to read or write **across all users** — an admin endpoint,
|
|
|
1546
1709
|
background job that fans out notifications, a cleanup task. For that, call
|
|
1547
1710
|
`Database.$asService()`. It returns a sibling client that runs as
|
|
1548
1711
|
**`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
|
|
1549
|
-
exact same surface — `
|
|
1712
|
+
exact same surface — `public` (and every other schema), the `$`-prefixed raw ops,
|
|
1713
|
+
and `$transaction`:
|
|
1550
1714
|
|
|
1551
1715
|
```ts
|
|
1552
1716
|
import { Database } from "@palbase/backend";
|
|
@@ -1555,32 +1719,32 @@ import { Database } from "@palbase/backend";
|
|
|
1555
1719
|
const mine = await Database.public.todos.findMany({});
|
|
1556
1720
|
|
|
1557
1721
|
// Service-role bypass: every user's rows. Explicit and greppable.
|
|
1558
|
-
const all = await Database.$asService().
|
|
1559
|
-
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");
|
|
1560
1724
|
|
|
1561
1725
|
// A service-role transaction (the role is fixed for the whole plan):
|
|
1562
|
-
await Database.$asService()
|
|
1563
|
-
tx.
|
|
1726
|
+
await Database.$asService().$transaction((tx) => {
|
|
1727
|
+
tx.public.todos.updateWhere({ id }, { done: true });
|
|
1564
1728
|
return null;
|
|
1565
1729
|
});
|
|
1566
1730
|
```
|
|
1567
1731
|
|
|
1568
1732
|
Guidelines:
|
|
1569
1733
|
|
|
1570
|
-
- **Be explicit.** Prefer the default `Database.*` and reach for
|
|
1734
|
+
- **Be explicit.** Prefer the default `Database.*` and reach for `$asService()`
|
|
1571
1735
|
only where you genuinely need cross-user access. It is intentionally easy to
|
|
1572
1736
|
grep for in review.
|
|
1573
1737
|
- **No double-bypass / no nesting.** The sibling does not re-expose
|
|
1574
|
-
|
|
1738
|
+
`$asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
|
|
1575
1739
|
transaction. Use `Database.$transaction(...)` for an authenticated one and
|
|
1576
|
-
`Database.$asService()
|
|
1740
|
+
`Database.$asService().$transaction(...)` for a service-role one; you cannot mix
|
|
1577
1741
|
enforced and bypassed operations inside a single plan.
|
|
1578
1742
|
- **It is a second transaction, on a second connection.** It has to be: the
|
|
1579
1743
|
Postgres role is bound once, when the transaction opens, so a sibling sharing
|
|
1580
1744
|
the request's transaction would run as `backend_authenticated` and silently
|
|
1581
1745
|
keep filtering. Three consequences worth knowing:
|
|
1582
1746
|
- The request opens it at most once, however many times you call
|
|
1583
|
-
|
|
1747
|
+
`$asService()`, and never at all if you don't use it.
|
|
1584
1748
|
- Both settle with the request — commit when your handler returns, roll back
|
|
1585
1749
|
when it throws — but they settle as two transactions. A row written through
|
|
1586
1750
|
one surface is **not visible** to the other until the request commits.
|
|
@@ -1614,9 +1778,10 @@ those values — never a dictionary, because a name in a dictionary key is a sec
|
|
|
1614
1778
|
place the name is written, and a table built under a key does not yet know what
|
|
1615
1779
|
to call itself when a sibling references it.
|
|
1616
1780
|
|
|
1617
|
-
Each table's only required field is `columns`; `
|
|
1618
|
-
[Row-Level Security](#row-level-security-rls),
|
|
1619
|
-
|
|
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).
|
|
1620
1785
|
|
|
1621
1786
|
```ts
|
|
1622
1787
|
import {
|
|
@@ -2042,14 +2207,28 @@ You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
|
|
|
2042
2207
|
of the schema, no generic, no cast:
|
|
2043
2208
|
|
|
2044
2209
|
```ts
|
|
2045
|
-
//
|
|
2046
|
-
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";
|
|
2213
|
+
|
|
2214
|
+
type Room = Tables["rooms"]["row"]; // typed from your db/*.ts
|
|
2047
2215
|
|
|
2048
|
-
|
|
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
|
+
}
|
|
2049
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()
|
|
2050
2230
|
export class RoomService {
|
|
2051
|
-
private readonly rooms:
|
|
2052
|
-
constructor(rooms: RoomsTable) { this.rooms = rooms; }
|
|
2231
|
+
constructor(private readonly rooms: RoomRepo) {}
|
|
2053
2232
|
|
|
2054
2233
|
async create(name: string) {
|
|
2055
2234
|
const room = await this.rooms.insert({ name });
|
|
@@ -2057,41 +2236,48 @@ export class RoomService {
|
|
|
2057
2236
|
// room.nope ← compile error
|
|
2058
2237
|
}
|
|
2059
2238
|
}
|
|
2060
|
-
|
|
2061
2239
|
```
|
|
2062
2240
|
|
|
2063
2241
|
```ts
|
|
2064
|
-
// rooms.module.ts
|
|
2242
|
+
// modules/rooms/rooms.module.ts — the four lists that make these classes exist.
|
|
2065
2243
|
import { Module, type Token } from "@palbase/backend";
|
|
2066
|
-
import { RoomsController } from "./
|
|
2067
|
-
import { RoomService } from "./
|
|
2244
|
+
import { RoomsController } from "./rooms.controller.ts";
|
|
2245
|
+
import { DbRoomRepo, RoomService } from "./room.service.ts";
|
|
2068
2246
|
|
|
2069
|
-
@Module({
|
|
2247
|
+
@Module({
|
|
2248
|
+
controllers: [RoomsController as Token],
|
|
2249
|
+
providers: [RoomService as Token, DbRoomRepo as Token],
|
|
2250
|
+
exports: [],
|
|
2251
|
+
imports: [],
|
|
2252
|
+
})
|
|
2070
2253
|
export class RoomsModule {}
|
|
2071
2254
|
```
|
|
2072
2255
|
|
|
2073
2256
|
```ts
|
|
2074
|
-
//
|
|
2257
|
+
// modules/rooms/rooms.controller.ts — HTTP only; no `Database` import here.
|
|
2075
2258
|
import { Controller, Post, Body, z } from "@palbase/backend";
|
|
2076
|
-
import { RoomService } from "
|
|
2259
|
+
import { RoomService } from "./room.service";
|
|
2077
2260
|
|
|
2078
2261
|
const CreateRoomBody = z.object({ name: z.string() });
|
|
2079
2262
|
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
2080
2263
|
|
|
2081
2264
|
@Controller("/rooms")
|
|
2082
|
-
export
|
|
2265
|
+
export class RoomsController {
|
|
2266
|
+
constructor(private readonly rooms: RoomService) {}
|
|
2267
|
+
|
|
2083
2268
|
@Post("")
|
|
2084
2269
|
// The return type names the 200 schema — `z.infer<typeof RoomOut>` works
|
|
2085
2270
|
// inline, no separate `export type` needed.
|
|
2086
2271
|
create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
|
|
2087
|
-
return
|
|
2272
|
+
return this.rooms.create(body.name);
|
|
2088
2273
|
}
|
|
2089
2274
|
}
|
|
2090
2275
|
```
|
|
2091
2276
|
|
|
2092
|
-
`Database.public.<name>` exposes `insert
|
|
2093
|
-
`
|
|
2094
|
-
|
|
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
|
|
2095
2281
|
operations (`insert`/`insertMany`/`updateWhere`/`deleteWhere`/`select`) rather
|
|
2096
2282
|
than awaited calls — see [database.md](./database.md#transactions). The raw
|
|
2097
2283
|
string-keyed ops (`Database.$insert("rooms", …)`, `Database.$query(…)`) are still
|
|
@@ -2113,10 +2299,11 @@ missing `WHERE user_id = …` in your handler can no longer leak another user's
|
|
|
2113
2299
|
rows — the policy enforces it. This is the recommended way to scope data per
|
|
2114
2300
|
user.
|
|
2115
2301
|
|
|
2116
|
-
Add `policies`
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
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.
|
|
2120
2307
|
|
|
2121
2308
|
### The `policy()` builder
|
|
2122
2309
|
|
|
@@ -2175,6 +2362,34 @@ once per statement (an initPlan) instead of once per row. `auth.role()` and
|
|
|
2175
2362
|
`auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
|
|
2176
2363
|
rows.
|
|
2177
2364
|
|
|
2365
|
+
**`auth.has_permission('resource.action')`** answers whether the caller holds
|
|
2366
|
+
that permission through any role they carry, and **`auth.app_roles()`** returns
|
|
2367
|
+
their roles as a sorted `text[]` (`{}` when they hold none). Both read
|
|
2368
|
+
`auth.user_roles` — the table `palbase roles` writes — and both take the caller
|
|
2369
|
+
from `auth.uid()`, so a policy cannot ask about somebody else. A revoked role is
|
|
2370
|
+
gone from the very next statement; there is nothing cached.
|
|
2371
|
+
|
|
2372
|
+
```ts
|
|
2373
|
+
policies: () => [
|
|
2374
|
+
policy("notes_owner").for("all")
|
|
2375
|
+
.using("user_id = (select auth.uid())")
|
|
2376
|
+
.withCheck("user_id = (select auth.uid())"),
|
|
2377
|
+
|
|
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
|
+
],
|
|
2384
|
+
```
|
|
2385
|
+
|
|
2386
|
+
> **The read policy is not optional.** `DELETE … WHERE id = $1` has to FIND the
|
|
2387
|
+
> row first, and that read is governed by the SELECT policies. With only the
|
|
2388
|
+
> DELETE policy above, a moderator holding the permission deletes **nothing** —
|
|
2389
|
+
> the row is invisible to them, so the `WHERE` matches nothing and the statement
|
|
2390
|
+
> answers `DELETE 0`. No error, no log. If a permission writes to somebody
|
|
2391
|
+
> else's row, a SELECT policy has to show them that row.
|
|
2392
|
+
|
|
2178
2393
|
> Name policies with a `pb_` prefix. Palbase reconciliation only manages
|
|
2179
2394
|
> policies it authored (`pb_`-prefixed) and never touches policies created by
|
|
2180
2395
|
> other modules (storage, cron, …).
|
|
@@ -2195,8 +2410,8 @@ export const todos = defineTable("todos", {
|
|
|
2195
2410
|
done: boolean().default(false),
|
|
2196
2411
|
created_at: timestamp().defaultNow(),
|
|
2197
2412
|
},
|
|
2198
|
-
// `policies`
|
|
2199
|
-
policies: [
|
|
2413
|
+
// `policies` is a CALLBACK. Non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
2414
|
+
policies: () => [
|
|
2200
2415
|
// Read: a user sees only their own todos.
|
|
2201
2416
|
policy("pb_todos_owner_select")
|
|
2202
2417
|
.for("select")
|
|
@@ -2328,7 +2543,7 @@ Code and schema move at different speeds, so three things keep them together:
|
|
|
2328
2543
|
## Your schema change is not live anywhere you have not applied it
|
|
2329
2544
|
|
|
2330
2545
|
The local stack and every Environment hold their own database, and editing
|
|
2331
|
-
|
|
2546
|
+
`db/*.ts` changes none of them: `palbase db apply` is what moves the local
|
|
2332
2547
|
one, `palbase push` is what moves an Environment's. Until then the declaration is
|
|
2333
2548
|
ahead of the tables.
|
|
2334
2549
|
|
|
@@ -2403,10 +2618,11 @@ existing data gets there.
|
|
|
2403
2618
|
|
|
2404
2619
|
## Row-Level Security
|
|
2405
2620
|
|
|
2406
|
-
Add `
|
|
2407
|
-
generated migration emits the `ENABLE ROW LEVEL SECURITY` +
|
|
2408
|
-
|
|
2409
|
-
|
|
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.
|
|
2410
2626
|
|
|
2411
2627
|
### Hand-writing a policy
|
|
2412
2628
|
|
|
@@ -2448,7 +2664,7 @@ no such safety net — write it the first time.
|
|
|
2448
2664
|
**Two different things are called "services" here, and this page is the second
|
|
2449
2665
|
one.** Read the first paragraph before searching this page for a layout rule.
|
|
2450
2666
|
|
|
2451
|
-
- **YOUR service layer** — `
|
|
2667
|
+
- **YOUR service layer** — `modules/<domain>/<name>.service.ts`, the `@Injectable()` classes your
|
|
2452
2668
|
own business logic lives in. The scaffold ships one, and the contract is at
|
|
2453
2669
|
the bottom of this page: [Your own service layer](#your-own-service-layer).
|
|
2454
2670
|
- **THE service singletons** — the platform objects you import from
|
|
@@ -2471,9 +2687,26 @@ turns out to be empty — it fails at the import.
|
|
|
2471
2687
|
a channel, but cannot subscribe (a stateless request can't hold a socket).
|
|
2472
2688
|
Subscription lives on the client SDK (`pb.realtime`).
|
|
2473
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
|
+
|
|
2474
2708
|
**Not available to backend handlers** (do not import them here): Functions, CMS,
|
|
2475
|
-
Links
|
|
2476
|
-
scope for backend endpoints.
|
|
2709
|
+
Links and Analytics — out of scope for backend endpoints.
|
|
2477
2710
|
|
|
2478
2711
|
## Cache
|
|
2479
2712
|
|
|
@@ -2603,7 +2836,7 @@ Writes mirror the `Database` / `Database.$asService()` model:
|
|
|
2603
2836
|
- `Flags.setOverride(key, value)` (default) writes an override for the **current
|
|
2604
2837
|
request user** — no `userId` argument, no admin power. It errors on an
|
|
2605
2838
|
anonymous request (no signed-in user).
|
|
2606
|
-
- `Flags
|
|
2839
|
+
- `Flags.$asService()` returns the cross-user admin surface
|
|
2607
2840
|
(`setOverrideForUser`, `setOverridesForUser`, `clearOverrideForUser`,
|
|
2608
2841
|
`clearAllOverridesForUser`, `batchSetOverrides`) for writing overrides for an
|
|
2609
2842
|
**arbitrary** user. Explicit and greppable, just like `Database.$asService()`.
|
|
@@ -2612,8 +2845,8 @@ Writes mirror the `Database` / `Database.$asService()` model:
|
|
|
2612
2845
|
// Current request user — no userId needed:
|
|
2613
2846
|
await Flags.setOverride("new-checkout", true);
|
|
2614
2847
|
|
|
2615
|
-
// Cross-user admin write — explicit target, via asService():
|
|
2616
|
-
await Flags
|
|
2848
|
+
// Cross-user admin write — explicit target, via $asService():
|
|
2849
|
+
await Flags.$asService().setOverrideForUser("user_123", "new-checkout", true);
|
|
2617
2850
|
```
|
|
2618
2851
|
|
|
2619
2852
|
## Realtime
|
|
@@ -2664,8 +2897,8 @@ to drive live chat, presence, dashboards, and other push features.
|
|
|
2664
2897
|
## Your own service layer
|
|
2665
2898
|
|
|
2666
2899
|
The singletons above are what a service CALLS. This section is the layer that
|
|
2667
|
-
calls them — `
|
|
2668
|
-
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).
|
|
2669
2902
|
|
|
2670
2903
|
The contract is three rules, and the scaffold's own test enforces all three:
|
|
2671
2904
|
|
|
@@ -2680,7 +2913,7 @@ it.** Mark the class `@Injectable()` and name what it needs as ordinary
|
|
|
2680
2913
|
constructor parameters:
|
|
2681
2914
|
|
|
2682
2915
|
```ts
|
|
2683
|
-
//
|
|
2916
|
+
// modules/notes/note.service.ts
|
|
2684
2917
|
import { Database, Injectable } from "@palbase/backend";
|
|
2685
2918
|
|
|
2686
2919
|
type NotesTable = typeof Database.public.notes;
|
|
@@ -2696,7 +2929,7 @@ export class NoteService {
|
|
|
2696
2929
|
```
|
|
2697
2930
|
|
|
2698
2931
|
```ts
|
|
2699
|
-
//
|
|
2932
|
+
// modules/reports/report.service.ts — a service that depends on another service
|
|
2700
2933
|
import { Injectable } from "@palbase/backend";
|
|
2701
2934
|
import { NoteService } from "./note.service.ts";
|
|
2702
2935
|
|
|
@@ -2713,7 +2946,7 @@ export class ReportService {
|
|
|
2713
2946
|
A controller asks the same way, and nothing wires it by hand:
|
|
2714
2947
|
|
|
2715
2948
|
```ts
|
|
2716
|
-
//
|
|
2949
|
+
// modules/notes/notes.controller.ts
|
|
2717
2950
|
import { Controller, Get } from "@palbase/backend";
|
|
2718
2951
|
import { NoteService } from "../services/note.service.ts";
|
|
2719
2952
|
|
|
@@ -2755,7 +2988,7 @@ never reaches the route table, the dispatcher or the OpenAPI document.
|
|
|
2755
2988
|
```ts
|
|
2756
2989
|
// notes.module.ts — beside the domain it owns, not in a directory we name
|
|
2757
2990
|
import { Module, type Token } from "@palbase/backend";
|
|
2758
|
-
import { NotesController } from "./
|
|
2991
|
+
import { NotesController } from "./modules/notes/notes.controller.ts";
|
|
2759
2992
|
import { NoteService } from "./services/note.service.ts";
|
|
2760
2993
|
import { ReportService } from "./services/report.service.ts";
|
|
2761
2994
|
|
|
@@ -2808,7 +3041,7 @@ NoteService())` is ordinary TypeScript, and the container is not required for it
|
|
|
2808
3041
|
to work.
|
|
2809
3042
|
|
|
2810
3043
|
```ts
|
|
2811
|
-
//
|
|
3044
|
+
// modules/notes/note.service.test.ts — `npm test`, no database
|
|
2812
3045
|
import { test } from "node:test";
|
|
2813
3046
|
import assert from "node:assert/strict";
|
|
2814
3047
|
|
|
@@ -2872,9 +3105,8 @@ Palbase error envelope:
|
|
|
2872
3105
|
{ "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
|
|
2873
3106
|
```
|
|
2874
3107
|
|
|
2875
|
-
Throw anywhere — in a controller method OR in a
|
|
2876
|
-
|
|
2877
|
-
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.
|
|
2878
3110
|
|
|
2879
3111
|
The runtime refuses some requests before a handler runs, and those refusals use
|
|
2880
3112
|
the same envelope and the same codes as the classes below — so a client decodes
|
|
@@ -2933,10 +3165,11 @@ HttpError) … }` matches any of them.
|
|
|
2933
3165
|
|
|
2934
3166
|
# Background Jobs
|
|
2935
3167
|
|
|
2936
|
-
`jobs/` is the background rail
|
|
2937
|
-
|
|
2938
|
-
"@palbase/backend"`). It does **not** receive
|
|
2939
|
-
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).
|
|
2940
3173
|
|
|
2941
3174
|
> **There is no queue.** `Queue.push` and `defineWorker` existed in earlier
|
|
2942
3175
|
> versions and never ran: nothing consumed the queue, so a push returned a job id
|
|
@@ -2949,14 +3182,22 @@ carries the non-service data (`env`, correlation ids).
|
|
|
2949
3182
|
|
|
2950
3183
|
## Jobs (cron-scheduled)
|
|
2951
3184
|
|
|
2952
|
-
A job runs on a cron schedule.
|
|
2953
|
-
|
|
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.
|
|
2954
3192
|
|
|
2955
3193
|
```ts
|
|
2956
3194
|
// jobs/cleanup.ts
|
|
2957
3195
|
import { Database, Job, Log, type JobMeta } from "@palbase/backend";
|
|
2958
3196
|
|
|
2959
|
-
|
|
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 })
|
|
2960
3201
|
export default class CleanupJob {
|
|
2961
3202
|
async run(meta: JobMeta) {
|
|
2962
3203
|
const expired = await Database.$findMany("sessions", { expired: true });
|
|
@@ -2975,53 +3216,87 @@ system-initiated).
|
|
|
2975
3216
|
|
|
2976
3217
|
# Hooks & Webhooks
|
|
2977
3218
|
|
|
2978
|
-
Like jobs, hooks and webhooks
|
|
2979
|
-
|
|
2980
|
-
"@palbase/backend"`). They do
|
|
2981
|
-
carries the non-service data
|
|
2982
|
-
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`).
|
|
2983
3224
|
|
|
2984
3225
|
## Hooks (platform events)
|
|
2985
3226
|
|
|
2986
|
-
React to auth, storage,
|
|
2987
|
-
|
|
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 |
|
|
2988
3238
|
|
|
2989
3239
|
```ts
|
|
2990
3240
|
// hooks/auth.ts
|
|
2991
|
-
import {
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
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
|
+
}
|
|
3000
3252
|
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
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
|
+
}
|
|
3004
3261
|
```
|
|
3005
3262
|
|
|
3006
|
-
`
|
|
3007
|
-
|
|
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.
|
|
3008
3267
|
|
|
3009
|
-
|
|
3010
|
-
`
|
|
3011
|
-
|
|
3012
|
-
|
|
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.
|
|
3013
3281
|
|
|
3014
3282
|
## Webhooks (inbound provider events)
|
|
3015
3283
|
|
|
3016
3284
|
Receive and verify webhooks from third-party providers. Files live under
|
|
3017
|
-
`webhooks
|
|
3018
|
-
|
|
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.
|
|
3019
3292
|
|
|
3020
3293
|
```ts
|
|
3021
3294
|
// webhooks/stripe.ts
|
|
3022
3295
|
import { Database, Log, On, Webhook, type WebhookMeta } from "@palbase/backend";
|
|
3023
3296
|
|
|
3024
|
-
|
|
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" } })
|
|
3025
3300
|
export default class StripeWebhook {
|
|
3026
3301
|
@On("checkout.session.completed")
|
|
3027
3302
|
async checkoutCompleted(event: unknown, meta: WebhookMeta) {
|