@palbase/backend 25.0.4 → 27.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 (104) hide show
  1. package/dist/bin/palbase-backend.cjs +2622 -1012
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +87 -51
  4. package/dist/bin/palbase-backend.js.map +1 -1
  5. package/dist/chunk-CGNN2PUH.js +213 -0
  6. package/dist/chunk-CGNN2PUH.js.map +1 -0
  7. package/dist/chunk-DRZFQRJI.js +370 -0
  8. package/dist/chunk-DRZFQRJI.js.map +1 -0
  9. package/dist/chunk-GYK6QYS4.js +708 -0
  10. package/dist/chunk-GYK6QYS4.js.map +1 -0
  11. package/dist/chunk-I3C4PFIW.js +930 -0
  12. package/dist/chunk-I3C4PFIW.js.map +1 -0
  13. package/dist/{chunk-AILPKEK5.js → chunk-OO7R25AI.js} +1433 -563
  14. package/dist/chunk-OO7R25AI.js.map +1 -0
  15. package/dist/chunk-TS4U7NBD.js +885 -0
  16. package/dist/chunk-TS4U7NBD.js.map +1 -0
  17. package/dist/{chunk-YOY5DFQS.js → chunk-TWX6JTGJ.js} +76 -34
  18. package/dist/{chunk-YOY5DFQS.js.map → chunk-TWX6JTGJ.js.map} +1 -1
  19. package/dist/{chunk-35PNTIRN.js → chunk-VVMJEVQP.js} +63 -162
  20. package/dist/chunk-VVMJEVQP.js.map +1 -0
  21. package/dist/{chunk-7D4SUZUM.js → chunk-VXPNPVAG.js} +3 -1
  22. package/dist/db/env.cjs.map +1 -1
  23. package/dist/db/env.d.cts +2 -2
  24. package/dist/db/env.d.ts +2 -2
  25. package/dist/db/index.cjs +754 -344
  26. package/dist/db/index.cjs.map +1 -1
  27. package/dist/db/index.d.cts +2 -2
  28. package/dist/db/index.d.ts +2 -2
  29. package/dist/db/index.js +7 -4
  30. package/dist/engine/index.cjs +2562 -981
  31. package/dist/engine/index.cjs.map +1 -1
  32. package/dist/engine/index.d.cts +6 -6
  33. package/dist/engine/index.d.ts +6 -6
  34. package/dist/engine/index.js +7 -6
  35. package/dist/index-BrvvxSpn.d.ts +4844 -0
  36. package/dist/index-Bve7BBTL.d.cts +4844 -0
  37. package/dist/{index-CUomTA3e.d.ts → index-NuzRCuxe.d.ts} +171 -296
  38. package/dist/{index-ClpDeSos.d.cts → index-VtToZmUm.d.cts} +171 -296
  39. package/dist/index.cjs +2694 -1169
  40. package/dist/index.cjs.map +1 -1
  41. package/dist/index.d.cts +165 -19
  42. package/dist/index.d.ts +165 -19
  43. package/dist/index.js +738 -477
  44. package/dist/index.js.map +1 -1
  45. package/dist/module-Dl1KFVtc.d.cts +54 -0
  46. package/dist/module-Dl1KFVtc.d.ts +54 -0
  47. package/dist/openapi/index.cjs +1330 -484
  48. package/dist/openapi/index.cjs.map +1 -1
  49. package/dist/openapi/index.d.cts +4 -2
  50. package/dist/openapi/index.d.ts +4 -2
  51. package/dist/openapi/index.js +1264 -474
  52. package/dist/openapi/index.js.map +1 -1
  53. package/dist/{registry-dZZ5JKYg.d.ts → registry-B0eyOF9x.d.ts} +1 -1
  54. package/dist/{registry-CC0WBQq6.d.cts → registry-Bk9_rbNd.d.cts} +1 -1
  55. package/dist/stack.cjs.map +1 -1
  56. package/dist/test/index.cjs +705 -141
  57. package/dist/test/index.cjs.map +1 -1
  58. package/dist/test/index.d.cts +30 -4
  59. package/dist/test/index.d.ts +30 -4
  60. package/dist/test/index.js +472 -124
  61. package/dist/test/index.js.map +1 -1
  62. package/docs/README.md +34 -18
  63. package/docs/auth.md +1 -1
  64. package/docs/background.md +2 -2
  65. package/docs/database.md +221 -50
  66. package/docs/endpoints.md +3 -4
  67. package/docs/events.md +3 -3
  68. package/docs/getting-started.md +1 -1
  69. package/docs/llms-full.txt +402 -117
  70. package/docs/migrations.md +2 -2
  71. package/docs/schema.md +19 -10
  72. package/docs/services.md +116 -26
  73. package/package.json +8 -4
  74. package/stager/generics.js +205 -0
  75. package/stager/stage.js +39 -3
  76. package/template/AGENTS.md +110 -70
  77. package/template/db/public.ts +1 -1
  78. package/template/{controllers → modules/health}/health.controller.ts +1 -1
  79. package/template/modules/health/health.module.ts +24 -0
  80. package/template/modules/notes/note.service.test.ts +49 -0
  81. package/template/modules/notes/note.service.ts +108 -0
  82. package/template/{controllers → modules/notes}/notes.controller.ts +17 -11
  83. package/template/modules/notes/notes.module.ts +37 -0
  84. package/template/package.json +5 -3
  85. package/template/scripts/test.sh +33 -0
  86. package/template/tsconfig.json +29 -30
  87. package/dist/chunk-35PNTIRN.js.map +0 -1
  88. package/dist/chunk-AILPKEK5.js.map +0 -1
  89. package/dist/chunk-CJSKYY76.js +0 -627
  90. package/dist/chunk-CJSKYY76.js.map +0 -1
  91. package/dist/chunk-CRQKCRGF.js +0 -276
  92. package/dist/chunk-CRQKCRGF.js.map +0 -1
  93. package/dist/chunk-G4R6BTLV.js +0 -662
  94. package/dist/chunk-G4R6BTLV.js.map +0 -1
  95. package/dist/chunk-XABBC7JP.js +0 -55
  96. package/dist/chunk-XABBC7JP.js.map +0 -1
  97. package/dist/endpoint-CTEHhb7A.d.ts +0 -2386
  98. package/dist/endpoint-DYHMo6cC.d.cts +0 -2386
  99. package/dist/index-CW21M9Z3.d.ts +0 -1222
  100. package/dist/index-CmBK76nx.d.cts +0 -1222
  101. package/template/services/note.service.test.ts +0 -45
  102. package/template/services/note.service.ts +0 -74
  103. /package/dist/{chunk-7D4SUZUM.js.map → chunk-VXPNPVAG.js.map} +0 -0
  104. /package/template/{models/notes → modules/notes/dto}/create.ts +0 -0
