@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
@@ -43,14 +43,16 @@ service the controllers call.
43
43
 
44
44
  ### The 7 rules (checklist)
45
45
 
46
- 1. **A controller needs NO export at all.** `@Controller` records the class into a
47
- `globalThis` registry as it decorates it, and the bundler imports the file for
48
- that side effect alone the shipped scaffold's own `HealthController` and
49
- `NotesController` are not exported. Exporting is harmless and reads well, so
50
- these examples do it; it is not a requirement. (`export default` **is**
46
+ 1. **A controller is exported by NAME and listed in a module.** `@Controller`
47
+ records the class as it decorates it, but that registration only says the
48
+ class EXISTSa module's `controllers` list is what decides it should be
49
+ served. So the module imports it by name, which means the class is exported;
50
+ an unexported one is registered, owned by nothing, and refused at build with
51
+ `unowned class`. Nothing is default-exported here. (`export default` **is**
51
52
  required for `jobs/`, `webhooks/`, `hooks/` and every `db/*.ts`, one class per
52
- file.) What IS fatal is a `@Controller` class that collected zero routes
53
- usually `experimentalDecorators` missing from `tsconfig.json`.
53
+ file those are read off disk by name, not through a module.) What IS fatal
54
+ is a `@Controller` class that collected zero routes — usually
55
+ `experimentalDecorators` missing from `tsconfig.json`.
54
56
  2. **Methods that call a service are `async` and return `Promise<T>`.** Services
55
57
  `await Database`, so they return promises; a sync return type on an async body
56
58
  is a tsc error. Annotate `: Promise<TodoSchema>`, not `: TodoSchema`, whenever
@@ -79,7 +81,7 @@ service the controllers call.
79
81
 
80
82
  > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
81
83
  > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
82
- > and will not compile against `@palbase/backend` 25.
84
+ > and will not compile against `@palbase/backend` 27.
83
85
 
84
86
  ### Complete CRUD example (copy-pasteable, compiles)
85
87
 
@@ -103,13 +105,14 @@ export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
103
105
 
104
106
  ```ts
105
107
  // services/todo.service.ts — plain class + singleton. The real work.
106
- import { Database, NotFound } from "@palbase/backend";
108
+ import { Database, Injectable, NotFound } from "@palbase/backend";
107
109
  import type { TodoSchema } from "../models/todos/shared.js";
108
110
 
109
111
  /** The typed surface of ONE table. Naming it keeps the seam one table wide:
110
112
  * a test fake implements five methods, not the whole `Database`. */
111
- type TodosTable = typeof Database.tables.todos;
113
+ type TodosTable = typeof Database.public.todos;
112
114
 
115
+ @Injectable()
113
116
  export class TodoService {
114
117
  private readonly todos: TodosTable;
115
118
 
@@ -123,7 +126,7 @@ export class TodoService {
123
126
  }
124
127
 
125
128
  list(userId: string): Promise<TodoSchema[]> {
126
- return this.todos.findMany({ user_id: userId });
129
+ return this.todos.findMany({ where: { user_id: userId } });
127
130
  }
128
131
  create(userId: string, title: string): Promise<TodoSchema> {
129
132
  return this.todos.insert({ user_id: userId, title });
@@ -139,13 +142,26 @@ export class TodoService {
139
142
  }
140
143
  }
141
144
 
142
- /** The wired instance. Controllers import THIS, never the class. It is also the
143
- * ONLY supported way to hold a dependency: a controller, job, hook or webhook is
144
- * constructed with no arguments, and one that declares a constructor parameter
145
- * is refused at build with the class named. */
146
- export const todoService = new TodoService(Database.tables.todos);
147
145
  ```
148
146
 
