@palbase/backend 23.1.0 → 24.0.1

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 (73) hide show
  1. package/dist/bin/palbase-backend.cjs +400 -47
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +4 -5
  4. package/dist/bin/palbase-backend.js.map +1 -1
  5. package/dist/{chunk-OHALWEOG.js → chunk-7Z6MGMXQ.js} +57 -2
  6. package/dist/chunk-7Z6MGMXQ.js.map +1 -0
  7. package/dist/{chunk-RCLNBJCM.js → chunk-H3JAISUY.js} +136 -1
  8. package/dist/chunk-H3JAISUY.js.map +1 -0
  9. package/dist/{chunk-PY7YJDCT.js → chunk-LCL7TUAI.js} +32 -3
  10. package/dist/chunk-LCL7TUAI.js.map +1 -0
  11. package/dist/{chunk-NS5V43YQ.js → chunk-P2Q27SGP.js} +19 -3
  12. package/dist/chunk-P2Q27SGP.js.map +1 -0
  13. package/dist/{chunk-R3KN6RHD.js → chunk-T5IOSOE5.js} +7 -2
  14. package/dist/chunk-T5IOSOE5.js.map +1 -0
  15. package/dist/{chunk-M5MCBWJI.js → chunk-YSQBC2VL.js} +275 -31
  16. package/dist/chunk-YSQBC2VL.js.map +1 -0
  17. package/dist/db/index.cjs +48 -3
  18. package/dist/db/index.cjs.map +1 -1
  19. package/dist/db/index.d.cts +2 -2
  20. package/dist/db/index.d.ts +2 -2
  21. package/dist/db/index.js +2 -2
  22. package/dist/{endpoint-CVWXh6oG.d.ts → endpoint-0_DGBajf.d.ts} +100 -3
  23. package/dist/{endpoint-c9h5jriX.d.cts → endpoint-CcQ1a36a.d.cts} +100 -3
  24. package/dist/engine/index.cjs +389 -34
  25. package/dist/engine/index.cjs.map +1 -1
  26. package/dist/engine/index.d.cts +4 -4
  27. package/dist/engine/index.d.ts +4 -4
  28. package/dist/engine/index.js +4 -4
  29. package/dist/{index-CwAJ7HEe.d.ts → index-BOS_rFBO.d.ts} +125 -21
  30. package/dist/{index-CxeQSfJP.d.cts → index-C84bLgeO.d.cts} +134 -9
  31. package/dist/{index-By8Dle5U.d.cts → index-MoQ31B6M.d.cts} +125 -21
  32. package/dist/{index-BZrJXnVh.d.ts → index-dJNhDZ7j.d.ts} +134 -9
  33. package/dist/index.cjs +166 -5
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +54 -12
  36. package/dist/index.d.ts +54 -12
  37. package/dist/index.js +28 -11
  38. package/dist/index.js.map +1 -1
  39. package/dist/openapi/index.cjs +16 -1
  40. package/dist/openapi/index.cjs.map +1 -1
  41. package/dist/openapi/index.d.cts +6 -4
  42. package/dist/openapi/index.d.ts +6 -4
  43. package/dist/openapi/index.js +6 -7
  44. package/dist/openapi/index.js.map +1 -1
  45. package/dist/{registry-CqPK2Qby.d.cts → registry-1X-skBNu.d.cts} +1 -1
  46. package/dist/{registry-B3niOVYp.d.ts → registry-CEod_5sz.d.ts} +1 -1
  47. package/dist/test/index.cjs +27 -0
  48. package/dist/test/index.cjs.map +1 -1
  49. package/dist/test/index.d.cts +1 -1
  50. package/dist/test/index.d.ts +1 -1
  51. package/dist/test/index.js +27 -0
  52. package/dist/test/index.js.map +1 -1
  53. package/docs/README.md +4 -4
  54. package/docs/database.md +115 -11
  55. package/docs/getting-started.md +5 -4
  56. package/docs/llms-full.txt +385 -89
  57. package/docs/migrations.md +81 -59
  58. package/docs/schema.md +82 -2
  59. package/docs/services.md +98 -9
  60. package/package.json +2 -2
  61. package/template/AGENTS.md +121 -41
  62. package/template/controllers/notes.controller.ts +64 -0
  63. package/template/package.json +1 -1
  64. package/template/services/note.service.ts +74 -0
  65. package/template/tsconfig.json +11 -1
  66. package/dist/chunk-HQRJDARQ.js +0 -90
  67. package/dist/chunk-HQRJDARQ.js.map +0 -1
  68. package/dist/chunk-M5MCBWJI.js.map +0 -1
  69. package/dist/chunk-NS5V43YQ.js.map +0 -1
  70. package/dist/chunk-OHALWEOG.js.map +0 -1
  71. package/dist/chunk-PY7YJDCT.js.map +0 -1
  72. package/dist/chunk-R3KN6RHD.js.map +0 -1
  73. package/dist/chunk-RCLNBJCM.js.map +0 -1
