@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/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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 EXISTS — a 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
|
|
45
|
-
|
|
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`
|
|
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.
|
|
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,14 +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 is
|
|
137
|
-
* refused with the class named — at decoration time for jobs/hooks/webhooks, and
|
|
138
|
-
* when the route table is built at boot for a controller. */
|
|
139
|
-
export const todoService = new TodoService(Database.tables.todos);
|
|
140
137
|
```
|
|
141
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
|
+
|
|
142
157
|
```ts
|
|
143
158
|
// controllers/todos.controller.ts — class controller. Thin: delegates to the service.
|
|
144
159
|
import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
|
|
@@ -219,7 +234,7 @@ generated client surface) changes; the verb/path do not affect it.
|
|
|
219
234
|
(stage, bundle, extract controller metadata). Exits non-zero on a decorator,
|
|
220
235
|
return-type or version-skew error, so a push that would deploy zero endpoints
|
|
221
236
|
fails on your machine instead. It also regenerates `palbase-env.d.ts` from
|
|
222
|
-
your `db/*.ts` files, which is what types `Database.
|
|
237
|
+
your `db/*.ts` files, which is what types `Database.public.*` (no import, no
|
|
223
238
|
generic) — so run it after editing the schema. There is no separate command
|
|
224
239
|
for that: `build` regenerates everything derived.
|
|
225
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
|
|
179
|
+
- [Database](./database.md) — how `Database.$asService()` steps outside RLS.
|
package/docs/background.md
CHANGED
|
@@ -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
|
|
30
|
-
for (const s of expired) await Database
|
|
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
|
|
13
|
+
## Typed by default — `Database.<şema>.<tablo>`
|
|
14
14
|
|
|
15
|
-
When you declare a schema under `db/*.ts`, `Database.
|
|
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.
|
|
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.
|
|
24
|
-
await Database.
|
|
25
|
-
await Database.
|
|
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
|
|
39
|
-
| `Database
|
|
40
|
-
| `Database
|
|
41
|
-
| `Database
|
|
42
|
-
| `Database
|
|
43
|
-
| `Database
|
|
44
|
-
| `Database
|
|
45
|
-
| `Database
|
|
46
|
-
| `Database
|
|
47
|
-
| `Database
|
|
48
|
-
| `Database
|
|
49
|
-
| `Database
|
|
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,137 @@ 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.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
Operators: `gt` · `gte` · `lt` · `lte` · `neq` · `in` · `contains` ·
|
|
68
|
+
`icontains` · `startsWith` · `endsWith` · `isNull`, plus `OR` / `AND` / `NOT`
|
|
69
|
+
for composition. A branch of `OR` / `AND` / `NOT` may also be a `sqlFragment`,
|
|
70
|
+
so one hand-written predicate does not force the whole query into raw SQL:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
await Database.public.crew_presence.findMany({
|
|
74
|
+
where: {
|
|
75
|
+
city: "IST",
|
|
76
|
+
AND: [sqlFragment`expires_at > now()`],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
``` The same filter language works for `updateMany`, `deleteMany`,
|
|
80
|
+
`count` and `search` — one language, so two spellings cannot drift apart. An
|
|
81
|
+
empty `in` list means NO rows (written into the SQL as `false`), never a silent
|
|
82
|
+
full scan.
|
|
83
|
+
|
|
84
|
+
### Comparing against server time — `now()`
|
|
85
|
+
|
|
86
|
+
`expires_at > now()` is one of the most-written predicates in any backend, and
|
|
87
|
+
the answer must come from the DATABASE's clock: sending `new Date()` from
|
|
88
|
+
JavaScript silently switches to the client's clock, and two machines do not
|
|
89
|
+
agree.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import { now } from "@palbase/backend";
|
|
93
|
+
|
|
94
|
+
await Database.public.sessions.findMany({
|
|
95
|
+
where: { user_id, expires_at: { gt: now() } },
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
It renders as `now()` with NO bound parameter. The bare form is equality
|
|
100
|
+
(`{ last_seen: now() }`), exactly like `col()`.
|
|
101
|
+
|
|
102
|
+
`increment()` / `decrement()` are refused here by name: they are WRITE
|
|
103
|
+
expressions, and a comparison against one would silently match nothing. And a
|
|
104
|
+
`{ "$expr": { "fn": "now" } }` object arriving in a request body is not `now()`
|
|
105
|
+
— only the `now()` call produces one, so spreading untrusted input into a filter
|
|
106
|
+
cannot forge a server-time comparison.
|
|
107
|
+
|
|
108
|
+
### Comparing one column with another — `col()`
|
|
109
|
+
|
|
110
|
+
The right-hand side of a comparison is normally a VALUE. `col()` puts a COLUMN
|
|
111
|
+
there, which is otherwise a reason to drop to raw SQL:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
import { col } from "@palbase/backend";
|
|
115
|
+
|
|
116
|
+
// Invoices that are not fully paid.
|
|
117
|
+
await Database.public.invoices.findMany({ where: { total: { gt: col("amount_paid") } } });
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The name is checked at COMPILE time against the row type: `col("amont_paid")`
|
|
121
|
+
does not compile. It is legal in `gt` / `gte` / `lt` / `lte` / `neq` and as a
|
|
122
|
+
bare value (which means equality); anywhere else — inside `in`, in a `set`, in
|
|
123
|
+
`orderBy` — it is refused by name.
|
|
124
|
+
|
|
125
|
+
`col()` is only ever produced by the `col()` call. A `{ "$col": "…" }` object
|
|
126
|
+
that arrives in a request body is NOT a column reference and is refused as an
|
|
127
|
+
unknown operator, so spreading untrusted input into a filter cannot turn a
|
|
128
|
+
tenant predicate into a tautology.
|
|
129
|
+
|
|
130
|
+
### Filtering through a relation — `has`
|
|
131
|
+
|
|
132
|
+
A filter on the row next to yours is the most common reason to drop to raw SQL,
|
|
133
|
+
and it is the one the schema already knows how to answer. Every foreign key you
|
|
134
|
+
declare produces a named relation on BOTH sides, and `has` filters through it:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// Interests that THIS user picked.
|
|
138
|
+
await Database.public.interests.findMany({
|
|
139
|
+
where: { has: { user_interests: { user_id: userId } } },
|
|
140
|
+
orderBy: { column: "sort_order", direction: "asc" },
|
|
141
|
+
});
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
That compiles to a correlated `IN (SELECT …)` — one statement, one round trip,
|
|
145
|
+
the RLS policies of BOTH tables still applied. The hand-written alternative was
|
|
146
|
+
a `sqlFragment` carrying a subquery, or two queries and a `Set` in JavaScript.
|
|
147
|
+
|
|
148
|
+
The relation names come from the schema, so `has: { user_intrests: … }` is a
|
|
149
|
+
compile error, and so is a column that the OTHER table does not have. A table
|
|
150
|
+
that declares no foreign key at all has no `has` — the key is absent, not empty.
|
|
151
|
+
|
|
152
|
+
Both directions work. From the child, the relation is the parent:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
await Database.public.user_interests.findMany({
|
|
156
|
+
where: { has: { interest: { name: { icontains: "yoga" } } } },
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The inner filter is the SAME language: operators, `OR` / `AND` / `NOT`, `col()`,
|
|
161
|
+
and a nested `has` one table further (three levels — the relation graph points
|
|
162
|
+
both ways, so it is a cycle, and an unbounded type would follow it forever).
|
|
163
|
+
|
|
164
|
+
`has` is available wherever a filter is: `findMany`, `count`, `updateMany`,
|
|
165
|
+
`deleteMany`, and inside `$transaction`. In a transaction the update still takes
|
|
166
|
+
its rows in primary-key order under `FOR NO KEY UPDATE`, exactly as a plain
|
|
167
|
+
filter does:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
await Database.$transaction((tx) => {
|
|
171
|
+
tx.public.interests.updateWhere(
|
|
172
|
+
{ has: { user_interests: { user_id: userId } } },
|
|
173
|
+
{ last_seen_at: now() },
|
|
174
|
+
);
|
|
175
|
+
return [];
|
|
176
|
+
});
|
|
177
|
+
```
|
|
69
178
|
|
|
70
179
|
### Ordering and paging
|
|
71
180
|
|
|
72
181
|
```ts
|
|
73
|
-
await Database.
|
|
74
|
-
{ household_id: hid },
|
|
75
|
-
|
|
76
|
-
|
|
182
|
+
await Database.public.entries.findMany({
|
|
183
|
+
where: { household_id: hid },
|
|
184
|
+
orderBy: { column: "created_at", direction: "desc" },
|
|
185
|
+
limit: 20,
|
|
186
|
+
offset: 40,
|
|
187
|
+
});
|
|
77
188
|
```
|
|
78
189
|
|
|
79
190
|
`orderBy.column` is checked against the row type: a mistyped column name is a
|
|
@@ -97,7 +208,7 @@ It runs in **the request's own transaction**, not a read-only one: a write insid
|
|
|
97
208
|
it commits with the rest of the request.
|
|
98
209
|
|
|
99
210
|
```ts
|
|
100
|
-
const rows = await Database
|
|
211
|
+
const rows = await Database.$query(
|
|
101
212
|
"SELECT c.name, count(*) FROM entries e JOIN categories c ON c.id = e.category_id" +
|
|
102
213
|
" WHERE e.household_id = $1 GROUP BY c.name",
|
|
103
214
|
[hid],
|
|
@@ -114,7 +225,7 @@ Inside a transaction, `insertMany` takes an optional conflict rule — which is
|
|
|
114
225
|
per row with a `23505` caught around each:
|
|
115
226
|
|
|
116
227
|
```ts
|
|
117
|
-
await Database
|
|
228
|
+
await Database.$transaction((tx) => {
|
|
118
229
|
tx.tables.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
|
|
119
230
|
return null;
|
|
120
231
|
});
|
|
@@ -141,7 +252,7 @@ carrying the constraint Postgres named:
|
|
|
141
252
|
import { UniqueViolation, Conflict } from "@palbase/backend";
|
|
142
253
|
|
|
143
254
|
try {
|
|
144
|
-
return await Database.
|
|
255
|
+
return await Database.public.users.insert({ email });
|
|
145
256
|
} catch (e) {
|
|
146
257
|
if (UniqueViolation.is(e) && e.constraint === "users_email_key") {
|
|
147
258
|
throw new Conflict("That email is taken", "email_taken");
|
|
@@ -171,7 +282,7 @@ middle, so nothing holds a database connection open while your code thinks.
|
|
|
171
282
|
```ts
|
|
172
283
|
import { Database, NotFound } from "@palbase/backend";
|
|
173
284
|
|
|
174
|
-
const { orderId } = await Database
|
|
285
|
+
const { orderId } = await Database.$transaction((tx) => {
|
|
175
286
|
const order = tx.tables.orders
|
|
176
287
|
.insert({ amount: 1000, status: "pending" })
|
|
177
288
|
.expectOne(new NotFound("order could not be created"));
|
|
@@ -220,9 +331,9 @@ does not write is not part of the transaction:
|
|
|
220
331
|
|
|
221
332
|
```ts
|
|
222
333
|
// Before the transaction: an ordinary value you can branch on.
|
|
223
|
-
const overrides = await Database.
|
|
334
|
+
const overrides = await Database.public.category_overrides.findMany({ where: { household_id } });
|
|
224
335
|
|
|
225
|
-
const stmt = await Database
|
|
336
|
+
const stmt = await Database.$transaction((tx) => { /* … */ });
|
|
226
337
|
```
|
|
227
338
|
|
|
228
339
|
### Writing conditions as filters
|
|
@@ -253,19 +364,113 @@ Three expressions may appear in the values you write:
|
|
|
253
364
|
|
|
254
365
|
| Expression | Where | Meaning |
|
|
255
366
|
|---|---|---|
|
|
256
|
-
| `now()` |
|
|
257
|
-
| `
|
|
258
|
-
| `
|
|
367
|
+
| `now()` | any `set` — `updateMany` and `updateWhere` | the server's clock |
|
|
368
|
+
| `increment(n)` | any `set` | `column = column + n`, atomically |
|
|
369
|
+
| `decrement(n)` | any `set` | `column = column - n`, atomically |
|
|
370
|
+
|
|
371
|
+
`inc` / `dec` are the OLD names for `increment` / `decrement`. They are the same
|
|
372
|
+
factory (`inc === increment`), kept as deprecated aliases; write the long names.
|
|
373
|
+
|
|
374
|
+
The expressions work on BOTH surfaces — the direct `updateMany` and the plan's
|
|
375
|
+
`updateWhere` — because they are one object:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
// Direct: one statement, no transaction needed.
|
|
379
|
+
await Database.public.accounts.updateMany({
|
|
380
|
+
where: { id: accountId },
|
|
381
|
+
set: { balance: decrement("5.00") },
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// Inside a plan: the same expression, the same meaning.
|
|
385
|
+
await Database.$transaction((tx) => {
|
|
386
|
+
tx.tables.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
|
|
387
|
+
return null;
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Give money a STRING amount.** `increment(0.1)` binds a JS number and
|
|
392
|
+
`0.1 + 0.2` is `0.30000000000000004`; `increment("0.1")` binds the decimal and
|
|
393
|
+
Postgres adds it exactly. Measured: `12345678901234567890` as a number becomes
|
|
394
|
+
`12345678901234600000`, as a string it stays whole.
|
|
395
|
+
|
|
396
|
+
**The amount is a magnitude, not a signed number.** `decrement("-5")` is
|
|
397
|
+
refused: a negative amount would ADD money through a call that reads as a
|
|
398
|
+
withdrawal, and the guard names `increment()` as the way to say the other
|
|
399
|
+
direction.
|
|
400
|
+
|
|
401
|
+
`increment` / `decrement` read the column's current value, which an inserted row
|
|
402
|
+
does not have — using one in an `insert` is a compile error, and the engine
|
|
403
|
+
refuses it by name if it arrives untyped.
|
|
404
|
+
|
|
405
|
+
### Zero rows is not silent success
|
|
406
|
+
|
|
407
|
+
`updateMany` returns EVERY row it matched, and matching nothing is an answer the
|
|
408
|
+
caller has to read:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
const [row] = await Database.public.accounts.updateMany({
|
|
412
|
+
where: { id: accountId, balance: { gte: amount } }, // the guard IS the filter
|
|
413
|
+
set: { balance: decrement(amount) },
|
|
414
|
+
});
|
|
415
|
+
if (row === undefined) throw new Conflict("insufficient balance");
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
The filter carries the condition, so the check and the write are ONE statement —
|
|
419
|
+
there is no window between reading a balance and spending it. An empty array
|
|
420
|
+
means the balance was too low, or the row does not exist, or RLS hides it from
|
|
421
|
+
this caller. None of those is "the transfer happened".
|
|
422
|
+
|
|
423
|
+
### Append-only tables — a record that cannot be rewritten
|
|
424
|
+
|
|
425
|
+
`appendOnly: true` on a table declaration means a row can be INSERTED and read,
|
|
426
|
+
never updated and never deleted. A correction is a COMPENSATING ENTRY: a new row
|
|
427
|
+
carrying the opposite sign.
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
export const entries = defineTable("entries", {
|
|
431
|
+
appendOnly: true,
|
|
432
|
+
columns: { /* … */ },
|
|
433
|
+
});
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**Three locks, because the first two are not enough on their own:**
|
|
437
|
+
|
|
438
|
+
| Lock | Stops | Does not stop |
|
|
439
|
+
|---|---|---|
|
|
440
|
+
| The TYPE | `update` / `updateMany` / `delete` / `deleteMany` / `put` / `supersede` are not on the table | untyped `$`-prefixed calls, anything off the wire |
|
|
441
|
+
| The ENGINE | those same operations refused by name, whichever door they come through | SQL that never passes through this SDK |
|
|
442
|
+
| 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 |
|
|
443
|
+
|
|
444
|
+
The triggers are not belt-and-braces. Measured on a live stack: the platform
|
|
445
|
+
re-grants table privileges on every boot, which quietly undid the REVOKE, and
|
|
446
|
+
the service role carries `BYPASSRLS`, so the policy never applied to it either.
|
|
447
|
+
With both gone the row really was deletable. The triggers are what actually
|
|
448
|
+
hold, and they say so:
|
|
449
|
+
|
|
450
|
+
```
|
|
451
|
+
ERROR: table entries is append-only: a row cannot be updated or deleted.
|
|
452
|
+
Write a COMPENSATING ENTRY instead (an INSERT carrying the opposite sign).
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`SELECT` and `INSERT` are untouched — append-only means the history is kept, not
|
|
456
|
+
that it is unreadable, and the correction path has to stay open.
|
|
457
|
+
|
|
458
|
+
**TRUNCATE needs its own trigger, and finding that out took a measurement.** A
|
|
459
|
+
row-level trigger never sees a `TRUNCATE` — Postgres truncates without touching
|
|
460
|
+
rows — so the owner could empty the whole ledger in one statement while every
|
|
461
|
+
other door was shut. The statement-level `BEFORE TRUNCATE` trigger closes it.
|
|
259
462
|
|
|
260
|
-
|
|
261
|
-
|
|
463
|
+
What is NOT closed: the table's owner can `ALTER TABLE … DISABLE TRIGGER` and
|
|
464
|
+
then write freely. That is not a hole this can fix — the same owner can drop the
|
|
465
|
+
table — and saying otherwise would be the kind of claim this table exists to
|
|
466
|
+
avoid.
|
|
262
467
|
|
|
263
468
|
### Limits
|
|
264
469
|
|
|
265
470
|
A plan may carry at most 1000 operations, 5000 rows in one `insertMany`, and
|
|
266
471
|
8 MiB of JSON. Exceeding any of them is reported before the request is sent.
|
|
267
472
|
|
|
268
|
-
## Bypassing RLS — `Database
|
|
473
|
+
## Bypassing RLS — `Database.$asService()`
|
|
269
474
|
|
|
270
475
|
When a table has [Row-Level Security](./schema.md#row-level-security-rls)
|
|
271
476
|
policies, every `Database.*` call runs as the request's verified user, so the
|
|
@@ -282,7 +487,7 @@ nothing your code does. See
|
|
|
282
487
|
|
|
283
488
|
Sometimes you need to read or write **across all users** — an admin endpoint, a
|
|
284
489
|
background job that fans out notifications, a cleanup task. For that, call
|
|
285
|
-
`Database
|
|
490
|
+
`Database.$asService()`. It returns a sibling client that runs as
|
|
286
491
|
**`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
|
|
287
492
|
exact same surface — `tables`, the raw string ops, and `transaction`:
|
|
288
493
|
|
|
@@ -290,14 +495,14 @@ exact same surface — `tables`, the raw string ops, and `transaction`:
|
|
|
290
495
|
import { Database } from "@palbase/backend";
|
|
291
496
|
|
|
292
497
|
// RLS-enforced (default): only the caller's own rows.
|
|
293
|
-
const mine = await Database.
|
|
498
|
+
const mine = await Database.public.todos.findMany({});
|
|
294
499
|
|
|
295
500
|
// Service-role bypass: every user's rows. Explicit and greppable.
|
|
296
|
-
const all = await Database
|
|
297
|
-
const rows = await Database
|
|
501
|
+
const all = await Database.$asService().tables.todos.findMany({});
|
|
502
|
+
const rows = await Database.$asService().query("SELECT count(*) FROM todos");
|
|
298
503
|
|
|
299
504
|
// A service-role transaction (the role is fixed for the whole plan):
|
|
300
|
-
await Database
|
|
505
|
+
await Database.$asService().transaction((tx) => {
|
|
301
506
|
tx.tables.todos.updateWhere({ id }, { done: true });
|
|
302
507
|
return null;
|
|
303
508
|
});
|
|
@@ -310,8 +515,8 @@ Guidelines:
|
|
|
310
515
|
grep for in review.
|
|
311
516
|
- **No double-bypass / no nesting.** The sibling does not re-expose
|
|
312
517
|
`asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
|
|
313
|
-
transaction. Use `Database
|
|
314
|
-
`Database
|
|
518
|
+
transaction. Use `Database.$transaction(...)` for an authenticated one and
|
|
519
|
+
`Database.$asService().transaction(...)` for a service-role one; you cannot mix
|
|
315
520
|
enforced and bypassed operations inside a single plan.
|
|
316
521
|
- **It is a second transaction, on a second connection.** It has to be: the
|
|
317
522
|
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. (
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
|
59
|
+
await Database.$insert("payment_failures", { data: event });
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
```
|
package/docs/getting-started.md
CHANGED
|
@@ -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.
|
|
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
|