@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
@@ -1,353 +1,158 @@
1
1
  # Palbase backend project
2
2
 
3
- This project runs inside the Palbase backend runtime. You author TypeScript; the
4
- runtime discovers it and serves it as a typed HTTP API, and generates the client
5
- SDKs your apps call. Routing is explicit: a controller is a **class** whose
6
- methods are routes.
3
+ You write TypeScript files; the runtime discovers them **by location** and serves
4
+ them as a typed HTTP API, then generates the client SDKs your apps call. There is
5
+ no router to register with, no module to list things in, no DI container.
7
6
 
8
7
  Everything comes from one package:
9
8
 
10
9
  ```ts
11
- import { Controller, Get, Post, Patch, Delete, Body, QueryParams, Param, User, z, Database, Secrets, Conflict, NotFound } from "@palbase/backend";
10
+ import { Controller, Get, Post, Body, Param, User, z, Database, NotFound } from "@palbase/backend";
12
11
  ```
13
12
 
14
13
  > Needs `experimentalDecorators: true` — already set in this scaffold's
15
- > `tsconfig.json`. They are legacy decorators; there is no `reflect-metadata`.
14
+ > `tsconfig.json`. Legacy decorators; no `reflect-metadata`.
16
15
 
17
- ## What is here
16
+ **The full reference** — every decorator, every service singleton, every schema
17
+ helper — is at <https://app.dev.palbase.studio/llms.txt> (one file for agents:
18
+ `/llms-full.txt`). This file does not repeat it. This file says what the code
19
+ should LOOK like.
18
20
 
19
- ```
20
- controllers/<name>.controller.ts a @Controller class; each method is a route
21
- services/<name>.service.ts the logic, as a plain class
22
- db/schema.ts the database: tables, columns, RLS policies
23
- palbase-stack.d.ts GENERATED — the names your stack actually holds
24
- palbase-env.d.ts GENERATED — your tables, as types
25
- ```
26
-
27
- Both generated files are written by `palbase build` and **committed**. They are
28
- what makes `Secrets.get("X")`, `Flags.isEnabled("y")`, `@Upload({ bucket })` and
29
- `Database.tables.*` compile: the names come from the stack and the schema, so
30
- there is no second list in this repo to drift out of date. A project that has
31
- never run `palbase build` has no valid names and those calls will not compile —
32
- that is the gate working, not a rough edge. Run it once and commit both files.
21
+ ## The layers
33
22
 
34
- Two folders appear as the project grows, and both are conventions the tooling
35
- already understands:
36
-
37
- ```
38
- models/<controller>/<endpoint>.ts zod schemas, once they outgrow the controller
39
- services/<name>.service.ts business logic, calls Database/Cache/Storage
40
- ```
41
-
42
- Put logic in a service. Keep controller methods thin — they validate through the
43
- decorator schemas and delegate. Do not call `Database` from a controller.
44
-
45
- ## Controllers — a class of routes
23
+ | Layer | DOES | NEVER |
24
+ |---|---|---|
25
+ | `controllers/<name>.controller.ts` | HTTP only: validate the body through a named schema, name the 200 shape as the return type, turn a missing row into a status | imports `Database` / `Storage` / `Cache` · holds business rules · stores per-request state on the instance |
26
+ | `services/<name>.service.ts` | the decisions: which rows, whose, in what order; throws the error classes | knows about HTTP (no `req`, no `res`) · reaches for a singleton itself — it takes its dependency through the constructor |
27
+ | `models/<controller>/<endpoint>.ts` | zod schemas, exported twice under one name: a VALUE and a same-named `z.infer` TYPE | holds logic |
28
+ | `db/public.ts` | IS the database: tables, columns, RLS policies | produces migration files — there are none |
46
29
 
47
- `@Controller("/base", { auth? })` sets the base path and an optional default auth
48
- for every route in the class. Each route is a method decorated with
49
- `@Get/@Post/@Put/@Patch/@Delete/@Query("<subpath>", { auth? })`. The served path
50
- is `base + subpath`.
30
+ In one line: **controllers thin, services thick, models quiet, schema the single
31
+ truth.**
51
32
 