@@ -1,88 +1,109 @@
1
1
  # Migrations
2
2
 
3
3
  `db/schema.ts` is the single source of truth for your Postgres schema. You change
4
- the schema by editing that file, then generating a **migration** from the diff
5
- with `palbase db diff`. Every schema change additive or destructive flows
6
- through a reviewable migration file committed to git. The deploy applies the
7
- migrations in `db/migrations/`; nothing is auto-applied behind your back.
4
+ the schema by editing that file and **nothing generates a migration from it**.
5
+ There is no diff-to-file step: the change is computed against the database *as it
6
+ is right now*, by the stack that owns it, which is what lets it cover type
7
+ changes, policies and constraints that a text diff cannot see.
8
+
9
+ Two paths lead out of an edit, for two different moments:
10
+
11
+ - **On your machine** — `palbase db plan` and `palbase db apply` act on the local
12
+ stack (`palbase start`). Try the change, look at it, keep going.
13
+ - **Anywhere real** — `palbase push` carries the code AND the schema to the
14
+ selected Environment together, because a deploy that lands a handler against a
15
+ column that does not exist yet fails at the first request, in production.
16
+
17
+ `db/migrations/*.sql` still exists, and still runs on deploy, for the objects the
18
+ declaration cannot express — see [Hand-written migrations](#hand-written-migrations).
19
+ It is not where an ordinary column change goes any more.
8
20
 
9
21
  ## The workflow
10
22
 
11
23
  ```bash
12
24
  # 1. Edit db/schema.ts (add a column, a table, a policy, …)
13
25
 
14
- # 2. Generate the migration from the diff (declared schema vs the selected Environment)
15
- palbase db diff -f add_priority
16
- # writes db/migrations/<timestamp>_add_priority.sql
26
+ # 2. See what it would take. THIS APPLIES NOTHING.
27
+ palbase db plan
28
+ # ALTER TABLE todos ADD COLUMN priority text
29
+ # ⚠ drop todos.notes — 41908 value(s) in 41908 row(s)
30
+ #
31
+ # the ⚠ changes take data away — `palbase db apply --approve` runs them too
17
32
 
18
- # 3. Review the generated SQL (especially destructive changes see below),
19
- # then commit + push. git push deploys; the migration runs on deploy.
20
- git add db/migrations && git commit -m "add priority column" && git push
33
+ # 3. Do it one transaction, against the LOCAL database.
34
+ palbase db apply
35
+
36
+ # 4. Ship it. Code and schema travel together.
37
+ palbase push
21
38
  ```
22
39
 
23
- `palbase db diff` introspects the selected Environment's live database (there is
24
- no local database each Environment runs on the server), diffs it against
25
- `db/schema.ts`, and writes one migration SQL file. If the schema is already in
26
- sync it writes nothing and tells you so.
40
+ `palbase db plan` hands `db/schema.ts` to the local stack, which computes the plan
41
+ against its own database and answers with what it would change. When there is
42
+ nothing to do it says `✓ the database matches db/schema.ts`. It writes nothing,
43
+ ever — neither a file nor a row — so it is safe to run at any moment, including
44
+ from a script (`--detailed-exitcode` exits 2 when the plan would change
45
+ something, 0 when it is in sync).
27
46
 
28
47
  ## Additive vs destructive
29
48
 
30
- The generated SQL labels what it does. An additive change (new table, new column)
31
- is plain DDL:
32
-
33
- ```sql
34
- -- palbase db diff: add_priority
35
- -- generated 20260605T142233
49
+ The plan separates the two, and a **destructive** change (dropping a column or a
50
+ table) is REFUSED unless you say so again:
36
51
 
37
- ALTER TABLE todos ADD COLUMN IF NOT EXISTS priority text;
38
52
  ```
