@palbase/backend 24.3.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/bin/palbase-backend.cjs +101 -60
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +17 -13
  4. package/dist/bin/palbase-backend.js.map +1 -1
  5. package/dist/{chunk-EIXCY4SS.js → chunk-43A3KGWL.js} +80 -49
  6. package/dist/chunk-43A3KGWL.js.map +1 -0
  7. package/dist/{chunk-ERDL5VAE.js → chunk-5CMLOAEF.js} +2 -2
  8. package/dist/chunk-OEQBHE2Z.js +825 -0
  9. package/dist/chunk-OEQBHE2Z.js.map +1 -0
  10. package/dist/{chunk-7Z6MGMXQ.js → chunk-XJ2RSHEU.js} +11 -5
  11. package/dist/chunk-XJ2RSHEU.js.map +1 -0
  12. package/dist/{chunk-UWSYTUGM.js → chunk-ZQRWW37O.js} +44 -1
  13. package/dist/chunk-ZQRWW37O.js.map +1 -0
  14. package/dist/db/env.cjs.map +1 -1
  15. package/dist/db/env.d.cts +29 -13
  16. package/dist/db/env.d.ts +29 -13
  17. package/dist/db/index.cjs +212 -111
  18. package/dist/db/index.cjs.map +1 -1
  19. package/dist/db/index.d.cts +1 -1
  20. package/dist/db/index.d.ts +1 -1
  21. package/dist/db/index.js +11 -1
  22. package/dist/engine/index.cjs +87 -50
  23. package/dist/engine/index.cjs.map +1 -1
  24. package/dist/engine/index.d.cts +2 -2
  25. package/dist/engine/index.d.ts +2 -2
  26. package/dist/engine/index.js +3 -3
  27. package/dist/{index-DEneI8Mn.d.ts → index-BF1f0DfA.d.ts} +5 -2
  28. package/dist/{index-C-ALG22n.d.cts → index-CoaDN9dL.d.cts} +5 -2
  29. package/dist/{index-BTMYod_l.d.ts → index-Ct1iiB4N.d.ts} +203 -61
  30. package/dist/{index-BLAbr9ZH.d.cts → index-CwaWRhyc.d.cts} +203 -61
  31. package/dist/index.cjs +550 -297
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +122 -20
  34. package/dist/index.d.ts +122 -20
  35. package/dist/index.js +164 -217
  36. package/dist/index.js.map +1 -1
  37. package/dist/openapi/index.cjs +100 -36
  38. package/dist/openapi/index.cjs.map +1 -1
  39. package/dist/openapi/index.js +59 -2
  40. package/dist/openapi/index.js.map +1 -1
  41. package/docs/README.md +64 -31
  42. package/docs/endpoints.md +25 -28
  43. package/docs/llms-full.txt +399 -153
  44. package/docs/schema.md +272 -91
  45. package/docs/services.md +39 -4
  46. package/package.json +1 -1
  47. package/template/AGENTS.md +119 -314
  48. package/template/CLAUDE.md +13 -0
  49. package/template/controllers/notes.controller.ts +6 -13
  50. package/template/db/public.ts +38 -0
  51. package/template/models/notes/create.ts +38 -0
  52. package/template/package.json +6 -3
  53. package/template/services/note.service.test.ts +45 -0
  54. package/template/services/note.service.ts +2 -2
  55. package/dist/chunk-7Z6MGMXQ.js.map +0 -1
  56. package/dist/chunk-D5CQES25.js +0 -556
  57. package/dist/chunk-D5CQES25.js.map +0 -1
  58. package/dist/chunk-EIXCY4SS.js.map +0 -1
  59. package/dist/chunk-UWSYTUGM.js.map +0 -1
  60. package/template/db/schema.ts +0 -35
  61. /package/dist/{chunk-ERDL5VAE.js.map → chunk-5CMLOAEF.js.map} +0 -0
package/docs/README.md CHANGED
@@ -23,17 +23,26 @@ services/<name>.service.ts # plain class + singleton — the real logic
23
23
  db/schema.ts # config-as-code Postgres schema (tables, columns, RLS) — auto-migrated on deploy