@@ -1,8 +1,9 @@
1
1
  # Palbase backend project
2
2
 
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.
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` already set in this scaffold's
14
- > `tsconfig.json`. Legacy decorators; no `reflect-metadata`.
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 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.
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
- | `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 |
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 `models/`, passed to `@Body(Schema)` |
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 | import its service singleton |
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,35 +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
- `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.
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.
62
70
  - **`middleware/`** — nothing mounts it and the engine has no middleware pipeline.
63
71
  Code written against it deploys, never runs, and nothing reports it. Put
64
72
  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.
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.
67
78
  - **A migration file** — there are none anywhere in this product. `db/public.ts`
68
79
  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.
80
+ - **`process.env`** for configuration — read a secret with `Secrets.get()`; its
81
+ value never enters this repository.
71
82
  - **`try { … } catch { return 500 }`** — throw the error class and let the runtime
72
83
  build the envelope. Swallowing it turns a 404 into a 500.
73
- - **Hand-built responses** — no `JSON.stringify`, no `new Response(...)`. The 200
84
+ - **Hand-built responses** — no `JSON.stringify`, no `new Response(...)`; the 200
74
85
  body IS the schema your return type names.
75
86
  - **An inline return type** — `Promise<{ ok: boolean }>`, a union, or a bare
76
- `interface`. The deploy REFUSES it. Name a zod schema.
87
+ `interface`. The deploy REFUSES it; name a zod schema.
77
88
 
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.)
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.)
83
95
 
84
96
  **Class and method names are your public API.** `NotesController.list` generates
85
97
  `pb.notes.list()`. Renaming either renames the call in every app; the verb and the
@@ -90,63 +102,92 @@ path do not affect it, so restructure paths freely and rename these two carefull
90
102
  1. `db/public.ts` — declare the table and its RLS policies.
91
103
  2. `palbase db plan` — read what would change.
92
104
  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`.
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`.
99
113
  Commit both.
100
- 8. `npm test` — exercise the service.
114
+ 9. `npm test` — exercise the service.
101
115
 
102
116
  ## The service shape, exactly
103
117
 
104
118
  ```ts
105
- // services/note.service.ts
106
- 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
+ }
107
129
 
108
- type NotesTable = typeof Database.tables.notes;
130
+ @Injectable()
131
+ export class DbNoteRepo extends NoteRepo {
132
+ findMany(where: { user_id: string }) {
133
+ return Database.public.notes.findMany(where);
134
+ }
135
+ }
109
136
 
137
+ @Injectable()
110
138
  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;
119
- }
139
+ // The container supplies this. Nothing registers it and nothing wires it.
140
+ constructor(private readonly notes: NoteRepo) {}
120
141
 
121
142
  list(userId: string) {
122
143
  return this.notes.findMany({ user_id: userId });
123
144
  }
124
145
  }
146
+ ```
125
147
 
126
- /** Controllers import THIS, never the class. */
127
- export const noteService = new NoteService(Database.tables.notes);
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 {}
128
157
  ```
129
158
 
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.
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.
133
167
 
134
168
  ## Tests
135
169
 
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.
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.
139
174
 
140
175
  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.
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.
145
188
 
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.
189
+ Relative imports inside a test need the extension (`./note.service.ts`); this
190
+ scaffold's `tsconfig.json` allows it.
150
191
 
151
192
  `npm test` answers "is the logic right". It is not the deploy's own validation —
152
193
  decorators, return types, SDK major — which answers "would this ship".
@@ -154,5 +195,4 @@ decorators, return types, SDK major — which answers "would this ship".
154
195
  ## Commands
155
196
 
156
197
  `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>.
198
+ repository goes stale silently.
@@ -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 "../models/notes/create";
5
- import { noteService } from "../services/note.service";
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 is a MODULE-LEVEL SINGLETON, imported. It is not a constructor
20
- // parameter: the runtime constructs this class with no arguments, and a
21
- // controller that declares one is refused at build with the class named rather
22
- // than handed `undefined` in production.
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 noteService.list(user.id);
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 noteService.create(user.id, body.body);
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 noteService.get(id);
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 noteService.remove(id);
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 {}
@@ -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": "node --test --experimental-strip-types services/note.service.test.ts && node --test --experimental-strip-types",
8
+ "test": "./scripts/test.sh",
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@palbase/backend": "^25.0.0"
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