52
- **The class does not have to be exported.** `@Controller` records the class, so
53
- importing the file IS the registration — there is no module to list it in.
33
+ The scaffold ships one worked vertical `notes` across all four. Open it: the
34
+ shape you find there is the shape to copy.
54
35
 
55
- The generated clients name a call `<class minus "Controller", lowercased>.<method>`:
56
- `NotesController.list` is `pb.notes.list()`. The verb and the path do not affect
57
- it, so restructure paths freely — but renaming a class or a method renames the
58
- call in every app. Those two names are your public API.
36
+ ## Where does this go
59
37
 
60
- The example below is not invented for the page: it is `notes`, the one vertical
61
- this scaffold ships, so every file it names is already on your disk to open.
38
+ | The need | Its home |
39
+ |---|---|
40
+ | Validate input | a zod schema in `models/`, passed to `@Body(Schema)` |
41
+ | Enforce ownership | an RLS policy in `db/public.ts` **and** the service's `where` — the policy is the backstop, not the reason to stop writing the filter |
42
+ | A business rule, a calculation, an ordering | the service |
43
+ | Return an HTTP status | `throw new NotFound(…)` in the controller (or the service — no request object needed) |
44
+ | Call another feature | import its service singleton |
45
+ | Scheduled or background work | a `@Job` class in `jobs/` — **there is no queue** |
46
+ | Read a setting or a secret | `Secrets.get(name)` / `Flags.isEnabled(name)` — the names are checked by the compiler |
47
+ | Share code between controllers | a plain module they import; not a base class, not middleware |
48
+
49
+ ## NEVER write this
50
+
51
+ These are what a language model reaches for by default. Every one of them is
52
+ wrong in this runtime:
53
+
54
+ - **Express-style handlers** — `app.get(...)`, `(req, res) => …`, `req.params`,
55
+ `req.body`. A route is a **method on a class**.
56
+ - **A `ctx` object** — services are imported directly. There is no context to
57
+ thread, and no handler signature that receives one.
58
+ - **A DI container, or injection via decorators** — the only supported way to hold
59
+ a dependency is a module-level singleton. A controller, job, hook or webhook is
60
+ constructed with **no arguments**; one that declares a constructor parameter is
61
+ refused at build with the class named.
62
+ - **`middleware/`** — nothing mounts it and the engine has no middleware pipeline.
63
+ Code written against it deploys, never runs, and nothing reports it. Put
64
+ cross-cutting work in a service; use route options for auth and rate limits.
65
+ - **A repository or DAO layer** — `Database.tables.<name>` is already typed from
66
+ your schema. A wrapper over it buys nothing and hides the seam.
67
+ - **A migration file** — there are none anywhere in this product. `db/public.ts`
68
+ is diffed against the live database and applied.
69
+ - **`process.env`** for configuration — a secret is read with `Secrets.get()`, and
70
+ its value never enters this repository.
71
+ - **`try { … } catch { return 500 }`** — throw the error class and let the runtime
72
+ build the envelope. Swallowing it turns a 404 into a 500.
73
+ - **Hand-built responses** — no `JSON.stringify`, no `new Response(...)`. The 200
74
+ body IS the schema your return type names.
75
+ - **An inline return type** — `Promise<{ ok: boolean }>`, a union, or a bare
76
+ `interface`. The deploy REFUSES it. Name a zod schema.
77
+
78
+ **A controller needs no export.** `@Controller` records the class as it decorates
79
+ it, so importing the file IS the registration — this scaffold's own
80
+ `HealthController` and `NotesController` are not exported. (`export default` **is**
81
+ required for `jobs/`, `webhooks/`, `hooks/` and `db/public.ts`, one class per
82
+ file.)
83
+
84
+ **Class and method names are your public API.** `NotesController.list` generates
85
+ `pb.notes.list()`. Renaming either renames the call in every app; the verb and the
86
+ path do not affect it, so restructure paths freely and rename these two carefully.
87
+
88
+ ## Adding a feature — this order
89
+
90
+ 1. `db/public.ts` — declare the table and its RLS policies.
91
+ 2. `palbase db plan` — read what would change.
92
+ 3. `palbase db apply` — apply it in one transaction.
93
+ 4. `models/<controller>/<endpoint>.ts` — the schema, as a value and a type.
94
+ 5. `services/<name>.service.ts` — the logic; take the dependency through the
95
+ constructor, wire the singleton on the last line.
96
+ 6. `controllers/<name>.controller.ts` — the routes; import the service, never
97
+ `Database`.
98
+ 7. `palbase build` — regenerates `palbase-env.d.ts` and `palbase-stack.d.ts`.
99
+ Commit both.
100
+ 8. `npm test` — exercise the service.
101
+
102
+ ## The service shape, exactly
62
103
 