24
24
  ```
25
25
 
26
- The four folders above are the daily surface. These also exist (own docs, linked
27
- below): `resources/` (plain modules a controller imports a client, a
28
- formatter, anything shared), `seeds/` (seed data), `jobs/` (background [background.md](./background.md)),
29
- `webhooks/` + `hooks/` (events — [events.md](./events.md)), `middleware/`.
26
+ The four folders above are the daily surface. Three more are discovered by name,
27
+ one class per file, `export default` required: `jobs/` (background
28
+ [background.md](./background.md)), `webhooks/` and `hooks/` (events
29
+ [events.md](./events.md)).
30
+
31
+ There is no `resources/`, no `seeds/` and no working `middleware/`. `middleware/`
32
+ is discovered by nothing and the engine has no middleware pipeline: code written
33
+ against it deploys, never runs, and nothing says so. Put cross-cutting work in a
34
+ service the controllers call.
30
35
 
31
36
  ### The 7 rules (checklist)
32
37
 
33
- 1. **`export default <ControllerClass>` is REQUIRED.** Every `controllers/*.controller.ts`
34
- must default-export its `@Controller` class, or the deploy FAILS with a
35
- "not a @Controller / non-controller export" error. Either `export default class X {}`
36
- or a trailing `export default X;` both work.
38
+ 1. **A controller needs NO export at all.** `@Controller` records the class into a
39
+ `globalThis` registry as it decorates it, and the bundler imports the file for
40
+ that side effect alone the shipped scaffold's own `HealthController` and
41
+ `NotesController` are not exported. Exporting is harmless and reads well, so
42
+ these examples do it; it is not a requirement. (`export default` **is**
43
+ required for `jobs/`, `webhooks/`, `hooks/` and `db/public.ts`, one class per
44
+ file.) What IS fatal is a `@Controller` class that collected zero routes —
45
+ usually `experimentalDecorators` missing from `tsconfig.json`.
37
46
  2. **Methods that call a service are `async` and return `Promise<T>`.** Services
38
47
  `await Database`, so they return promises; a sync return type on an async body
39
48
  is a tsc error. Annotate `: Promise<TodoSchema>`, not `: TodoSchema`, whenever
@@ -62,7 +71,7 @@ formatter, anything shared), `seeds/` (seed data), `jobs/` (background — [back
62
71
 
63
72
  > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
64
73
  > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
65
- > and will not compile against `@palbase/backend` 4.
74
+ > and will not compile against `@palbase/backend` 25.
66
75
 
67
76
  ### Complete CRUD example (copy-pasteable, compiles)
68
77
 
@@ -89,25 +98,44 @@ export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
89
98
  import { Database, NotFound } from "@palbase/backend";
90
99
  import type { TodoSchema } from "../models/todos/shared.js";
91
100
 
101
+ /** The typed surface of ONE table. Naming it keeps the seam one table wide:
102
+ * a test fake implements five methods, not the whole `Database`. */
103
+ type TodosTable = typeof Database.tables.todos;
104
+
92
105
  export class TodoService {
106
+ private readonly todos: TodosTable;
107
+
108
+ // THE SEAM IS THE CONSTRUCTOR: the class is handed the table rather than
109
+ // reaching for the singleton, so a test constructs it with a stand-in and
110
+ // never needs a database. Assign in the BODY — a parameter property
111
+ // (`constructor(private todos: …)`) is refused by Node's type-stripping test
112
+ // runner, and refused for the whole FILE.
113
+ constructor(todos: TodosTable) {
114
+ this.todos = todos;
115
+ }
116
+
93
117
  list(userId: string): Promise<TodoSchema[]> {
94
- return Database.tables.todos.findMany({ user_id: userId });
118
+ return this.todos.findMany({ user_id: userId });
95
119
  }
96
120
  create(userId: string, title: string): Promise<TodoSchema> {
97
- return Database.tables.todos.insert({ user_id: userId, title });
121
+ return this.todos.insert({ user_id: userId, title });
98
122
  }
99
123
  async get(userId: string, id: string): Promise<TodoSchema> {
100
- const t = await Database.tables.todos.findById(id);
124
+ const t = await this.todos.findById(id);
101
125
  if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
102
126
  return t;
103
127
  }
104
128
  async remove(userId: string, id: string): Promise<void> {
105
- const t = await Database.tables.todos.findById(id);
106
- if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
107
- await Database.tables.todos.delete(id);
129
+ await this.get(userId, id);
130
+ await this.todos.delete(id);
108
131
  }
109
132
  }