147
+ ```ts
148
+ // todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
149
+ import { Module, type Token } from "@palbase/backend";
150
+ import { TodosController } from "./controllers/todos.controller.ts";
151
+ import { TodoService } from "./services/todo.service.ts";
152
+
153
+ @Module({
154
+ controllers: [TodosController as Token],
155
+ providers: [TodoService as Token],
156
+ })
157
+ export class TodosModule {}
158
+ ```
159
+
160
+ A class no module lists does not exist: it is refused at build, by name, and it
161
+ never reaches the route table or the OpenAPI document. Nothing is wired by hand
162
+ — a controller names `TodoService` as a constructor parameter and the container
163
+ supplies it. See [services.md](./services.md#your-own-service-layer).
164
+
149
165
  ```ts
150
166
  // controllers/todos.controller.ts — class controller. Thin: delegates to the service.
151
167
  import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
@@ -158,7 +174,7 @@ import { CreateTodoBody } from "../models/todos/create.js";
158
174
  export default class TodosController {
159
175
  // The service arrives as an IMPORTED SINGLETON, not a constructor parameter:
160
176
  // the runtime constructs this class with no arguments, and one that declares a
161
- // parameter is refused at build with the class named.
177
+ // parameter is refused when the route table is built at boot, with the class named.
162
178
 
163
179
  @Get("") // GET /todos → operationId todos.list
164
180
  async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
@@ -226,7 +242,7 @@ generated client surface) changes; the verb/path do not affect it.
226
242
  (stage, bundle, extract controller metadata). Exits non-zero on a decorator,
227
243
  return-type or version-skew error, so a push that would deploy zero endpoints
228
244
  fails on your machine instead. It also regenerates `palbase-env.d.ts` from
229
- your `db/*.ts` files, which is what types `Database.tables.*` (no import, no
245
+ your `db/*.ts` files, which is what types `Database.public.*` (no import, no
230
246
  generic) — so run it after editing the schema. There is no separate command
231
247
  for that: `build` regenerates everything derived.
232
248
  - `palbase push` — deploy the current backend to the selected Environment. For a
@@ -377,7 +393,7 @@ start. The loop is: edit, validate, push to a dev Environment.
377
393
  type or an SDK major skew fails here rather than shipping a deploy that
378
394
  serves zero endpoints. It is wired into a `pre-push` git hook for you. It also
379
395
  regenerates `palbase-env.d.ts` from your `db/*.ts` files, which is what types
380
- `Database.tables.*` (no import, no generic), so run it after editing the
396
+ `Database.public.*` (no import, no generic), so run it after editing the
381
397
  schema — there is no separate command for that. See
382
398
  [migrations.md](./migrations.md) for the schema side.
383
399
  - `palbase push` deploys the current backend to the selected Environment. For a
@@ -555,10 +571,9 @@ export default class RoomsController {
555
571
 
556
572
  1. **The controller does not touch the database.** It delegates to a service, as
557
573
  above. A method here that reaches for `Database` has moved the logic into the
558
- layer that is hardest to test. (An export is not required at all: `@Controller`
559
- records the class as it decorates it, and importing the file IS the
560
- registration the shipped scaffold's own controllers are not exported.
561
- `export default` here is style.)
574
+ layer that is hardest to test. (Export the class by NAME so its module can
575
+ list it in `controllers`; a class no module lists is refused at build with
576
+ `unowned class`. Nothing here is default-exported.)
562
577
  2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
563
578
  returns promises, so a body that `await`s it cannot have a sync return type
564
579
  (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
@@ -863,7 +878,7 @@ single-use and expire; a used or expired one fails closed with a `400`.
863
878
 
864
879
  - [Row-Level Security](./schema.md#row-level-security-rls) — pushing per-user
865
880
  access rules into Postgres, where `auth.uid()` is this same verified user.
866
- - [Database](./database.md) — how `Database.asService()` steps outside RLS.
881
+ - [Database](./database.md) — how `Database.$asService()` steps outside RLS.
867
882
 
868
883
 
869
884
 
@@ -881,19 +896,19 @@ import { Database } from "@palbase/backend";
881
896
  Only **middleware** still uses `ctx.db` (see [background.md](./background.md)
882
897
  and [events.md](./events.md) for worker/job/hook/webhook examples).
883
898
 
884
- ## Typed by default — `Database.tables`
899
+ ## Typed by default — `Database.<şema>.<tablo>`
885
900
 
886
- When you declare a schema under `db/*.ts`, `Database.tables.<name>` is typed everywhere
901
+ When you declare a schema under `db/*.ts`, `Database.public.<name>` is typed everywhere
887
902
  with no import and no generic. `insert` demands the right columns; rows come
888
903
  back typed; nullable columns are `T | null`. This is the path you should use:
889
904
 
890
905
  ```ts
891
- const todo = await Database.tables.todos.insert({ title: "buy milk" });
906
+ const todo = await Database.public.todos.insert({ title: "buy milk" });
892
907
  todo.id; // string ✓
893
908
  todo.done; // boolean ✓
894
- const open = await Database.tables.todos.findMany({ done: false });
895
- await Database.tables.todos.update(todo.id, { done: true });
896
- await Database.tables.todos.delete(todo.id);
909
+ const open = await Database.public.todos.findMany({ where: { done: false } });
910
+ await Database.public.todos.update({ where: { id: todo.id }, set: { done: true } });
911
+ await Database.public.todos.delete(todo.id);
897
912
  // todo.nope ← compile error
898
913
  ```
899
914
 
@@ -906,18 +921,18 @@ cover, the string-keyed ops are still available:
906
921
 
907
922
  | Method | Returns |
908
923
  |--------|---------|
909
- | `Database.insert(table, data)` | the inserted row (`Record<string, unknown>`) |
910
- | `Database.upsert(table, data, { onConflict })` | the inserted-or-updated row |
911
- | `Database.update(table, id, data)` | the updated row, or `null` if none matched |
912
- | `Database.delete(table, id)` | `void` |
913
- | `Database.updateMany(table, where, set)` | every row the filter matched, updated |
914
- | `Database.deleteMany(table, where)` | how many rows went |
915
- | `Database.findById(table, id)` | the row or `null` |
916
- | `Database.findMany(table, where?, opts?)` | matching rows (array) |
917
- | `Database.count(table, where?)` | how many rows match |
918
- | `Database.query(sql, params?)` | rows from a SQL query, in the request's own transaction |
919
- | `Database.transaction(fn)` | runs a whole transaction plan in one request |
920
- | `Database.attempt(fn)` | a savepoint: a failure inside rolls back only its own writes |
924
+ | `Database.$insert(table, data)` | the inserted row (`Record<string, unknown>`) |
925
+ | `Database.$put(table, data, { onConflict })` | the inserted-or-updated row |
926
+ | `Database.$update(table, id, data)` | the updated row, or `null` if none matched |
927
+ | `Database.$delete(table, id)` | `void` |
928
+ | `Database.$updateMany(table, where, set)` | every row the filter matched, updated |
929
+ | `Database.$deleteMany(table, where)` | how many rows went |
930
+ | `Database.$findById(table, id)` | the row or `null` |
931
+ | `Database.$findMany(table, where?, opts?)` | matching rows (array) |
932
+ | `Database.$count(table, where?)` | how many rows match |
933
+ | `Database.$query(sql, params?)` | rows from a SQL query, in the request's own transaction |
934
+ | `Database.$transaction(fn)` | runs a whole transaction plan in one request |
935
+ | `Database.$attempt(fn)` | a savepoint: a failure inside rolls back only its own writes |
921
936
 
922
937
  ### Filters
923
938
 
@@ -925,26 +940,103 @@ A filter's keys are ANDed. Each value is either a plain value (equality) or an
925
940
  operator object:
926
941
 
927
942
  ```ts
928
- await Database.tables.entries.findMany({
929
- household_id: hid, // equality
930
- amount_kurus: { gte: 1000 }, // >=
931
- category: { in: ["food", "rent"] },
932
- status: { neq: "void" },
943
+ await Database.public.entries.findMany({
944
+ where: {
945
+ household_id: hid, // equality
946
+ amount_kurus: { gte: 1000 }, // >=
947
+ category: { in: ["food", "rent"] },
948
+ status: { neq: "void" },
949
+ },
950
+ });
951
+ ```
952
+
953
+ Operators: `gt` · `gte` · `lt` · `lte` · `neq` · `in` · `contains` ·
954
+ `icontains` · `startsWith` · `endsWith` · `isNull`, plus `OR` / `AND` / `NOT`
955
+ for composition. The same filter language works for `updateMany`, `deleteMany`,
956
+ `count` and `search` — one language, so two spellings cannot drift apart. An
957
+ empty `in` list means NO rows (written into the SQL as `false`), never a silent
958
+ full scan.
959
+
960
+ ### Comparing one column with another — `col()`
961
+
962
+ The right-hand side of a comparison is normally a VALUE. `col()` puts a COLUMN
963
+ there, which is otherwise a reason to drop to raw SQL:
964
+
965
+ ```ts
966
+ import { col } from "@palbase/backend";
967
+
968
+ // Invoices that are not fully paid.
969
+ await Database.public.invoices.findMany({ where: { total: { gt: col("amount_paid") } } });
970
+ ```
971
+
972
+ The name is checked at COMPILE time against the row type: `col("amont_paid")`
973
+ does not compile. It is legal in `gt` / `gte` / `lt` / `lte` / `neq` and as a
974
+ bare value (which means equality); anywhere else — inside `in`, in a `set`, in
975
+ `orderBy` — it is refused by name.
976
+
977
+ `col()` is only ever produced by the `col()` call. A `{ "$col": "…" }` object
978
+ that arrives in a request body is NOT a column reference and is refused as an
979
+ unknown operator, so spreading untrusted input into a filter cannot turn a
980
+ tenant predicate into a tautology.
981
+
982
+ ### Filtering through a relation — `has`
983
+
984
+ A filter on the row next to yours is the most common reason to drop to raw SQL,
985
+ and it is the one the schema already knows how to answer. Every foreign key you
986
+ declare produces a named relation on BOTH sides, and `has` filters through it:
987
+
988
+ ```ts
989
+ // Interests that THIS user picked.
990
+ await Database.public.interests.findMany({
991
+ where: { has: { user_interests: { user_id: userId } } },
992
+ orderBy: { column: "sort_order", direction: "asc" },
993
+ });
994
+ ```
995
+
996
+ That compiles to a correlated `IN (SELECT …)` — one statement, one round trip,
997
+ the RLS policies of BOTH tables still applied. The hand-written alternative was
998
+ a `sqlFragment` carrying a subquery, or two queries and a `Set` in JavaScript.
999
+
1000
+ The relation names come from the schema, so `has: { user_intrests: … }` is a
1001
+ compile error, and so is a column that the OTHER table does not have. A table
1002
+ that declares no foreign key at all has no `has` — the key is absent, not empty.
1003
+
1004
+ Both directions work. From the child, the relation is the parent:
1005
+
1006
+ ```ts
1007
+ await Database.public.user_interests.findMany({
1008
+ where: { has: { interest: { name: { icontains: "yoga" } } } },
933
1009
  });
934
1010
  ```
935
1011
 
936
- Operators: `gt` · `gte` · `lt` · `lte` · `neq` · `in`. The same filter language
937
- works for `updateMany`, `deleteMany` and `count`one language, so two spellings
938
- cannot drift apart. An empty `in` list means NO rows (written into the SQL as
939
- `false`), never a silent full scan.
1012
+ The inner filter is the SAME language: operators, `OR` / `AND` / `NOT`, `col()`,
1013
+ and a nested `has` one table further (three levels the relation graph points
1014
+ both ways, so it is a cycle, and an unbounded type would follow it forever).
1015
+
1016
+ `has` is available wherever a filter is: `findMany`, `count`, `updateMany`,
1017
+ `deleteMany`, and inside `$transaction`. In a transaction the update still takes
1018
+ its rows in primary-key order under `FOR NO KEY UPDATE`, exactly as a plain
1019
+ filter does:
1020
+
1021
+ ```ts
1022
+ await Database.$transaction((tx) => {
1023
+ tx.public.interests.updateWhere(
1024
+ { has: { user_interests: { user_id: userId } } },
1025
+ { last_seen_at: now() },
1026
+ );
1027
+ return [];
1028
+ });
1029
+ ```
940
1030
 
941
1031
  ### Ordering and paging
942
1032
 
943
1033
  ```ts
944
- await Database.tables.entries.findMany(
945
- { household_id: hid },
946
- { orderBy: { column: "created_at", direction: "desc" }, limit: 20, offset: 40 },
947
- );
1034
+ await Database.public.entries.findMany({
1035
+ where: { household_id: hid },
1036
+ orderBy: { column: "created_at", direction: "desc" },
1037
+ limit: 20,
1038
+ offset: 40,
1039
+ });
948
1040
  ```
949
1041
 
950
1042
  `orderBy.column` is checked against the row type: a mistyped column name is a
@@ -968,7 +1060,7 @@ It runs in **the request's own transaction**, not a read-only one: a write insid
968
1060
  it commits with the rest of the request.
969
1061
 
970
1062
  ```ts
971
- const rows = await Database.query(
1063
+ const rows = await Database.$query(
972
1064
  "SELECT c.name, count(*) FROM entries e JOIN categories c ON c.id = e.category_id" +
973
1065
  " WHERE e.household_id = $1 GROUP BY c.name",
974
1066
  [hid],
@@ -985,7 +1077,7 @@ Inside a transaction, `insertMany` takes an optional conflict rule — which is
985
1077
  per row with a `23505` caught around each:
986
1078
 
987
1079
  ```ts
988
- await Database.transaction((tx) => {
1080
+ await Database.$transaction((tx) => {
989
1081
  tx.tables.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
990
1082
  return null;
991
1083
  });
@@ -1012,7 +1104,7 @@ carrying the constraint Postgres named:
1012
1104
  import { UniqueViolation, Conflict } from "@palbase/backend";
1013
1105
 
1014
1106
  try {
1015
- return await Database.tables.users.insert({ email });
1107
+ return await Database.public.users.insert({ email });
1016
1108
  } catch (e) {
1017
1109
  if (UniqueViolation.is(e) && e.constraint === "users_email_key") {
1018
1110
  throw new Conflict("That email is taken", "email_taken");
@@ -1042,7 +1134,7 @@ middle, so nothing holds a database connection open while your code thinks.
1042
1134
  ```ts
1043
1135
  import { Database, NotFound } from "@palbase/backend";
1044
1136
 
1045
- const { orderId } = await Database.transaction((tx) => {
1137
+ const { orderId } = await Database.$transaction((tx) => {
1046
1138
  const order = tx.tables.orders
1047
1139
  .insert({ amount: 1000, status: "pending" })
1048
1140
  .expectOne(new NotFound("order could not be created"));
@@ -1091,9 +1183,9 @@ does not write is not part of the transaction:
1091
1183
 
1092
1184
  ```ts
1093
1185
  // Before the transaction: an ordinary value you can branch on.
1094
- const overrides = await Database.tables.category_overrides.findMany({ household_id });
1186
+ const overrides = await Database.public.category_overrides.findMany({ where: { household_id } });
1095
1187
 
1096
- const stmt = await Database.transaction((tx) => { /* … */ });
1188
+ const stmt = await Database.$transaction((tx) => { /* … */ });
1097
1189
  ```
1098
1190
 
1099
1191
  ### Writing conditions as filters
@@ -1124,19 +1216,113 @@ Three expressions may appear in the values you write:
1124
1216
 
1125
1217
  | Expression | Where | Meaning |
1126
1218
  |---|---|---|
1127
- | `now()` | anywhere | the server's clock |
1128
- | `inc(n)` | `updateWhere`'s `set` | `column = column + n`, atomically |
1129
- | `dec(n)` | `updateWhere`'s `set` | `column = column - n`, atomically |
1219
+ | `now()` | any `set` — `updateMany` and `updateWhere` | the server's clock |
1220
+ | `increment(n)` | any `set` | `column = column + n`, atomically |
1221
+ | `decrement(n)` | any `set` | `column = column - n`, atomically |
1222
+
1223
+ `inc` / `dec` are the OLD names for `increment` / `decrement`. They are the same
1224
+ factory (`inc === increment`), kept as deprecated aliases; write the long names.
1225
+
1226
+ The expressions work on BOTH surfaces — the direct `updateMany` and the plan's
1227
+ `updateWhere` — because they are one object:
1228
+
1229
+ ```ts
1230
+ // Direct: one statement, no transaction needed.
1231
+ await Database.public.accounts.updateMany({
1232
+ where: { id: accountId },
1233
+ set: { balance: decrement("5.00") },
1234
+ });
1235
+
1236
+ // Inside a plan: the same expression, the same meaning.
1237
+ await Database.$transaction((tx) => {
1238
+ tx.tables.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
1239
+ return null;
1240
+ });
1241
+ ```
1242
+
1243
+ **Give money a STRING amount.** `increment(0.1)` binds a JS number and
1244
+ `0.1 + 0.2` is `0.30000000000000004`; `increment("0.1")` binds the decimal and
1245
+ Postgres adds it exactly. Measured: `12345678901234567890` as a number becomes
1246
+ `12345678901234600000`, as a string it stays whole.
1247
+
1248
+ **The amount is a magnitude, not a signed number.** `decrement("-5")` is
1249
+ refused: a negative amount would ADD money through a call that reads as a
1250
+ withdrawal, and the guard names `increment()` as the way to say the other
1251
+ direction.
1252
+
1253
+ `increment` / `decrement` read the column's current value, which an inserted row
1254
+ does not have — using one in an `insert` is a compile error, and the engine
1255
+ refuses it by name if it arrives untyped.
1256
+
1257
+ ### Zero rows is not silent success
1258
+
1259
+ `updateMany` returns EVERY row it matched, and matching nothing is an answer the
1260
+ caller has to read:
1261
+
1262
+ ```ts
1263
+ const [row] = await Database.public.accounts.updateMany({
1264
+ where: { id: accountId, balance: { gte: amount } }, // the guard IS the filter
1265
+ set: { balance: decrement(amount) },
1266
+ });
1267
+ if (row === undefined) throw new Conflict("insufficient balance");
1268
+ ```
1269
+
1270
+ The filter carries the condition, so the check and the write are ONE statement —
1271
+ there is no window between reading a balance and spending it. An empty array
1272
+ means the balance was too low, or the row does not exist, or RLS hides it from
1273
+ this caller. None of those is "the transfer happened".
1274
+
1275
+ ### Append-only tables — a record that cannot be rewritten
1130
1276
 
1131
- `inc`/`dec` read the column's current value, which an inserted row does not
1132
- have using them in an `insert` is a compile error.
1277
+ `appendOnly: true` on a table declaration means a row can be INSERTED and read,
1278
+ never updated and never deleted. A correction is a COMPENSATING ENTRY: a new row
1279
+ carrying the opposite sign.
1280
+
1281
+ ```ts
1282
+ export const entries = defineTable("entries", {
1283
+ appendOnly: true,
1284
+ columns: { /* … */ },
1285
+ });
1286
+ ```
1287
+
1288
+ **Three locks, because the first two are not enough on their own:**
1289
+
1290
+ | Lock | Stops | Does not stop |
1291
+ |---|---|---|
1292
+ | The TYPE | `update` / `updateMany` / `delete` / `deleteMany` / `put` / `supersede` are not on the table | untyped `$`-prefixed calls, anything off the wire |
1293
+ | The ENGINE | those same operations refused by name, whichever door they come through | SQL that never passes through this SDK |
1294
+ | The DATABASE | `REVOKE UPDATE, DELETE`, a RESTRICTIVE policy, and two triggers — `BEFORE UPDATE OR DELETE` per row and `BEFORE TRUNCATE` per statement | the table's OWNER, who can `ALTER TABLE … DISABLE TRIGGER` first (measured) — as an owner can also simply drop the table |
1295
+
1296
+ The triggers are not belt-and-braces. Measured on a live stack: the platform
1297
+ re-grants table privileges on every boot, which quietly undid the REVOKE, and
1298
+ the service role carries `BYPASSRLS`, so the policy never applied to it either.
1299
+ With both gone the row really was deletable. The triggers are what actually
1300
+ hold, and they say so:
1301
+
1302
+ ```
1303
+ ERROR: table entries is append-only: a row cannot be updated or deleted.
1304
+ Write a COMPENSATING ENTRY instead (an INSERT carrying the opposite sign).
1305
+ ```
1306
+
1307
+ `SELECT` and `INSERT` are untouched — append-only means the history is kept, not
1308
+ that it is unreadable, and the correction path has to stay open.
1309
+
1310
+ **TRUNCATE needs its own trigger, and finding that out took a measurement.** A
1311
+ row-level trigger never sees a `TRUNCATE` — Postgres truncates without touching
1312
+ rows — so the owner could empty the whole ledger in one statement while every
1313
+ other door was shut. The statement-level `BEFORE TRUNCATE` trigger closes it.
1314
+
1315
+ What is NOT closed: the table's owner can `ALTER TABLE … DISABLE TRIGGER` and
1316
+ then write freely. That is not a hole this can fix — the same owner can drop the
1317
+ table — and saying otherwise would be the kind of claim this table exists to
1318
+ avoid.
1133
1319
 
1134
1320
  ### Limits
1135
1321
 
1136
1322
  A plan may carry at most 1000 operations, 5000 rows in one `insertMany`, and
1137
1323
  8 MiB of JSON. Exceeding any of them is reported before the request is sent.
1138
1324
 
1139
- ## Bypassing RLS — `Database.asService()`
1325
+ ## Bypassing RLS — `Database.$asService()`
1140
1326
 
1141
1327
  When a table has [Row-Level Security](./schema.md#row-level-security-rls)
1142
1328
  policies, every `Database.*` call runs as the request's verified user, so the
@@ -1153,7 +1339,7 @@ nothing your code does. See
1153
1339
 
1154
1340
  Sometimes you need to read or write **across all users** — an admin endpoint, a
1155
1341
  background job that fans out notifications, a cleanup task. For that, call
1156
- `Database.asService()`. It returns a sibling client that runs as
1342
+ `Database.$asService()`. It returns a sibling client that runs as
1157
1343
  **`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