63
104
  ```ts
64
- // controllers/notes.controller.ts
65
- import { Body, Controller, Delete, Get, NotFound, Param, Post, User, z } from "@palbase/backend";
66
- import type { UserT } from "@palbase/backend";
67
- import { noteService } from "../services/note.service";
68
-
69
- // Small controllers declare their schemas beside the routes; `models/` below is
70
- // where these two move once they outgrow the file.
71
- export const NoteSchema = z.object({
72
- id: z.string(),
73
- user_id: z.string(),
74
- body: z.string(),
75
- created_at: z.string(),
76
- });
77
- export type NoteSchema = z.infer<typeof NoteSchema>;
78
-
79
- export const CreateNoteBody = z.object({ body: z.string().min(1) });
80
- export type CreateNoteBody = z.infer<typeof CreateNoteBody>;
81
-
82
- @Controller("/notes")
83
- class NotesController {
84
- @Get("")
85
- list(@User() user: UserT): Promise<NoteSchema[]> {
86
- return noteService.list(user.id);
87
- }
105
+ // services/note.service.ts
106
+ import { Database } from "@palbase/backend";
88
107
 
89
- @Post("")
90
- create(@Body(CreateNoteBody) body: CreateNoteBody, @User() user: UserT): Promise<NoteSchema> {
91
- return noteService.create(user.id, body.body);
92
- }
108
+ type NotesTable = typeof Database.tables.notes;
93
109
 
94
- @Get("/{id}")
95
- async get(@Param("id") id: string): Promise<NoteSchema> {
96
- const note = await noteService.get(id);
97
- if (!note) throw new NotFound("no note with that id");
98
- return note;
110
+ export class NoteService {
111
+ private readonly notes: NotesTable;
112
+
113
+ // Assigned in the BODY. A parameter property — `constructor(private notes: …)`
114
+ // — is refused by Node's type-stripping test runner, and refused for the whole
115
+ // FILE, so one of them anywhere in a test's import graph reads like a dozen
116
+ // broken tests and is one keyword.
117
+ constructor(notes: NotesTable) {
118
+ this.notes = notes;
99
119
  }
100
120
 
101
- @Delete("/{id}")
102
- remove(@Param("id") id: string): Promise<void> {
103
- return noteService.remove(id);
121
+ list(userId: string) {
122
+ return this.notes.findMany({ user_id: userId });
104
123
  }
105
124
  }
106
- ```
107
-
108
- ### Parameter decorators — each request piece, injected
109
-
110
- | Decorator | Injects | Notes |
111
- |---|---|---|
112
- | `@Body(schema)` | request body | annotate `: T` (the same-named `z.infer` type) |
113
- | `@QueryParams(schema)` | query string | typed + validated → OpenAPI query params |
114
- | `@Param("id")` | one path param | typed `string` |
115
- | `@Headers(schema?)` | request headers | raw `Record<string,string>`; pass a schema to type + validate |
116
- | `@User()` | the signed-in user (`: UserT`, non-null) | `@OptionalUser()` (`UserT \| null`) on `auth: false` routes |
117
- | `@Client()` | parsed client info | name / version / platform |
118
- | `@RequestId()` / `@TraceId()` | ids | `: string` |
119
- | `@Req()` | the raw request | escape hatch |
120
-
121
- > `@User()` (value) and `UserT` (type) are imported separately —
122
- > `import { User } from "@palbase/backend"` and
123
- > `import type { UserT } from "@palbase/backend"` — because the value name
124
- > `User` is the decorator. Write `@User() user: UserT`.
125
-
126
- `@Query(subpath)` is the HTTP **QUERY** method (RFC 10008): a read that is safe
127
- and idempotent but carries a body. Its input goes in `@Body` — never combine it
128
- with `@QueryParams`.
129
-
130
- ### Output and errors
131
-
132
- The 200 response **is** the method's return type, and it must NAME a zod schema.
133
- There is no response decorator: both the runtime's output validation and the
134
- client codegen read the annotation.
135
-
136
- ```ts
137
- @Get("") list(): Promise<TodoSchema[]> // array (auto-wrapped in z.array)
138
- @Get("/{id}") one(): Promise<TodoSchema> // one object
139
- @Get("/ping") ping(): void // no response body
140
- ```
141
-
142
- `: TodoSchema` (non-Promise) and `: Promise<z.infer<typeof TodoSchema>>` are
143
- equally valid. A **missing** annotation, an inline object (`Promise<{ ok: boolean }>`),
144
- a union, or a name with no matching zod schema in scope is a hard build error —
145
- name the schema.
146
-
147
- Throw a global error class from anywhere; no request object needed:
148
-
149
- ```ts
150
- import { NotFound, Conflict, Forbidden, Unauthorized, BadRequest, TooManyRequests } from "@palbase/backend";
151
-
152
- throw new Conflict("title already taken"); // → 409, standard envelope
153
- throw new BadRequest({ fields: ["title"] }); // data-first: the data, then an optional message
154
- throw new TooManyRequests({ retryAfter: 30 }); // data-first
155
- ```
156
-
157
- ### Auth is on unless you opt out
158
-
159
- Every route requires a signed-in user by default. `@Controller("/x", { auth: false })`
160
- opens all of a class's routes; `@Get("", { auth: true })` overrides it for one.
161
-
162
- An application-wide default sits under those two:
163
-
164
- ```ts
165
- import { defineDefaultAuth } from "@palbase/backend";
166
- defineDefaultAuth({ verifiedEmail: true }); // every route, unless it says otherwise
167
- ```
168
-
169
- Put that call in `auth.ts` at the project root — the bundler imports that file by
170
- name, so the declaration cannot be silently left out of the build. Resolution is
171
- route → controller → application → `true`. Writing the same auth setting on ten
172
- controllers is how the eleventh one gets forgotten.
173
-
174
- ## Models — schemas, one file per endpoint
175
-
176
- ```ts
177
- // models/todos/create.ts
178
- import { z } from "@palbase/backend";
179
- export const CreateTodoBody = z.object({ title: z.string().min(1) });
180
- export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
181
- ```
182
-
183
- Each name is a zod **value** (passed to `@Body`/`@QueryParams`, named by a return
184
- type) plus an optional same-named `z.infer` **type**, which is what lets the
185
- annotation read `Promise<TodoSchema>` instead of `Promise<z.infer<typeof TodoSchema>>`.
186
- Shared response schemas go in `models/<controller>/shared.ts`.
187
-
188
- ## Services — business logic
189
-
190
- Plain classes wired as a module-level singleton. No DI container, no decorators
191
- — and this is the ONLY supported way to hold a dependency anywhere in this
192
- runtime. A controller, hook, job or webhook is constructed with **no arguments**;
193
- one that declares a constructor parameter is refused at build with the class
194
- named, because there is no injector to fill it and the field would simply be
195
- `undefined` in production.
196
-
197
- `services/note.service.ts` in this scaffold is the worked example. The shape:
198
-
199
- ```ts
200
- export class NoteService {
201
- private readonly notes: typeof Database.tables.notes;
202
- // Assigned in the BODY — `constructor(private notes: …)` is a parameter
203
- // property, and Node's type-stripping test runner refuses those per FILE.
204
- constructor(notes: typeof Database.tables.notes) { this.notes = notes; }
205
- list(userId: string) { return this.notes.findMany({ user_id: userId }); }
206
- }
207
125
 
208
126
  /** Controllers import THIS, never the class. */
209
127
  export const noteService = new NoteService(Database.tables.notes);
210
128
  ```