110
- export const todoService = new TodoService();
133
+
134
+ /** The wired instance. Controllers import THIS, never the class. It is also the
135
+ * ONLY supported way to hold a dependency: a controller, job, hook or webhook is
136
+ * constructed with no arguments, and one that declares a constructor parameter
137
+ * is refused at build with the class named. */
138
+ export const todoService = new TodoService(Database.tables.todos);
111
139
  ```
112
140
 
113
141
  ```ts
@@ -119,31 +147,33 @@ import { TodoSchema } from "../models/todos/shared.js";
119
147
  import { CreateTodoBody } from "../models/todos/create.js";
120
148
 
121
149
  @Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
122
- export class TodosController {
123
- private todos = todoService;
150
+ export default class TodosController {
151
+ // The service arrives as an IMPORTED SINGLETON, not a constructor parameter:
152
+ // the runtime constructs this class with no arguments, and one that declares a
153
+ // parameter is refused at build with the class named.
124
154
 
125
155
  @Get("") // GET /todos → operationId todos.list
126
156
  async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
127
- return this.todos.list(user.id);
157
+ return todoService.list(user.id);
128
158
  }
129
159
 
130
160
  @Post("") // POST /todos → todos.create
131
161
  async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
132
- return this.todos.create(user.id, body.title);
162
+ return todoService.create(user.id, body.title);
133
163
  }
134
164
 
135
165
  @Get("/{id}") // GET /todos/{id} → todos.get
136
166
  async get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
137
- return this.todos.get(user.id, id);
167
+ return todoService.get(user.id, id);
138
168
  }
139
169
 
140
170
  @Delete("/{id}") // DELETE /todos/{id} → todos.remove; no body → : Promise<void>
141
171
  async remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
142
- await this.todos.remove(user.id, id);
172
+ await todoService.remove(user.id, id);
143
173
  }
144
174
  }
145
-
146
- export default TodosController; // REQUIRED the runtime loads the default export
175
+ // No export is needed at all — @Controller registered the class as it decorated
176
+ // it. `export default` here is style, not a requirement.
147
177
  ```
148
178
 
149
179
  ```ts
@@ -230,7 +260,10 @@ The **only difference** is the trigger argument:
230
260
  | **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
231
261
  | **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
232
262
  | **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
233
- | **Middleware** (`middleware/**`) | `(ctx, next)` | `MiddlewareContext` — the **one exception** |
263
+
264
+ `defineMiddleware` is still exported and takes `(ctx, next)`, but **no bundler
265
+ reads a `middleware/` directory and the engine never calls one** — there is no
266
+ `ctx` anywhere on a path that runs. Every handler above imports its services.
234
267
 
235
268
  `meta` carries non-service data: `env` (Environment variables),
236
269
  `environmentId`, and for webhooks `requestId`. Services always come from
@@ -247,16 +280,16 @@ my-backend/
247
280
  ├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
248
281
  │ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
249
282
  ├── services/ # plain classes/singletons your controllers call
250
- ├── db/schema.ts # table definitions (optional, enables typed DB)
251
- ├── db/migrations/ # explicit SQL migrations for type changes (optional)
252
- ├── resources/ # external connections, set up once at boot (optional)
253
- ├── seeds/ # seed data (optional)
283
+ ├── db/public.ts # the database itself: tables, columns, RLS policies
254
284
  ├── jobs/ # cron-scheduled jobs (optional)
255
285
  ├── hooks/ # auth/storage/document event hooks (optional)
256
- ├── webhooks/ # inbound provider webhooks (optional)
257
- └── middleware/ # cross-cutting request middleware (optional)
286
+ └── webhooks/ # inbound provider webhooks (optional)
258
287
  ```
259
288
 
289
+ There is **no `db/migrations/`**. Nothing generates a migration file, nothing
290
+ commits one and nothing replays one: `db/schema.ts` is diffed against the live
291
+ database and applied — see [migrations.md](./migrations.md).
292
+
260
293
  HTTP endpoints are **not** file-path routed. You author a class controller
261
294
  (`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
