@palbase/backend 24.2.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.
- package/dist/bin/palbase-backend.cjs +101 -60
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +17 -13
- package/dist/bin/palbase-backend.js.map +1 -1
- package/dist/{chunk-EIXCY4SS.js → chunk-43A3KGWL.js} +80 -49
- package/dist/chunk-43A3KGWL.js.map +1 -0
- package/dist/{chunk-ERDL5VAE.js → chunk-5CMLOAEF.js} +2 -2
- package/dist/chunk-OEQBHE2Z.js +825 -0
- package/dist/chunk-OEQBHE2Z.js.map +1 -0
- package/dist/{chunk-7Z6MGMXQ.js → chunk-XJ2RSHEU.js} +11 -5
- package/dist/chunk-XJ2RSHEU.js.map +1 -0
- package/dist/{chunk-UWSYTUGM.js → chunk-ZQRWW37O.js} +44 -1
- package/dist/chunk-ZQRWW37O.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +29 -13
- package/dist/db/env.d.ts +29 -13
- package/dist/db/index.cjs +233 -110
- 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 +11 -1
- package/dist/engine/index.cjs +87 -50
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +2 -2
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +3 -3
- package/dist/{index-C0PMn5jl.d.ts → index-BF1f0DfA.d.ts} +5 -2
- package/dist/{index-DAwHMppB.d.cts → index-CoaDN9dL.d.cts} +5 -2
- package/dist/{index-ByBMibIJ.d.ts → index-Ct1iiB4N.d.ts} +232 -60
- package/dist/{index-D4rts8T7.d.cts → index-CwaWRhyc.d.cts} +232 -60
- package/dist/index.cjs +572 -296
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -20
- package/dist/index.d.ts +124 -20
- package/dist/index.js +164 -216
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +100 -36
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.js +59 -2
- package/dist/openapi/index.js.map +1 -1
- package/docs/README.md +64 -31
- package/docs/endpoints.md +25 -28
- package/docs/llms-full.txt +465 -148
- package/docs/schema.md +338 -86
- package/docs/services.md +39 -4
- package/package.json +1 -1
- package/template/AGENTS.md +119 -314
- package/template/CLAUDE.md +13 -0
- package/template/controllers/notes.controller.ts +6 -13
- package/template/db/public.ts +38 -0
- package/template/models/notes/create.ts +38 -0
- package/template/package.json +6 -3
- package/template/services/note.service.test.ts +45 -0
- package/template/services/note.service.ts +2 -2
- package/dist/chunk-7Z6MGMXQ.js.map +0 -1
- package/dist/chunk-EIXCY4SS.js.map +0 -1
- package/dist/chunk-LCL7TUAI.js +0 -534
- package/dist/chunk-LCL7TUAI.js.map +0 -1
- package/dist/chunk-UWSYTUGM.js.map +0 -1
- package/template/db/schema.ts +0 -35
- /package/dist/{chunk-ERDL5VAE.js.map → chunk-5CMLOAEF.js.map} +0 -0
package/template/AGENTS.md
CHANGED
|
@@ -1,353 +1,158 @@
|
|
|
1
1
|
# Palbase backend project
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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,
|
|
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`.
|
|
14
|
+
> `tsconfig.json`. Legacy decorators; no `reflect-metadata`.
|
|
16
15
|
|
|
17
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
models/<controller>/<endpoint>.ts
|
|
39
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
61
|
-
|
|
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
|
-
//
|
|
65
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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
|
-
|
|
353
|
-
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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>;
|
package/template/package.json
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "A Palbase backend
|
|
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": "^
|
|
12
|
+
"@palbase/backend": "^25.0.0"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.18.0"
|
|
13
16
|
},
|
|
14
17
|
"devDependencies": {
|
|
15
18
|
"@types/node": "^22",
|