211
129
 
212
- The constructor is the seam: the class is handed the table rather than reaching
213
- for the singleton, so a test constructs it with a stand-in and never needs a
214
- database. `fakeDatabase()` from `@palbase/backend/test` is that stand-in when you
215
- want the whole surface instead of one table.
216
-
217
- `Database.tables.<name>` gives:
218
-
219
- | | |
220
- |---|---|
221
- | `insert(row)` · `upsert(row, { onConflict })` | write one |
222
- | `update(id, data)` · `delete(id)` | write one, by id |
223
- | `updateMany(where, set)` · `deleteMany(where)` | write every row the filter matches, in ONE statement |
224
- | `findById(id)` · `findMany(where?, opts?)` | read |
225
- | `count(where?)` | how many match |
226
-
227
- `where` takes a plain value for equality or an operator object —
228
- `{ n: { gte: 3 } }`, `{ id: { in: [...] } }`, also `gt` / `lt` / `lte` / `neq`.
229
- `opts` takes `{ orderBy: { column, direction }, limit, offset }`, so ordering and
230
- paging happen in SQL rather than in JavaScript after fetching everything.
231
- `updateMany`/`deleteMany` REFUSE an empty filter — a whole-table write is not
232
- something to reach by accident.
233
-
234
- `Database.transaction(fn)` runs a plan that commits or rolls back as one, and
235
- `Database.attempt(fn)` opens a savepoint so one failed write does not poison the
236
- rest of the request.
237
-
238
- ## The database — declared, not migrated
239
-
240
- `db/schema.ts` is the database. There are no migration files to write, order, or
241
- reconcile; three verbs share one engine:
242
-
243
- - **plan** — what it would take to make the live database match the declaration
244
- - **apply** — make it match, in one transaction
245
- - **push** — ship the code, and REFUSE while the two still disagree
246
-
247
- That refusal is the point: code and schema move at different speeds, and a deploy
248
- that lands a handler against a column that does not exist yet fails at the first
249
- request, in production. So: edit `db/schema.ts`, plan, apply, then push.
250
-
251
- RLS is on by default and a table with no policies is deny-all. Add
252
- `policies: [policy(...)]` so Postgres enforces ownership — a query that forgets
253
- its `where user_id = …` still cannot read another user's rows.
254
-
255
- After a schema change run `palbase build`: it regenerates BOTH generated files —
256
- `palbase-env.d.ts` (your tables) and `palbase-stack.d.ts` (the stack's names) —
257
- so `Database.tables.*` keeps matching what is actually there. Commit them.
258
-
259
- ## Secrets, flags, buckets — read from the stack
260
-
261
- There is no `config/` directory and nothing to declare in this repo. Settings are
262
- written to the STACK with the CLI, and the code READS them:
263
-
264
- ```ts
265
- import { Secrets, Flags } from "@palbase/backend";
266
- const dsn = await Secrets.get("SENTRY_DSN"); // compiles only if the stack holds it
267
- if (await Flags.isEnabled("newCheckout")) { … } // same
268
- ```
269
-
270
- The name in the quotes is checked by the compiler against `palbase-stack.d.ts`,
271
- which `palbase build` generates from the stack. A secret's VALUE never enters
272
- this repository, and you never read `process.env` for one.
273
-
274
- Writing a setting is a CLI job, not a code job — `palbase --help` names the
275
- commands (`secret`, `flags`, `storage`, `notifications`, `auth`). Settings have
276
- one door; a second one is how the two come to disagree.
277
-
278
- ## Lifecycle — for things that are expensive to build
279
-
280
- Most handlers need nothing here: the SDK's singletons are already pooled. But a
281
- long-lived object you build yourself needs somewhere to close, and a process that
282
- never closes one leaks it per deploy:
283
-
284
- ```ts
285
- import { onStart, onShutdown } from "@palbase/backend";
286
-
287
- let client: SomeClient | undefined;
288
- onStart("graph", () => { client = SomeClient.create({ maxPoolSize: 8 }); });
289
- onShutdown("graph", async () => { await client?.close(); });
290
- ```
291
-
292
- The NAME is the first argument, and it is what a failure is reported by — a stack
293
- trace through an anonymous closure names nothing. `onStart` runs ONCE as the app
294
- comes up, and a hook that throws refuses the boot rather than letting the app
295
- serve traffic with a half-built dependency. `onShutdown` hooks run in reverse
296
- order and are best-effort: one that throws is reported and the rest still run.
297
-
298
- **These hooks run OUTSIDE a request, so no Palbase service is reachable from
299
- them** — `Secrets`, `Database`, `Log` and the rest all throw "accessed outside a
300
- request scope". If your object needs a secret, build it lazily on first use
301
- inside a handler instead:
302
-
303
- ```ts
304
- let client: SomeClient | undefined;
305
- async function graph() {
306
- client ??= SomeClient.create({ url: await Secrets.get("GRAPH_URL") });
307
- return client;
308
- }
309
- onShutdown("graph", async () => { await client?.close(); });
310
- ```
130
+ The constructor is the seam, and it is the whole reason the layer pays off: a test
131
+ hands in a stand-in and never needs a database. `services/note.service.test.ts` in
132
+ this scaffold is that test read it before writing your own.
311
133
 
312
134
  ## Tests
313
135
 
314
- `npm test` runs Node's own test runner over every `*.test.ts`. Nothing to
315
- install and nothing to configure Node discovers the files and strips the types
316
- itself.
317
-
318
- Test the **service** layer. Controllers are thin by construction, so the logic
319
- that can be wrong lives in a service — a plain class you construct directly with
320
- a stand-in, exactly as `services/note.service.ts` is written. For a stand-in
321
- covering the whole database surface rather than one table:
136
+ `npm test` runs the scaffold's canary test by name and then discovers the rest.
137
+ The canary is named on purpose: a glob that matches nothing exits **0**, so a
138
+ project whose tests silently stopped being discovered would report success.
322
139
 
323
- ```ts
324
- import { fakeDatabase } from "@palbase/backend/test";
325
- const { db, queries } = fakeDatabase();
326
- ```
327
-
328
- It answers `tables.*` from memory and records the `query` calls made. What it
329
- does NOT do is enforce RLS, constraints or unique violations — those are real
330
- database behaviour, and the test that proves them runs against a real one.
140
+ Test the **service** layer. Controllers are thin by construction, so what can be
141
+ wrong lives in a service — a plain class you construct directly with a stand-in.
142
+ For a stand-in covering the whole database surface rather than one table, use
143
+ `fakeDatabase()` from `@palbase/backend/test`; it answers from memory and does not
144
+ enforce RLS, constraints or unique violations.
331
145
 
332
- Node **strips** types, it never emits code, so TypeScript features that require
333
- emitted code are refused outright: parameter properties (`constructor(private x: T)`),
334
- `enum`, and `namespace` all fail with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. The
335
- refusal is per FILE, so one parameter property anywhere in a test's import graph
336
- takes down every test in that file at once — it reads like a dozen broken tests
337
- and is one keyword. Assign fields in the constructor body; use a `const` object
338
- or a union instead of `enum`; use a module instead of `namespace`.
339
-
340
- Node's ESM resolver wants the extension on relative imports, so a test imports
341
- `./note.service.ts`, not `./note.service`. This scaffold's `tsconfig.json`
342
- allows that.
146
+ Node **strips** types, it never emits code, so parameter properties, `enum` and
147
+ `namespace` fail with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` per FILE. Relative
148
+ imports inside a test need the extension (`./note.service.ts`); this scaffold's
149
+ `tsconfig.json` allows it.
343
150
 
344
151
  `npm test` answers "is the logic right". It is not the deploy's own validation —
345
152
  decorators, return types, SDK major — which answers "would this ship".
346
153
 
347
- ## Commands and the full reference
348
-
349
- This file deliberately does not list the CLI surface: commands change, and a copy
350
- inside your repository goes stale silently. `palbase --help` is authoritative.
154
+ ## Commands
351
155
 
352
- The complete SDK reference controllers, models, schema, services, errors,
353
- jobs, hooks, webhooks lives at <https://app.dev.palbase.studio/docs/backend>.
156
+ `palbase --help` is authoritative; a copy of the command surface inside this
157
+ repository goes stale silently. The full SDK reference is at
158
+ <https://app.dev.palbase.studio/llms.txt>.
@@ -0,0 +1,13 @@
1
+ @AGENTS.md
2
+
3
+ <!--
4
+ Claude Code reads CLAUDE.md, not AGENTS.md. This file exists so it reaches the
5
+ one guide this project keeps — the line above imports it whole.
6
+
7
+ Every other coding agent (Codex, Cursor, Copilot, Windsurf, Gemini CLI, Zed,
8
+ Aider, …) reads AGENTS.md directly, so there is nothing to duplicate here.
9
+
10
+ Keep the rules in AGENTS.md. Two hand-maintained copies drift apart, and the one
11
+ that drifts is the one nobody is reading when it matters. Claude-specific
12
+ instructions, if you ever need any, go BELOW the import.
13
+ -->
@@ -1,6 +1,7 @@
1
- import { Body, Controller, Delete, Get, NotFound, Param, Post, User, z } from "@palbase/backend";
1
+ import { Body, Controller, Delete, Get, NotFound, Param, Post, User } from "@palbase/backend";
2
2
  import type { UserT } from "@palbase/backend";
3
3
 
4
+ import { CreateNoteBody, NoteSchema } from "../models/notes/create";
4
5
  import { noteService } from "../services/note.service";
5
6
 
6
7
  // The other half of the vertical that starts in `services/note.service.ts`.
@@ -20,18 +21,10 @@ import { noteService } from "../services/note.service";
20
21
  // controller that declares one is refused at build with the class named rather
21
22
  // than handed `undefined` in production.
22
23
 
23
- export const NoteSchema = z.object({
24
- id: z.string(),
25
- user_id: z.string(),
26
- body: z.string(),
27
- created_at: z.string(),
28
- });
29
- export type NoteSchema = z.infer<typeof NoteSchema>;
30
-
31
- export const CreateNoteBody = z.object({
32
- body: z.string().min(1),
33
- });
34
- export type CreateNoteBody = z.infer<typeof CreateNoteBody>;
24
+ // The schemas are in `models/notes/create.ts`. A controller with one endpoint
25
+ // can keep them beside the routes; the moment there are two, they go to
26
+ // `models/`, and this scaffold ships them already moved — because the shape you
27
+ // find here is the shape the next file copies.
35
28
 
36
29
  // Auth is required unless a route opts out, so `@User()` is non-null here and
37
30
  // every method below acts as somebody.
@@ -0,0 +1,38 @@
1
+ import { defineSchema, defineTable, ownedByUser, policy, text, timestamp, uuid } from "@palbase/backend";
2
+
3
+ // `db/public.ts` IS the database. There are no migration files to write, order,
4
+ // or reconcile: the CLI diffs this declaration against the live database, shows
5
+ // you what it would take to make them match, applies it in one transaction, and
6
+ // REFUSES to push code while the two still disagree.
7
+ //
8
+ // Row-Level Security is on by default. A table with RLS and no policies is
9
+ // deny-all — the correct starting state, because nothing reads a table until a
10
+ // policy says who may. `notes` below is scoped to its owner; delete it and
11
+ // declare your own.
12
+ const notes = defineTable("notes", {
13
+ columns: {
14
+ id: uuid().primaryKey().defaultRandom(),
15
+ // The column that OWNS the row: a real foreign key to the tenant's own
16
+ // `auth.users`, so deleting an account takes its rows with it instead of
17
+ // leaving orphans an erasure request cannot reach. `ownedByUser()` implies
18
+ // text, NOT NULL and ON DELETE CASCADE — the three rules you would
19
+ // otherwise have to remember. For a column that merely POINTS at a user
20
+ // (`created_by`, `edited_by`), use `userRef({ onDelete })` instead.
21
+ user_id: ownedByUser(),
22
+ body: text().notNull(),
23
+ created_at: timestamp().defaultNow(),
24
+ },
25
+ policies: [
26
+ // Postgres enforces ownership, not the handler: a query that forgets
27
+ // its `where user_id = …` still cannot see another user's rows.
28
+ policy("notes_owner")
29
+ .for("all")
30
+ .to("authenticated")
31
+ .using("user_id = (select auth.uid())")
32
+ .withCheck("user_id = (select auth.uid())"),
33
+ ],
34
+ });
35
+
36
+ // A table knows its own name, so the schema just collects them. The schema's
37
+ // own name is declared here — `public` is the one your handlers read.
38
+ export default defineSchema("public", { tables: [notes] });
@@ -0,0 +1,38 @@
1
+ import { z } from "@palbase/backend";
2
+
3
+ // SCHEMAS LIVE HERE once a controller has more than a screen of them:
4
+ // `models/<controller>/<endpoint>.ts`. Nothing discovers this directory — it is
5
+ // an ordinary import path — so the shape is yours. What is NOT optional is the
6
+ // double export below.
7
+ //
8
+ // Each name is exported TWICE, under the SAME name:
9
+ //
10
+ // * a zod VALUE — what you pass to `@Body(...)` / `@QueryParams(...)`, and what
11
+ // a return type names;
12
+ // * a same-named `z.infer` TYPE — what the parameter and return annotations are
13
+ // written with.
14
+ //
15
+ // That pairing is what lets a route read `Promise<NoteSchema>` instead of
16
+ // `Promise<z.infer<typeof NoteSchema>>`. Drop the type and the annotation stops
17
+ // compiling; drop the value and the deploy refuses the route, because a response
18
+ // schema has to be a value it can bind.
19
+ //
20
+ // Import them as VALUES (`import { NoteSchema }`), never with `import type` — the
21
+ // deploy emits a value reference to the name, and `import type` erases the
22
+ // binding it would reference.
23
+
24
+ /** One note row, as it goes out on the wire. */
25
+ export const NoteSchema = z.object({
26
+ id: z.string(),
27
+ user_id: z.string(),
28
+ body: z.string(),
29
+ created_at: z.string(),
30
+ });
31
+ export type NoteSchema = z.infer<typeof NoteSchema>;
32
+
33
+ /** The POST /notes body. Ownership is NOT here: the service writes `user_id`
34
+ * from the signed-in user, so a caller cannot claim someone else's row. */
35
+ export const CreateNoteBody = z.object({
36
+ body: z.string().min(1),
37
+ });
38
+ export type CreateNoteBody = z.infer<typeof CreateNoteBody>;
@@ -3,13 +3,16 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
- "description": "A Palbase backend \u2014 class controllers, a declared database, and the secrets it needs.",
6
+ "description": "A Palbase backend class controllers, a declared database, and the secrets it needs.",
7
7
  "scripts": {
8
- "test": "node --test --experimental-strip-types",
8
+ "test": "node --test --experimental-strip-types services/note.service.test.ts && node --test --experimental-strip-types",
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@palbase/backend": "^24.0.0"
12
+ "@palbase/backend": "^25.0.0"
13
+ },
14
+ "engines": {
15
+ "node": ">=22.18.0"
13
16
  },
14
17
  "devDependencies": {
15
18
  "@types/node": "^22",