262
295
  `controllers/` mounts it. See [routing.md](./routing.md).
package/docs/endpoints.md CHANGED
@@ -12,35 +12,38 @@ imported singletons (see [services.md](./services.md)).
12
12
 
13
13
  ```ts
14
14
  // controllers/rooms.controller.ts
15
- import { Controller, Get, Post, Body, Param, User, NotFound, Database } from "@palbase/backend";
15
+ import { Controller, Get, Post, Body, Param, User } from "@palbase/backend";
16
16
  import type { UserT } from "@palbase/backend";
17
+ import { roomService } from "../services/room.service.js";
17
18
  import { CreateRoomBody } from "../models/rooms/create.js";
18
19
  import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
19
20
 
21
+ // A controller does not import `Database`. Everything here is HTTP: validate the
22
+ // body through a named schema, name the 200 shape as the return type, delegate.
23
+ // Which rows, whose, in what order is the service's job — and the service is the
24
+ // thing worth testing, because it is the thing that can be wrong.
20
25
  @Controller("/rooms")
21
26
  export default class RoomsController {
22
27
  @Post("")
23
- async create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
24
- const room = await Database.insert("rooms", { name: body.name, capacity: body.capacity ?? null });
25
- return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
28
+ create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
29
+ return roomService.create(user.id, body);
26
30
  }
27
31
 
28
32
  @Get("/{id}")
29
- async getOne(@Param("id") id: string): Promise<RoomSchema> {
30
- const room = await Database.findById("rooms", id);
31
- if (!room) throw new NotFound("Room does not exist", "room_not_found");
32
- return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
33
+ getOne(@Param("id") id: string): Promise<RoomSchema> {
34
+ return roomService.get(id);
33
35
  }
34
36
  }
35
37
  ```
36
38
 
37
39
  **Two non-negotiables** (the most common codegen mistakes):
38
40
 
39
- 1. **`export default <Controller>` is REQUIRED.** Above it is
40
- `export default class RoomsController`; the trailing form `export class
41
- RoomsController {…}` + `export default RoomsController;` is equivalent. Without
42
- a default export the deploy aborts with a "not a @Controller / non-controller
43
- export" error — the runtime loads the file's default export.
41
+ 1. **The controller does not touch the database.** It delegates to a service, as
42
+ above. A method here that reaches for `Database` has moved the logic into the
43
+ layer that is hardest to test. (An export is not required at all: `@Controller`
44
+ records the class as it decorates it, and importing the file IS the
45
+ registration — the shipped scaffold's own controllers are not exported.
46
+ `export default` here is style.)
44
47
  2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
45
48
  returns promises, so a body that `await`s it cannot have a sync return type
46
49
  (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
@@ -150,20 +153,14 @@ throw new PalError(418, "teapot", "custom"); // → custom status/code
150
153
 
151
154
  See [errors.md](./errors.md) for the full set + the wire envelope shape.
152
155
 
153
- ## Middleware
156
+ ## There is no middleware
154
157
 
155
- A middleware wraps a request. Define one in `middleware/<name>.ts`:
158
+ `defineMiddleware(async (ctx, next) => { })` is still exported and still
159
+ type-checks, but **nothing mounts a `middleware/` directory and the engine has no
160
+ middleware pipeline** — a handler written against it deploys, never runs, and
161
+ nothing reports it. There is no `ctx` object anywhere on a path that executes, so
162
+ treat the export as a leftover rather than a seam.
156
163
 
157
- ```ts
158
- // middleware/logger.ts
159
- import { defineMiddleware } from "@palbase/backend";
160
-
161
- export default defineMiddleware(async (ctx, next) => {
162
- ctx.log.info(`start ${ctx.requestId}`);
163
- await next();
164
- ctx.log.info(`done ${ctx.requestId}`);
165
- });
166
- ```
167
-
168
- The middleware handler receives `(ctx, next)` — call `await next()` to run the
169
- rest of the chain (other middleware, then the endpoint method).
164
+ Cross-cutting work goes in a service the controllers call, and the route concerns
165
+ that used to live in a wrapper are route options instead: `auth` on `@Controller`
166
+ or the method decorator, and `rateLimit: { max, window }` per route.