39
-
40
- A **destructive** change (dropping a column or table losing data) is generated
41
- with a clear warning comment, and `palbase db diff` prints a warning. Review it
42
- before committing:
43
-
44
- ```sql
45
- -- DESTRUCTIVE: dropping todos.notes loses its data
46
- ALTER TABLE todos DROP COLUMN notes;
53
+ $ palbase db apply
54
+ refused: this would take data away run it again with --approve if that is
55
+ what you mean
47
56
  ```
48
57
 
49
- A **column type change** is emitted as a commented stub auto-migration never
50
- alters types, so you write the real `ALTER ... TYPE` with whatever `USING` cast
51
- and backfill your data needs:
58
+ Destructive lines carry their **row counts**, and that is the whole design: "this
59
+ drops a column" is a shrug, "this drops a column with 41,908 values in it" is a
60
+ decision. `palbase db apply --approve` runs them; `palbase push --approve` is the
61
+ same consent for an Environment.
62
+
63
+ A **column type change** is not applied by this rail at all. The plan lists it
64
+ under `not applied by this rail — change these yourself:` and you write the real
65
+ `ALTER ... TYPE` as a hand-written migration, with whatever `USING` cast and
66
+ backfill your data needs:
52
67
 
53
68
  ```sql
54
- -- TYPE CHANGE: todos.priority text -> integer (review; auto-migrate does not ALTER types)
55
- -- ALTER TABLE todos ALTER COLUMN priority TYPE integer;
69
+ -- todos.priority text -> integer
70
+ ALTER TABLE todos ALTER COLUMN priority TYPE integer USING priority::integer;
56
71
  ```
57
72
 
58
73
  ## The drift gate
59
74
 
