@palbase/backend 25.1.0 → 27.1.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.
- package/dist/bin/palbase-backend.cjs +2432 -1039
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +87 -51
- package/dist/bin/palbase-backend.js.map +1 -1
- package/dist/chunk-BQN723PL.js +930 -0
- package/dist/chunk-BQN723PL.js.map +1 -0
- package/dist/chunk-CGNN2PUH.js +213 -0
- package/dist/chunk-CGNN2PUH.js.map +1 -0
- package/dist/{chunk-VDF2T4AS.js → chunk-EB3TUX5J.js} +1228 -591
- package/dist/chunk-EB3TUX5J.js.map +1 -0
- package/dist/chunk-JVZQCC77.js +728 -0
- package/dist/chunk-JVZQCC77.js.map +1 -0
- package/dist/chunk-OZKSM3JW.js +370 -0
- package/dist/chunk-OZKSM3JW.js.map +1 -0
- package/dist/{chunk-YOY5DFQS.js → chunk-TWX6JTGJ.js} +76 -34
- package/dist/{chunk-YOY5DFQS.js.map → chunk-TWX6JTGJ.js.map} +1 -1
- package/dist/{chunk-35PNTIRN.js → chunk-VVMJEVQP.js} +63 -162
- package/dist/chunk-VVMJEVQP.js.map +1 -0
- package/dist/{chunk-7D4SUZUM.js → chunk-VXPNPVAG.js} +3 -1
- package/dist/chunk-XABHGMUT.js +885 -0
- package/dist/chunk-XABHGMUT.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +2 -2
- package/dist/db/env.d.ts +2 -2
- package/dist/db/index.cjs +780 -344
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +7 -4
- package/dist/engine/index.cjs +2366 -1002
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +6 -6
- package/dist/engine/index.d.ts +6 -6
- package/dist/engine/index.js +7 -6
- package/dist/{index-CUomTA3e.d.ts → index-CAKOgAlP.d.ts} +171 -296
- package/dist/index-CgE4sVhg.d.cts +4864 -0
- package/dist/{index-ClpDeSos.d.cts → index-H-0qv5d4.d.cts} +171 -296
- package/dist/index-V7QRh1wg.d.ts +4864 -0
- package/dist/index.cjs +2720 -1169
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +165 -19
- package/dist/index.d.ts +165 -19
- package/dist/index.js +738 -477
- package/dist/index.js.map +1 -1
- package/dist/module-Dl1KFVtc.d.cts +54 -0
- package/dist/module-Dl1KFVtc.d.ts +54 -0
- package/dist/openapi/index.cjs +1330 -484
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +4 -2
- package/dist/openapi/index.d.ts +4 -2
- package/dist/openapi/index.js +1264 -474
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-dZZ5JKYg.d.ts → registry-4EI8aaFs.d.ts} +1 -1
- package/dist/{registry-CC0WBQq6.d.cts → registry-DHsPDY0_.d.cts} +1 -1
- package/dist/stack.cjs.map +1 -1
- package/dist/test/index.cjs +732 -141
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +30 -4
- package/dist/test/index.d.ts +30 -4
- package/dist/test/index.js +490 -124
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +33 -18
- package/docs/auth.md +1 -1
- package/docs/background.md +2 -2
- package/docs/database.md +255 -50
- package/docs/endpoints.md +3 -4
- package/docs/events.md +3 -3
- package/docs/getting-started.md +1 -1
- package/docs/llms-full.txt +435 -117
- package/docs/migrations.md +2 -2
- package/docs/schema.md +19 -10
- package/docs/services.md +116 -26
- package/package.json +8 -4
- package/stager/generics.js +205 -0
- package/stager/stage.js +39 -3
- package/template/AGENTS.md +110 -72
- package/template/db/public.ts +1 -1
- package/template/{controllers → modules/health}/health.controller.ts +1 -1
- package/template/modules/health/health.module.ts +24 -0
- package/template/modules/notes/note.service.test.ts +49 -0
- package/template/modules/notes/note.service.ts +108 -0
- package/template/{controllers → modules/notes}/notes.controller.ts +17 -11
- package/template/modules/notes/notes.module.ts +37 -0
- package/template/package.json +5 -3
- package/template/scripts/test.sh +33 -0
- package/template/tsconfig.json +29 -30
- package/dist/chunk-35PNTIRN.js.map +0 -1
- package/dist/chunk-CJSKYY76.js +0 -627
- package/dist/chunk-CJSKYY76.js.map +0 -1
- package/dist/chunk-CRQKCRGF.js +0 -276
- package/dist/chunk-CRQKCRGF.js.map +0 -1
- package/dist/chunk-G4R6BTLV.js +0 -662
- package/dist/chunk-G4R6BTLV.js.map +0 -1
- package/dist/chunk-VDF2T4AS.js.map +0 -1
- package/dist/chunk-XABBC7JP.js +0 -55
- package/dist/chunk-XABBC7JP.js.map +0 -1
- package/dist/endpoint-CTEHhb7A.d.ts +0 -2386
- package/dist/endpoint-DYHMo6cC.d.cts +0 -2386
- package/dist/index-CW21M9Z3.d.ts +0 -1222
- package/dist/index-CmBK76nx.d.cts +0 -1222
- package/template/services/note.service.test.ts +0 -45
- package/template/services/note.service.ts +0 -76
- /package/dist/{chunk-7D4SUZUM.js.map → chunk-VXPNPVAG.js.map} +0 -0
- /package/template/{models/notes → modules/notes/dto}/create.ts +0 -0
package/docs/llms-full.txt
CHANGED
|
@@ -43,14 +43,16 @@ service the controllers call.
|
|
|
43
43
|
|
|
44
44
|
### The 7 rules (checklist)
|
|
45
45
|
|
|
46
|
-
1. **A controller
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 EXISTS — a 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
|
|
53
|
-
|
|
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`
|
|
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.
|
|
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,14 +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 is
|
|
145
|
-
* refused with the class named — at decoration time for jobs/hooks/webhooks, and
|
|
146
|
-
* when the route table is built at boot for a controller. */
|
|
147
|
-
export const todoService = new TodoService(Database.tables.todos);
|
|
148
145
|
```
|
|
149
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
|
+
|
|
150
165
|
```ts
|
|
151
166
|
// controllers/todos.controller.ts — class controller. Thin: delegates to the service.
|
|
152
167
|
import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
|
|
@@ -227,7 +242,7 @@ generated client surface) changes; the verb/path do not affect it.
|
|
|
227
242
|
(stage, bundle, extract controller metadata). Exits non-zero on a decorator,
|
|
228
243
|
return-type or version-skew error, so a push that would deploy zero endpoints
|
|
229
244
|
fails on your machine instead. It also regenerates `palbase-env.d.ts` from
|
|
230
|
-
your `db/*.ts` files, which is what types `Database.
|
|
245
|
+
your `db/*.ts` files, which is what types `Database.public.*` (no import, no
|
|
231
246
|
generic) — so run it after editing the schema. There is no separate command
|
|
232
247
|
for that: `build` regenerates everything derived.
|
|
233
248
|
- `palbase push` — deploy the current backend to the selected Environment. For a
|
|
@@ -378,7 +393,7 @@ start. The loop is: edit, validate, push to a dev Environment.
|
|
|
378
393
|
type or an SDK major skew fails here rather than shipping a deploy that
|
|
379
394
|
serves zero endpoints. It is wired into a `pre-push` git hook for you. It also
|
|
380
395
|
regenerates `palbase-env.d.ts` from your `db/*.ts` files, which is what types
|
|
381
|
-
`Database.
|
|
396
|
+
`Database.public.*` (no import, no generic), so run it after editing the
|
|
382
397
|
schema — there is no separate command for that. See
|
|
383
398
|
[migrations.md](./migrations.md) for the schema side.
|
|
384
399
|
- `palbase push` deploys the current backend to the selected Environment. For a
|
|
@@ -556,10 +571,9 @@ export default class RoomsController {
|
|
|
556
571
|
|
|
557
572
|
1. **The controller does not touch the database.** It delegates to a service, as
|
|
558
573
|
above. A method here that reaches for `Database` has moved the logic into the
|
|
559
|
-
layer that is hardest to test. (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
`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.)
|
|
563
577
|
2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
|
|
564
578
|
returns promises, so a body that `await`s it cannot have a sync return type
|
|
565
579
|
(`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
|
|
@@ -864,7 +878,7 @@ single-use and expire; a used or expired one fails closed with a `400`.
|
|
|
864
878
|
|
|
865
879
|
- [Row-Level Security](./schema.md#row-level-security-rls) — pushing per-user
|
|
866
880
|
access rules into Postgres, where `auth.uid()` is this same verified user.
|
|
867
|
-
- [Database](./database.md) — how `Database
|
|
881
|
+
- [Database](./database.md) — how `Database.$asService()` steps outside RLS.
|
|
868
882
|
|
|
869
883
|
|
|
870
884
|
|
|
@@ -882,19 +896,19 @@ import { Database } from "@palbase/backend";
|
|
|
882
896
|
Only **middleware** still uses `ctx.db` (see [background.md](./background.md)
|
|
883
897
|
and [events.md](./events.md) for worker/job/hook/webhook examples).
|
|
884
898
|
|
|
885
|
-
## Typed by default — `Database
|
|
899
|
+
## Typed by default — `Database.<şema>.<tablo>`
|
|
886
900
|
|
|
887
|
-
When you declare a schema under `db/*.ts`, `Database.
|
|
901
|
+
When you declare a schema under `db/*.ts`, `Database.public.<name>` is typed everywhere
|
|
888
902
|
with no import and no generic. `insert` demands the right columns; rows come
|
|
889
903
|
back typed; nullable columns are `T | null`. This is the path you should use:
|
|
890
904
|
|
|
891
905
|
```ts
|
|
892
|
-
const todo = await Database.
|
|
906
|
+
const todo = await Database.public.todos.insert({ title: "buy milk" });
|
|
893
907
|
todo.id; // string ✓
|
|
894
908
|
todo.done; // boolean ✓
|
|
895
|
-
const open = await Database.
|
|
896
|
-
await Database.
|
|
897
|
-
await Database.
|
|
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);
|
|
898
912
|
// todo.nope ← compile error
|
|
899
913
|
```
|
|
900
914
|
|
|
@@ -907,18 +921,18 @@ cover, the string-keyed ops are still available:
|
|
|
907
921
|
|
|
908
922
|
| Method | Returns |
|
|
909
923
|
|--------|---------|
|
|
910
|
-
| `Database
|
|
911
|
-
| `Database
|
|
912
|
-
| `Database
|
|
913
|
-
| `Database
|
|
914
|
-
| `Database
|
|
915
|
-
| `Database
|
|
916
|
-
| `Database
|
|
917
|
-
| `Database
|
|
918
|
-
| `Database
|
|
919
|
-
| `Database
|
|
920
|
-
| `Database
|
|
921
|
-
| `Database
|
|
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 |
|
|
922
936
|
|
|
923
937
|
### Filters
|
|
924
938
|
|
|
@@ -926,26 +940,137 @@ A filter's keys are ANDed. Each value is either a plain value (equality) or an
|
|
|
926
940
|
operator object:
|
|
927
941
|
|
|
928
942
|
```ts
|
|
929
|
-
await Database.
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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. A branch of `OR` / `AND` / `NOT` may also be a `sqlFragment`,
|
|
956
|
+
so one hand-written predicate does not force the whole query into raw SQL:
|
|
957
|
+
|
|
958
|
+
```ts
|
|
959
|
+
await Database.public.crew_presence.findMany({
|
|
960
|
+
where: {
|
|
961
|
+
city: "IST",
|
|
962
|
+
AND: [sqlFragment`expires_at > now()`],
|
|
963
|
+
},
|
|
964
|
+
});
|
|
965
|
+
``` The same filter language works for `updateMany`, `deleteMany`,
|
|
966
|
+
`count` and `search` — one language, so two spellings cannot drift apart. An
|
|
967
|
+
empty `in` list means NO rows (written into the SQL as `false`), never a silent
|
|
968
|
+
full scan.
|
|
969
|
+
|
|
970
|
+
### Comparing against server time — `now()`
|
|
971
|
+
|
|
972
|
+
`expires_at > now()` is one of the most-written predicates in any backend, and
|
|
973
|
+
the answer must come from the DATABASE's clock: sending `new Date()` from
|
|
974
|
+
JavaScript silently switches to the client's clock, and two machines do not
|
|
975
|
+
agree.
|
|
976
|
+
|
|
977
|
+
```ts
|
|
978
|
+
import { now } from "@palbase/backend";
|
|
979
|
+
|
|
980
|
+
await Database.public.sessions.findMany({
|
|
981
|
+
where: { user_id, expires_at: { gt: now() } },
|
|
934
982
|
});
|
|
935
983
|
```
|
|
936
984
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
`
|
|
985
|
+
It renders as `now()` with NO bound parameter. The bare form is equality
|
|
986
|
+
(`{ last_seen: now() }`), exactly like `col()`.
|
|
987
|
+
|
|
988
|
+
`increment()` / `decrement()` are refused here by name: they are WRITE
|
|
989
|
+
expressions, and a comparison against one would silently match nothing. And a
|
|
990
|
+
`{ "$expr": { "fn": "now" } }` object arriving in a request body is not `now()`
|
|
991
|
+
— only the `now()` call produces one, so spreading untrusted input into a filter
|
|
992
|
+
cannot forge a server-time comparison.
|
|
993
|
+
|
|
994
|
+
### Comparing one column with another — `col()`
|
|
995
|
+
|
|
996
|
+
The right-hand side of a comparison is normally a VALUE. `col()` puts a COLUMN
|
|
997
|
+
there, which is otherwise a reason to drop to raw SQL:
|
|
998
|
+
|
|
999
|
+
```ts
|
|
1000
|
+
import { col } from "@palbase/backend";
|
|
1001
|
+
|
|
1002
|
+
// Invoices that are not fully paid.
|
|
1003
|
+
await Database.public.invoices.findMany({ where: { total: { gt: col("amount_paid") } } });
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
The name is checked at COMPILE time against the row type: `col("amont_paid")`
|
|
1007
|
+
does not compile. It is legal in `gt` / `gte` / `lt` / `lte` / `neq` and as a
|
|
1008
|
+
bare value (which means equality); anywhere else — inside `in`, in a `set`, in
|
|
1009
|
+
`orderBy` — it is refused by name.
|
|
1010
|
+
|
|
1011
|
+
`col()` is only ever produced by the `col()` call. A `{ "$col": "…" }` object
|
|
1012
|
+
that arrives in a request body is NOT a column reference and is refused as an
|
|
1013
|
+
unknown operator, so spreading untrusted input into a filter cannot turn a
|
|
1014
|
+
tenant predicate into a tautology.
|
|
1015
|
+
|
|
1016
|
+
### Filtering through a relation — `has`
|
|
1017
|
+
|
|
1018
|
+
A filter on the row next to yours is the most common reason to drop to raw SQL,
|
|
1019
|
+
and it is the one the schema already knows how to answer. Every foreign key you
|
|
1020
|
+
declare produces a named relation on BOTH sides, and `has` filters through it:
|
|
1021
|
+
|
|
1022
|
+
```ts
|
|
1023
|
+
// Interests that THIS user picked.
|
|
1024
|
+
await Database.public.interests.findMany({
|
|
1025
|
+
where: { has: { user_interests: { user_id: userId } } },
|
|
1026
|
+
orderBy: { column: "sort_order", direction: "asc" },
|
|
1027
|
+
});
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
That compiles to a correlated `IN (SELECT …)` — one statement, one round trip,
|
|
1031
|
+
the RLS policies of BOTH tables still applied. The hand-written alternative was
|
|
1032
|
+
a `sqlFragment` carrying a subquery, or two queries and a `Set` in JavaScript.
|
|
1033
|
+
|
|
1034
|
+
The relation names come from the schema, so `has: { user_intrests: … }` is a
|
|
1035
|
+
compile error, and so is a column that the OTHER table does not have. A table
|
|
1036
|
+
that declares no foreign key at all has no `has` — the key is absent, not empty.
|
|
1037
|
+
|
|
1038
|
+
Both directions work. From the child, the relation is the parent:
|
|
1039
|
+
|
|
1040
|
+
```ts
|
|
1041
|
+
await Database.public.user_interests.findMany({
|
|
1042
|
+
where: { has: { interest: { name: { icontains: "yoga" } } } },
|
|
1043
|
+
});
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
The inner filter is the SAME language: operators, `OR` / `AND` / `NOT`, `col()`,
|
|
1047
|
+
and a nested `has` one table further (three levels — the relation graph points
|
|
1048
|
+
both ways, so it is a cycle, and an unbounded type would follow it forever).
|
|
1049
|
+
|
|
1050
|
+
`has` is available wherever a filter is: `findMany`, `count`, `updateMany`,
|
|
1051
|
+
`deleteMany`, and inside `$transaction`. In a transaction the update still takes
|
|
1052
|
+
its rows in primary-key order under `FOR NO KEY UPDATE`, exactly as a plain
|
|
1053
|
+
filter does:
|
|
1054
|
+
|
|
1055
|
+
```ts
|
|
1056
|
+
await Database.$transaction((tx) => {
|
|
1057
|
+
tx.public.interests.updateWhere(
|
|
1058
|
+
{ has: { user_interests: { user_id: userId } } },
|
|
1059
|
+
{ last_seen_at: now() },
|
|
1060
|
+
);
|
|
1061
|
+
return [];
|
|
1062
|
+
});
|
|
1063
|
+
```
|
|
941
1064
|
|
|
942
1065
|
### Ordering and paging
|
|
943
1066
|
|
|
944
1067
|
```ts
|
|
945
|
-
await Database.
|
|
946
|
-
{ household_id: hid },
|
|
947
|
-
|
|
948
|
-
|
|
1068
|
+
await Database.public.entries.findMany({
|
|
1069
|
+
where: { household_id: hid },
|
|
1070
|
+
orderBy: { column: "created_at", direction: "desc" },
|
|
1071
|
+
limit: 20,
|
|
1072
|
+
offset: 40,
|
|
1073
|
+
});
|
|
949
1074
|
```
|
|
950
1075
|
|
|
951
1076
|
`orderBy.column` is checked against the row type: a mistyped column name is a
|
|
@@ -969,7 +1094,7 @@ It runs in **the request's own transaction**, not a read-only one: a write insid
|
|
|
969
1094
|
it commits with the rest of the request.
|
|
970
1095
|
|
|
971
1096
|
```ts
|
|
972
|
-
const rows = await Database
|
|
1097
|
+
const rows = await Database.$query(
|
|
973
1098
|
"SELECT c.name, count(*) FROM entries e JOIN categories c ON c.id = e.category_id" +
|
|
974
1099
|
" WHERE e.household_id = $1 GROUP BY c.name",
|
|
975
1100
|
[hid],
|
|
@@ -986,7 +1111,7 @@ Inside a transaction, `insertMany` takes an optional conflict rule — which is
|
|
|
986
1111
|
per row with a `23505` caught around each:
|
|
987
1112
|
|
|
988
1113
|
```ts
|
|
989
|
-
await Database
|
|
1114
|
+
await Database.$transaction((tx) => {
|
|
990
1115
|
tx.tables.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
|
|
991
1116
|
return null;
|
|
992
1117
|
});
|
|
@@ -1013,7 +1138,7 @@ carrying the constraint Postgres named:
|
|
|
1013
1138
|
import { UniqueViolation, Conflict } from "@palbase/backend";
|
|
1014
1139
|
|
|
1015
1140
|
try {
|
|
1016
|
-
return await Database.
|
|
1141
|
+
return await Database.public.users.insert({ email });
|
|
1017
1142
|
} catch (e) {
|
|
1018
1143
|
if (UniqueViolation.is(e) && e.constraint === "users_email_key") {
|
|
1019
1144
|
throw new Conflict("That email is taken", "email_taken");
|
|
@@ -1043,7 +1168,7 @@ middle, so nothing holds a database connection open while your code thinks.
|
|
|
1043
1168
|
```ts
|
|
1044
1169
|
import { Database, NotFound } from "@palbase/backend";
|
|
1045
1170
|
|
|
1046
|
-
const { orderId } = await Database
|
|
1171
|
+
const { orderId } = await Database.$transaction((tx) => {
|
|
1047
1172
|
const order = tx.tables.orders
|
|
1048
1173
|
.insert({ amount: 1000, status: "pending" })
|
|
1049
1174
|
.expectOne(new NotFound("order could not be created"));
|
|
@@ -1092,9 +1217,9 @@ does not write is not part of the transaction:
|
|
|
1092
1217
|
|
|
1093
1218
|
```ts
|
|
1094
1219
|
// Before the transaction: an ordinary value you can branch on.
|
|
1095
|
-
const overrides = await Database.
|
|
1220
|
+
const overrides = await Database.public.category_overrides.findMany({ where: { household_id } });
|
|
1096
1221
|
|
|
1097
|
-
const stmt = await Database
|
|
1222
|
+
const stmt = await Database.$transaction((tx) => { /* … */ });
|
|
1098
1223
|
```
|
|
1099
1224
|
|
|
1100
1225
|
### Writing conditions as filters
|
|
@@ -1125,19 +1250,113 @@ Three expressions may appear in the values you write:
|
|
|
1125
1250
|
|
|
1126
1251
|
| Expression | Where | Meaning |
|
|
1127
1252
|
|---|---|---|
|
|
1128
|
-
| `now()` |
|
|
1129
|
-
| `
|
|
1130
|
-
| `
|
|
1253
|
+
| `now()` | any `set` — `updateMany` and `updateWhere` | the server's clock |
|
|
1254
|
+
| `increment(n)` | any `set` | `column = column + n`, atomically |
|
|
1255
|
+
| `decrement(n)` | any `set` | `column = column - n`, atomically |
|
|
1256
|
+
|
|
1257
|
+
`inc` / `dec` are the OLD names for `increment` / `decrement`. They are the same
|
|
1258
|
+
factory (`inc === increment`), kept as deprecated aliases; write the long names.
|
|
1259
|
+
|
|
1260
|
+
The expressions work on BOTH surfaces — the direct `updateMany` and the plan's
|
|
1261
|
+
`updateWhere` — because they are one object:
|
|
1262
|
+
|
|
1263
|
+
```ts
|
|
1264
|
+
// Direct: one statement, no transaction needed.
|
|
1265
|
+
await Database.public.accounts.updateMany({
|
|
1266
|
+
where: { id: accountId },
|
|
1267
|
+
set: { balance: decrement("5.00") },
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
// Inside a plan: the same expression, the same meaning.
|
|
1271
|
+
await Database.$transaction((tx) => {
|
|
1272
|
+
tx.tables.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
|
|
1273
|
+
return null;
|
|
1274
|
+
});
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
**Give money a STRING amount.** `increment(0.1)` binds a JS number and
|
|
1278
|
+
`0.1 + 0.2` is `0.30000000000000004`; `increment("0.1")` binds the decimal and
|
|
1279
|
+
Postgres adds it exactly. Measured: `12345678901234567890` as a number becomes
|
|
1280
|
+
`12345678901234600000`, as a string it stays whole.
|
|
1281
|
+
|
|
1282
|
+
**The amount is a magnitude, not a signed number.** `decrement("-5")` is
|
|
1283
|
+
refused: a negative amount would ADD money through a call that reads as a
|
|
1284
|
+
withdrawal, and the guard names `increment()` as the way to say the other
|
|
1285
|
+
direction.
|
|
1286
|
+
|
|
1287
|
+
`increment` / `decrement` read the column's current value, which an inserted row
|
|
1288
|
+
does not have — using one in an `insert` is a compile error, and the engine
|
|
1289
|
+
refuses it by name if it arrives untyped.
|
|
1290
|
+
|
|
1291
|
+
### Zero rows is not silent success
|
|
1292
|
+
|
|
1293
|
+
`updateMany` returns EVERY row it matched, and matching nothing is an answer the
|
|
1294
|
+
caller has to read:
|
|
1295
|
+
|
|
1296
|
+
```ts
|
|
1297
|
+
const [row] = await Database.public.accounts.updateMany({
|
|
1298
|
+
where: { id: accountId, balance: { gte: amount } }, // the guard IS the filter
|
|
1299
|
+
set: { balance: decrement(amount) },
|
|
1300
|
+
});
|
|
1301
|
+
if (row === undefined) throw new Conflict("insufficient balance");
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
The filter carries the condition, so the check and the write are ONE statement —
|
|
1305
|
+
there is no window between reading a balance and spending it. An empty array
|
|
1306
|
+
means the balance was too low, or the row does not exist, or RLS hides it from
|
|
1307
|
+
this caller. None of those is "the transfer happened".
|
|
1308
|
+
|
|
1309
|
+
### Append-only tables — a record that cannot be rewritten
|
|
1310
|
+
|
|
1311
|
+
`appendOnly: true` on a table declaration means a row can be INSERTED and read,
|
|
1312
|
+
never updated and never deleted. A correction is a COMPENSATING ENTRY: a new row
|
|
1313
|
+
carrying the opposite sign.
|
|
1314
|
+
|
|
1315
|
+
```ts
|
|
1316
|
+
export const entries = defineTable("entries", {
|
|
1317
|
+
appendOnly: true,
|
|
1318
|
+
columns: { /* … */ },
|
|
1319
|
+
});
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
**Three locks, because the first two are not enough on their own:**
|
|
1323
|
+
|
|
1324
|
+
| Lock | Stops | Does not stop |
|
|
1325
|
+
|---|---|---|
|
|
1326
|
+
| The TYPE | `update` / `updateMany` / `delete` / `deleteMany` / `put` / `supersede` are not on the table | untyped `$`-prefixed calls, anything off the wire |
|
|
1327
|
+
| The ENGINE | those same operations refused by name, whichever door they come through | SQL that never passes through this SDK |
|
|
1328
|
+
| 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 |
|
|
1329
|
+
|
|
1330
|
+
The triggers are not belt-and-braces. Measured on a live stack: the platform
|
|
1331
|
+
re-grants table privileges on every boot, which quietly undid the REVOKE, and
|
|
1332
|
+
the service role carries `BYPASSRLS`, so the policy never applied to it either.
|
|
1333
|
+
With both gone the row really was deletable. The triggers are what actually
|
|
1334
|
+
hold, and they say so:
|
|
1131
1335
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1336
|
+
```
|
|
1337
|
+
ERROR: table entries is append-only: a row cannot be updated or deleted.
|
|
1338
|
+
Write a COMPENSATING ENTRY instead (an INSERT carrying the opposite sign).
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
`SELECT` and `INSERT` are untouched — append-only means the history is kept, not
|
|
1342
|
+
that it is unreadable, and the correction path has to stay open.
|
|
1343
|
+
|
|
1344
|
+
**TRUNCATE needs its own trigger, and finding that out took a measurement.** A
|
|
1345
|
+
row-level trigger never sees a `TRUNCATE` — Postgres truncates without touching
|
|
1346
|
+
rows — so the owner could empty the whole ledger in one statement while every
|
|
1347
|
+
other door was shut. The statement-level `BEFORE TRUNCATE` trigger closes it.
|
|
1348
|
+
|
|
1349
|
+
What is NOT closed: the table's owner can `ALTER TABLE … DISABLE TRIGGER` and
|
|
1350
|
+
then write freely. That is not a hole this can fix — the same owner can drop the
|
|
1351
|
+
table — and saying otherwise would be the kind of claim this table exists to
|
|
1352
|
+
avoid.
|
|
1134
1353
|
|
|
1135
1354
|
### Limits
|
|
1136
1355
|
|
|
1137
1356
|
A plan may carry at most 1000 operations, 5000 rows in one `insertMany`, and
|
|
1138
1357
|
8 MiB of JSON. Exceeding any of them is reported before the request is sent.
|
|
1139
1358
|
|
|
1140
|
-
## Bypassing RLS — `Database
|
|
1359
|
+
## Bypassing RLS — `Database.$asService()`
|
|
1141
1360
|
|
|
1142
1361
|
When a table has [Row-Level Security](./schema.md#row-level-security-rls)
|
|
1143
1362
|
policies, every `Database.*` call runs as the request's verified user, so the
|
|
@@ -1154,7 +1373,7 @@ nothing your code does. See
|
|
|
1154
1373
|
|
|
1155
1374
|
Sometimes you need to read or write **across all users** — an admin endpoint, a
|
|
1156
1375
|
background job that fans out notifications, a cleanup task. For that, call
|
|
1157
|
-
`Database
|
|
1376
|
+
`Database.$asService()`. It returns a sibling client that runs as
|
|
1158
1377
|
**`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
|
|
1159
1378
|
exact same surface — `tables`, the raw string ops, and `transaction`:
|
|
1160
1379
|
|
|
@@ -1162,14 +1381,14 @@ exact same surface — `tables`, the raw string ops, and `transaction`:
|
|
|
1162
1381
|
import { Database } from "@palbase/backend";
|
|
1163
1382
|
|
|
1164
1383
|
// RLS-enforced (default): only the caller's own rows.
|
|
1165
|
-
const mine = await Database.
|
|
1384
|
+
const mine = await Database.public.todos.findMany({});
|
|
1166
1385
|
|
|
1167
1386
|
// Service-role bypass: every user's rows. Explicit and greppable.
|
|
1168
|
-
const all = await Database
|
|
1169
|
-
const rows = await Database
|
|
1387
|
+
const all = await Database.$asService().tables.todos.findMany({});
|
|
1388
|
+
const rows = await Database.$asService().query("SELECT count(*) FROM todos");
|
|
1170
1389
|
|
|
1171
1390
|
// A service-role transaction (the role is fixed for the whole plan):
|
|
1172
|
-
await Database
|
|
1391
|
+
await Database.$asService().transaction((tx) => {
|
|
1173
1392
|
tx.tables.todos.updateWhere({ id }, { done: true });
|
|
1174
1393
|
return null;
|
|
1175
1394
|
});
|
|
@@ -1182,8 +1401,8 @@ Guidelines:
|
|
|
1182
1401
|
grep for in review.
|
|
1183
1402
|
- **No double-bypass / no nesting.** The sibling does not re-expose
|
|
1184
1403
|
`asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
|
|
1185
|
-
transaction. Use `Database
|
|
1186
|
-
`Database
|
|
1404
|
+
transaction. Use `Database.$transaction(...)` for an authenticated one and
|
|
1405
|
+
`Database.$asService().transaction(...)` for a service-role one; you cannot mix
|
|
1187
1406
|
enforced and bypassed operations inside a single plan.
|
|
1188
1407
|
- **It is a second transaction, on a second connection.** It has to be: the
|
|
1189
1408
|
Postgres role is bound once, when the transaction opens, so a sibling sharing
|
|
@@ -1209,7 +1428,7 @@ Declare your tables under `db/`, **one file per schema**: `db/public.ts` is the
|
|
|
1209
1428
|
schema Palbase expects to find, `db/billing.ts` declares a second one. Each file
|
|
1210
1429
|
default-exports a `defineSchema("<name>", { tables })` call. That drives
|
|
1211
1430
|
[migrations](./migrations.md) (additive changes auto-apply on deploy; type
|
|
1212
|
-
changes need an explicit migration) and makes `Database.
|
|
1431
|
+
changes need an explicit migration) and makes `Database.public.*` typed
|
|
1213
1432
|
everywhere — by default, with no import and no generic.
|
|
1214
1433
|
|
|
1215
1434
|
> Coming from a single `db/schema.ts` with tables declared inline? That layout is
|
|
@@ -1616,14 +1835,14 @@ and an index is not one.
|
|
|
1616
1835
|
## Typed DB access — by default
|
|
1617
1836
|
|
|
1618
1837
|
You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
|
|
1619
|
-
`palbase-env.d.ts`, which types `Database.
|
|
1838
|
+
`palbase-env.d.ts`, which types `Database.public.<name>` everywhere — no import
|
|
1620
1839
|
of the schema, no generic, no cast:
|
|
1621
1840
|
|
|
1622
1841
|
```ts
|
|
1623
1842
|
// services/room.service.ts — the layer that touches the database.
|
|
1624
1843
|
import { Database } from "@palbase/backend";
|
|
1625
1844
|
|
|
1626
|
-
type RoomsTable = typeof Database.
|
|
1845
|
+
type RoomsTable = typeof Database.public.rooms; // typed from your db/*.ts
|
|
1627
1846
|
|
|
1628
1847
|
export class RoomService {
|
|
1629
1848
|
private readonly rooms: RoomsTable;
|
|
@@ -1636,13 +1855,22 @@ export class RoomService {
|
|
|
1636
1855
|
}
|
|
1637
1856
|
}
|
|
1638
1857
|
|
|
1639
|
-
|
|
1858
|
+
```
|
|
1859
|
+
|
|
1860
|
+
```ts
|
|
1861
|
+
// rooms.module.ts
|
|
1862
|
+
import { Module, type Token } from "@palbase/backend";
|
|
1863
|
+
import { RoomsController } from "./controllers/rooms.controller.ts";
|
|
1864
|
+
import { RoomService } from "./services/room.service.ts";
|
|
1865
|
+
|
|
1866
|
+
@Module({ controllers: [RoomsController as Token], providers: [RoomService as Token] })
|
|
1867
|
+
export class RoomsModule {}
|
|
1640
1868
|
```
|
|
1641
1869
|
|
|
1642
1870
|
```ts
|
|
1643
1871
|
// controllers/rooms.controller.ts — HTTP only; no `Database` import here.
|
|
1644
1872
|
import { Controller, Post, Body, z } from "@palbase/backend";
|
|
1645
|
-
import {
|
|
1873
|
+
import { RoomService } from "../services/room.service.js";
|
|
1646
1874
|
|
|
1647
1875
|
const CreateRoomBody = z.object({ name: z.string() });
|
|
1648
1876
|
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
@@ -1658,12 +1886,12 @@ export default class RoomsController {
|
|
|
1658
1886
|
}
|
|
1659
1887
|
```
|
|
1660
1888
|
|
|
1661
|
-
`Database.
|
|
1662
|
-
`findById(id)`, `findMany(query?)`. `Database
|
|
1889
|
+
`Database.public.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
|
|
1890
|
+
`findById(id)`, `findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
|
|
1663
1891
|
whose `tx.tables.<name>` is typed from the same schema, but carries plan
|
|
1664
1892
|
operations (`insert`/`insertMany`/`updateWhere`/`deleteWhere`/`select`) rather
|
|
1665
1893
|
than awaited calls — see [database.md](./database.md#transactions). The raw
|
|
1666
|
-
string-keyed ops (`Database
|
|
1894
|
+
string-keyed ops (`Database.$insert("rooms", …)`, `Database.$query(…)`) are still
|
|
1667
1895
|
available for dynamic table names and read-only SQL.
|
|
1668
1896
|
|
|
1669
1897
|
If you want a row type explicitly, import it from the generated env module:
|
|
@@ -1784,10 +2012,10 @@ export const todos = defineTable("todos", {
|
|
|
1784
2012
|
export default defineSchema("public", { tables: [todos] });
|
|
1785
2013
|
```
|
|
1786
2014
|
|
|
1787
|
-
With this in place, `await Database.
|
|
2015
|
+
With this in place, `await Database.public.todos.findMany({})` returns only the
|
|
1788
2016
|
calling user's rows — no `WHERE owner = …` needed in the handler. To read or
|
|
1789
2017
|
write across all users (e.g. an admin job), use the explicit bypass:
|
|
1790
|
-
`Database
|
|
2018
|
+
`Database.$asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
|
|
1791
2019
|
|
|
1792
2020
|
### How policies are applied
|
|
1793
2021
|
|
|
@@ -1902,7 +2130,7 @@ one, `palbase push` is what moves an Environment's. Until then the declaration i
|
|
|
1902
2130
|
ahead of the tables.
|
|
1903
2131
|
|
|
1904
2132
|
The TYPES move separately again. `palbase build` regenerates `palbase-env.d.ts`
|
|
1905
|
-
from `db/*.ts`, which is what types `Database.
|
|
2133
|
+
from `db/*.ts`, which is what types `Database.public.<name>` in your
|
|
1906
2134
|
services — so after a schema edit, run it. (There is no second command for this:
|
|
1907
2135
|
`build` regenerates everything derived, because one verb to remember is one verb
|
|
1908
2136
|
to forget.) Typed and applied are independent, and knowing which one you are
|
|
@@ -1975,7 +2203,7 @@ existing data gets there.
|
|
|
1975
2203
|
Add `rls: true` + `policies: [policy(...)]` to a table in `db/*.ts`; the
|
|
1976
2204
|
generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
|
|
1977
2205
|
See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
|
|
1978
|
-
`Database.
|
|
2206
|
+
`Database.public.*` access.
|
|
1979
2207
|
|
|
1980
2208
|
### Hand-writing a policy
|
|
1981
2209
|
|
|
@@ -2059,7 +2287,7 @@ await Cache.del("k");
|
|
|
2059
2287
|
// Stampede-safe read-through: concurrent callers wait on ONE execution of fn
|
|
2060
2288
|
// and share its result, however many of them there are.
|
|
2061
2289
|
const profile = await Cache.getOrSet("user:42", 300, async () => {
|
|
2062
|
-
return Database
|
|
2290
|
+
return Database.$findById("users", "42");
|
|
2063
2291
|
});
|
|
2064
2292
|
```
|
|
2065
2293
|
|
|
@@ -2167,7 +2395,7 @@ export default class CheckoutController {
|
|
|
2167
2395
|
|
|
2168
2396
|
### Overriding a flag — current user vs cross-user
|
|
2169
2397
|
|
|
2170
|
-
Writes mirror the `Database` / `Database
|
|
2398
|
+
Writes mirror the `Database` / `Database.$asService()` model:
|
|
2171
2399
|
|
|
2172
2400
|
- `Flags.setOverride(key, value)` (default) writes an override for the **current
|
|
2173
2401
|
request user** — no `userId` argument, no admin power. It errors on an
|
|
@@ -2175,7 +2403,7 @@ Writes mirror the `Database` / `Database.asService()` model:
|
|
|
2175
2403
|
- `Flags.asService()` returns the cross-user admin surface
|
|
2176
2404
|
(`setOverrideForUser`, `setOverridesForUser`, `clearOverrideForUser`,
|
|
2177
2405
|
`clearAllOverridesForUser`, `batchSetOverrides`) for writing overrides for an
|
|
2178
|
-
**arbitrary** user. Explicit and greppable, just like `Database
|
|
2406
|
+
**arbitrary** user. Explicit and greppable, just like `Database.$asService()`.
|
|
2179
2407
|
|
|
2180
2408
|
```ts
|
|
2181
2409
|
// Current request user — no userId needed:
|
|
@@ -2244,47 +2472,137 @@ A controller's job is HTTP — validate a body through a named schema, name the
|
|
|
2244
2472
|
whose, in what order is the service's. A controller that reaches for `Database`
|
|
2245
2473
|
has moved the logic into the layer that is hardest to test.
|
|
2246
2474
|
|
|
2247
|
-
**2.
|
|
2248
|
-
|
|
2249
|
-
|
|
2475
|
+
**2. A dependency arrives through the CONSTRUCTOR, and the container supplies
|
|
2476
|
+
it.** Mark the class `@Injectable()` and name what it needs as ordinary
|
|
2477
|
+
constructor parameters:
|
|
2250
2478
|
|
|
2251
2479
|
```ts
|
|
2252
2480
|
// services/note.service.ts
|
|
2253
|
-
import { Database } from "@palbase/backend";
|
|
2481
|
+
import { Database, Injectable } from "@palbase/backend";
|
|
2254
2482
|
|
|
2255
|
-
type NotesTable = typeof Database.
|
|
2483
|
+
type NotesTable = typeof Database.public.notes;
|
|
2256
2484
|
|
|
2485
|
+
@Injectable()
|
|
2257
2486
|
export class NoteService {
|
|
2258
|
-
private readonly notes: NotesTable;
|
|
2259
|
-
|
|
2260
|
-
// Assigned in the BODY. A parameter property (`constructor(private notes: …)`)
|
|
2261
|
-
// is refused by Node's type-stripping test runner, for the whole file.
|
|
2262
|
-
constructor(notes: NotesTable) {
|
|
2263
|
-
this.notes = notes;
|
|
2264
|
-
}
|
|
2487
|
+
private readonly notes: NotesTable = Database.public.notes;
|
|
2265
2488
|
|
|
2266
2489
|
list(userId: string) {
|
|
2267
|
-
return this.notes.findMany({ user_id: userId });
|
|
2490
|
+
return this.notes.findMany({ where: { user_id: userId } });
|
|
2268
2491
|
}
|
|
2269
2492
|
}
|
|
2493
|
+
```
|
|
2270
2494
|
|
|
2271
|
-
|
|
2495
|
+
```ts
|
|
2496
|
+
// services/report.service.ts — a service that depends on another service
|
|
2497
|
+
import { Injectable } from "@palbase/backend";
|
|
2498
|
+
import { NoteService } from "./note.service.ts";
|
|
2499
|
+
|
|
2500
|
+
@Injectable()
|
|
2501
|
+
export class ReportService {
|
|
2502
|
+
constructor(private readonly notes: NoteService) {}
|
|
2503
|
+
|
|
2504
|
+
async count(userId: string): Promise<number> {
|
|
2505
|
+
return (await this.notes.list(userId)).length;
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2272
2508
|
```
|
|
2273
2509
|
|
|
2510
|
+
A controller asks the same way, and nothing wires it by hand:
|
|
2511
|
+
|
|
2274
2512
|
```ts
|
|
2275
2513
|
// controllers/notes.controller.ts
|
|
2276
|
-
import {
|
|
2514
|
+
import { Controller, Get } from "@palbase/backend";
|
|
2515
|
+
import { NoteService } from "../services/note.service.ts";
|
|
2516
|
+
|
|
2517
|
+
@Controller("/notes")
|
|
2518
|
+
export class NotesController {
|
|
2519
|
+
constructor(private readonly notes: NoteService) {}
|
|
2520
|
+
|
|
2521
|
+
@Get("") list(): Promise<Note[]> { return this.notes.list(currentUserId()); }
|
|
2522
|
+
}
|
|
2277
2523
|
```
|
|
2278
2524
|
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2525
|
+
There is **no `inject()`, no `@Inject`, and no token registry**. A dependency is
|
|
2526
|
+
named by its parameter's TYPE and by nothing else — which is also why an
|
|
2527
|
+
`interface` cannot be one: an interface does not exist at runtime, so there is
|
|
2528
|
+
nothing to hand over. Use a class, or an `abstract class` when you want the
|
|
2529
|
+
abstraction:
|
|
2530
|
+
|
|
2531
|
+
```ts
|
|
2532
|
+
export abstract class Clock {
|
|
2533
|
+
abstract now(): number;
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
@Injectable()
|
|
2537
|
+
export class SystemClock extends Clock {
|
|
2538
|
+
now(): number { return Date.now(); }
|
|
2539
|
+
}
|
|
2540
|
+
```
|
|
2541
|
+
|
|
2542
|
+
The abstraction is the TOKEN; the implementation is the PROVIDER. `SystemClock`
|
|
2543
|
+
goes in a module's `providers` and `Clock` goes in nobody's: the container
|
|
2544
|
+
resolves an abstraction to the single owned class that `extends` it. Listing
|
|
2545
|
+
`Clock` itself is refused — `new Clock()` succeeds in JavaScript and hands back
|
|
2546
|
+
an object missing every abstract member, and that object would be injected.
|
|
2547
|
+
|
|
2548
|
+
**3. ONE module says what exists, who owns it, and what it may reach.** A class
|
|
2549
|
+
that no module lists does not exist: it is refused at build, by name, and it
|
|
2550
|
+
never reaches the route table, the dispatcher or the OpenAPI document.
|
|
2551
|
+
|
|
2552
|
+
```ts
|
|
2553
|
+
// notes.module.ts — beside the domain it owns, not in a directory we name
|
|
2554
|
+
import { Module, type Token } from "@palbase/backend";
|
|
2555
|
+
import { NotesController } from "./controllers/notes.controller.ts";
|
|
2556
|
+
import { NoteService } from "./services/note.service.ts";
|
|
2557
|
+
import { ReportService } from "./services/report.service.ts";
|
|
2558
|
+
|
|
2559
|
+
@Module({
|
|
2560
|
+
controllers: [NotesController as Token], // the entry points this module owns
|
|
2561
|
+
providers: [NoteService as Token, ReportService as Token], // what it owns
|
|
2562
|
+
exports: [NoteService as Token], // what OTHER modules may reach
|
|
2563
|
+
imports: [], // whose exports THIS module may reach
|
|
2564
|
+
})
|
|
2565
|
+
export class NotesModule {}
|
|
2566
|
+
```
|
|
2567
|
+
|
|
2568
|
+
The four lists answer four different questions, and nothing else answers them:
|
|
2569
|
+
|
|
2570
|
+
| list | question |
|
|
2571
|
+
|---|---|
|
|
2572
|
+
| `providers` | which classes does this module OWN |
|
|
2573
|
+
| `controllers` | which entry points does it own |
|
|
2574
|
+
| `exports` | which of its own classes may another module reach |
|
|
2575
|
+
| `imports` | whose exports may this module reach |
|
|
2576
|
+
|
|
2577
|
+
`ReportService` is NOT exported above, so no other module can depend on it —
|
|
2578
|
+
and the build says so by name if one tries. That is the whole boundary: a
|
|
2579
|
+
module's internals stay internal until it says otherwise.
|
|
2580
|
+
|
|
2581
|
+
There is no root module and nothing to mount one into. A small project writes
|
|
2582
|
+
one module file; a large one writes a module per domain and the boundaries are
|
|
2583
|
+
enforced at build rather than by convention.
|
|
2584
|
+
|
|
2585
|
+
**A constructor stays pure wiring.** It runs while the app is coming up, it is
|
|
2586
|
+
synchronous, and it must not do I/O. Real work at startup belongs in `onStart`,
|
|
2587
|
+
which is awaited before the first request is served — a connection opened in a
|
|
2588
|
+
constructor fails somewhere nobody is watching, and one opened in `onStart`
|
|
2589
|
+
fails at boot with the error in front of the operator.
|
|
2590
|
+
|
|
2591
|
+
Rule 2 is also what makes rule 1 pay off. A test can build the graph with a
|
|
2592
|
+
stand-in in place of any part of it, however deep:
|
|
2593
|
+
|
|
2594
|
+
```ts
|
|
2595
|
+
import { isolated } from "@palbase/backend/test";
|
|
2596
|
+
|
|
2597
|
+
const frozen = new (class extends Clock { now() { return 0; } })();
|
|
2598
|
+
const report = isolated().with(Clock, frozen).get(ReportService);
|
|
2599
|
+
```
|
|
2284
2600
|
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2601
|
+
`isolated()` rebuilds the graph with the overrides in place and touches no
|
|
2602
|
+
process-wide state, so the next test in the same file does not meet whatever
|
|
2603
|
+
this one substituted. Or construct the class yourself — `new ReportService(new
|
|
2604
|
+
NoteService())` is ordinary TypeScript, and the container is not required for it
|
|
2605
|
+
to work.
|
|
2288
2606
|
|
|
2289
2607
|
```ts
|
|
2290
2608
|
// services/note.service.test.ts — `npm test`, no database
|
|
@@ -2438,8 +2756,8 @@ import { Database, Job, Log, type JobMeta } from "@palbase/backend";
|
|
|
2438
2756
|
@Job({ schedule: "0 3 * * *", timeout: 120 }) // schedule: standard cron; timeout: optional, seconds
|
|
2439
2757
|
export default class CleanupJob {
|
|
2440
2758
|
async run(meta: JobMeta) {
|
|
2441
|
-
const expired = await Database
|
|
2442
|
-
for (const s of expired) await Database
|
|
2759
|
+
const expired = await Database.$findMany("sessions", { expired: true });
|
|
2760
|
+
for (const s of expired) await Database.$delete("sessions", s.id as string);
|
|
2443
2761
|
Log.info(`cleaned ${expired.length} sessions in ${meta.environmentId}`);
|
|
2444
2762
|
}
|
|
2445
2763
|
}
|
|
@@ -2471,7 +2789,7 @@ import { auth, Database, Log } from "@palbase/backend";
|
|
|
2471
2789
|
|
|
2472
2790
|
export const onUserCreated = auth.onUserCreated(async (event, meta) => {
|
|
2473
2791
|
Log.info(`new user: ${event.user.email}`);
|
|
2474
|
-
await Database
|
|
2792
|
+
await Database.$insert("profiles", {
|
|
2475
2793
|
user_id: event.user.id,
|
|
2476
2794
|
email: event.user.email,
|
|
2477
2795
|
});
|
|
@@ -2504,13 +2822,13 @@ import { Database, Log, On, Webhook, type WebhookMeta } from "@palbase/backend";
|
|
|
2504
2822
|
export default class StripeWebhook {
|
|
2505
2823
|
@On("checkout.session.completed")
|
|
2506
2824
|
async checkoutCompleted(event: unknown, meta: WebhookMeta) {
|
|
2507
|
-
await Database
|
|
2825
|
+
await Database.$insert("orders", { status: "paid", data: event });
|
|
2508
2826
|
}
|
|
2509
2827
|
|
|
2510
2828
|
@On("payment_intent.payment_failed")
|
|
2511
2829
|
async paymentFailed(event: unknown, meta: WebhookMeta) {
|
|
2512
2830
|
Log.error("payment failed");
|
|
2513
|
-
await Database
|
|
2831
|
+
await Database.$insert("payment_failures", { data: event });
|
|
2514
2832
|
}
|
|
2515
2833
|
}
|
|
2516
2834
|
```
|