@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
package/docs/README.md CHANGED
@@ -35,14 +35,16 @@ service the controllers call.
35
35
 
36
36
  ### The 7 rules (checklist)
37
37
 
38
- 1. **A controller needs NO export at all.** `@Controller` records the class into a
39
- `globalThis` registry as it decorates it, and the bundler imports the file for
40
- that side effect alone the shipped scaffold's own `HealthController` and
41
- `NotesController` are not exported. Exporting is harmless and reads well, so
42
- these examples do it; it is not a requirement. (`export default` **is**
38
+ 1. **A controller is exported by NAME and listed in a module.** `@Controller`
39
+ records the class as it decorates it, but that registration only says the
40
+ class EXISTSa module's `controllers` list is what decides it should be
41
+ served. So the module imports it by name, which means the class is exported;
42
+ an unexported one is registered, owned by nothing, and refused at build with
43
+ `unowned class`. Nothing is default-exported here. (`export default` **is**
43
44
  required for `jobs/`, `webhooks/`, `hooks/` and every `db/*.ts`, one class per
44
- file.) What IS fatal is a `@Controller` class that collected zero routes
45
- usually `experimentalDecorators` missing from `tsconfig.json`.
45
+ file those are read off disk by name, not through a module.) What IS fatal
46
+ is a `@Controller` class that collected zero routes — usually
47
+ `experimentalDecorators` missing from `tsconfig.json`.
46
48
  2. **Methods that call a service are `async` and return `Promise<T>`.** Services
47
49
  `await Database`, so they return promises; a sync return type on an async body
48
50
  is a tsc error. Annotate `: Promise<TodoSchema>`, not `: TodoSchema`, whenever
@@ -71,7 +73,7 @@ service the controllers call.
71
73
 
72
74
  > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
73
75
  > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
74
- > and will not compile against `@palbase/backend` 25.
76
+ > and will not compile against `@palbase/backend` 27.
75
77
 
76
78
  ### Complete CRUD example (copy-pasteable, compiles)
77
79
 
@@ -95,13 +97,14 @@ export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
95
97
 
96
98
  ```ts
97
99
  // services/todo.service.ts — plain class + singleton. The real work.
98
- import { Database, NotFound } from "@palbase/backend";
100
+ import { Database, Injectable, NotFound } from "@palbase/backend";
99
101
  import type { TodoSchema } from "../models/todos/shared.js";
100
102
 
101
103
  /** The typed surface of ONE table. Naming it keeps the seam one table wide:
102
104
  * a test fake implements five methods, not the whole `Database`. */
103
- type TodosTable = typeof Database.tables.todos;
105
+ type TodosTable = typeof Database.public.todos;
104
106
 
107
+ @Injectable()
105
108
  export class TodoService {
106
109
  private readonly todos: TodosTable;
107
110
 
@@ -115,7 +118,7 @@ export class TodoService {
115
118
  }
116
119
 
117
120
  list(userId: string): Promise<TodoSchema[]> {
118
- return this.todos.findMany({ user_id: userId });
121
+ return this.todos.findMany({ where: { user_id: userId } });
119
122
  }
120
123
  create(userId: string, title: string): Promise<TodoSchema> {
121
124
  return this.todos.insert({ user_id: userId, title });
@@ -131,13 +134,26 @@ export class TodoService {
131
134
  }
132
135
  }
133
136
 
134
- /** The wired instance. Controllers import THIS, never the class. It is also the
135
- * ONLY supported way to hold a dependency: a controller, job, hook or webhook is
136
- * constructed with no arguments, and one that declares a constructor parameter
137
- * is refused at build with the class named. */
138
- export const todoService = new TodoService(Database.tables.todos);
139
137
  ```
140
138
 
