@palbase/backend 25.1.0 → 27.1.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 +2432 -1039
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +87 -51
- package/dist/bin/palbase-backend.js.map +1 -1
- package/dist/chunk-BQN723PL.js +930 -0
- package/dist/chunk-BQN723PL.js.map +1 -0
- package/dist/chunk-CGNN2PUH.js +213 -0
- package/dist/chunk-CGNN2PUH.js.map +1 -0
- package/dist/{chunk-VDF2T4AS.js → chunk-EB3TUX5J.js} +1228 -591
- package/dist/chunk-EB3TUX5J.js.map +1 -0
- package/dist/chunk-JVZQCC77.js +728 -0
- package/dist/chunk-JVZQCC77.js.map +1 -0
- package/dist/chunk-OZKSM3JW.js +370 -0
- package/dist/chunk-OZKSM3JW.js.map +1 -0
- package/dist/{chunk-YOY5DFQS.js → chunk-TWX6JTGJ.js} +76 -34
- package/dist/{chunk-YOY5DFQS.js.map → chunk-TWX6JTGJ.js.map} +1 -1
- package/dist/{chunk-35PNTIRN.js → chunk-VVMJEVQP.js} +63 -162
- package/dist/chunk-VVMJEVQP.js.map +1 -0
- package/dist/{chunk-7D4SUZUM.js → chunk-VXPNPVAG.js} +3 -1
- package/dist/chunk-XABHGMUT.js +885 -0
- package/dist/chunk-XABHGMUT.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +2 -2
- package/dist/db/env.d.ts +2 -2
- package/dist/db/index.cjs +780 -344
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +7 -4
- package/dist/engine/index.cjs +2366 -1002
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +6 -6
- package/dist/engine/index.d.ts +6 -6
- package/dist/engine/index.js +7 -6
- package/dist/{index-CUomTA3e.d.ts → index-CAKOgAlP.d.ts} +171 -296
- package/dist/index-CgE4sVhg.d.cts +4864 -0
- package/dist/{index-ClpDeSos.d.cts → index-H-0qv5d4.d.cts} +171 -296
- package/dist/index-V7QRh1wg.d.ts +4864 -0
- package/dist/index.cjs +2720 -1169
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +165 -19
- package/dist/index.d.ts +165 -19
- package/dist/index.js +738 -477
- package/dist/index.js.map +1 -1
- package/dist/module-Dl1KFVtc.d.cts +54 -0
- package/dist/module-Dl1KFVtc.d.ts +54 -0
- package/dist/openapi/index.cjs +1330 -484
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +4 -2
- package/dist/openapi/index.d.ts +4 -2
- package/dist/openapi/index.js +1264 -474
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-dZZ5JKYg.d.ts → registry-4EI8aaFs.d.ts} +1 -1
- package/dist/{registry-CC0WBQq6.d.cts → registry-DHsPDY0_.d.cts} +1 -1
- package/dist/stack.cjs.map +1 -1
- package/dist/test/index.cjs +732 -141
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +30 -4
- package/dist/test/index.d.ts +30 -4
- package/dist/test/index.js +490 -124
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +33 -18
- package/docs/auth.md +1 -1
- package/docs/background.md +2 -2
- package/docs/database.md +255 -50
- package/docs/endpoints.md +3 -4
- package/docs/events.md +3 -3
- package/docs/getting-started.md +1 -1
- package/docs/llms-full.txt +435 -117
- package/docs/migrations.md +2 -2
- package/docs/schema.md +19 -10
- package/docs/services.md +116 -26
- package/package.json +8 -4
- package/stager/generics.js +205 -0
- package/stager/stage.js +39 -3
- package/template/AGENTS.md +110 -72
- package/template/db/public.ts +1 -1
- package/template/{controllers → modules/health}/health.controller.ts +1 -1
- package/template/modules/health/health.module.ts +24 -0
- package/template/modules/notes/note.service.test.ts +49 -0
- package/template/modules/notes/note.service.ts +108 -0
- package/template/{controllers → modules/notes}/notes.controller.ts +17 -11
- package/template/modules/notes/notes.module.ts +37 -0
- package/template/package.json +5 -3
- package/template/scripts/test.sh +33 -0
- package/template/tsconfig.json +29 -30
- package/dist/chunk-35PNTIRN.js.map +0 -1
- package/dist/chunk-CJSKYY76.js +0 -627
- package/dist/chunk-CJSKYY76.js.map +0 -1
- package/dist/chunk-CRQKCRGF.js +0 -276
- package/dist/chunk-CRQKCRGF.js.map +0 -1
- package/dist/chunk-G4R6BTLV.js +0 -662
- package/dist/chunk-G4R6BTLV.js.map +0 -1
- package/dist/chunk-VDF2T4AS.js.map +0 -1
- package/dist/chunk-XABBC7JP.js +0 -55
- package/dist/chunk-XABBC7JP.js.map +0 -1
- package/dist/endpoint-CTEHhb7A.d.ts +0 -2386
- package/dist/endpoint-DYHMo6cC.d.cts +0 -2386
- package/dist/index-CW21M9Z3.d.ts +0 -1222
- package/dist/index-CmBK76nx.d.cts +0 -1222
- package/template/services/note.service.test.ts +0 -45
- package/template/services/note.service.ts +0 -76
- /package/dist/{chunk-7D4SUZUM.js.map → chunk-VXPNPVAG.js.map} +0 -0
- /package/template/{models/notes → modules/notes/dto}/create.ts +0 -0
package/template/AGENTS.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Palbase backend project
|
|
2
2
|
|
|
3
|
-
You write TypeScript
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
You write TypeScript classes and ONE module lists them; the runtime serves them
|
|
4
|
+
as a typed HTTP API and generates the client SDKs your apps call. There is no
|
|
5
|
+
router to register with — a `@Module` says what exists, who owns it and what it
|
|
6
|
+
may reach, and a class no module lists does not exist.
|
|
6
7
|
|
|
7
8
|
Everything comes from one package:
|
|
8
9
|
|
|
@@ -10,21 +11,22 @@ Everything comes from one package:
|
|
|
10
11
|
import { Controller, Get, Post, Body, Param, User, z, Database, NotFound } from "@palbase/backend";
|
|
11
12
|
```
|
|
12
13
|
|
|
13
|
-
> Needs `experimentalDecorators: true`
|
|
14
|
-
> `tsconfig.json`.
|
|
14
|
+
> Needs `experimentalDecorators: true` and `emitDecoratorMetadata: true` — both
|
|
15
|
+
> already set in this scaffold's `tsconfig.json`. The container reads a
|
|
16
|
+
> constructor's parameter TYPES out of that metadata; without it every injected
|
|
17
|
+
> field would arrive `undefined`, and the build says so by name.
|
|
15
18
|
|
|
16
|
-
**The full reference** — every decorator, every service
|
|
17
|
-
helper — is at <https://app.dev.palbase.studio/llms.txt> (
|
|
18
|
-
|
|
19
|
-
should LOOK like.
|
|
19
|
+
**The full reference** — every decorator, every platform service, every schema
|
|
20
|
+
helper — is at <https://app.dev.palbase.studio/llms.txt> (`/llms-full.txt` for
|
|
21
|
+
agents). This file does not repeat it; it says what the code should LOOK like.
|
|
20
22
|
|
|
21
23
|
## The layers
|
|
22
24
|
|
|
23
25
|
| Layer | DOES | NEVER |
|
|
24
26
|
|---|---|---|
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
27
|
+
| `modules/<domain>/<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 |
|
|
28
|
+
| `modules/<domain>/<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 |
|
|
29
|
+
| `modules/<domain>/dto/<endpoint>.ts` | zod schemas, exported twice under one name: a VALUE and a same-named `z.infer` TYPE | holds logic |
|
|
28
30
|
| `db/public.ts` | IS the database: tables, columns, RLS policies | produces migration files — there are none |
|
|
29
31
|
|
|
30
32
|
In one line: **controllers thin, services thick, models quiet, schema the single
|
|
@@ -37,11 +39,11 @@ shape you find there is the shape to copy.
|
|
|
37
39
|
|
|
38
40
|
| The need | Its home |
|
|
39
41
|
|---|---|
|
|
40
|
-
| Validate input | a zod schema in `
|
|
42
|
+
| Validate input | a zod schema in the module's `dto/`, passed to `@Body(Schema)` |
|
|
41
43
|
| 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
44
|
| A business rule, a calculation, an ordering | the service |
|
|
43
45
|
| Return an HTTP status | `throw new NotFound(…)` in the controller (or the service — no request object needed) |
|
|
44
|
-
| Call another feature |
|
|
46
|
+
| Call another feature | name its class in your constructor — its module must `exports` it and yours must `imports` that module |
|
|
45
47
|
| Scheduled or background work | a `@Job` class in `jobs/` — **there is no queue** |
|
|
46
48
|
| Read a setting or a secret | `Secrets.get(name)` / `Flags.isEnabled(name)` — the names are checked by the compiler |
|
|
47
49
|
| Share code between controllers | a plain module they import; not a base class, not middleware |
|
|
@@ -51,37 +53,45 @@ shape you find there is the shape to copy.
|
|
|
51
53
|
These are what a language model reaches for by default. Every one of them is
|
|
52
54
|
wrong in this runtime:
|
|
53
55
|
|
|
54
|
-
- **Express-style handlers** — `app.get(...)`, `(req, res) => …`, `req.params`,
|
|
55
|
-
|
|
56
|
-
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
`
|
|
63
|
-
|
|
56
|
+
- **Express-style handlers** — `app.get(...)`, `(req, res) => …`, `req.params`, `req.body`. A route is a **method on a class**.
|
|
57
|
+
- **A `ctx` object** — there is no context to thread and no handler receives one.
|
|
58
|
+
- **`@Inject`, `useFactory`, `forwardRef`, or a token registry** — a dependency
|
|
59
|
+
is named by its constructor parameter's TYPE and by nothing else. None of them
|
|
60
|
+
exists here and none is needed: a real cycle cannot be built (the import dies
|
|
61
|
+
first), so there is nothing for `forwardRef` to rescue. Mark the class
|
|
62
|
+
`@Injectable()`, name what it needs, list it in a module's `providers`. An
|
|
63
|
+
`interface` cannot be a dependency (no runtime existence) — use a class or an
|
|
64
|
+
`abstract class`.
|
|
65
|
+
- **A class that no module lists** — refused at build, by name; it never reaches
|
|
66
|
+
the route table, the dispatcher or the OpenAPI document. Being in
|
|
67
|
+
a folder grants nothing; being in a module's list is what does.
|
|
68
|
+
- **I/O in a constructor** — it runs while the app comes up, is synchronous, and
|
|
69
|
+
stays pure wiring. Startup work goes in `onStart`, awaited before request one.
|
|
64
70
|
- **`middleware/`** — nothing mounts it and the engine has no middleware pipeline.
|
|
65
71
|
Code written against it deploys, never runs, and nothing reports it. Put
|
|
66
72
|
cross-cutting work in a service; use route options for auth and rate limits.
|
|
67
|
-
- **A
|
|
68
|
-
|
|
73
|
+
- **A DAO layer over the whole database**, or a generic `Repository<T>` — the
|
|
74
|
+
first re-exposes what `Database.public.<name>` already types; the second is
|
|
75
|
+
refused outright, because a generic has no runtime type to read. The scaffold's
|
|
76
|
+
seam is narrower: one `abstract class` naming ONE table's methods, and one
|
|
77
|
+
`@Injectable()` class that `extends` it.
|
|
69
78
|
- **A migration file** — there are none anywhere in this product. `db/public.ts`
|
|
70
79
|
is diffed against the live database and applied.
|
|
71
|
-
- **`process.env`** for configuration — a secret
|
|
72
|
-
|
|
80
|
+
- **`process.env`** for configuration — read a secret with `Secrets.get()`; its
|
|
81
|
+
value never enters this repository.
|
|
73
82
|
- **`try { … } catch { return 500 }`** — throw the error class and let the runtime
|
|
74
83
|
build the envelope. Swallowing it turns a 404 into a 500.
|
|
75
|
-
- **Hand-built responses** — no `JSON.stringify`, no `new Response(...)
|
|
84
|
+
- **Hand-built responses** — no `JSON.stringify`, no `new Response(...)`; the 200
|
|
76
85
|
body IS the schema your return type names.
|
|
77
86
|
- **An inline return type** — `Promise<{ ok: boolean }>`, a union, or a bare
|
|
78
|
-
`interface`. The deploy REFUSES it
|
|
87
|
+
`interface`. The deploy REFUSES it; name a zod schema.
|
|
79
88
|
|
|
80
|
-
**A controller
|
|
81
|
-
it
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
file
|
|
89
|
+
**A controller is exported by NAME, and no file is default-exported.** Its module
|
|
90
|
+
imports it (`import { NotesController } from "./notes.controller"`) to
|
|
91
|
+
list it in `controllers` — that list is the registration, and a class no list
|
|
92
|
+
names is refused at build. (`export default` **is** required for `jobs/`,
|
|
93
|
+
`webhooks/`, `hooks/` and `db/public.ts`, one class per file — those are read off
|
|
94
|
+
disk by name, not through a module.)
|
|
85
95
|
|
|
86
96
|
**Class and method names are your public API.** `NotesController.list` generates
|
|
87
97
|
`pb.notes.list()`. Renaming either renames the call in every app; the verb and the
|
|
@@ -92,63 +102,92 @@ path do not affect it, so restructure paths freely and rename these two carefull
|
|
|
92
102
|
1. `db/public.ts` — declare the table and its RLS policies.
|
|
93
103
|
2. `palbase db plan` — read what would change.
|
|
94
104
|
3. `palbase db apply` — apply it in one transaction.
|
|
95
|
-
4. `
|
|
96
|
-
5. `
|
|
97
|
-
constructor
|
|
98
|
-
6. `
|
|
99
|
-
`Database`.
|
|
100
|
-
7. `
|
|
105
|
+
4. `modules/<domain>/dto/<endpoint>.ts` — the schema, as a value and a type.
|
|
106
|
+
5. `modules/<domain>/<name>.service.ts` — the logic; `@Injectable()`, and name what it
|
|
107
|
+
needs as constructor parameters. Nothing is wired by hand.
|
|
108
|
+
6. `modules/<domain>/<name>.controller.ts` — the routes; name the service in the
|
|
109
|
+
constructor, never import `Database`.
|
|
110
|
+
7. `modules/<domain>/<domain>.module.ts` — list them. A new domain is a FOLDER.
|
|
111
|
+
Until a module names it, the class does not exist.
|
|
112
|
+
8. `palbase build` — regenerates `palbase-env.d.ts` and `palbase-stack.d.ts`.
|
|
101
113
|
Commit both.
|
|
102
|
-
|
|
114
|
+
9. `npm test` — exercise the service.
|
|
103
115
|
|
|
104
116
|
## The service shape, exactly
|
|
105
117
|
|
|
106
118
|
```ts
|
|
107
|
-
//
|
|
108
|
-
import { Database } from "@palbase/backend";
|
|
119
|
+
// modules/notes/note.service.ts
|
|
120
|
+
import { Database, Injectable } from "@palbase/backend";
|
|
121
|
+
|
|
122
|
+
// `Database.public.notes` is a VALUE, so it cannot BE a dependency — a
|
|
123
|
+
// dependency is named by its parameter's TYPE. Naming one table behind an
|
|
124
|
+
// `abstract class` is what gives the service a seam, and keeps that seam one
|
|
125
|
+
// table wide.
|
|
126
|
+
export abstract class NoteRepo {
|
|
127
|
+
abstract findMany(where: { user_id: string }): Promise<Note[]>;
|
|
128
|
+
}
|
|
109
129
|
|
|
110
|
-
|
|
130
|
+
@Injectable()
|
|
131
|
+
export class DbNoteRepo extends NoteRepo {
|
|
132
|
+
findMany(where: { user_id: string }) {
|
|
133
|
+
return Database.public.notes.findMany(where);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
111
136
|
|
|
137
|
+
@Injectable()
|
|
112
138
|
export class NoteService {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// Assigned in the BODY. A parameter property — `constructor(private notes: …)`
|
|
116
|
-
// — is refused by Node's type-stripping test runner, and refused for the whole
|
|
117
|
-
// FILE, so one of them anywhere in a test's import graph reads like a dozen
|
|
118
|
-
// broken tests and is one keyword.
|
|
119
|
-
constructor(notes: NotesTable) {
|
|
120
|
-
this.notes = notes;
|
|
121
|
-
}
|
|
139
|
+
// The container supplies this. Nothing registers it and nothing wires it.
|
|
140
|
+
constructor(private readonly notes: NoteRepo) {}
|
|
122
141
|
|
|
123
142
|
list(userId: string) {
|
|
124
143
|
return this.notes.findMany({ user_id: userId });
|
|
125
144
|
}
|
|
126
145
|
}
|
|
146
|
+
```
|
|
127
147
|
|
|
128
|
-
|
|
129
|
-
|
|
148
|
+
```ts
|
|
149
|
+
// notes.module.ts — named after the DOMAIN. There is no AppModule.
|
|
150
|
+
@Module({
|
|
151
|
+
controllers: [NotesController],
|
|
152
|
+
providers: [NoteService, DbNoteRepo],
|
|
153
|
+
exports: [], // nothing outside may reach these until a name is added here
|
|
154
|
+
imports: [], // whose exports THIS module may reach
|
|
155
|
+
})
|
|
156
|
+
export class NotesModule {}
|
|
130
157
|
```
|
|
131
158
|
|
|
132
|
-
The constructor is the seam
|
|
133
|
-
|
|
134
|
-
|
|
159
|
+
The constructor is the seam: a test hands in a stand-in and never needs a
|
|
160
|
+
database. `modules/notes/note.service.test.ts` is that test — read it first.
|
|
161
|
+
|
|
162
|
+
The abstraction is the TOKEN, the implementation is the PROVIDER: `DbNoteRepo` is
|
|
163
|
+
listed, `NoteRepo` is not. The container finds the single class that `extends` it
|
|
164
|
+
— zero or two, and the build refuses by name. Listing the `abstract class` itself
|
|
165
|
+
is refused too: `new NoteRepo()` succeeds and returns an object missing every
|
|
166
|
+
abstract member.
|
|
135
167
|
|
|
136
168
|
## Tests
|
|
137
169
|
|
|
138
|
-
`npm test` runs
|
|
139
|
-
|
|
140
|
-
|
|
170
|
+
`npm test` runs `bun test` through `scripts/test.sh`. The wrapper reads the
|
|
171
|
+
SUMMARY, not just the exit code: `bun test` exits **0** and prints nothing when
|
|
172
|
+
something calls `process.exit()` mid-suite, and a run that discovered zero tests
|
|
173
|
+
also exits 0. Either one exits **90** instead. Do not call `bun test` directly.
|
|
141
174
|
|
|
142
175
|
Test the **service** layer. Controllers are thin by construction, so what can be
|
|
143
|
-
wrong lives in a service
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
176
|
+
wrong lives in a service, and you reach it through the container:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { isolated } from "@palbase/backend/test";
|
|
180
|
+
const svc = isolated().with(NoteRepo, new FakeRepo()).get(NoteService);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`isolated()` rebuilds the graph with the override in place. The substitution is
|
|
184
|
+
DEEP — it works the same two hops below the class under test — and touches no
|
|
185
|
+
process-wide state. For a stand-in covering the whole database rather than one
|
|
186
|
+
table, use `fakeDatabase()` from `@palbase/backend/test`; it answers from memory
|
|
187
|
+
and enforces no RLS, constraints or unique violations.
|
|
147
188
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
imports inside a test need the extension (`./note.service.ts`); this scaffold's
|
|
151
|
-
`tsconfig.json` allows it.
|
|
189
|
+
Relative imports inside a test need the extension (`./note.service.ts`); this
|
|
190
|
+
scaffold's `tsconfig.json` allows it.
|
|
152
191
|
|
|
153
192
|
`npm test` answers "is the logic right". It is not the deploy's own validation —
|
|
154
193
|
decorators, return types, SDK major — which answers "would this ship".
|
|
@@ -156,5 +195,4 @@ decorators, return types, SDK major — which answers "would this ship".
|
|
|
156
195
|
## Commands
|
|
157
196
|
|
|
158
197
|
`palbase --help` is authoritative; a copy of the command surface inside this
|
|
159
|
-
repository goes stale silently.
|
|
160
|
-
<https://app.dev.palbase.studio/llms.txt>.
|
|
198
|
+
repository goes stale silently.
|
package/template/db/public.ts
CHANGED
|
@@ -22,7 +22,7 @@ const notes = defineTable("notes", {
|
|
|
22
22
|
body: text().notNull(),
|
|
23
23
|
created_at: timestamp().defaultNow(),
|
|
24
24
|
},
|
|
25
|
-
policies: [
|
|
25
|
+
policies: () => [
|
|
26
26
|
// Postgres enforces ownership, not the handler: a query that forgets
|
|
27
27
|
// its `where user_id = …` still cannot see another user's rows.
|
|
28
28
|
policy("notes_owner")
|
|
@@ -22,7 +22,7 @@ export type HealthResponse = z.infer<typeof HealthResponse>;
|
|
|
22
22
|
// Auth is required unless a route opts out. A health check is the rare route
|
|
23
23
|
// that genuinely has no caller identity, hence `auth: false`.
|
|
24
24
|
@Controller("/health", { auth: false })
|
|
25
|
-
class HealthController {
|
|
25
|
+
export class HealthController {
|
|
26
26
|
@Get("")
|
|
27
27
|
check(): HealthResponse {
|
|
28
28
|
return { status: "ok" };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Module, type Token } from "@palbase/backend";
|
|
2
|
+
|
|
3
|
+
import { HealthController } from "./health.controller.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "Is this process answering?" — a domain like any other.
|
|
7
|
+
*
|
|
8
|
+
* There is NO root module in this system, and that is why you will not find an
|
|
9
|
+
* `app.module.ts` here. Nest has one because Nest mounts a tree: `AppModule`
|
|
10
|
+
* imports every feature module and the app is what that file lists. Here each
|
|
11
|
+
* `*.module.ts` is found wherever it lives, so a file named after the app would
|
|
12
|
+
* root nothing, list nothing, and teach a shape the runtime does not have.
|
|
13
|
+
*
|
|
14
|
+
* The rule has no exceptions, which is the point: EVERY domain is a folder under
|
|
15
|
+
* `modules/`, including this one. Adding a domain is adding a folder — never a
|
|
16
|
+
* line in a file every branch edits.
|
|
17
|
+
*/
|
|
18
|
+
@Module({
|
|
19
|
+
controllers: [HealthController as Token],
|
|
20
|
+
providers: [],
|
|
21
|
+
exports: [],
|
|
22
|
+
imports: [],
|
|
23
|
+
})
|
|
24
|
+
export class HealthModule {}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { isolated } from "@palbase/backend/test";
|
|
3
|
+
|
|
4
|
+
import { NoteRepo, NoteService, type Note } from "./note.service.ts";
|
|
5
|
+
|
|
6
|
+
// WHY THIS TEST NEEDS NO DATABASE
|
|
7
|
+
//
|
|
8
|
+
// `NoteService` asks for a `NoteRepo` and the container supplies it. A test
|
|
9
|
+
// supplies a different one — `isolated()` rebuilds the graph with the override
|
|
10
|
+
// in place and touches no process-wide state, so the next test in this file does
|
|
11
|
+
// not meet whatever this one substituted.
|
|
12
|
+
//
|
|
13
|
+
// The substitution is DEEP: it would work the same if `NoteRepo` were two hops
|
|
14
|
+
// below the class under test. That is what makes the constructor worth using as
|
|
15
|
+
// the seam.
|
|
16
|
+
//
|
|
17
|
+
// Test your own services the same way.
|
|
18
|
+
|
|
19
|
+
class FakeRepo extends NoteRepo {
|
|
20
|
+
readonly seen: unknown[] = [];
|
|
21
|
+
async findMany(where: { user_id: string }): Promise<Note[]> {
|
|
22
|
+
this.seen.push(where);
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
async insert(row: { user_id: string; body: string }): Promise<Note> {
|
|
26
|
+
return { id: "n_1", user_id: row.user_id, body: row.body } as Note;
|
|
27
|
+
}
|
|
28
|
+
async findById(): Promise<Note | null> {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
async delete(): Promise<void> {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("NoteService", () => {
|
|
35
|
+
it("asks only for the caller's notes", async () => {
|
|
36
|
+
const repo = new FakeRepo();
|
|
37
|
+
const svc = isolated().with(NoteRepo, repo).get(NoteService);
|
|
38
|
+
|
|
39
|
+
await svc.list("u_1");
|
|
40
|
+
|
|
41
|
+
expect(repo.seen).toEqual([{ user_id: "u_1" }]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("writes ownership from the CALLER, never from the body", async () => {
|
|
45
|
+
const svc = isolated().with(NoteRepo, new FakeRepo()).get(NoteService);
|
|
46
|
+
const note = await svc.create("u_1", "hello");
|
|
47
|
+
expect(note.user_id).toBe("u_1");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Database, Injectable } from "@palbase/backend";
|
|
2
|
+
import type { Tables } from "@palbase/backend/env";
|
|
3
|
+
|
|
4
|
+
// A SERVICE is where the logic lives, and `@Injectable()` is the whole
|
|
5
|
+
// declaration: it says this class can be resolved. Nothing registers it and
|
|
6
|
+
// nothing wires it — a module lists it, and the container builds it.
|
|
7
|
+
//
|
|
8
|
+
// THE SEAM IS THE CONSTRUCTOR. A class names what it needs as ordinary
|
|
9
|
+
// constructor parameters and the container supplies them, so a test can put a
|
|
10
|
+
// stand-in in place of any of them, however deep:
|
|
11
|
+
//
|
|
12
|
+
// import { isolated } from "@palbase/backend/test";
|
|
13
|
+
// const svc = isolated().with(NoteRepo, fakeRepo).get(NoteService);
|
|
14
|
+
//
|
|
15
|
+
// The constructor stays PURE WIRING: it runs while the app is coming up, it is
|
|
16
|
+
// synchronous, and it does no I/O. Real work at startup goes in `onStart`,
|
|
17
|
+
// which is awaited before the first request — a connection opened here would
|
|
18
|
+
// fail somewhere nobody is watching.
|
|
19
|
+
//
|
|
20
|
+
// A dependency is named by its parameter's TYPE and by nothing else. There is
|
|
21
|
+
// no `inject()`, no `@Inject`, no token registry — and an `interface` cannot be
|
|
22
|
+
// one, because it does not exist at runtime. Use a class, or an `abstract
|
|
23
|
+
// class` when you want the abstraction.
|
|
24
|
+
//
|
|
25
|
+
// `Database.public.notes` is typed from `db/public.ts` through the generated
|
|
26
|
+
// `palbase-env.d.ts`, which `palbase build` writes. Before the first build that
|
|
27
|
+
// file does not exist yet and the table is unknown to the type checker; build
|
|
28
|
+
// once and the whole surface below is typed with no import and no generic.
|
|
29
|
+
|
|
30
|
+
/** One row of `notes`, exactly as `db/public.ts` declares it. */
|
|
31
|
+
export type Note = Tables["notes"]["row"];
|
|
32
|
+
|
|
33
|
+
/** The typed surface of one table: `insert`, `update`, `delete`, `findById`,
|
|
34
|
+
* `findMany`. Naming it here is what keeps the seam ONE table wide — a test
|
|
35
|
+
* fake implements five methods, not the whole `Database`. */
|
|
36
|
+
type NotesTable = typeof Database.public.notes;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The one table this vertical touches, behind a class the container can build.
|
|
40
|
+
*
|
|
41
|
+
* `Database.public.notes` is a VALUE, not a class, so it cannot be a dependency
|
|
42
|
+
* on its own — a dependency is named by its parameter's TYPE. Naming the table
|
|
43
|
+
* here keeps the seam ONE table wide: a test stands in for five methods rather
|
|
44
|
+
* than for the whole `Database`.
|
|
45
|
+
*
|
|
46
|
+
* It is `abstract` so a test can substitute it by NAME. The container resolves
|
|
47
|
+
* an abstraction to the single class that extends it, and `DbNoteRepo` below is
|
|
48
|
+
* that class — declared by `extends`, not by a second syntax.
|
|
49
|
+
*/
|
|
50
|
+
export abstract class NoteRepo {
|
|
51
|
+
abstract findMany(where: { user_id: string }): Promise<Note[]>;
|
|
52
|
+
abstract insert(row: { user_id: string; body: string }): Promise<Note>;
|
|
53
|
+
abstract findById(id: string): Promise<Note | null>;
|
|
54
|
+
abstract delete(id: string): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Injectable()
|
|
58
|
+
export class DbNoteRepo extends NoteRepo {
|
|
59
|
+
private readonly notes: NotesTable = Database.public.notes;
|
|
60
|
+
|
|
61
|
+
findMany(where: { user_id: string }): Promise<Note[]> {
|
|
62
|
+
// `where` is a NAMED field, not the whole argument: the same object also
|
|
63
|
+
// carries `orderBy`, `limit` and the operators, so the filter has to say
|
|
64
|
+
// which of them it is.
|
|
65
|
+
return this.notes.findMany({ where });
|
|
66
|
+
}
|
|
67
|
+
insert(row: { user_id: string; body: string }): Promise<Note> {
|
|
68
|
+
return this.notes.insert(row);
|
|
69
|
+
}
|
|
70
|
+
findById(id: string): Promise<Note | null> {
|
|
71
|
+
return this.notes.findById(id);
|
|
72
|
+
}
|
|
73
|
+
delete(id: string): Promise<void> {
|
|
74
|
+
return this.notes.delete(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@Injectable()
|
|
79
|
+
export class NoteService {
|
|
80
|
+
// The dependency arrives through the constructor and the container supplies
|
|
81
|
+
// it. Nothing here registers anything: `notes.module.ts` lists this class,
|
|
82
|
+
// and that list is the only place ownership is decided.
|
|
83
|
+
constructor(private readonly notes: NoteRepo) {}
|
|
84
|
+
|
|
85
|
+
/** The caller's notes. The filter is written out even though the table's RLS
|
|
86
|
+
* policy already scopes the read to `auth.uid()`: the policy is the backstop
|
|
87
|
+
* that holds when a query forgets, not a reason to stop writing the query. */
|
|
88
|
+
list(userId: string): Promise<Note[]> {
|
|
89
|
+
return this.notes.findMany({ user_id: userId });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `user_id` is `notNull` with no default, so ownership is written here —
|
|
93
|
+
* it is not something the request body may carry. */
|
|
94
|
+
create(userId: string, body: string): Promise<Note> {
|
|
95
|
+
return this.notes.insert({ user_id: userId, body });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** No `userId` argument, and that is not an oversight: another user's note is
|
|
99
|
+
* invisible to this read, so it comes back `null` exactly as a missing id
|
|
100
|
+
* does. Postgres enforces it, not this method. */
|
|
101
|
+
get(id: string): Promise<Note | null> {
|
|
102
|
+
return this.notes.findById(id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
remove(id: string): Promise<void> {
|
|
106
|
+
return this.notes.delete(id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
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 "
|
|
5
|
-
import {
|
|
4
|
+
import { CreateNoteBody, NoteSchema } from "./dto/create";
|
|
5
|
+
import { NoteService } from "./note.service";
|
|
6
6
|
|
|
7
7
|
// The other half of the vertical that starts in `services/note.service.ts`.
|
|
8
8
|
//
|
|
@@ -16,10 +16,14 @@ import { noteService } from "../services/note.service";
|
|
|
16
16
|
// controller does not import `Database`. If a method here starts reaching for a
|
|
17
17
|
// table, the logic has leaked into the layer that is hardest to test.
|
|
18
18
|
//
|
|
19
|
-
// The dependency
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
19
|
+
// The dependency arrives through the CONSTRUCTOR and the container supplies it.
|
|
20
|
+
// Nothing is wired by hand and nothing is imported as an instance: this class
|
|
21
|
+
// names `NoteService` as a parameter, `notes.module.ts` lists both, and the
|
|
22
|
+
// container builds the graph once at boot.
|
|
23
|
+
//
|
|
24
|
+
// A class no module lists does not exist — it is refused at build, by name, and
|
|
25
|
+
// never reaches the route table or the OpenAPI document. Being in `controllers/`
|
|
26
|
+
// grants nothing; being in a module's list is what does.
|
|
23
27
|
|
|
24
28
|
// The schemas are in `models/notes/create.ts`. A controller with one endpoint
|
|
25
29
|
// can keep them beside the routes; the moment there are two, they go to
|
|
@@ -29,15 +33,17 @@ import { noteService } from "../services/note.service";
|
|
|
29
33
|
// Auth is required unless a route opts out, so `@User()` is non-null here and
|
|
30
34
|
// every method below acts as somebody.
|
|
31
35
|
@Controller("/notes")
|
|
32
|
-
class NotesController {
|
|
36
|
+
export class NotesController {
|
|
37
|
+
constructor(private readonly notes: NoteService) {}
|
|
38
|
+
|
|
33
39
|
@Get("")
|
|
34
40
|
list(@User() user: UserT): Promise<NoteSchema[]> {
|
|
35
|
-
return
|
|
41
|
+
return this.notes.list(user.id);
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
@Post("")
|
|
39
45
|
create(@Body(CreateNoteBody) body: CreateNoteBody, @User() user: UserT): Promise<NoteSchema> {
|
|
40
|
-
return
|
|
46
|
+
return this.notes.create(user.id, body.body);
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
@Get("/{id}")
|
|
@@ -45,13 +51,13 @@ class NotesController {
|
|
|
45
51
|
// `null` covers both "no such note" and "not yours" — the second is not
|
|
46
52
|
// distinguishable from the outside on purpose, because telling a caller
|
|
47
53
|
// that a row they may not read exists is itself a leak.
|
|
48
|
-
const note = await
|
|
54
|
+
const note = await this.notes.get(id);
|
|
49
55
|
if (!note) throw new NotFound("no note with that id");
|
|
50
56
|
return note;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
@Delete("/{id}")
|
|
54
60
|
remove(@Param("id") id: string): Promise<void> {
|
|
55
|
-
return
|
|
61
|
+
return this.notes.remove(id);
|
|
56
62
|
}
|
|
57
63
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Module, type Token } from "@palbase/backend";
|
|
2
|
+
|
|
3
|
+
import { NotesController } from "./notes.controller.ts";
|
|
4
|
+
import { DbNoteRepo, NoteService } from "./note.service.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ONE module says what exists, who owns it, and what it may reach.
|
|
8
|
+
*
|
|
9
|
+
* Named after the DOMAIN and living WITH it: `modules/notes/` holds the module,
|
|
10
|
+
* the controller, the service and the schemas that belong to notes, and nothing
|
|
11
|
+
* else. A second domain is a second folder — never a second entry in a file
|
|
12
|
+
* everybody edits, which is the merge conflict a module system exists to avoid.
|
|
13
|
+
*
|
|
14
|
+
* There is no root module and nothing to mount one into — the health probe is
|
|
15
|
+
* `modules/health/`, a domain like this one. The rule has no exceptions, which
|
|
16
|
+
* is what makes "add a domain" mean "add a folder".
|
|
17
|
+
*
|
|
18
|
+
* The four lists answer four different questions, and nothing else answers them:
|
|
19
|
+
*
|
|
20
|
+
* providers which classes does this module OWN
|
|
21
|
+
* controllers which entry points does it own
|
|
22
|
+
* exports which of its own classes may ANOTHER module reach
|
|
23
|
+
* imports whose exports may THIS module reach
|
|
24
|
+
*
|
|
25
|
+
* A class listed nowhere does not exist: the build refuses it by name, and it
|
|
26
|
+
* never reaches the route table, the dispatcher or the OpenAPI document.
|
|
27
|
+
*/
|
|
28
|
+
@Module({
|
|
29
|
+
controllers: [NotesController as Token],
|
|
30
|
+
providers: [NoteService as Token, DbNoteRepo as Token],
|
|
31
|
+
// Nothing is exported yet, so nothing outside this module can depend on its
|
|
32
|
+
// classes. Add a name here when another module genuinely needs it — and say
|
|
33
|
+
// why in the commit, because an export is a promise.
|
|
34
|
+
exports: [],
|
|
35
|
+
imports: [],
|
|
36
|
+
})
|
|
37
|
+
export class NotesModule {}
|
package/template/package.json
CHANGED
|
@@ -5,17 +5,19 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A Palbase backend — class controllers, a declared database, and the secrets it needs.",
|
|
7
7
|
"scripts": {
|
|
8
|
-
"test": "
|
|
8
|
+
"test": "./scripts/test.sh",
|
|
9
9
|
"typecheck": "tsc --noEmit"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@palbase/backend": "^
|
|
12
|
+
"@palbase/backend": "^27.0.0",
|
|
13
|
+
"reflect-metadata": "^0.2.2"
|
|
13
14
|
},
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=22.18.0"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
18
19
|
"@types/node": "^22",
|
|
19
|
-
"typescript": "^5"
|
|
20
|
+
"typescript": "^5",
|
|
21
|
+
"bun-types": "^1.3.0"
|
|
20
22
|
}
|
|
21
23
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# `bun test` EXIT CODE IS NOT ENOUGH.
|
|
4
|
+
#
|
|
5
|
+
# Measured on macOS with bun 1.3.9: a suite that calls `process.exit(0)` part way
|
|
6
|
+
# through — which anything in the import graph can do — makes `bun test` exit 0
|
|
7
|
+
# and print NO SUMMARY AT ALL. A CI step reading only the exit code calls that a
|
|
8
|
+
# pass, and the tests that never ran are the ones you needed.
|
|
9
|
+
#
|
|
10
|
+
# So the summary is read, and a run that discovered zero tests is a failure too:
|
|
11
|
+
# a filter that matches nothing reports silence, and silence is not a pass.
|
|
12
|
+
set -uo pipefail
|
|
13
|
+
|
|
14
|
+
out="$(bun test "$@" 2>&1)"
|
|
15
|
+
code=$?
|
|
16
|
+
printf '%s\n' "$out"
|
|
17
|
+
|
|
18
|
+
if [ "$code" -ne 0 ]; then
|
|
19
|
+
exit "$code"
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
# `Ran 1 test` is singular — a pattern that demands `tests` fails a healthy
|
|
23
|
+
# one-test suite, and a gate that gives a false positive is not a gate.
|
|
24
|
+
if ! printf '%s' "$out" | grep -qE 'Ran [0-9]+ tests?'; then
|
|
25
|
+
echo "test.sh: bun test exited 0 but printed no summary — the run did not finish." >&2
|
|
26
|
+
echo " Something called process.exit() mid-suite; the tests after it never ran." >&2
|
|
27
|
+
exit 90
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
if printf '%s' "$out" | grep -qE 'Ran 0 tests?'; then
|
|
31
|
+
echo "test.sh: zero tests ran. A filter that matches nothing reports silence." >&2
|
|
32
|
+
exit 90
|
|
33
|
+
fi
|