@palbase/backend 25.1.0 → 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 +2401 -1039
  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-VDF2T4AS.js → chunk-OO7R25AI.js} +1213 -591
  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 +2339 -1006
  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 +33 -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 +401 -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 -72
  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-CJSKYY76.js +0 -627
  89. package/dist/chunk-CJSKYY76.js.map +0 -1
  90. package/dist/chunk-CRQKCRGF.js +0 -276
  91. package/dist/chunk-CRQKCRGF.js.map +0 -1
  92. package/dist/chunk-G4R6BTLV.js +0 -662
  93. package/dist/chunk-G4R6BTLV.js.map +0 -1
  94. package/dist/chunk-VDF2T4AS.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 -76
  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,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
- `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 with the class named at decoration time for `jobs/`, `hooks/` and
62
- `webhooks/`, and when the route table is built at boot for a controller. The
63
- release never serves either way.
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 repository or DAO layer** `Database.tables.<name>` is already typed from
68
- 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.
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 is read with `Secrets.get()`, and
72
- its value never enters this repository.
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(...)`. The 200
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. Name a zod schema.
87
+ `interface`. The deploy REFUSES it; name a zod schema.
79
88
 
80
- **A controller needs no export.** `@Controller` records the class as it decorates
81
- it, so importing the file IS the registration — this scaffold's own
82
- `HealthController` and `NotesController` are not exported. (`export default` **is**
83
- required for `jobs/`, `webhooks/`, `hooks/` and `db/public.ts`, one class per
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. `models/<controller>/<endpoint>.ts` — the schema, as a value and a type.
96
- 5. `services/<name>.service.ts` — the logic; take the dependency through the
97
- constructor, wire the singleton on the last line.
98
- 6. `controllers/<name>.controller.ts` — the routes; import the service, never
99
- `Database`.
100
- 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`.
101
113
  Commit both.
102
- 8. `npm test` — exercise the service.
114
+ 9. `npm test` — exercise the service.
103
115
 
104
116
  ## The service shape, exactly
105
117
 
106
118
  ```ts
107
- // services/note.service.ts
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
- 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
+ }
111
136
 
137
+ @Injectable()
112
138
  export class NoteService {
113
- private readonly notes: NotesTable;
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
- /** Controllers import THIS, never the class. */
129
- 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 {}
130
157
  ```
131
158
 
132
- The constructor is the seam, and it is the whole reason the layer pays off: a test
133
- hands in a stand-in and never needs a database. `services/note.service.test.ts` in
134
- 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.
135
167
 
136
168
  ## Tests
137
169
 
138
- `npm test` runs the scaffold's canary test by name and then discovers the rest.
139
- The canary is named on purpose: a glob that matches nothing exits **0**, so a
140
- 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.
141
174
 
142
175
  Test the **service** layer. Controllers are thin by construction, so what can be
143
- wrong lives in a service a plain class you construct directly with a stand-in.
144
- For a stand-in covering the whole database surface rather than one table, use
145
- `fakeDatabase()` from `@palbase/backend/test`; it answers from memory and does not
146
- 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.
147
188
 
148
- Node **strips** types, it never emits code, so parameter properties, `enum` and
149
- `namespace` fail with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX` per FILE. Relative
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. The full SDK reference is at
160
- <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 when the route table is built at boot,
22
- // with the class named rather 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