@palbase/backend 33.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.map +1 -1
- package/dist/bin/palbase-backend.js +4 -4
- package/dist/{chunk-DCDHAKF3.js → chunk-IKDONZ5D.js} +4 -4
- 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 +4 -4
- 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 +4 -4
- package/dist/openapi/index.d.cts +2 -2
- package/dist/openapi/index.d.ts +2 -2
- 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/dist/{chunk-DCDHAKF3.js.map → chunk-IKDONZ5D.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/README.md
CHANGED
|
@@ -16,15 +16,24 @@ deploy gate or the TypeScript compile.
|
|
|
16
16
|
|
|
17
17
|
### Folder layout
|
|
18
18
|
|
|
19
|
+
Every domain is a FOLDER under `modules/`, carrying its own controller, service,
|
|
20
|
+
schemas, and the one module that lists them:
|
|
21
|
+
|
|
19
22
|
```
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
modules/<domain>/<domain>.module.ts # @Module: controllers · providers · exports · imports
|
|
24
|
+
modules/<domain>/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
|
|
25
|
+
modules/<domain>/<name>.service.ts # @Injectable() class — the real logic (controllers stay thin)
|
|
26
|
+
modules/<domain>/dto/<endpoint>.ts # zod schemas — one file per endpoint
|
|
27
|
+
db/<schema>.ts # one file per Postgres schema (db/public.ts in a fresh scaffold)
|
|
24
28
|
```
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
There is no root `controllers/`, `services/` or `models/` directory, and no
|
|
31
|
+
`app.module.ts` to mount anything into: a `*.module.ts` is found wherever it
|
|
32
|
+
lives, so adding a domain means adding a folder — never a line in a file every
|
|
33
|
+
branch edits.
|
|
34
|
+
|
|
35
|
+
The schema stays at the root, in `db/`. Three more directories are discovered by
|
|
36
|
+
name, one class per file, `export default` required: `jobs/` (background —
|
|
28
37
|
[background.md](./background.md)), `webhooks/` and `hooks/` (events —
|
|
29
38
|
[events.md](./events.md)).
|
|
30
39
|
|
|
@@ -58,7 +67,7 @@ service the controllers call.
|
|
|
58
67
|
+ the runtime read that named type to bind the 200 response zod schema. There
|
|
59
68
|
is no `@Returns` decorator. A body route with no named return type is a build
|
|
60
69
|
error; a method with no response body annotates `: Promise<void>` / `: void`.
|
|
61
|
-
5. **Schemas live in `
|
|
70
|
+
5. **Schemas live in `modules/<domain>/dto/<endpoint>.ts`,** exported as a zod
|
|
62
71
|
VALUE and a same-named `z.infer` TYPE. The controller imports them and writes
|
|
63
72
|
`@Body(CreateTodoBody) body: CreateTodoBody` (value in the decorator, same name
|
|
64
73
|
as the type annotation).
|
|
@@ -78,7 +87,7 @@ service the controllers call.
|
|
|
78
87
|
### Complete CRUD example (copy-pasteable, compiles)
|
|
79
88
|
|
|
80
89
|
```ts
|
|
81
|
-
//
|
|
90
|
+
// modules/todos/dto/shared.ts — the response shape, reused across endpoints.
|
|
82
91
|
import { z } from "@palbase/backend";
|
|
83
92
|
export const TodoSchema = z.object({
|
|
84
93
|
id: z.string(),
|
|
@@ -89,41 +98,63 @@ export type TodoSchema = z.infer<typeof TodoSchema>;
|
|
|
89
98
|
```
|
|
90
99
|
|
|
91
100
|
```ts
|
|
92
|
-
//
|
|
101
|
+
// modules/todos/dto/create.ts — the POST body.
|
|
93
102
|
import { z } from "@palbase/backend";
|
|
94
103
|
export const CreateTodoBody = z.object({ title: z.string().min(1) });
|
|
95
104
|
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
|
|
96
105
|
```
|
|
97
106
|
|
|
98
107
|
```ts
|
|
99
|
-
//
|
|
108
|
+
// modules/todos/todo.service.ts — @Injectable() classes. The real work.
|
|
100
109
|
import { Database, Injectable, NotFound } from "@palbase/backend";
|
|
101
|
-
import type {
|
|
110
|
+
import type { Tables } from "@palbase/backend/env";
|
|
111
|
+
|
|
112
|
+
/** One row of `todos`, exactly as `db/public.ts` declares it. */
|
|
113
|
+
export type Todo = Tables["todos"]["row"];
|
|
114
|
+
|
|
115
|
+
/** The one table this vertical touches, behind a class the container can build.
|
|
116
|
+
* `Database.public.todos` is a VALUE, and a dependency is named by its
|
|
117
|
+
* parameter's TYPE — so the seam is an `abstract class`, and it stays ONE table
|
|
118
|
+
* wide: a test fake implements four methods, not the whole `Database`. */
|
|
119
|
+
export abstract class TodoRepo {
|
|
120
|
+
// Mirror the table's own call shape: `findMany` takes `{ where, orderBy, … }`,
|
|
121
|
+
// not a bare filter.
|
|
122
|
+
abstract findMany(q: { where: { user_id: string } }): Promise<Todo[]>;
|
|
123
|
+
abstract insert(row: { user_id: string; title: string }): Promise<Todo>;
|
|
124
|
+
abstract findById(id: string): Promise<Todo | null>;
|
|
125
|
+
abstract delete(id: string): Promise<void>;
|
|
126
|
+
}
|
|
102
127
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
128
|
+
@Injectable()
|
|
129
|
+
export class DbTodoRepo extends TodoRepo {
|
|
130
|
+
findMany(q: { where: { user_id: string } }): Promise<Todo[]> {
|
|
131
|
+
return Database.public.todos.findMany(q);
|
|
132
|
+
}
|
|
133
|
+
insert(row: { user_id: string; title: string }): Promise<Todo> {
|
|
134
|
+
return Database.public.todos.insert(row);
|
|
135
|
+
}
|
|
136
|
+
findById(id: string): Promise<Todo | null> {
|
|
137
|
+
return Database.public.todos.findById(id);
|
|
138
|
+
}
|
|
139
|
+
delete(id: string): Promise<void> {
|
|
140
|
+
return Database.public.todos.delete(id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
106
143
|
|
|
107
144
|
@Injectable()
|
|
108
145
|
export class TodoService {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
// never needs a database. Assign in the BODY — a parameter property
|
|
114
|
-
// (`constructor(private todos: …)`) is refused by Node's type-stripping test
|
|
115
|
-
// runner, and refused for the whole FILE.
|
|
116
|
-
constructor(todos: TodosTable) {
|
|
117
|
-
this.todos = todos;
|
|
118
|
-
}
|
|
146
|
+
// THE SEAM IS THE CONSTRUCTOR: the container supplies this, so a test hands in
|
|
147
|
+
// a stand-in and never needs a database. Nothing registers it and nothing
|
|
148
|
+
// wires it — `todos.module.ts` lists the class, and that list is the ownership.
|
|
149
|
+
constructor(private readonly todos: TodoRepo) {}
|
|
119
150
|
|
|
120
|
-
list(userId: string): Promise<
|
|
151
|
+
list(userId: string): Promise<Todo[]> {
|
|
121
152
|
return this.todos.findMany({ where: { user_id: userId } });
|
|
122
153
|
}
|
|
123
|
-
create(userId: string, title: string): Promise<
|
|
154
|
+
create(userId: string, title: string): Promise<Todo> {
|
|
124
155
|
return this.todos.insert({ user_id: userId, title });
|
|
125
156
|
}
|
|
126
|
-
async get(userId: string, id: string): Promise<
|
|
157
|
+
async get(userId: string, id: string): Promise<Todo> {
|
|
127
158
|
const t = await this.todos.findById(id);
|
|
128
159
|
if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
|
|
129
160
|
return t;
|
|
@@ -133,68 +164,74 @@ export class TodoService {
|
|
|
133
164
|
await this.todos.delete(id);
|
|
134
165
|
}
|
|
135
166
|
}
|
|
136
|
-
|
|
137
167
|
```
|
|
138
168
|
|
|
139
169
|
```ts
|
|
140
|
-
// todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
|
|
170
|
+
// modules/todos/todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
|
|
141
171
|
import { Module, type Token } from "@palbase/backend";
|
|
142
|
-
import { TodosController } from "./
|
|
143
|
-
import { TodoService } from "./
|
|
172
|
+
import { TodosController } from "./todos.controller.ts";
|
|
173
|
+
import { DbTodoRepo, TodoService } from "./todo.service.ts";
|
|
144
174
|
|
|
145
175
|
@Module({
|
|
146
176
|
controllers: [TodosController as Token],
|
|
147
|
-
providers: [TodoService as Token],
|
|
177
|
+
providers: [TodoService as Token, DbTodoRepo as Token],
|
|
178
|
+
exports: [], // which of its own classes ANOTHER module may reach
|
|
179
|
+
imports: [], // whose exports THIS module may reach
|
|
148
180
|
})
|
|
149
181
|
export class TodosModule {}
|
|
150
182
|
```
|
|
151
183
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
184
|
+
The abstraction is the TOKEN and the implementation is the PROVIDER: `DbTodoRepo`
|
|
185
|
+
is listed, `TodoRepo` is not — the container resolves an abstraction to the single
|
|
186
|
+
class that `extends` it. A class no module lists does not exist: it is refused at
|
|
187
|
+
build, by name, and it never reaches the route table or the OpenAPI document.
|
|
188
|
+
Nothing is wired by hand — a controller names `TodoService` as a constructor
|
|
189
|
+
parameter and the container supplies it. See
|
|
190
|
+
[services.md](./services.md#your-own-service-layer).
|
|
156
191
|
|
|
157
192
|
```ts
|
|
158
|
-
//
|
|
193
|
+
// modules/todos/todos.controller.ts — class controller. Thin: delegates to the service.
|
|
159
194
|
import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
|
|
160
195
|
import type { UserT } from "@palbase/backend"; // the user TYPE (@User is the value)
|
|
161
|
-
import {
|
|
162
|
-
import { TodoSchema } from "
|
|
163
|
-
import { CreateTodoBody } from "
|
|
196
|
+
import { TodoService } from "./todo.service";
|
|
197
|
+
import { TodoSchema } from "./dto/shared";
|
|
198
|
+
import { CreateTodoBody } from "./dto/create";
|
|
164
199
|
|
|
165
200
|
@Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
|
|
166
|
-
export
|
|
167
|
-
// The service arrives
|
|
168
|
-
//
|
|
169
|
-
|
|
201
|
+
export class TodosController {
|
|
202
|
+
// The service arrives through the CONSTRUCTOR and the container supplies it.
|
|
203
|
+
// Nothing is imported as an instance and nothing is wired by hand.
|
|
204
|
+
constructor(private readonly todos: TodoService) {}
|
|
170
205
|
|
|
171
206
|
@Get("") // GET /todos → operationId todos.list
|
|
172
|
-
|
|
173
|
-
return
|
|
207
|
+
list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
|
|
208
|
+
return this.todos.list(user.id);
|
|
174
209
|
}
|
|
175
210
|
|
|
176
211
|
@Post("") // POST /todos → todos.create
|
|
177
|
-
|
|
178
|
-
return
|
|
212
|
+
create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
|
|
213
|
+
return this.todos.create(user.id, body.title);
|
|
179
214
|
}
|
|
180
215
|
|
|
181
216
|
@Get("/{id}") // GET /todos/{id} → todos.get
|
|
182
|
-
|
|
183
|
-
return
|
|
217
|
+
get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
|
|
218
|
+
return this.todos.get(user.id, id);
|
|
184
219
|
}
|
|
185
220
|
|
|
186
221
|
@Delete("/{id}") // DELETE /todos/{id} → todos.remove; no body → : Promise<void>
|
|
187
|
-
|
|
188
|
-
|
|
222
|
+
remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
|
|
223
|
+
return this.todos.remove(user.id, id);
|
|
189
224
|
}
|
|
190
225
|
}
|
|
191
|
-
//
|
|
192
|
-
//
|
|
226
|
+
// Exported by NAME, because `todos.module.ts` imports it to list it in
|
|
227
|
+
// `controllers` — that list IS the registration. Nothing here is
|
|
228
|
+
// default-exported.
|
|
193
229
|
```
|
|
194
230
|
|
|
195
231
|
```ts
|
|
196
232
|
// db/public.ts — config-as-code; the deploy auto-migrates additive changes.
|
|
197
233
|
// ONE SCHEMA PER FILE: this is `public`; a sibling `db/billing.ts` declares its own.
|
|
234
|
+
// The schema stays at the ROOT — it is not part of any module.
|
|
198
235
|
import { defineSchema, defineTable, uuid, text, boolean, timestamp, policy, ownedByUser } from "@palbase/backend";
|
|
199
236
|
|
|
200
237
|
export const todos = defineTable("todos", {
|
|
@@ -205,8 +242,8 @@ export const todos = defineTable("todos", {
|
|
|
205
242
|
completed: boolean().default(false),
|
|
206
243
|
created_at: timestamp().defaultNow(),
|
|
207
244
|
},
|
|
208
|
-
|
|
209
|
-
policies: [
|
|
245
|
+
// RLS is ON by default, and `policies` is a CALLBACK — not an array.
|
|
246
|
+
policies: () => [
|
|
210
247
|
policy("pb_owner_all")
|
|
211
248
|
.for("all")
|
|
212
249
|
.to("authenticated")
|
|
@@ -262,54 +299,63 @@ generated client surface) changes; the verb/path do not affect it.
|
|
|
262
299
|
|
|
263
300
|
## Mental model
|
|
264
301
|
|
|
265
|
-
Every handler type
|
|
302
|
+
Every handler type reaches the PLATFORM services through the same import:
|
|
266
303
|
|
|
267
304
|
```ts
|
|
268
|
-
import { Database, Log, Cache
|
|
305
|
+
import { Database, Log, Cache } from "@palbase/backend";
|
|
269
306
|
```
|
|
270
307
|
|
|
308
|
+
(Your OWN services are different: a controller names one as a constructor
|
|
309
|
+
parameter and the container supplies it — see the example above.)
|
|
310
|
+
|
|
271
311
|
The **only difference** is the trigger argument:
|
|
272
312
|
|
|
273
313
|
| You are writing… | Handler signature | Trigger arg |
|
|
274
314
|
|------------------|-------------------|-------------|
|
|
275
|
-
| **Endpoints** (`
|
|
315
|
+
| **Endpoints** (`modules/<domain>/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@QueryParams`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
|
|
276
316
|
| **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
|
|
277
317
|
| **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
|
|
278
318
|
| **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
|
|
279
319
|
|
|
280
320
|
`defineMiddleware(fn)` **throws when called**: no bundler reads a `middleware/`
|
|
281
321
|
directory and the engine never calls one, so a handler written against it would
|
|
282
|
-
deploy and never run. There is no `ctx` anywhere on a path that runs —
|
|
283
|
-
|
|
322
|
+
deploy and never run. There is no `ctx` anywhere on a path that runs — a handler
|
|
323
|
+
imports the platform services it needs.
|
|
284
324
|
|
|
285
325
|
`meta` carries non-service data: `env` (Environment variables),
|
|
286
|
-
`environmentId`, and for webhooks `requestId`.
|
|
287
|
-
the
|
|
326
|
+
`environmentId`, and for webhooks `requestId`. Platform services always come
|
|
327
|
+
from the import — not from `ctx` or any argument.
|
|
288
328
|
|
|
289
329
|
## Project shape
|
|
290
330
|
|
|
291
331
|
```
|
|
292
332
|
my-backend/
|
|
293
|
-
├── package.json
|
|
294
|
-
├── tsconfig.json
|
|
295
|
-
├──
|
|
296
|
-
│
|
|
297
|
-
├──
|
|
298
|
-
│ └──
|
|
299
|
-
|
|
300
|
-
├──
|
|
301
|
-
├──
|
|
302
|
-
├──
|
|
303
|
-
|
|
333
|
+
├── package.json # depends on @palbase/backend
|
|
334
|
+
├── tsconfig.json # experimentalDecorators + emitDecoratorMetadata (both required)
|
|
335
|
+
├── modules/ # ONE FOLDER PER DOMAIN — there is no root module
|
|
336
|
+
│ ├── health/
|
|
337
|
+
│ │ ├── health.controller.ts # @Controller("/health", { auth: false }) + @Get
|
|
338
|
+
│ │ └── health.module.ts # the four lists that make the class exist
|
|
339
|
+
│ └── notes/
|
|
340
|
+
│ ├── notes.controller.ts # HTTP only — never imports `Database`
|
|
341
|
+
│ ├── note.service.ts # @Injectable(); the logic worth testing
|
|
342
|
+
│ ├── note.service.test.ts # the service, through the container, no database
|
|
343
|
+
│ ├── notes.module.ts # controllers · providers · exports · imports
|
|
344
|
+
│ └── dto/create.ts # zod schemas (value + same-named z.infer type)
|
|
345
|
+
├── db/public.ts # the database itself: tables, columns, RLS policies
|
|
346
|
+
├── jobs/ # cron-scheduled jobs (optional)
|
|
347
|
+
├── hooks/ # auth/storage/document event hooks (optional)
|
|
348
|
+
└── webhooks/ # inbound provider webhooks (optional)
|
|
304
349
|
```
|
|
305
350
|
|
|
306
351
|
There is **no `db/migrations/`**. Nothing generates a migration file, nothing
|
|
307
352
|
commits one and nothing replays one: `db/*.ts` is diffed against the live
|
|
308
353
|
database and applied — see [migrations.md](./migrations.md).
|
|
309
354
|
|
|
310
|
-
HTTP endpoints are **not** file-path routed
|
|
311
|
-
(`@Controller("/base")` with `@Get`/`@Post`/… methods)
|
|
312
|
-
`controllers
|
|
355
|
+
HTTP endpoints are **not** file-path routed, and no directory mounts them. You
|
|
356
|
+
author a class controller (`@Controller("/base")` with `@Get`/`@Post`/… methods)
|
|
357
|
+
and a `@Module` lists it in `controllers` — that list is the registration. See
|
|
358
|
+
[routing.md](./routing.md).
|
|
313
359
|
|
|
314
360
|
## Documentation
|
|
315
361
|
|
|
@@ -328,5 +374,3 @@ HTTP endpoints are **not** file-path routed. You author a class controller
|
|
|
328
374
|
|
|
329
375
|
For AI coding tools: a single concatenated corpus is generated at
|
|
330
376
|
[`llms-full.txt`](./llms-full.txt) (and an index at [`llms.txt`](./llms.txt)).
|
|
331
|
-
</content>
|
|
332
|
-
</invoke>
|
package/docs/auth.md
CHANGED
|
@@ -8,17 +8,20 @@ user.
|
|
|
8
8
|
```ts
|
|
9
9
|
import { Controller, Get, Post, Body, User, OptionalUser } from "@palbase/backend";
|
|
10
10
|
import type { UserT } from "@palbase/backend";
|
|
11
|
+
import { TodoService } from "./todo.service";
|
|
11
12
|
|
|
12
13
|
@Controller("/todos") // no auth option → every route needs a user
|
|
13
|
-
export
|
|
14
|
+
export class TodosController {
|
|
15
|
+
constructor(private readonly todos: TodoService) {}
|
|
16
|
+
|
|
14
17
|
@Post("")
|
|
15
18
|
create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT) {
|
|
16
|
-
return
|
|
19
|
+
return this.todos.create(user.id, body.title); // user is non-null — guaranteed
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
@Get("/featured", { auth: false }) // one public route
|
|
20
23
|
featured(@OptionalUser() user: UserT | null) {
|
|
21
|
-
return
|
|
24
|
+
return this.todos.featured(user?.id ?? null); // may be null — handle it
|
|
22
25
|
}
|
|
23
26
|
}
|
|
24
27
|
```
|
|
@@ -26,11 +29,12 @@ export default class TodosController {
|
|
|
26
29
|
## What `@User()` gives you
|
|
27
30
|
|
|
28
31
|
```ts
|
|
29
|
-
interface User
|
|
32
|
+
interface UserT { // exported as `UserT`; the value name `User` is the decorator
|
|
30
33
|
id: string;
|
|
31
34
|
email?: string; // absent for phone-only users
|
|
32
35
|
emailVerified: boolean;
|
|
33
|
-
role: string;
|
|
36
|
+
role: string; // the DATABASE role — always "authenticated" when signed in
|
|
37
|
+
roles: string[]; // your APPLICATION roles, read per request
|
|
34
38
|
metadata: Record<string, unknown>;
|
|
35
39
|
device: VerifiedDevice | null;
|
|
36
40
|
}
|
|
@@ -79,7 +83,7 @@ transaction as the user row.
|
|
|
79
83
|
|
|
80
84
|
```ts
|
|
81
85
|
@Controller("/notes")
|
|
82
|
-
export
|
|
86
|
+
export class NotesController {
|
|
83
87
|
// Anyone signed in.
|
|
84
88
|
@Get("") list(@User() user: UserT) { … }
|
|
85
89
|
|
|
@@ -197,7 +201,7 @@ publish(@User() user: UserT) {
|
|
|
197
201
|
if (!user.emailVerified) {
|
|
198
202
|
throw new Forbidden("Confirm your email address before publishing.");
|
|
199
203
|
}
|
|
200
|
-
return
|
|
204
|
+
return this.posts.publish(user.id);
|
|
201
205
|
}
|
|
202
206
|
```
|
|
203
207
|
|
package/docs/background.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Background Jobs
|
|
2
2
|
|
|
3
|
-
`jobs/` is the background rail
|
|
4
|
-
|
|
5
|
-
"@palbase/backend"`). It does **not** receive
|
|
6
|
-
carries the non-service data (`env`, correlation
|
|
3
|
+
`jobs/` is the background rail, and it stays at the ROOT — a job is not part of
|
|
4
|
+
any module. It reaches the platform services through the same import as everything
|
|
5
|
+
else (`import { Database, Log } from "@palbase/backend"`). It does **not** receive
|
|
6
|
+
a `req`; a small `meta` argument carries the non-service data (`env`, correlation
|
|
7
|
+
ids).
|
|
7
8
|
|
|
8
9
|
> **There is no queue.** `Queue.push` and `defineWorker` existed in earlier
|
|
9
10
|
> versions and never ran: nothing consumed the queue, so a push returned a job id
|
|
@@ -16,14 +17,22 @@ carries the non-service data (`env`, correlation ids).
|
|
|
16
17
|
|
|
17
18
|
## Jobs (cron-scheduled)
|
|
18
19
|
|
|
19
|
-
A job runs on a cron schedule.
|
|
20
|
-
|
|
20
|
+
A job runs on a cron schedule. The file lives under `jobs/`, one class per file,
|
|
21
|
+
`export default` required.
|
|
22
|
+
|
|
23
|
+
**`name` is required and DECLARED.** It is the job's identity — the row the
|
|
24
|
+
scheduler holds it under — so it is not taken from the file name any more:
|
|
25
|
+
renaming the file would otherwise start a different job. Lowercase letters,
|
|
26
|
+
digits and dashes.
|
|
21
27
|
|
|
22
28
|
```ts
|
|
23
29
|
// jobs/cleanup.ts
|
|
24
30
|
import { Database, Job, Log, type JobMeta } from "@palbase/backend";
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
// name: the scheduler's identity; schedule: standard 5-field cron;
|
|
33
|
+
// timeout: optional seconds (default 30, ceiling 300);
|
|
34
|
+
// retry: optional, how many times a FAILED run is retried (default 5, ceiling 10).
|
|
35
|
+
@Job({ name: "cleanup", schedule: "0 3 * * *", timeout: 120 })
|
|
27
36
|
export default class CleanupJob {
|
|
28
37
|
async run(meta: JobMeta) {
|
|
29
38
|
const expired = await Database.$findMany("sessions", { expired: true });
|
package/docs/database.md
CHANGED
|
@@ -7,8 +7,9 @@ jobs, hooks, and webhooks all use the same import:
|
|
|
7
7
|
import { Database } from "@palbase/backend";
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
There is no `ctx` and no `ctx.db` anywhere on a path that runs — every handler
|
|
11
|
+
imports `Database` (see [background.md](./background.md) and
|
|
12
|
+
[events.md](./events.md) for the job/hook/webhook examples).
|
|
12
13
|
|
|
13
14
|
## Typed by default — `Database.<şema>.<tablo>`
|
|
14
15
|
|
|
@@ -76,7 +77,9 @@ await Database.public.crew_presence.findMany({
|
|
|
76
77
|
AND: [sqlFragment`expires_at > now()`],
|
|
77
78
|
},
|
|
78
79
|
});
|
|
79
|
-
```
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The same filter language works for `updateMany`, `deleteMany`,
|
|
80
83
|
`count` and `search` — one language, so two spellings cannot drift apart. An
|
|
81
84
|
empty `in` list means NO rows (written into the SQL as `false`), never a silent
|
|
82
85
|
full scan.
|
|
@@ -372,7 +375,7 @@ filter object built from request input that happened to come back empty — is
|
|
|
372
375
|
exactly the shape that must not silently succeed. `count()` with no filter IS
|
|
373
376
|
allowed: counting is a read.
|
|
374
377
|
|
|
375
|
-
### `Database
|
|
378
|
+
### `Database.$query` — raw SQL
|
|
376
379
|
|
|
377
380
|
Use it for shapes the helpers do not cover (joins, aggregates, window functions).
|
|
378
381
|
It runs in **the request's own transaction**, not a read-only one: a write inside
|
|
@@ -397,7 +400,7 @@ per row with a `23505` caught around each:
|
|
|
397
400
|
|
|
398
401
|
```ts
|
|
399
402
|
await Database.$transaction((tx) => {
|
|
400
|
-
tx.
|
|
403
|
+
tx.public.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
|
|
401
404
|
return null;
|
|
402
405
|
});
|
|
403
406
|
```
|
|
@@ -454,11 +457,11 @@ middle, so nothing holds a database connection open while your code thinks.
|
|
|
454
457
|
import { Database, NotFound } from "@palbase/backend";
|
|
455
458
|
|
|
456
459
|
const { orderId } = await Database.$transaction((tx) => {
|
|
457
|
-
const order = tx.
|
|
460
|
+
const order = tx.public.orders
|
|
458
461
|
.insert({ amount: 1000, status: "pending" })
|
|
459
462
|
.expectOne(new NotFound("order could not be created"));
|
|
460
463
|
|
|
461
|
-
tx.
|
|
464
|
+
tx.public.order_items.insertMany(
|
|
462
465
|
cart.map((line) => ({ order_id: order.id, sku: line.sku })),
|
|
463
466
|
);
|
|
464
467
|
|
|
@@ -514,7 +517,7 @@ filter plus an expectation — which is also stronger, because the check and the
|
|
|
514
517
|
write are now the same statement and nothing can slip between them:
|
|
515
518
|
|
|
516
519
|
```ts
|
|
517
|
-
tx.
|
|
520
|
+
tx.public.invites
|
|
518
521
|
.updateWhere({ token, accepted_at: null }, { accepted_at: now() })
|
|
519
522
|
.expectOne(new Conflict("invite already used", "invite_used"));
|
|
520
523
|
```
|
|
@@ -554,7 +557,7 @@ await Database.public.accounts.updateMany({
|
|
|
554
557
|
|
|
555
558
|
// Inside a plan: the same expression, the same meaning.
|
|
556
559
|
await Database.$transaction((tx) => {
|
|
557
|
-
tx.
|
|
560
|
+
tx.public.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
|
|
558
561
|
return null;
|
|
559
562
|
});
|
|
560
563
|
```
|
|
@@ -660,7 +663,8 @@ Sometimes you need to read or write **across all users** — an admin endpoint,
|
|
|
660
663
|
background job that fans out notifications, a cleanup task. For that, call
|
|
661
664
|
`Database.$asService()`. It returns a sibling client that runs as
|
|
662
665
|
**`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
|
|
663
|
-
exact same surface — `
|
|
666
|
+
exact same surface — `public` (and every other schema), the `$`-prefixed raw ops,
|
|
667
|
+
and `$transaction`:
|
|
664
668
|
|
|
665
669
|
```ts
|
|
666
670
|
import { Database } from "@palbase/backend";
|
|
@@ -669,32 +673,32 @@ import { Database } from "@palbase/backend";
|
|
|
669
673
|
const mine = await Database.public.todos.findMany({});
|
|
670
674
|
|
|
671
675
|
// Service-role bypass: every user's rows. Explicit and greppable.
|
|
672
|
-
const all = await Database.$asService().
|
|
673
|
-
const rows = await Database.$asService()
|
|
676
|
+
const all = await Database.$asService().public.todos.findMany({});
|
|
677
|
+
const rows = await Database.$asService().$query("SELECT count(*) FROM todos");
|
|
674
678
|
|
|
675
679
|
// A service-role transaction (the role is fixed for the whole plan):
|
|
676
|
-
await Database.$asService()
|
|
677
|
-
tx.
|
|
680
|
+
await Database.$asService().$transaction((tx) => {
|
|
681
|
+
tx.public.todos.updateWhere({ id }, { done: true });
|
|
678
682
|
return null;
|
|
679
683
|
});
|
|
680
684
|
```
|
|
681
685
|
|
|
682
686
|
Guidelines:
|
|
683
687
|
|
|
684
|
-
- **Be explicit.** Prefer the default `Database.*` and reach for
|
|
688
|
+
- **Be explicit.** Prefer the default `Database.*` and reach for `$asService()`
|
|
685
689
|
only where you genuinely need cross-user access. It is intentionally easy to
|
|
686
690
|
grep for in review.
|
|
687
691
|
- **No double-bypass / no nesting.** The sibling does not re-expose
|
|
688
|
-
|
|
692
|
+
`$asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
|
|
689
693
|
transaction. Use `Database.$transaction(...)` for an authenticated one and
|
|
690
|
-
`Database.$asService()
|
|
694
|
+
`Database.$asService().$transaction(...)` for a service-role one; you cannot mix
|
|
691
695
|
enforced and bypassed operations inside a single plan.
|
|
692
696
|
- **It is a second transaction, on a second connection.** It has to be: the
|
|
693
697
|
Postgres role is bound once, when the transaction opens, so a sibling sharing
|
|
694
698
|
the request's transaction would run as `backend_authenticated` and silently
|
|
695
699
|
keep filtering. Three consequences worth knowing:
|
|
696
700
|
- The request opens it at most once, however many times you call
|
|
697
|
-
|
|
701
|
+
`$asService()`, and never at all if you don't use it.
|
|
698
702
|
- Both settle with the request — commit when your handler returns, roll back
|
|
699
703
|
when it throws — but they settle as two transactions. A row written through
|
|
700
704
|
one surface is **not visible** to the other until the request commits.
|