139
+ ```ts
140
+ // todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
141
+ import { Module, type Token } from "@palbase/backend";
142
+ import { TodosController } from "./controllers/todos.controller.ts";
143
+ import { TodoService } from "./services/todo.service.ts";
144
+
145
+ @Module({
146
+ controllers: [TodosController as Token],
147
+ providers: [TodoService as Token],
148
+ })
149
+ export class TodosModule {}
150
+ ```
151
+
152
+ A class no module lists does not exist: it is refused at build, by name, and it
153
+ never reaches the route table or the OpenAPI document. Nothing is wired by hand
154
+ — a controller names `TodoService` as a constructor parameter and the container
155
+ supplies it. See [services.md](./services.md#your-own-service-layer).
156
+
141
157
  ```ts
142
158
  // controllers/todos.controller.ts — class controller. Thin: delegates to the service.
143
159
  import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
@@ -150,7 +166,7 @@ import { CreateTodoBody } from "../models/todos/create.js";
150
166
  export default class TodosController {
151
167
  // The service arrives as an IMPORTED SINGLETON, not a constructor parameter:
152
168
  // the runtime constructs this class with no arguments, and one that declares a
153
- // parameter is refused at build with the class named.
169
+ // parameter is refused when the route table is built at boot, with the class named.
154
170
 
155
171
  @Get("") // GET /todos → operationId todos.list
156
172
  async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
@@ -218,7 +234,7 @@ generated client surface) changes; the verb/path do not affect it.
218
234
  (stage, bundle, extract controller metadata). Exits non-zero on a decorator,
219
235
  return-type or version-skew error, so a push that would deploy zero endpoints
220
236
  fails on your machine instead. It also regenerates `palbase-env.d.ts` from
221
- your `db/*.ts` files, which is what types `Database.tables.*` (no import, no
237
+ your `db/*.ts` files, which is what types `Database.public.*` (no import, no
222
238
  generic) — so run it after editing the schema. There is no separate command
223
239
  for that: `build` regenerates everything derived.
224
240
  - `palbase push` — deploy the current backend to the selected Environment. For a
package/docs/auth.md CHANGED
@@ -176,4 +176,4 @@ single-use and expire; a used or expired one fails closed with a `400`.
176
176
 
177
177
  - [Row-Level Security](./schema.md#row-level-security-rls) — pushing per-user
178
178
  access rules into Postgres, where `auth.uid()` is this same verified user.
179
- - [Database](./database.md) — how `Database.asService()` steps outside RLS.
179
+ - [Database](./database.md) — how `Database.$asService()` steps outside RLS.
@@ -26,8 +26,8 @@ import { Database, Job, Log, type JobMeta } from "@palbase/backend";
26
26
  @Job({ schedule: "0 3 * * *", timeout: 120 }) // schedule: standard cron; timeout: optional, seconds
27
27
  export default class CleanupJob {
28
28
  async run(meta: JobMeta) {
29
- const expired = await Database.findMany("sessions", { expired: true });
30
- for (const s of expired) await Database.delete("sessions", s.id as string);
29
+ const expired = await Database.$findMany("sessions", { expired: true });
30
+ for (const s of expired) await Database.$delete("sessions", s.id as string);
31
31
  Log.info(`cleaned ${expired.length} sessions in ${meta.environmentId}`);
32
32
  }
33
33
  }
package/docs/database.md CHANGED
@@ -10,19 +10,19 @@ import { Database } from "@palbase/backend";
10
10
  Only **middleware** still uses `ctx.db` (see [background.md](./background.md)
11
11
  and [events.md](./events.md) for worker/job/hook/webhook examples).
12
12
 
13
- ## Typed by default — `Database.tables`
13
+ ## Typed by default — `Database.<şema>.<tablo>`
14
14
 
15
- When you declare a schema under `db/*.ts`, `Database.tables.<name>` is typed everywhere
15
+ When you declare a schema under `db/*.ts`, `Database.public.<name>` is typed everywhere
16
16
  with no import and no generic. `insert` demands the right columns; rows come
17
17
  back typed; nullable columns are `T | null`. This is the path you should use:
18
18
 
19
19
  ```ts
20
- const todo = await Database.tables.todos.insert({ title: "buy milk" });
20
+ const todo = await Database.public.todos.insert({ title: "buy milk" });
21
21
  todo.id; // string ✓
22
22
  todo.done; // boolean ✓
23
- const open = await Database.tables.todos.findMany({ done: false });
24
- await Database.tables.todos.update(todo.id, { done: true });
25
- await Database.tables.todos.delete(todo.id);
23
+ const open = await Database.public.todos.findMany({ where: { done: false } });
24
+ await Database.public.todos.update({ where: { id: todo.id }, set: { done: true } });
25
+ await Database.public.todos.delete(todo.id);
26
26
  // todo.nope ← compile error
27
27
  ```
28
28
 
@@ -35,18 +35,18 @@ cover, the string-keyed ops are still available:
35
35
 
36
36
  | Method | Returns |
37
37
  |--------|---------|
38
- | `Database.insert(table, data)` | the inserted row (`Record<string, unknown>`) |
39
- | `Database.upsert(table, data, { onConflict })` | the inserted-or-updated row |
40
- | `Database.update(table, id, data)` | the updated row, or `null` if none matched |
41
- | `Database.delete(table, id)` | `void` |
42
- | `Database.updateMany(table, where, set)` | every row the filter matched, updated |
43
- | `Database.deleteMany(table, where)` | how many rows went |
44
- | `Database.findById(table, id)` | the row or `null` |
45
- | `Database.findMany(table, where?, opts?)` | matching rows (array) |
46
- | `Database.count(table, where?)` | how many rows match |
47
- | `Database.query(sql, params?)` | rows from a SQL query, in the request's own transaction |
48
- | `Database.transaction(fn)` | runs a whole transaction plan in one request |
49
- | `Database.attempt(fn)` | a savepoint: a failure inside rolls back only its own writes |
38
+ | `Database.$insert(table, data)` | the inserted row (`Record<string, unknown>`) |
39
+ | `Database.$put(table, data, { onConflict })` | the inserted-or-updated row |
40
+ | `Database.$update(table, id, data)` | the updated row, or `null` if none matched |
41
+ | `Database.$delete(table, id)` | `void` |
42
+ | `Database.$updateMany(table, where, set)` | every row the filter matched, updated |
43
+ | `Database.$deleteMany(table, where)` | how many rows went |
44
+ | `Database.$findById(table, id)` | the row or `null` |
45
+ | `Database.$findMany(table, where?, opts?)` | matching rows (array) |
46
+ | `Database.$count(table, where?)` | how many rows match |
47
+ | `Database.$query(sql, params?)` | rows from a SQL query, in the request's own transaction |
48
+ | `Database.$transaction(fn)` | runs a whole transaction plan in one request |
49
+ | `Database.$attempt(fn)` | a savepoint: a failure inside rolls back only its own writes |
50
50
 
51
51
  ### Filters
52
52
 
@@ -54,26 +54,103 @@ A filter's keys are ANDed. Each value is either a plain value (equality) or an
54
54
  operator object:
55
55
 
56
56
  ```ts
57
- await Database.tables.entries.findMany({
58
- household_id: hid, // equality
59
- amount_kurus: { gte: 1000 }, // >=
60
- category: { in: ["food", "rent"] },
61
- status: { neq: "void" },
57
+ await Database.public.entries.findMany({
58
+ where: {
59
+ household_id: hid, // equality
60
+ amount_kurus: { gte: 1000 }, // >=
61
+ category: { in: ["food", "rent"] },
62
+ status: { neq: "void" },
63
+ },
62
64
  });
63
65
  ```
64
66
 
65
- Operators: `gt` · `gte` · `lt` · `lte` · `neq` · `in`. The same filter language
66
- works for `updateMany`, `deleteMany` and `count` one language, so two spellings
67
- cannot drift apart. An empty `in` list means NO rows (written into the SQL as
68
- `false`), never a silent full scan.
67
+ Operators: `gt` · `gte` · `lt` · `lte` · `neq` · `in` · `contains` ·
68
+ `icontains` · `startsWith` · `endsWith` · `isNull`, plus `OR` / `AND` / `NOT`
69
+ for composition. The same filter language works for `updateMany`, `deleteMany`,
70
+ `count` and `search` — one language, so two spellings cannot drift apart. An
71
+ empty `in` list means NO rows (written into the SQL as `false`), never a silent
72
+ full scan.
73
+
74
+ ### Comparing one column with another — `col()`
75
+
76
+ The right-hand side of a comparison is normally a VALUE. `col()` puts a COLUMN
77
+ there, which is otherwise a reason to drop to raw SQL:
78
+
79
+ ```ts
80
+ import { col } from "@palbase/backend";
81
+
82
+ // Invoices that are not fully paid.
83
+ await Database.public.invoices.findMany({ where: { total: { gt: col("amount_paid") } } });
84
+ ```
85
+
86
+ The name is checked at COMPILE time against the row type: `col("amont_paid")`
87
+ does not compile. It is legal in `gt` / `gte` / `lt` / `lte` / `neq` and as a
88
+ bare value (which means equality); anywhere else — inside `in`, in a `set`, in
89
+ `orderBy` — it is refused by name.
90
+
91
+ `col()` is only ever produced by the `col()` call. A `{ "$col": "…" }` object
92
+ that arrives in a request body is NOT a column reference and is refused as an
93
+ unknown operator, so spreading untrusted input into a filter cannot turn a
94
+ tenant predicate into a tautology.
95
+
96
+ ### Filtering through a relation — `has`
97
+
98
+ A filter on the row next to yours is the most common reason to drop to raw SQL,
99
+ and it is the one the schema already knows how to answer. Every foreign key you
100
+ declare produces a named relation on BOTH sides, and `has` filters through it:
101
+
102
+ ```ts
103
+ // Interests that THIS user picked.
104
+ await Database.public.interests.findMany({
105
+ where: { has: { user_interests: { user_id: userId } } },
106
+ orderBy: { column: "sort_order", direction: "asc" },
107
+ });
108
+ ```
109
+
110
+ That compiles to a correlated `IN (SELECT …)` — one statement, one round trip,
111
+ the RLS policies of BOTH tables still applied. The hand-written alternative was
112
+ a `sqlFragment` carrying a subquery, or two queries and a `Set` in JavaScript.
113
+
114
+ The relation names come from the schema, so `has: { user_intrests: … }` is a
115
+ compile error, and so is a column that the OTHER table does not have. A table
116
+ that declares no foreign key at all has no `has` — the key is absent, not empty.
117
+
118
+ Both directions work. From the child, the relation is the parent:
119
+
120
+ ```ts
121
+ await Database.public.user_interests.findMany({
122
+ where: { has: { interest: { name: { icontains: "yoga" } } } },
123
+ });
124
+ ```
125
+
126
+ The inner filter is the SAME language: operators, `OR` / `AND` / `NOT`, `col()`,
127
+ and a nested `has` one table further (three levels — the relation graph points
128
+ both ways, so it is a cycle, and an unbounded type would follow it forever).
129
+
130
+ `has` is available wherever a filter is: `findMany`, `count`, `updateMany`,
131
+ `deleteMany`, and inside `$transaction`. In a transaction the update still takes
132
+ its rows in primary-key order under `FOR NO KEY UPDATE`, exactly as a plain
133
+ filter does:
134
+
135
+ ```ts
136
+ await Database.$transaction((tx) => {
137
+ tx.public.interests.updateWhere(
138
+ { has: { user_interests: { user_id: userId } } },
139
+ { last_seen_at: now() },
140
+ );
141
+ return [];
142
+ });
143
+ ```
69
144
 
70
145
  ### Ordering and paging
71
146
 
72
147
  ```ts
73
- await Database.tables.entries.findMany(
74
- { household_id: hid },
75
- { orderBy: { column: "created_at", direction: "desc" }, limit: 20, offset: 40 },
76
- );
148
+ await Database.public.entries.findMany({
149
+ where: { household_id: hid },
150
+ orderBy: { column: "created_at", direction: "desc" },
151
+ limit: 20,
152
+ offset: 40,
153
+ });
77
154
  ```
78
155
 
79
156
  `orderBy.column` is checked against the row type: a mistyped column name is a
@@ -97,7 +174,7 @@ It runs in **the request's own transaction**, not a read-only one: a write insid
97
174
  it commits with the rest of the request.
98
175
 
99
176
  ```ts
100
- const rows = await Database.query(
177
+ const rows = await Database.$query(
101
178
  "SELECT c.name, count(*) FROM entries e JOIN categories c ON c.id = e.category_id" +
102
179
  " WHERE e.household_id = $1 GROUP BY c.name",
103
180
  [hid],
@@ -114,7 +191,7 @@ Inside a transaction, `insertMany` takes an optional conflict rule — which is
114
191
  per row with a `23505` caught around each:
115
192
 
116
193
  ```ts
117
- await Database.transaction((tx) => {
194
+ await Database.$transaction((tx) => {
118
195
  tx.tables.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
119
196
  return null;
120
197
  });
@@ -141,7 +218,7 @@ carrying the constraint Postgres named:
141
218
  import { UniqueViolation, Conflict } from "@palbase/backend";
142
219
 
143
220
  try {
144
- return await Database.tables.users.insert({ email });
221
+ return await Database.public.users.insert({ email });
145
222
  } catch (e) {
146
223
  if (UniqueViolation.is(e) && e.constraint === "users_email_key") {
147
224
  throw new Conflict("That email is taken", "email_taken");
@@ -171,7 +248,7 @@ middle, so nothing holds a database connection open while your code thinks.
171
248
  ```ts
172
249
  import { Database, NotFound } from "@palbase/backend";
173
250
 
174
- const { orderId } = await Database.transaction((tx) => {
251
+ const { orderId } = await Database.$transaction((tx) => {
175
252
  const order = tx.tables.orders
176
253
  .insert({ amount: 1000, status: "pending" })
177
254
  .expectOne(new NotFound("order could not be created"));
@@ -220,9 +297,9 @@ does not write is not part of the transaction:
220
297
 
221
298
  ```ts
222
299
  // Before the transaction: an ordinary value you can branch on.
223
- const overrides = await Database.tables.category_overrides.findMany({ household_id });
300
+ const overrides = await Database.public.category_overrides.findMany({ where: { household_id } });
224
301
 
225
- const stmt = await Database.transaction((tx) => { /* … */ });
302
+ const stmt = await Database.$transaction((tx) => { /* … */ });
226
303
  ```
227
304
 
228
305
  ### Writing conditions as filters
@@ -253,19 +330,113 @@ Three expressions may appear in the values you write:
253
330
 
254
331
  | Expression | Where | Meaning |
255
332
  |---|---|---|
256
- | `now()` | anywhere | the server's clock |
257
- | `inc(n)` | `updateWhere`'s `set` | `column = column + n`, atomically |
258
- | `dec(n)` | `updateWhere`'s `set` | `column = column - n`, atomically |
333
+ | `now()` | any `set` — `updateMany` and `updateWhere` | the server's clock |
334
+ | `increment(n)` | any `set` | `column = column + n`, atomically |
335
+ | `decrement(n)` | any `set` | `column = column - n`, atomically |
336
+
337
+ `inc` / `dec` are the OLD names for `increment` / `decrement`. They are the same
338
+ factory (`inc === increment`), kept as deprecated aliases; write the long names.
339
+
340
+ The expressions work on BOTH surfaces — the direct `updateMany` and the plan's
341
+ `updateWhere` — because they are one object:
342
+
343
+ ```ts
344
+ // Direct: one statement, no transaction needed.
345
+ await Database.public.accounts.updateMany({
346
+ where: { id: accountId },
347
+ set: { balance: decrement("5.00") },
348
+ });
349
+
350
+ // Inside a plan: the same expression, the same meaning.
351
+ await Database.$transaction((tx) => {
352
+ tx.tables.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
353
+ return null;
354
+ });
355
+ ```
356
+
357
+ **Give money a STRING amount.** `increment(0.1)` binds a JS number and
358
+ `0.1 + 0.2` is `0.30000000000000004`; `increment("0.1")` binds the decimal and
359
+ Postgres adds it exactly. Measured: `12345678901234567890` as a number becomes
360
+ `12345678901234600000`, as a string it stays whole.
361
+
362
+ **The amount is a magnitude, not a signed number.** `decrement("-5")` is
363
+ refused: a negative amount would ADD money through a call that reads as a
364
+ withdrawal, and the guard names `increment()` as the way to say the other
365
+ direction.
366
+
367
+ `increment` / `decrement` read the column's current value, which an inserted row
368
+ does not have — using one in an `insert` is a compile error, and the engine
369
+ refuses it by name if it arrives untyped.
370
+
371
+ ### Zero rows is not silent success
372
+
373
+ `updateMany` returns EVERY row it matched, and matching nothing is an answer the
374
+ caller has to read:
375
+
376
+ ```ts
377
+ const [row] = await Database.public.accounts.updateMany({
378
+ where: { id: accountId, balance: { gte: amount } }, // the guard IS the filter
379
+ set: { balance: decrement(amount) },
380
+ });
381
+ if (row === undefined) throw new Conflict("insufficient balance");
382
+ ```
383
+
384
+ The filter carries the condition, so the check and the write are ONE statement —
385
+ there is no window between reading a balance and spending it. An empty array
386
+ means the balance was too low, or the row does not exist, or RLS hides it from
387
+ this caller. None of those is "the transfer happened".
388
+
389
+ ### Append-only tables — a record that cannot be rewritten
390
+
391
+ `appendOnly: true` on a table declaration means a row can be INSERTED and read,
392
+ never updated and never deleted. A correction is a COMPENSATING ENTRY: a new row
393
+ carrying the opposite sign.
394
+
395
+ ```ts
396
+ export const entries = defineTable("entries", {
397
+ appendOnly: true,
398
+ columns: { /* … */ },
399
+ });
400
+ ```
401
+
402
+ **Three locks, because the first two are not enough on their own:**
403
+
404
+ | Lock | Stops | Does not stop |
405
+ |---|---|---|
406
+ | The TYPE | `update` / `updateMany` / `delete` / `deleteMany` / `put` / `supersede` are not on the table | untyped `$`-prefixed calls, anything off the wire |
407
+ | The ENGINE | those same operations refused by name, whichever door they come through | SQL that never passes through this SDK |
408
+ | 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 |
409
+
410
+ The triggers are not belt-and-braces. Measured on a live stack: the platform
411
+ re-grants table privileges on every boot, which quietly undid the REVOKE, and
412
+ the service role carries `BYPASSRLS`, so the policy never applied to it either.
413
+ With both gone the row really was deletable. The triggers are what actually
414
+ hold, and they say so:
415
+
416
+ ```
417
+ ERROR: table entries is append-only: a row cannot be updated or deleted.
418
+ Write a COMPENSATING ENTRY instead (an INSERT carrying the opposite sign).
419
+ ```
420
+
421
+ `SELECT` and `INSERT` are untouched — append-only means the history is kept, not
422
+ that it is unreadable, and the correction path has to stay open.
423
+
424
+ **TRUNCATE needs its own trigger, and finding that out took a measurement.** A
425
+ row-level trigger never sees a `TRUNCATE` — Postgres truncates without touching
426
+ rows — so the owner could empty the whole ledger in one statement while every
427
+ other door was shut. The statement-level `BEFORE TRUNCATE` trigger closes it.
259
428
 
260
- `inc`/`dec` read the column's current value, which an inserted row does not
261
- have using them in an `insert` is a compile error.
429
+ What is NOT closed: the table's owner can `ALTER TABLE DISABLE TRIGGER` and
430
+ then write freely. That is not a hole this can fix — the same owner can drop the
431
+ table — and saying otherwise would be the kind of claim this table exists to
432
+ avoid.
262
433
 
263
434
  ### Limits
264
435
 
265
436
  A plan may carry at most 1000 operations, 5000 rows in one `insertMany`, and
266
437
  8 MiB of JSON. Exceeding any of them is reported before the request is sent.
267
438
 
268
- ## Bypassing RLS — `Database.asService()`
439
+ ## Bypassing RLS — `Database.$asService()`
269
440
 
270
441
  When a table has [Row-Level Security](./schema.md#row-level-security-rls)
271
442
  policies, every `Database.*` call runs as the request's verified user, so the
@@ -282,7 +453,7 @@ nothing your code does. See
282
453
 
283
454
  Sometimes you need to read or write **across all users** — an admin endpoint, a
284
455
  background job that fans out notifications, a cleanup task. For that, call
285
- `Database.asService()`. It returns a sibling client that runs as
456
+ `Database.$asService()`. It returns a sibling client that runs as
286
457
  **`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