60
- You can't push a schema change without its migration:
61
-
62
- - **`palbase db check`** exits non-zero when `db/schema.ts` declares something the
63
- database lacks (i.e. you edited the schema but didn't run `palbase db diff`).
64
- - The scaffold installs a **git pre-push hook** that runs `palbase db check`, so a
65
- plain `git push` is **blocked** until you generate + commit the migration.
66
- (Bypass with `git push --no-verify` but the deploy-time gate still rejects it.)
67
- - On deploy, after migrations run, Palbase asserts `db/schema.ts` matches the live
68
- database. Any unresolved drift **fails the deploy** and keeps the previous
75
+ Code and schema move at different speeds, so three things keep them together:
76
+
77
+ - **`palbase db plan --detailed-exitcode`** exits 2 when the declaration and the
78
+ database disagree and 0 when they match — the form to put in CI, because it
79
+ answers with a status code and still writes nothing.
80
+ - The CLI installs a **git pre-push hook** that runs `palbase build`, the same
81
+ validation the deploy runs, so a push that would produce a failed deploy is
82
+ caught on your machine first. (Bypass with `git push --no-verify` the server
83
+ still gates it.)
84
+ - On deploy, the migrations run and Palbase then asserts `db/schema.ts` matches
85
+ the live database. Unresolved drift **fails the deploy** and keeps the previous
69
86
  version live — a broken schema never goes out silently.
70
87
 
71
- ## Your schema change is not live until you deploy it
88
+ ## Your schema change is not live anywhere you have not applied it
89
+
90
+ The local stack and every Environment hold their own database, and editing
91
+ `db/schema.ts` changes none of them: `palbase db apply` is what moves the local
92
+ one, `palbase push` is what moves an Environment's. Until then the declaration is
93
+ ahead of the tables.
72
94
 
73
- There is no local database. `Database` always talks to the selected deployed
74
- Environment, so a change in `db/schema.ts` does not exist in that database until
75
- you generate the migration and deploy it the types can be ahead of the tables.
76
- Run `palbase db types` after editing the schema to refresh `palbase-env.d.ts` so
77
- `Database.tables.<name>` is fully typed in your services, and deploy to a dev
78
- Environment to exercise it against real data.
95
+ The TYPES move separately again. `palbase build` regenerates `palbase-env.d.ts`
96
+ from `db/schema.ts`, which is what types `Database.tables.<name>` in your
97
+ services so after a schema edit, run it. (There is no second command for this:
98
+ `build` regenerates everything derived, because one verb to remember is one verb
99
+ to forget.) Typed and applied are independent, and knowing which one you are
100
+ missing is usually the whole debugging step.
79
101
 
80
102
  ## Hand-written migrations
81
103
 
82
104
  `db/migrations/*.sql` is plain SQL applied in filename order and tracked in
83
- `schema_migrations` (idempotent — a migration runs once). `palbase db diff`
84
- generates them for you, but you can also hand-write one for anything the diff
85
- can't express: a data backfill, a complex type change with a `USING` cast, a
105
+ `schema_migrations` (idempotent — a migration runs once). Nothing generates
106
+ these; you write one for anything the declaration cannot express: a data backfill, a complex type change with a `USING` cast, a
86
107
  trigger, a `CHECK` constraint, an `EXCLUDE` constraint, a composite/multi-column
87
108
  `UNIQUE`, an index (`CREATE INDEX`), or `CREATE EXTENSION` for an extension that
88
109
  isn't in the DSL allowlist (e.g. `btree_gist`). The DSL column builders cover
@@ -126,12 +147,12 @@ Two patterns follow from this:
126
147
  - **Extra constraint/index on a DSL-expressible table** — declare the table in
127
148
  `db/schema.ts` and add **only** the constraint/index via the migration. The
128
149
  base table stays modeled, the gate ignores the constraint, and `palbase db
129
- check` stays clean.
150
+ plan` stays clean.
130
151
  - **A whole object the DSL can't model** (like `instructor_sessions` above — its
131
152
  `tstzrange` column has no DSL builder) — the table lives entirely in raw SQL,
132
- not in `db/schema.ts`. The deploy still succeeds, but `palbase db check` (the
133
- read-only client warning) will print a `- table <name>` would-be-drop line for
134
- it; that's expected and the deploy-time gate does not block on it.
153
+ not in `db/schema.ts`. The deploy still succeeds, but `palbase db plan` will
154
+ list it as a would-be-drop for the local database; that is expected, and the
155
+ deploy-time gate does not block on it. Never approve that one.
135
156
 
136
157
  ## Adding a NOT NULL column to a table with rows
137
158
 
@@ -158,8 +179,9 @@ RLS on and no applicable policy, Postgres denies everything: empty reads, refuse
158
179
  writes, no error anywhere that says why.
159
180
 
160
181
  **Guard the CREATE.** Postgres has no `CREATE POLICY IF NOT EXISTS`, so a
161
- migration that replays — a fresh Environment, a branch, `palbase db reset`
162
- fails on the second run. Drop first:
182
+ migration that replays — a fresh Environment, or `palbase start --reset`, which
183
+ throws the local database away and brings it back empty — fails on the second
184
+ run. Drop first:
163
185
 
164
186
  ```sql
165
187
  DROP POLICY IF EXISTS owner_all ON notes;
package/docs/schema.md CHANGED
@@ -8,8 +8,9 @@ everywhere — by default, with no import and no generic.
8
8
  ## Defining a schema
9
9
 
10
10
  The table NAME comes from the object key under `tables`. Each table value is an
11
- object `{ columns, rls?, policies? }` `columns` is required; `rls` and
12
- `policies` enable [Row-Level Security](#row-level-security-rls).
11
+ object whose only required field is `columns`; `rls` and `policies` enable
12
+ [Row-Level Security](#row-level-security-rls), and `indexes` declares plain
13
+ btree [indexes](#indexes).
13
14
 
14
15
  ```ts
15
16
  import {
@@ -65,6 +66,85 @@ Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
65
66
  `.defaultNow()` (timestamp → `now()`), `.references(table, column)`,
66
67
  `.onDelete("cascade" | "set null" | "restrict" | "no action")`.
67
68
 
69
+ ## Indexes
70
+
71
+ `indexes` declares plain (non-unique) btree indexes over an ordered column list:
72
+
73
+ ```ts
74
+ export default defineSchema({
75
+ tables: {
76
+ sessions: {
77
+ columns: {
78
+ id: uuid().primaryKey().defaultRandom(),
79
+ room_id: uuid().notNull().references("rooms", "id"),
80
+ started_at: timestamp().defaultNow(),
81
+ },
82
+ indexes: [
83
+ { name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
84
+ ],
85
+ },
86
+ },
87
+ });
88
+ ```
89
+
90
+ On deploy each entry becomes a statement of its own — `CREATE INDEX IF NOT
91
+ EXISTS <name> ON <table> (<columns>)` — not a clause of the `CREATE TABLE`. The
92
+ name and every column are identifier-validated before any SQL is built.
93
+
94
+ **The diff key is the NAME, and the diff is additive.** Three consequences, worth
95
+ knowing before you name an index:
96
+
97
+ - An index that exists in the database but is not in `indexes` is never dropped.
98
+ `db/schema.ts` does not own the database's indexes; it only adds the ones it
99
+ names.
100
+ - Removing an entry from `indexes` therefore does **not** drop the index. Drop it
101
+ in an explicit [migration](./migrations.md).
102
+ - Changing `columns` under a name that is already live does **nothing** — the
103
+ deploy matches the name, sees it, and skips. Give the new column list a NEW
104
+ name (and drop the old index in a migration if you want it gone).
105
+
106
+ ### Scope: plain btree over columns, nothing else
107
+
108
+ `indexes` models exactly one kind of index. It cannot express:
109
+
110
+ | what you want | declare it with |
111
+ |---------------|-----------------|
112
+ | a UNIQUE index | `unique: [{ name, columns }]` on the table |
113
+ | a partial index (`WHERE …`) | `raw()` |
114
+ | an expression index (`lower(email)`, …) | `raw()` |
115
+ | any other method (`gin`, `gist`, `hnsw`, …) | `raw()` |
116
+
117
+ The limit is deliberate, not an oversight. Comparing a partial predicate or an
118
+ expression against the live database needs the same round-trip through Postgres'
119
+ own deparse that `checks` uses; without it an edited predicate would silently do
120
+ nothing. Rather than ship a half-working partial-index path, the typed field
121
+ stays columns-only and `raw()` carries the rest:
122
+
123
+ ```ts
124
+ import { defineSchema, raw, uuid, text, timestamp } from "@palbase/backend";
125
+
126
+ // …
127
+ orders: {
128
+ columns: {
129
+ id: uuid().primaryKey().defaultRandom(),
130
+ status: text().notNull(),
131
+ created_at: timestamp().defaultNow(),
132
+ },
133
+ raw: [
134
+ raw(
135
+ "orders_pending_idx",
136
+ "CREATE INDEX IF NOT EXISTS orders_pending_idx ON orders (created_at) WHERE status = 'pending'",
137
+ { down: "DROP INDEX IF EXISTS orders_pending_idx" },
138
+ ),
139
+ ],
140
+ },
141
+ ```
142
+
143
+ `raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
144
+ `indexes`, is tracked by NAME only — a changed body needs a new name. Pass
145
+ `down` yourself for an index: the default drop statement drops a *constraint*,
146
+ and an index is not one.
147
+
68
148
  ## Typed DB access — by default
69
149
 
70
150
  You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
package/docs/services.md CHANGED
@@ -1,12 +1,27 @@
1
- # Services
1
+ # Service singletons — `Database`, `Storage`, `Cache`, …
2
2
 
3
- Import service singletons from `@palbase/backend` in every handler type
4
- endpoints, workers, jobs, hooks, and webhooks all use the same imports. Only
5
- **middleware** uses a `ctx` argument (`ctx.db`, `ctx.log`, etc.).
3
+ **Two different things are called "services" here, and this page is the second
4
+ one.** Read the first paragraph before searching this page for a layout rule.
6
5
 
7
- Available singletons: `Database`, `Documents`, `Storage`, `Cache`, `Queue`,
6
+ - **YOUR service layer** `services/<name>.service.ts`, the plain classes your
7
+ own business logic lives in. The scaffold ships one, and the contract is at
8
+ the bottom of this page: [Your own service layer](#your-own-service-layer).
9
+ - **THE service singletons** — the platform objects you import from
10
+ `@palbase/backend` (`Database`, `Cache`, `Storage`, …). Everything between
11
+ here and that last section is about these.
12
+
13
+ Import them in every handler type — endpoints, workers, jobs, hooks, and
14
+ webhooks all use the same imports. Only **middleware** uses a `ctx` argument
15
+ (`ctx.db`, `ctx.log`, etc.).
16
+
17
+ Available singletons: `Database`, `Documents`, `Storage`, `Cache`,
8
18
  `Log`, `Notifications`, `Flags`, `Realtime`.
9
19
 
20
+ There is **no `Queue`**. It was listed here for months and has never been
21
+ exported: background work is a cron `@Job` under `jobs/` (see
22
+ [background.md](./background.md)). Importing it does not fail at a queue that
23
+ turns out to be empty — it fails at the import.
24
+
10
25
  `Realtime` is **broadcast-only** on the backend — a handler can push an event to
11
26
  a channel, but cannot subscribe (a stateless request can't hold a socket).
12
27
  Subscription lives on the client SDK (`pb.realtime`).
@@ -76,9 +91,10 @@ const { data: doc } = await Documents.doc("rooms/abc").get();
76
91
  ## Notifications
77
92
 
78
93
  **No provider setup is required.** Every project starts with a managed sender, so
79
- email and SMS deliver on a brand-new project with no provider, no API key, and no
80
- `config/notifications.ts`. You configure your own provider only to send from your
81
- own domain see [Config](./config.md).
94
+ email and SMS deliver on a brand-new project with no provider and no API key. You
95
+ configure your own provider only to send from your own domain, and that is a CLI
96
+ act against the stack, not a file in this repository:
97
+ `palbase notifications providers`.
82
98
 
83
99
  ```ts
84
100
  import { Notifications } from "@palbase/backend";
@@ -91,7 +107,7 @@ await Notifications.email.send({
91
107
  });
92
108
  await Notifications.email.send({
93
109
  to: user.email,
94
- templateSlug: "receipt", // defined in config/notifications.ts
110
+ templateSlug: "receipt", // created with `palbase notifications templates`
95
111
  locale: "tr", // without this the extra locale rows are unreachable
96
112
  variables: { total: "₺240" },
97
113
  });
@@ -198,3 +214,76 @@ and never blocks the handler waiting on subscribers.
198
214
 
199
215
  Pair it with the client SDK's `pb.realtime.channel("room:42").on("message", …)`
200
216
  to drive live chat, presence, dashboards, and other push features.
217
+
218
+ ## Your own service layer
219
+
220
+ The singletons above are what a service CALLS. This section is the layer that
221
+ calls them — `services/<name>.service.ts`, which the scaffold ships a working
222
+ example of (`services/note.service.ts` and the controller that uses it).
223
+
224
+ The contract is three rules, and the scaffold's own test enforces all three:
225
+
226
+ **1. The logic lives in a service; the controller does not touch the database.**
227
+ A controller's job is HTTP — validate a body through a named schema, name the
228
+ 200 shape as the return type, turn a missing row into a status. Which rows,
229
+ whose, in what order is the service's. A controller that reaches for `Database`
230
+ has moved the logic into the layer that is hardest to test.
231
+
232
+ **2. The dependency arrives through the CONSTRUCTOR, and the module exports one
233
+ wired instance.** There is no DI container and no decorator — the last line of
234
+ the file is the wiring:
235
+
236
+ ```ts
237
+ // services/note.service.ts
238
+ import { Database } from "@palbase/backend";
239
+
240
+ type NotesTable = typeof Database.tables.notes;
241
+
242
+ export class NoteService {
243
+ private readonly notes: NotesTable;
244
+
245
+ // Assigned in the BODY. A parameter property (`constructor(private notes: …)`)
246
+ // is refused by Node's type-stripping test runner, for the whole file.
247
+ constructor(notes: NotesTable) {
248
+ this.notes = notes;
249
+ }
250
+
251
+ list(userId: string) {
252
+ return this.notes.findMany({ user_id: userId });
253
+ }
254
+ }
255
+
256
+ export const noteService = new NoteService(Database.tables.notes);
257
+ ```
258
+
259
+ ```ts
260
+ // controllers/notes.controller.ts
261
+ import { noteService } from "../services/note.service";
262
+ ```
263
+
264
+ **3. That module-level singleton is the only way to hold a dependency.**
265
+ Controllers, hooks, jobs and webhooks are all constructed by the runtime with
266
+ **no arguments**. A class that declares a constructor parameter is refused at
267
+ build with the class named — there is no injector to fill it, so the field would
268
+ otherwise be `undefined` in production. Import the singleton instead.
269
+
270
+ Rule 2 is also what makes rule 1 pay off: because the service is handed its
271
+ table rather than reaching for the singleton, a test constructs it with a
272
+ stand-in and never needs a database.
273
+
274
+ ```ts
275
+ // services/note.service.test.ts — `npm test`, no database
276
+ import assert from "node:assert/strict";
277
+ import { test } from "node:test";
278
+ import { NoteService } from "./note.service.ts";
279
+
280
+ test("list filters by owner", async () => {
281
+ const seen: unknown[] = [];
282
+ const fake = { findMany: async (q: unknown) => (seen.push(q), []) };
283
+ await new NoteService(fake as never).list("u_1");
284
+ assert.deepEqual(seen, [{ user_id: "u_1" }]);
285
+ });
286
+ ```
287
+
288
+ Node's ESM resolver wants the extension on a relative import inside a test
289
+ (`./note.service.ts`); the scaffold's `tsconfig.json` allows it.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "23.1.0",
4
- "description": "Palbase Backend SDK \u2014 class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
3
+ "version": "24.0.1",
4
+ "description": "Palbase Backend SDK class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",