1158
1344
  exact same surface — `tables`, the raw string ops, and `transaction`:
1159
1345
 
@@ -1161,14 +1347,14 @@ exact same surface — `tables`, the raw string ops, and `transaction`:
1161
1347
  import { Database } from "@palbase/backend";
1162
1348
 
1163
1349
  // RLS-enforced (default): only the caller's own rows.
1164
- const mine = await Database.tables.todos.findMany({});
1350
+ const mine = await Database.public.todos.findMany({});
1165
1351
 
1166
1352
  // Service-role bypass: every user's rows. Explicit and greppable.
1167
- const all = await Database.asService().tables.todos.findMany({});
1168
- const rows = await Database.asService().query("SELECT count(*) FROM todos");
1353
+ const all = await Database.$asService().tables.todos.findMany({});
1354
+ const rows = await Database.$asService().query("SELECT count(*) FROM todos");
1169
1355
 
1170
1356
  // A service-role transaction (the role is fixed for the whole plan):
1171
- await Database.asService().transaction((tx) => {
1357
+ await Database.$asService().transaction((tx) => {
1172
1358
  tx.tables.todos.updateWhere({ id }, { done: true });
1173
1359
  return null;
1174
1360
  });
@@ -1181,8 +1367,8 @@ Guidelines:
1181
1367
  grep for in review.
1182
1368
  - **No double-bypass / no nesting.** The sibling does not re-expose
1183
1369
  `asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
1184
- transaction. Use `Database.transaction(...)` for an authenticated one and
1185
- `Database.asService().transaction(...)` for a service-role one; you cannot mix
1370
+ transaction. Use `Database.$transaction(...)` for an authenticated one and
1371
+ `Database.$asService().transaction(...)` for a service-role one; you cannot mix
1186
1372
  enforced and bypassed operations inside a single plan.
1187
1373
  - **It is a second transaction, on a second connection.** It has to be: the
1188
1374
  Postgres role is bound once, when the transaction opens, so a sibling sharing
@@ -1208,7 +1394,7 @@ Declare your tables under `db/`, **one file per schema**: `db/public.ts` is the
1208
1394
  schema Palbase expects to find, `db/billing.ts` declares a second one. Each file
1209
1395
  default-exports a `defineSchema("<name>", { tables })` call. That drives
1210
1396
  [migrations](./migrations.md) (additive changes auto-apply on deploy; type
1211
- changes need an explicit migration) and makes `Database.tables.*` typed
1397
+ changes need an explicit migration) and makes `Database.public.*` typed
1212
1398
  everywhere — by default, with no import and no generic.
1213
1399
 
1214
1400
  > Coming from a single `db/schema.ts` with tables declared inline? That layout is
@@ -1615,14 +1801,14 @@ and an index is not one.
1615
1801
  ## Typed DB access — by default
1616
1802
 
1617
1803
  You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
1618
- `palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
1804
+ `palbase-env.d.ts`, which types `Database.public.<name>` everywhere — no import
1619
1805
  of the schema, no generic, no cast:
1620
1806
 
1621
1807
  ```ts
1622
1808
  // services/room.service.ts — the layer that touches the database.
1623
1809
  import { Database } from "@palbase/backend";
1624
1810
 
1625
- type RoomsTable = typeof Database.tables.rooms; // typed from your db/*.ts
1811
+ type RoomsTable = typeof Database.public.rooms; // typed from your db/*.ts
1626
1812
 
1627
1813
  export class RoomService {
1628
1814
  private readonly rooms: RoomsTable;
@@ -1635,13 +1821,22 @@ export class RoomService {
1635
1821
  }
1636
1822
  }
1637
1823
 
1638
- export const roomService = new RoomService(Database.tables.rooms);
1824
+ ```
1825
+
1826
+ ```ts
1827
+ // rooms.module.ts
1828
+ import { Module, type Token } from "@palbase/backend";
1829
+ import { RoomsController } from "./controllers/rooms.controller.ts";
1830
+ import { RoomService } from "./services/room.service.ts";
1831
+
1832
+ @Module({ controllers: [RoomsController as Token], providers: [RoomService as Token] })
1833
+ export class RoomsModule {}
1639
1834
  ```
1640
1835
 
1641
1836
  ```ts
1642
1837
  // controllers/rooms.controller.ts — HTTP only; no `Database` import here.
1643
1838
  import { Controller, Post, Body, z } from "@palbase/backend";
1644
- import { roomService } from "../services/room.service.js";
1839
+ import { RoomService } from "../services/room.service.js";
1645
1840
 
1646
1841
  const CreateRoomBody = z.object({ name: z.string() });
1647
1842
  const RoomOut = z.object({ id: z.string(), name: z.string() });
@@ -1657,12 +1852,12 @@ export default class RoomsController {
1657
1852
  }
1658
1853
  ```
1659
1854
 
1660
- `Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
1661
- `findById(id)`, `findMany(query?)`. `Database.transaction(fn)` yields a `tx`
1855
+ `Database.public.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
1856
+ `findById(id)`, `findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
1662
1857
  whose `tx.tables.<name>` is typed from the same schema, but carries plan
1663
1858
  operations (`insert`/`insertMany`/`updateWhere`/`deleteWhere`/`select`) rather
1664
1859
  than awaited calls — see [database.md](./database.md#transactions). The raw
1665
- string-keyed ops (`Database.insert("rooms", …)`, `Database.query(…)`) are still
1860
+ string-keyed ops (`Database.$insert("rooms", …)`, `Database.$query(…)`) are still
1666
1861
  available for dynamic table names and read-only SQL.
1667
1862
 
1668
1863
  If you want a row type explicitly, import it from the generated env module:
@@ -1783,10 +1978,10 @@ export const todos = defineTable("todos", {
1783
1978
  export default defineSchema("public", { tables: [todos] });
1784
1979
  ```
1785
1980
 
1786
- With this in place, `await Database.tables.todos.findMany({})` returns only the
1981
+ With this in place, `await Database.public.todos.findMany({})` returns only the
1787
1982
  calling user's rows — no `WHERE owner = …` needed in the handler. To read or
1788
1983
  write across all users (e.g. an admin job), use the explicit bypass:
1789
- `Database.asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
1984
+ `Database.$asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
1790
1985
 
1791
1986
  ### How policies are applied
1792
1987
 
@@ -1901,7 +2096,7 @@ one, `palbase push` is what moves an Environment's. Until then the declaration i
1901
2096
  ahead of the tables.
1902
2097
 
1903
2098
  The TYPES move separately again. `palbase build` regenerates `palbase-env.d.ts`
1904
- from `db/*.ts`, which is what types `Database.tables.<name>` in your
2099
+ from `db/*.ts`, which is what types `Database.public.<name>` in your
1905
2100
  services — so after a schema edit, run it. (There is no second command for this:
1906
2101
  `build` regenerates everything derived, because one verb to remember is one verb
1907
2102
  to forget.) Typed and applied are independent, and knowing which one you are
@@ -1974,7 +2169,7 @@ existing data gets there.
1974
2169
  Add `rls: true` + `policies: [policy(...)]` to a table in `db/*.ts`; the
1975
2170
  generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
1976
2171
  See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
1977
- `Database.tables.*` access.
2172
+ `Database.public.*` access.
1978
2173
 
1979
2174
  ### Hand-writing a policy
1980
2175
 
@@ -2058,7 +2253,7 @@ await Cache.del("k");
2058
2253
  // Stampede-safe read-through: concurrent callers wait on ONE execution of fn
2059
2254
  // and share its result, however many of them there are.
2060
2255
  const profile = await Cache.getOrSet("user:42", 300, async () => {
2061
- return Database.findById("users", "42");
2256
+ return Database.$findById("users", "42");
2062
2257
  });
2063
2258
  ```
2064
2259
 
@@ -2166,7 +2361,7 @@ export default class CheckoutController {
2166
2361
 
2167
2362
  ### Overriding a flag — current user vs cross-user
2168
2363
 
2169
- Writes mirror the `Database` / `Database.asService()` model:
2364
+ Writes mirror the `Database` / `Database.$asService()` model:
2170
2365
 
2171
2366
  - `Flags.setOverride(key, value)` (default) writes an override for the **current
2172
2367
  request user** — no `userId` argument, no admin power. It errors on an
@@ -2174,7 +2369,7 @@ Writes mirror the `Database` / `Database.asService()` model:
2174
2369
  - `Flags.asService()` returns the cross-user admin surface
2175
2370
  (`setOverrideForUser`, `setOverridesForUser`, `clearOverrideForUser`,
2176
2371
  `clearAllOverridesForUser`, `batchSetOverrides`) for writing overrides for an
2177
- **arbitrary** user. Explicit and greppable, just like `Database.asService()`.
2372
+ **arbitrary** user. Explicit and greppable, just like `Database.$asService()`.
2178
2373
 
2179
2374
  ```ts
2180
2375
  // Current request user — no userId needed:
@@ -2243,47 +2438,137 @@ A controller's job is HTTP — validate a body through a named schema, name the
2243
2438
  whose, in what order is the service's. A controller that reaches for `Database`
2244
2439
  has moved the logic into the layer that is hardest to test.
2245
2440
 
2246
- **2. The dependency arrives through the CONSTRUCTOR, and the module exports one
2247
- wired instance.** There is no DI container and no decorator the last line of
2248
- the file is the wiring:
2441
+ **2. A dependency arrives through the CONSTRUCTOR, and the container supplies
2442
+ it.** Mark the class `@Injectable()` and name what it needs as ordinary
2443
+ constructor parameters:
2249
2444
 
2250
2445
  ```ts
2251
2446
  // services/note.service.ts
2252
- import { Database } from "@palbase/backend";
2447
+ import { Database, Injectable } from "@palbase/backend";
2253
2448
 
2254
- type NotesTable = typeof Database.tables.notes;
2449
+ type NotesTable = typeof Database.public.notes;
2255
2450
 
2451
+ @Injectable()
2256
2452
  export class NoteService {
2257
- private readonly notes: NotesTable;
2258
-
2259
- // Assigned in the BODY. A parameter property (`constructor(private notes: …)`)
2260
- // is refused by Node's type-stripping test runner, for the whole file.
2261
- constructor(notes: NotesTable) {
2262
- this.notes = notes;
2263
- }
2453
+ private readonly notes: NotesTable = Database.public.notes;
2264
2454
 
2265
2455
  list(userId: string) {
2266
- return this.notes.findMany({ user_id: userId });
2456
+ return this.notes.findMany({ where: { user_id: userId } });
2267
2457
  }
2268
2458
  }
2459
+ ```
2269
2460
 
2270
- export const noteService = new NoteService(Database.tables.notes);
2461
+ ```ts
2462
+ // services/report.service.ts — a service that depends on another service
2463
+ import { Injectable } from "@palbase/backend";
2464
+ import { NoteService } from "./note.service.ts";
2465
+
2466
+ @Injectable()
2467
+ export class ReportService {
2468
+ constructor(private readonly notes: NoteService) {}
2469
+
2470
+ async count(userId: string): Promise<number> {
2471
+ return (await this.notes.list(userId)).length;
2472
+ }
2473
+ }
2271
2474
  ```
2272
2475
 
2476
+ A controller asks the same way, and nothing wires it by hand:
2477
+
2273
2478
  ```ts
2274
2479
  // controllers/notes.controller.ts
2275
- import { noteService } from "../services/note.service";
2480
+ import { Controller, Get } from "@palbase/backend";
2481
+ import { NoteService } from "../services/note.service.ts";
2482
+
2483
+ @Controller("/notes")
2484
+ export class NotesController {
2485
+ constructor(private readonly notes: NoteService) {}
2486
+
2487
+ @Get("") list(): Promise<Note[]> { return this.notes.list(currentUserId()); }
2488
+ }
2489
+ ```
2490
+
2491
+ There is **no `inject()`, no `@Inject`, and no token registry**. A dependency is
2492
+ named by its parameter's TYPE and by nothing else — which is also why an
2493
+ `interface` cannot be one: an interface does not exist at runtime, so there is
2494
+ nothing to hand over. Use a class, or an `abstract class` when you want the
2495
+ abstraction:
2496
+
2497
+ ```ts
2498
+ export abstract class Clock {
2499
+ abstract now(): number;
2500
+ }
2501
+
2502
+ @Injectable()
2503
+ export class SystemClock extends Clock {
2504
+ now(): number { return Date.now(); }
2505
+ }
2506
+ ```
2507
+
2508
+ The abstraction is the TOKEN; the implementation is the PROVIDER. `SystemClock`
2509
+ goes in a module's `providers` and `Clock` goes in nobody's: the container
2510
+ resolves an abstraction to the single owned class that `extends` it. Listing
2511
+ `Clock` itself is refused — `new Clock()` succeeds in JavaScript and hands back
2512
+ an object missing every abstract member, and that object would be injected.
2513
+
2514
+ **3. ONE module says what exists, who owns it, and what it may reach.** A class
2515
+ that no module lists does not exist: it is refused at build, by name, and it
2516
+ never reaches the route table, the dispatcher or the OpenAPI document.
2517
+
2518
+ ```ts
2519
+ // notes.module.ts — beside the domain it owns, not in a directory we name
2520
+ import { Module, type Token } from "@palbase/backend";
2521
+ import { NotesController } from "./controllers/notes.controller.ts";
2522
+ import { NoteService } from "./services/note.service.ts";
2523
+ import { ReportService } from "./services/report.service.ts";
2524
+
2525
+ @Module({
2526
+ controllers: [NotesController as Token], // the entry points this module owns
2527
+ providers: [NoteService as Token, ReportService as Token], // what it owns
2528
+ exports: [NoteService as Token], // what OTHER modules may reach
2529
+ imports: [], // whose exports THIS module may reach
2530
+ })
2531
+ export class NotesModule {}
2276
2532
  ```
2277
2533
 
2278
- **3. That module-level singleton is the only way to hold a dependency.**
2279
- Controllers, hooks, jobs and webhooks are all constructed by the runtime with
2280
- **no arguments**. A class that declares a constructor parameter is refused at
2281
- build with the class named — there is no injector to fill it, so the field would
2282
- otherwise be `undefined` in production. Import the singleton instead.
2534
+ The four lists answer four different questions, and nothing else answers them:
2535
+
2536
+ | list | question |
2537
+ |---|---|
2538
+ | `providers` | which classes does this module OWN |
2539
+ | `controllers` | which entry points does it own |
2540
+ | `exports` | which of its own classes may another module reach |
2541
+ | `imports` | whose exports may this module reach |
2542
+
2543
+ `ReportService` is NOT exported above, so no other module can depend on it —
2544
+ and the build says so by name if one tries. That is the whole boundary: a
2545
+ module's internals stay internal until it says otherwise.
2546
+
2547
+ There is no root module and nothing to mount one into. A small project writes
2548
+ one module file; a large one writes a module per domain and the boundaries are
2549
+ enforced at build rather than by convention.
2550
+
2551
+ **A constructor stays pure wiring.** It runs while the app is coming up, it is
2552
+ synchronous, and it must not do I/O. Real work at startup belongs in `onStart`,
2553
+ which is awaited before the first request is served — a connection opened in a
2554
+ constructor fails somewhere nobody is watching, and one opened in `onStart`
2555
+ fails at boot with the error in front of the operator.
2556
+
2557
+ Rule 2 is also what makes rule 1 pay off. A test can build the graph with a
2558
+ stand-in in place of any part of it, however deep:
2559
+
2560
+ ```ts
2561
+ import { isolated } from "@palbase/backend/test";
2562
+
2563
+ const frozen = new (class extends Clock { now() { return 0; } })();
2564
+ const report = isolated().with(Clock, frozen).get(ReportService);
2565
+ ```
2283
2566
 
2284
- Rule 2 is also what makes rule 1 pay off: because the service is handed its
2285
- table rather than reaching for the singleton, a test constructs it with a
2286
- stand-in and never needs a database.
2567
+ `isolated()` rebuilds the graph with the overrides in place and touches no
2568
+ process-wide state, so the next test in the same file does not meet whatever
2569
+ this one substituted. Or construct the class yourself — `new ReportService(new
2570
+ NoteService())` is ordinary TypeScript, and the container is not required for it
2571
+ to work.
2287
2572
 
2288
2573
  ```ts
2289
2574
  // services/note.service.test.ts — `npm test`, no database
@@ -2437,8 +2722,8 @@ import { Database, Job, Log, type JobMeta } from "@palbase/backend";
2437
2722
  @Job({ schedule: "0 3 * * *", timeout: 120 }) // schedule: standard cron; timeout: optional, seconds
2438
2723
  export default class CleanupJob {
2439
2724
  async run(meta: JobMeta) {
2440
- const expired = await Database.findMany("sessions", { expired: true });
2441
- for (const s of expired) await Database.delete("sessions", s.id as string);
2725
+ const expired = await Database.$findMany("sessions", { expired: true });
2726
+ for (const s of expired) await Database.$delete("sessions", s.id as string);
2442
2727
  Log.info(`cleaned ${expired.length} sessions in ${meta.environmentId}`);
2443
2728
  }
2444
2729
  }
@@ -2470,7 +2755,7 @@ import { auth, Database, Log } from "@palbase/backend";
2470
2755
 
2471
2756
  export const onUserCreated = auth.onUserCreated(async (event, meta) => {
2472
2757
  Log.info(`new user: ${event.user.email}`);
2473
- await Database.insert("profiles", {
2758
+ await Database.$insert("profiles", {
2474
2759
  user_id: event.user.id,
2475
2760
  email: event.user.email,
2476
2761
  });
@@ -2503,13 +2788,13 @@ import { Database, Log, On, Webhook, type WebhookMeta } from "@palbase/backend";
2503
2788
  export default class StripeWebhook {
2504
2789
  @On("checkout.session.completed")
2505
2790
  async checkoutCompleted(event: unknown, meta: WebhookMeta) {
2506
- await Database.insert("orders", { status: "paid", data: event });
2791
+ await Database.$insert("orders", { status: "paid", data: event });
2507
2792
  }
2508
2793
 
2509
2794
  @On("payment_intent.payment_failed")
2510
2795
  async paymentFailed(event: unknown, meta: WebhookMeta) {
2511
2796
  Log.error("payment failed");
2512
- await Database.insert("payment_failures", { data: event });
2797
+ await Database.$insert("payment_failures", { data: event });
2513
2798
  }
2514
2799
  }
2515
2800
  ```