287
458
  exact same surface — `tables`, the raw string ops, and `transaction`:
288
459
 
@@ -290,14 +461,14 @@ exact same surface — `tables`, the raw string ops, and `transaction`:
290
461
  import { Database } from "@palbase/backend";
291
462
 
292
463
  // RLS-enforced (default): only the caller's own rows.
293
- const mine = await Database.tables.todos.findMany({});
464
+ const mine = await Database.public.todos.findMany({});
294
465
 
295
466
  // Service-role bypass: every user's rows. Explicit and greppable.
296
- const all = await Database.asService().tables.todos.findMany({});
297
- const rows = await Database.asService().query("SELECT count(*) FROM todos");
467
+ const all = await Database.$asService().tables.todos.findMany({});
468
+ const rows = await Database.$asService().query("SELECT count(*) FROM todos");
298
469
 
299
470
  // A service-role transaction (the role is fixed for the whole plan):
300
- await Database.asService().transaction((tx) => {
471
+ await Database.$asService().transaction((tx) => {
301
472
  tx.tables.todos.updateWhere({ id }, { done: true });
302
473
  return null;
303
474
  });
@@ -310,8 +481,8 @@ Guidelines:
310
481
  grep for in review.
311
482
  - **No double-bypass / no nesting.** The sibling does not re-expose
312
483
  `asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
313
- transaction. Use `Database.transaction(...)` for an authenticated one and
314
- `Database.asService().transaction(...)` for a service-role one; you cannot mix
484
+ transaction. Use `Database.$transaction(...)` for an authenticated one and
485
+ `Database.$asService().transaction(...)` for a service-role one; you cannot mix
315
486
  enforced and bypassed operations inside a single plan.
316
487
  - **It is a second transaction, on a second connection.** It has to be: the
317
488
  Postgres role is bound once, when the transaction opens, so a sibling sharing
package/docs/endpoints.md CHANGED
@@ -40,10 +40,9 @@ export default class RoomsController {
40
40
 
41
41
  1. **The controller does not touch the database.** It delegates to a service, as
42
42
  above. A method here that reaches for `Database` has moved the logic into the
43
- layer that is hardest to test. (An export is not required at all: `@Controller`
44
- records the class as it decorates it, and importing the file IS the
45
- registration the shipped scaffold's own controllers are not exported.
46
- `export default` here is style.)
43
+ layer that is hardest to test. (Export the class by NAME so its module can
44
+ list it in `controllers`; a class no module lists is refused at build with
45
+ `unowned class`. Nothing here is default-exported.)
47
46
  2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
48
47
  returns promises, so a body that `await`s it cannot have a sync return type
49
48
  (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
package/docs/events.md CHANGED
@@ -17,7 +17,7 @@ import { auth, Database, Log } from "@palbase/backend";
17
17
 
18
18
  export const onUserCreated = auth.onUserCreated(async (event, meta) => {
19
19
  Log.info(`new user: ${event.user.email}`);
20
- await Database.insert("profiles", {
20
+ await Database.$insert("profiles", {
21
21
  user_id: event.user.id,
22
22
  email: event.user.email,
23
23
  });
@@ -50,13 +50,13 @@ import { Database, Log, On, Webhook, type WebhookMeta } from "@palbase/backend";
50
50
  export default class StripeWebhook {
51
51
  @On("checkout.session.completed")
52
52
  async checkoutCompleted(event: unknown, meta: WebhookMeta) {
53
- await Database.insert("orders", { status: "paid", data: event });
53
+ await Database.$insert("orders", { status: "paid", data: event });
54
54
  }
55
55
 
56
56
  @On("payment_intent.payment_failed")
57
57
  async paymentFailed(event: unknown, meta: WebhookMeta) {
58
58
  Log.error("payment failed");
59
- await Database.insert("payment_failures", { data: event });
59
+ await Database.$insert("payment_failures", { data: event });
60
60
  }
61
61
  }
62
62
  ```
@@ -48,7 +48,7 @@ start. The loop is: edit, validate, push to a dev Environment.
48
48
  type or an SDK major skew fails here rather than shipping a deploy that
49
49
  serves zero endpoints. It is wired into a `pre-push` git hook for you. It also
50
50
  regenerates `palbase-env.d.ts` from your `db/*.ts` files, which is what types
51
- `Database.tables.*` (no import, no generic), so run it after editing the
51
+ `Database.public.*` (no import, no generic), so run it after editing the
52
52
  schema — there is no separate command for that. See
53
53
  [migrations.md](./migrations.md) for the schema side.
54
54
  - `palbase push` deploys the current backend to the selected Environment. For a