@palbase/backend 24.3.0 → 25.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/palbase-backend.cjs +103 -60
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +17 -13
- package/dist/bin/palbase-backend.js.map +1 -1
- package/dist/{chunk-EIXCY4SS.js → chunk-34I4GB7D.js} +82 -49
- package/dist/chunk-34I4GB7D.js.map +1 -0
- package/dist/{chunk-UWSYTUGM.js → chunk-35PNTIRN.js} +48 -1
- package/dist/chunk-35PNTIRN.js.map +1 -0
- package/dist/chunk-HBOJLP2Z.js +840 -0
- package/dist/chunk-HBOJLP2Z.js.map +1 -0
- package/dist/{chunk-7Z6MGMXQ.js → chunk-XJ2RSHEU.js} +11 -5
- package/dist/chunk-XJ2RSHEU.js.map +1 -0
- package/dist/{chunk-ERDL5VAE.js → chunk-YOY5DFQS.js} +2 -2
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +31 -14
- package/dist/db/env.d.ts +31 -14
- package/dist/db/index.cjs +226 -111
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +11 -1
- package/dist/engine/index.cjs +89 -50
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +2 -2
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +3 -3
- package/dist/{index-BTMYod_l.d.ts → index-B4CcpqLb.d.ts} +224 -75
- package/dist/{index-DEneI8Mn.d.ts → index-B8v6hVyU.d.ts} +5 -2
- package/dist/{index-BLAbr9ZH.d.cts → index-DmVyY6N7.d.cts} +224 -75
- package/dist/{index-C-ALG22n.d.cts → index-VsjBQ4Kw.d.cts} +5 -2
- package/dist/index.cjs +580 -301
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +125 -22
- package/dist/index.d.ts +125 -22
- package/dist/index.js +173 -217
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +100 -36
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.js +59 -2
- package/dist/openapi/index.js.map +1 -1
- package/docs/README.md +64 -31
- package/docs/endpoints.md +25 -28
- package/docs/llms-full.txt +430 -153
- package/docs/schema.md +303 -91
- package/docs/services.md +39 -4
- package/package.json +1 -1
- package/template/AGENTS.md +119 -314
- package/template/CLAUDE.md +13 -0
- package/template/controllers/notes.controller.ts +6 -13
- package/template/db/public.ts +38 -0
- package/template/models/notes/create.ts +38 -0
- package/template/package.json +6 -3
- package/template/services/note.service.test.ts +45 -0
- package/template/services/note.service.ts +2 -2
- package/dist/chunk-7Z6MGMXQ.js.map +0 -1
- package/dist/chunk-D5CQES25.js +0 -556
- package/dist/chunk-D5CQES25.js.map +0 -1
- package/dist/chunk-EIXCY4SS.js.map +0 -1
- package/dist/chunk-UWSYTUGM.js.map +0 -1
- package/template/db/schema.ts +0 -35
- /package/dist/{chunk-ERDL5VAE.js.map → chunk-YOY5DFQS.js.map} +0 -0
package/docs/llms-full.txt
CHANGED
|
@@ -31,17 +31,26 @@ services/<name>.service.ts # plain class + singleton — the real logic
|
|
|
31
31
|
db/schema.ts # config-as-code Postgres schema (tables, columns, RLS) — auto-migrated on deploy
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
The four folders above are the daily surface.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
The four folders above are the daily surface. Three more are discovered by name,
|
|
35
|
+
one class per file, `export default` required: `jobs/` (background —
|
|
36
|
+
[background.md](./background.md)), `webhooks/` and `hooks/` (events —
|
|
37
|
+
[events.md](./events.md)).
|
|
38
|
+
|
|
39
|
+
There is no `resources/`, no `seeds/` and no working `middleware/`. `middleware/`
|
|
40
|
+
is discovered by nothing and the engine has no middleware pipeline: code written
|
|
41
|
+
against it deploys, never runs, and nothing says so. Put cross-cutting work in a
|
|
42
|
+
service the controllers call.
|
|
38
43
|
|
|
39
44
|
### The 7 rules (checklist)
|
|
40
45
|
|
|
41
|
-
1.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
1. **A controller needs NO export at all.** `@Controller` records the class into a
|
|
47
|
+
`globalThis` registry as it decorates it, and the bundler imports the file for
|
|
48
|
+
that side effect alone — the shipped scaffold's own `HealthController` and
|
|
49
|
+
`NotesController` are not exported. Exporting is harmless and reads well, so
|
|
50
|
+
these examples do it; it is not a requirement. (`export default` **is**
|
|
51
|
+
required for `jobs/`, `webhooks/`, `hooks/` and `db/public.ts`, one class per
|
|
52
|
+
file.) What IS fatal is a `@Controller` class that collected zero routes —
|
|
53
|
+
usually `experimentalDecorators` missing from `tsconfig.json`.
|
|
45
54
|
2. **Methods that call a service are `async` and return `Promise<T>`.** Services
|
|
46
55
|
`await Database`, so they return promises; a sync return type on an async body
|
|
47
56
|
is a tsc error. Annotate `: Promise<TodoSchema>`, not `: TodoSchema`, whenever
|
|
@@ -70,7 +79,7 @@ formatter, anything shared), `seeds/` (seed data), `jobs/` (background — [back
|
|
|
70
79
|
|
|
71
80
|
> **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
|
|
72
81
|
> `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
|
|
73
|
-
> and will not compile against `@palbase/backend`
|
|
82
|
+
> and will not compile against `@palbase/backend` 25.
|
|
74
83
|
|
|
75
84
|
### Complete CRUD example (copy-pasteable, compiles)
|
|
76
85
|
|
|
@@ -97,25 +106,44 @@ export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
|
|
|
97
106
|
import { Database, NotFound } from "@palbase/backend";
|
|
98
107
|
import type { TodoSchema } from "../models/todos/shared.js";
|
|
99
108
|
|
|
109
|
+
/** The typed surface of ONE table. Naming it keeps the seam one table wide:
|
|
110
|
+
* a test fake implements five methods, not the whole `Database`. */
|
|
111
|
+
type TodosTable = typeof Database.tables.todos;
|
|
112
|
+
|
|
100
113
|
export class TodoService {
|
|
114
|
+
private readonly todos: TodosTable;
|
|
115
|
+
|
|
116
|
+
// THE SEAM IS THE CONSTRUCTOR: the class is handed the table rather than
|
|
117
|
+
// reaching for the singleton, so a test constructs it with a stand-in and
|
|
118
|
+
// never needs a database. Assign in the BODY — a parameter property
|
|
119
|
+
// (`constructor(private todos: …)`) is refused by Node's type-stripping test
|
|
120
|
+
// runner, and refused for the whole FILE.
|
|
121
|
+
constructor(todos: TodosTable) {
|
|
122
|
+
this.todos = todos;
|
|
123
|
+
}
|
|
124
|
+
|
|
101
125
|
list(userId: string): Promise<TodoSchema[]> {
|
|
102
|
-
return
|
|
126
|
+
return this.todos.findMany({ user_id: userId });
|
|
103
127
|
}
|
|
104
128
|
create(userId: string, title: string): Promise<TodoSchema> {
|
|
105
|
-
return
|
|
129
|
+
return this.todos.insert({ user_id: userId, title });
|
|
106
130
|
}
|
|
107
131
|
async get(userId: string, id: string): Promise<TodoSchema> {
|
|
108
|
-
const t = await
|
|
132
|
+
const t = await this.todos.findById(id);
|
|
109
133
|
if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
|
|
110
134
|
return t;
|
|
111
135
|
}
|
|
112
136
|
async remove(userId: string, id: string): Promise<void> {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await Database.tables.todos.delete(id);
|
|
137
|
+
await this.get(userId, id);
|
|
138
|
+
await this.todos.delete(id);
|
|
116
139
|
}
|
|
117
140
|
}
|
|
118
|
-
|
|
141
|
+
|
|
142
|
+
/** The wired instance. Controllers import THIS, never the class. It is also the
|
|
143
|
+
* ONLY supported way to hold a dependency: a controller, job, hook or webhook is
|
|
144
|
+
* constructed with no arguments, and one that declares a constructor parameter
|
|
145
|
+
* is refused at build with the class named. */
|
|
146
|
+
export const todoService = new TodoService(Database.tables.todos);
|
|
119
147
|
```
|
|
120
148
|
|
|
121
149
|
```ts
|
|
@@ -127,31 +155,33 @@ import { TodoSchema } from "../models/todos/shared.js";
|
|
|
127
155
|
import { CreateTodoBody } from "../models/todos/create.js";
|
|
128
156
|
|
|
129
157
|
@Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
|
|
130
|
-
export class TodosController {
|
|
131
|
-
|
|
158
|
+
export default class TodosController {
|
|
159
|
+
// The service arrives as an IMPORTED SINGLETON, not a constructor parameter:
|
|
160
|
+
// the runtime constructs this class with no arguments, and one that declares a
|
|
161
|
+
// parameter is refused at build with the class named.
|
|
132
162
|
|
|
133
163
|
@Get("") // GET /todos → operationId todos.list
|
|
134
164
|
async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
|
|
135
|
-
return
|
|
165
|
+
return todoService.list(user.id);
|
|
136
166
|
}
|
|
137
167
|
|
|
138
168
|
@Post("") // POST /todos → todos.create
|
|
139
169
|
async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
|
|
140
|
-
return
|
|
170
|
+
return todoService.create(user.id, body.title);
|
|
141
171
|
}
|
|
142
172
|
|
|
143
173
|
@Get("/{id}") // GET /todos/{id} → todos.get
|
|
144
174
|
async get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
|
|
145
|
-
return
|
|
175
|
+
return todoService.get(user.id, id);
|
|
146
176
|
}
|
|
147
177
|
|
|
148
178
|
@Delete("/{id}") // DELETE /todos/{id} → todos.remove; no body → : Promise<void>
|
|
149
179
|
async remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
|
|
150
|
-
await
|
|
180
|
+
await todoService.remove(user.id, id);
|
|
151
181
|
}
|
|
152
182
|
}
|
|
153
|
-
|
|
154
|
-
export default
|
|
183
|
+
// No export is needed at all — @Controller registered the class as it decorated
|
|
184
|
+
// it. `export default` here is style, not a requirement.
|
|
155
185
|
```
|
|
156
186
|
|
|
157
187
|
```ts
|
|
@@ -238,7 +268,10 @@ The **only difference** is the trigger argument:
|
|
|
238
268
|
| **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
|
|
239
269
|
| **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
|
|
240
270
|
| **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
|
|
241
|
-
|
|
271
|
+
|
|
272
|
+
`defineMiddleware` is still exported and takes `(ctx, next)`, but **no bundler
|
|
273
|
+
reads a `middleware/` directory and the engine never calls one** — there is no
|
|
274
|
+
`ctx` anywhere on a path that runs. Every handler above imports its services.
|
|
242
275
|
|
|
243
276
|
`meta` carries non-service data: `env` (Environment variables),
|
|
244
277
|
`environmentId`, and for webhooks `requestId`. Services always come from
|
|
@@ -255,16 +288,16 @@ my-backend/
|
|
|
255
288
|
├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
|
|
256
289
|
│ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
|
|
257
290
|
├── services/ # plain classes/singletons your controllers call
|
|
258
|
-
├── db/
|
|
259
|
-
├── db/migrations/ # explicit SQL migrations for type changes (optional)
|
|
260
|
-
├── resources/ # external connections, set up once at boot (optional)
|
|
261
|
-
├── seeds/ # seed data (optional)
|
|
291
|
+
├── db/public.ts # the database itself: tables, columns, RLS policies
|
|
262
292
|
├── jobs/ # cron-scheduled jobs (optional)
|
|
263
293
|
├── hooks/ # auth/storage/document event hooks (optional)
|
|
264
|
-
|
|
265
|
-
└── middleware/ # cross-cutting request middleware (optional)
|
|
294
|
+
└── webhooks/ # inbound provider webhooks (optional)
|
|
266
295
|
```
|
|
267
296
|
|
|
297
|
+
There is **no `db/migrations/`**. Nothing generates a migration file, nothing
|
|
298
|
+
commits one and nothing replays one: `db/schema.ts` is diffed against the live
|
|
299
|
+
database and applied — see [migrations.md](./migrations.md).
|
|
300
|
+
|
|
268
301
|
HTTP endpoints are **not** file-path routed. You author a class controller
|
|
269
302
|
(`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
|
|
270
303
|
`controllers/` mounts it. See [routing.md](./routing.md).
|
|
@@ -493,35 +526,38 @@ imported singletons (see [services.md](./services.md)).
|
|
|
493
526
|
|
|
494
527
|
```ts
|
|
495
528
|
// controllers/rooms.controller.ts
|
|
496
|
-
import { Controller, Get, Post, Body, Param, User
|
|
529
|
+
import { Controller, Get, Post, Body, Param, User } from "@palbase/backend";
|
|
497
530
|
import type { UserT } from "@palbase/backend";
|
|
531
|
+
import { roomService } from "../services/room.service.js";
|
|
498
532
|
import { CreateRoomBody } from "../models/rooms/create.js";
|
|
499
533
|
import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
|
|
500
534
|
|
|
535
|
+
// A controller does not import `Database`. Everything here is HTTP: validate the
|
|
536
|
+
// body through a named schema, name the 200 shape as the return type, delegate.
|
|
537
|
+
// Which rows, whose, in what order is the service's job — and the service is the
|
|
538
|
+
// thing worth testing, because it is the thing that can be wrong.
|
|
501
539
|
@Controller("/rooms")
|
|
502
540
|
export default class RoomsController {
|
|
503
541
|
@Post("")
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
542
|
+
create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
|
|
543
|
+
return roomService.create(user.id, body);
|
|
507
544
|
}
|
|
508
545
|
|
|
509
546
|
@Get("/{id}")
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (!room) throw new NotFound("Room does not exist", "room_not_found");
|
|
513
|
-
return { id: room.id as string, name: room.name as string, capacity: (room.capacity as number) ?? null };
|
|
547
|
+
getOne(@Param("id") id: string): Promise<RoomSchema> {
|
|
548
|
+
return roomService.get(id);
|
|
514
549
|
}
|
|
515
550
|
}
|
|
516
551
|
```
|
|
517
552
|
|
|
518
553
|
**Two non-negotiables** (the most common codegen mistakes):
|
|
519
554
|
|
|
520
|
-
1.
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
555
|
+
1. **The controller does not touch the database.** It delegates to a service, as
|
|
556
|
+
above. A method here that reaches for `Database` has moved the logic into the
|
|
557
|
+
layer that is hardest to test. (An export is not required at all: `@Controller`
|
|
558
|
+
records the class as it decorates it, and importing the file IS the
|
|
559
|
+
registration — the shipped scaffold's own controllers are not exported.
|
|
560
|
+
`export default` here is style.)
|
|
525
561
|
2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
|
|
526
562
|
returns promises, so a body that `await`s it cannot have a sync return type
|
|
527
563
|
(`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
|
|
@@ -631,23 +667,17 @@ throw new PalError(418, "teapot", "custom"); // → custom status/code
|
|
|
631
667
|
|
|
632
668
|
See [errors.md](./errors.md) for the full set + the wire envelope shape.
|
|
633
669
|
|
|
634
|
-
##
|
|
670
|
+
## There is no middleware
|
|
635
671
|
|
|
636
|
-
|
|
672
|
+
`defineMiddleware(async (ctx, next) => { … })` is still exported and still
|
|
673
|
+
type-checks, but **nothing mounts a `middleware/` directory and the engine has no
|
|
674
|
+
middleware pipeline** — a handler written against it deploys, never runs, and
|
|
675
|
+
nothing reports it. There is no `ctx` object anywhere on a path that executes, so
|
|
676
|
+
treat the export as a leftover rather than a seam.
|
|
637
677
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
export default defineMiddleware(async (ctx, next) => {
|
|
643
|
-
ctx.log.info(`start ${ctx.requestId}`);
|
|
644
|
-
await next();
|
|
645
|
-
ctx.log.info(`done ${ctx.requestId}`);
|
|
646
|
-
});
|
|
647
|
-
```
|
|
648
|
-
|
|
649
|
-
The middleware handler receives `(ctx, next)` — call `await next()` to run the
|
|
650
|
-
rest of the chain (other middleware, then the endpoint method).
|
|
678
|
+
Cross-cutting work goes in a service the controllers call, and the route concerns
|
|
679
|
+
that used to live in a wrapper are route options instead: `auth` on `@Controller`
|
|
680
|
+
or the method decorator, and `rateLimit: { max, window }` per route.
|
|
651
681
|
|
|
652
682
|
|
|
653
683
|
|
|
@@ -1172,51 +1202,109 @@ Guidelines:
|
|
|
1172
1202
|
|
|
1173
1203
|
# Schema & typed database access
|
|
1174
1204
|
|
|
1175
|
-
Declare your tables
|
|
1205
|
+
Declare your tables under `db/`, **one file per schema**: `db/public.ts` is the
|
|
1206
|
+
schema Palbase expects to find, `db/billing.ts` declares a second one. Each file
|
|
1207
|
+
default-exports a `defineSchema("<name>", { tables })` call. That drives
|
|
1176
1208
|
[migrations](./migrations.md) (additive changes auto-apply on deploy; type
|
|
1177
1209
|
changes need an explicit migration) and makes `Database.tables.*` typed
|
|
1178
1210
|
everywhere — by default, with no import and no generic.
|
|
1179
1211
|
|
|
1212
|
+
> Coming from a single `db/schema.ts` with tables declared inline? That layout is
|
|
1213
|
+
> gone, and a push says so by name. The migration guide at
|
|
1214
|
+
> `/docs/backend/schema-migration` walks the four changes with before/after code.
|
|
1215
|
+
|
|
1180
1216
|
## Defining a schema
|
|
1181
1217
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1218
|
+
A table is declared with `defineTable("<name>", { … })` and is a **value that
|
|
1219
|
+
knows its own name**. `defineSchema` takes the schema's name and an ARRAY of
|
|
1220
|
+
those values — never a dictionary, because a name in a dictionary key is a second
|
|
1221
|
+
place the name is written, and a table built under a key does not yet know what
|
|
1222
|
+
to call itself when a sibling references it.
|
|
1223
|
+
|
|
1224
|
+
Each table's only required field is `columns`; `rls` and `policies` enable
|
|
1184
1225
|
[Row-Level Security](#row-level-security-rls), and `indexes` declares plain
|
|
1185
1226
|
btree [indexes](#indexes).
|
|
1186
1227
|
|
|
1187
1228
|
```ts
|
|
1188
1229
|
import {
|
|
1189
|
-
defineSchema,
|
|
1190
|
-
uuid, text, integer, boolean, timestamp, jsonb, enumType,
|
|
1230
|
+
defineSchema, defineTable,
|
|
1231
|
+
uuid, text, integer, boolean, timestamp, jsonb, enumType, ownedByUser,
|
|
1191
1232
|
} from "@palbase/backend";
|
|
1192
1233
|
|
|
1193
|
-
export
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1234
|
+
export const rooms = defineTable("rooms", {
|
|
1235
|
+
columns: {
|
|
1236
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1237
|
+
name: text().notNull(),
|
|
1238
|
+
capacity: integer().nullable(),
|
|
1239
|
+
is_active: boolean().default(true),
|
|
1240
|
+
created_at: timestamp().defaultNow(),
|
|
1241
|
+
},
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
export const sessions = defineTable("sessions", {
|
|
1245
|
+
columns: {
|
|
1246
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1247
|
+
room_id: uuid().references(() => rooms.id).onDelete("cascade"),
|
|
1248
|
+
user_id: ownedByUser(),
|
|
1249
|
+
data: jsonb().nullable(),
|
|
1250
|
+
started_at: timestamp().defaultNow(),
|
|
1251
|
+
},
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
export const orders = defineTable("orders", {
|
|
1255
|
+
columns: {
|
|
1256
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1257
|
+
status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
|
|
1258
|
+
amount: integer().notNull(),
|
|
1259
|
+
},
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
export default defineSchema("public", {
|
|
1263
|
+
tables: [rooms, sessions, orders],
|
|
1264
|
+
});
|
|
1265
|
+
```
|
|
1266
|
+
|
|
1267
|
+
A `defineTable` value **is its columns** — `rooms.id` is the `id` builder, which
|
|
1268
|
+
is what makes `references(() => rooms.id)` an ordinary expression. The table's own
|
|
1269
|
+
metadata hangs off a symbol rather than a plain field, so a column may be called
|
|
1270
|
+
`name`, `columns`, `rls` or `indexes` without shadowing the table's identity.
|
|
1271
|
+
|
|
1272
|
+
### One file per schema, and `exposed`
|
|
1273
|
+
|
|
1274
|
+
The schema name comes from the declaration; the file name must agree with it. A
|
|
1275
|
+
`db/billing.ts` declaring `defineSchema("accounts", …)` is refused at push with
|
|
1276
|
+
both names in the error.
|
|
1277
|
+
|
|
1278
|
+
`exposed` decides whether a schema is served over `/v1/db`, and the default is
|
|
1279
|
+
NOT uniform: **`public` defaults to `true`**, every other schema to `false`. The
|
|
1280
|
+
asymmetry is deliberate — `public` is reachable today and stays reachable, because
|
|
1281
|
+
a uniform default would silently 404 every existing project's `/v1/db` traffic on
|
|
1282
|
+
upgrade, while a schema you add later is not on the internet just because you
|
|
1283
|
+
declared it.
|
|
1284
|
+
|
|
1285
|
+
```ts
|
|
1286
|
+
// db/public.ts — reachable, and you write nothing to get that
|
|
1287
|
+
export default defineSchema("public", { tables: [rooms, sessions] });
|
|
1288
|
+
|
|
1289
|
+
// db/billing.ts — declared and typed, but not reachable from a client
|
|
1290
|
+
export default defineSchema("billing", { tables: [invoices] });
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
Write the field only to go against the grain — `exposed: false` closes `public`,
|
|
1294
|
+
`exposed: true` opens a second schema. Server-side `Database.*` ignores it either
|
|
1295
|
+
way: your controllers, jobs and hooks read every schema you declared.
|
|
1296
|
+
|
|
1297
|
+
A foreign key may cross schemas: import the table binding and point at it.
|
|
1298
|
+
|
|
1299
|
+
```ts
|
|
1300
|
+
// db/billing.ts
|
|
1301
|
+
import { lists } from "./public";
|
|
1302
|
+
|
|
1303
|
+
export const invoices = defineTable("invoices", {
|
|
1304
|
+
columns: {
|
|
1305
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1306
|
+
list_id: uuid().references(() => lists.id),
|
|
1307
|
+
amount: numeric(),
|
|
1220
1308
|
},
|
|
1221
1309
|
});
|
|
1222
1310
|
```
|
|
@@ -1235,9 +1323,124 @@ export default defineSchema({
|
|
|
1235
1323
|
|
|
1236
1324
|
Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
|
|
1237
1325
|
`.default(value)`, `.defaultRandom()` (uuid → `gen_random_uuid()`),
|
|
1238
|
-
`.defaultNow()` (timestamp → `now()`),
|
|
1326
|
+
`.defaultNow()` (timestamp → `now()`),
|
|
1327
|
+
`.references(() => table.column, { as?, reverseAs?, onDelete? })`,
|
|
1328
|
+
`.selfReferences("column", opts?)`,
|
|
1239
1329
|
`.onDelete("cascade" | "set null" | "restrict" | "no action")`, `.ignored()`.
|
|
1240
1330
|
|
|
1331
|
+
## Foreign keys
|
|
1332
|
+
|
|
1333
|
+
The target of `references` is a **thunk**, not a direct reference. The callback is
|
|
1334
|
+
invoked inside `defineSchema`, where every binding exists and every table already
|
|
1335
|
+
knows its name — which is what makes a cycle expressible at all: in `x → y, y → x`
|
|
1336
|
+
the second table does not exist yet when the first is built.
|
|
1337
|
+
|
|
1338
|
+
```ts
|
|
1339
|
+
list_id: uuid().references(() => lists.id),
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
**Pointing at this same table** takes no thunk and no annotation — the target
|
|
1343
|
+
table is the one being declared, so there is nothing to defer:
|
|
1344
|
+
|
|
1345
|
+
```ts
|
|
1346
|
+
parent_id: uuid().nullable().selfReferences("id"),
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
Naming a column the table does not have is refused where you declare it.
|
|
1350
|
+
|
|
1351
|
+
**Two tables that point at each other** need an explicit return type on ONE side,
|
|
1352
|
+
and one is enough — measured. Without it TypeScript chases its own tail (TS7022):
|
|
1353
|
+
|
|
1354
|
+
```ts
|
|
1355
|
+
import { type AnyColumn } from "@palbase/backend";
|
|
1356
|
+
|
|
1357
|
+
export const users = defineTable("users", {
|
|
1358
|
+
columns: {
|
|
1359
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1360
|
+
primary_org_id: uuid().nullable().references((): AnyColumn => orgs.id),
|
|
1361
|
+
},
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
export const orgs = defineTable("orgs", {
|
|
1365
|
+
columns: {
|
|
1366
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1367
|
+
owner_id: uuid().nullable().references(() => users.id),
|
|
1368
|
+
},
|
|
1369
|
+
});
|
|
1370
|
+
```
|
|
1371
|
+
|
|
1372
|
+
### Relation names
|
|
1373
|
+
|
|
1374
|
+
Every foreign key produces **two** named relations, and the two names are derived
|
|
1375
|
+
separately:
|
|
1376
|
+
|
|
1377
|
+
| Direction | Where it appears | Default name | Option that changes it |
|
|
1378
|
+
|---|---|---|---|
|
|
1379
|
+
| forward (child → parent) | on the child | the column minus `_id` — `list_id` → `list` | `as` |
|
|
1380
|
+
| reverse (parent → children) | on the parent | the **child table's** name — `lists.todos` | `reverseAs` |
|
|
1381
|
+
|
|
1382
|
+
So `todos.list_id → lists` gives `todos.list` and `lists.todos`, and neither
|
|
1383
|
+
needs declaring. `as` renames the forward side only: `author_id` declared
|
|
1384
|
+
`{ as: "author" }` gives `posts.author` and still gives `users.posts`. That is
|
|
1385
|
+
why the two options exist separately — `posts` and `comments` can both call their
|
|
1386
|
+
forward relation `author` without colliding, because what lands on `users` is
|
|
1387
|
+
`posts` and `comments`.
|
|
1388
|
+
|
|
1389
|
+
**Two foreign keys from one table to the same parent** collide on the reverse
|
|
1390
|
+
side: both reverse relations want the child table's name. Name them:
|
|
1391
|
+
|
|
1392
|
+
```ts
|
|
1393
|
+
billing_address_id: uuid().references(() => addresses.id, { reverseAs: "billed_orders" }),
|
|
1394
|
+
shipping_address_id: uuid().references(() => addresses.id, { reverseAs: "shipped_orders" }),
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
The forward names here (`billing_address`, `shipping_address`) are already
|
|
1398
|
+
distinct, so no `as` is needed. Add one when two columns WOULD derive the same
|
|
1399
|
+
forward name.
|
|
1400
|
+
|
|
1401
|
+
Any two relations resolving to one name on one table are refused at push, with
|
|
1402
|
+
both relations named and the option that separates them.
|
|
1403
|
+
|
|
1404
|
+
## Rows that belong to a user
|
|
1405
|
+
|
|
1406
|
+
There is no `public.users` table: auth users live in the `auth` schema of the same
|
|
1407
|
+
Postgres. Three column factories declare a real foreign key onto it. They are
|
|
1408
|
+
factories rather than chain methods because the column type, its nullability and
|
|
1409
|
+
its `ON DELETE` are part of what each one MEANS — so they cannot be written wrong.
|
|
1410
|
+
|
|
1411
|
+
```ts
|
|
1412
|
+
user_id: ownedByUser(), // text, NOT NULL, ON DELETE CASCADE
|
|
1413
|
+
edited_by: userRef({ onDelete: "set null" }).nullable(),
|
|
1414
|
+
device_id: installationRef({ onDelete: "cascade" }),
|
|
1415
|
+
```
|
|
1416
|
+
|
|
1417
|
+
| | `ownedByUser()` | `userRef({ onDelete })` | `installationRef({ onDelete })` |
|
|
1418
|
+
|---|---|---|---|
|
|
1419
|
+
| References | `auth.users(id)` | `auth.users(id)` | `auth.installations(id)` |
|
|
1420
|
+
| Means | the row **belongs to** that user | the row **points at** a user | the row is scoped to an app install |
|
|
1421
|
+
| `ON DELETE` | `cascade`, no argument | required: `cascade` / `set null` | required: `cascade` / `set null` |
|
|
1422
|
+
| Account erasure follows it | yes | no | no |
|
|
1423
|
+
| Per table | **at most one** | unlimited | unlimited |
|
|
1424
|
+
|
|
1425
|
+
Several `userRef` columns on one table are fine — they are ordinary foreign keys
|
|
1426
|
+
and each takes its own relation name from its column (`created_by`, `edited_by`).
|
|
1427
|
+
Only two of them resolving to the SAME name is refused, and `userRef({ onDelete,
|
|
1428
|
+
as })` is how you separate them. `auth.users` is not a table of your schema, so
|
|
1429
|
+
none of these produce a reverse relation to collide over.
|
|
1430
|
+
|
|
1431
|
+
`ownedByUser()` takes no `onDelete` because there is only one correct answer:
|
|
1432
|
+
ownership is what account erasure walks, so a row owned by an account has to go
|
|
1433
|
+
when the account does. `"set null"` on a `userRef` needs a `.nullable()` column.
|
|
1434
|
+
|
|
1435
|
+
**One `ownedByUser()` per table, enforced.** Two on one table are refused at push
|
|
1436
|
+
with both column names in the error. The rule exists because the alternative was
|
|
1437
|
+
worse than a refusal: when several columns could reference `auth.users`, the owner
|
|
1438
|
+
was whichever came FIRST IN DECLARATION ORDER — so moving a `created_by` above a
|
|
1439
|
+
`user_id` silently changed which rows an account deletion took with it.
|
|
1440
|
+
|
|
1441
|
+
An installation reference is **not** ownership. A user-owned row still needs its
|
|
1442
|
+
own `ownedByUser()`, or erasing the account leaves it behind.
|
|
1443
|
+
|
|
1241
1444
|
## Removing a column
|
|
1242
1445
|
|
|
1243
1446
|
A deploy applies the schema while the PREVIOUS release is still answering requests, so
|
|
@@ -1254,23 +1457,47 @@ Removing a column is therefore two deploys:
|
|
|
1254
1457
|
|
|
1255
1458
|
```ts
|
|
1256
1459
|
// 1. Mark it. The column stays; nothing breaks; no DDL is produced.
|
|
1257
|
-
|
|
1258
|
-
|
|
1460
|
+
const notes = defineTable("notes", {
|
|
1461
|
+
columns: { id: uuid().primaryKey(), old_body: text().ignored() },
|
|
1259
1462
|
});
|
|
1463
|
+
export default defineSchema("public", { tables: [notes] });
|
|
1260
1464
|
```
|
|
1261
1465
|
|
|
1262
1466
|
```ts
|
|
1263
1467
|
// 2. Ship that. Then delete the column and ship again — this time the gate passes,
|
|
1264
1468
|
// because the release now serving promised it does not name the column.
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
});
|
|
1469
|
+
const notes = defineTable("notes", { columns: { id: uuid().primaryKey() } });
|
|
1470
|
+
export default defineSchema("public", { tables: [notes] });
|
|
1268
1471
|
```
|
|
1269
1472
|
|
|
1270
1473
|
`palbase db plan` tells you which step you are on before you push. Locally,
|
|
1271
1474
|
`palbase db apply` is not restricted — local is where you experiment, and there is no
|
|
1272
1475
|
traffic to protect.
|
|
1273
1476
|
|
|
1477
|
+
### When you cannot wait two deploys
|
|
1478
|
+
|
|
1479
|
+
There is an escape, and it is deliberately loud:
|
|
1480
|
+
|
|
1481
|
+
```
|
|
1482
|
+
palbase push --accept-breaking
|
|
1483
|
+
```
|
|
1484
|
+
|
|
1485
|
+
It opens the gate for one push. Use it when the running release is ALREADY broken and
|
|
1486
|
+
the fix is the very change the gate refuses — an incident, not an inconvenience. Outside
|
|
1487
|
+
that, two deploys cost less than the one this can break.
|
|
1488
|
+
|
|
1489
|
+
It is not silent, and that is the whole design: the push prints the consents it is
|
|
1490
|
+
sending, and the server records a `BREAK-GLASS` line naming the digest that was serving
|
|
1491
|
+
and every object the gate had refused. So the decision has an author and a time, and
|
|
1492
|
+
whoever asks later why a column disappeared finds the answer instead of a normal-looking
|
|
1493
|
+
push.
|
|
1494
|
+
|
|
1495
|
+
Two things it will not do. It does not apply to a cloud push — `--accept-breaking` there
|
|
1496
|
+
is refused by name rather than ignored, because the gate needs to know what is serving
|
|
1497
|
+
and only a linked checkout can tell it. And it does not skip the data-loss consent:
|
|
1498
|
+
`--approve` is a separate question about erasing rows, and answering one does not answer
|
|
1499
|
+
the other.
|
|
1500
|
+
|
|
1274
1501
|
The word is `ignored` and not `deprecated` on purpose: deprecation is defined, in
|
|
1275
1502
|
RFC 9745 and in the GraphQL spec alike, as changing NO behaviour. This changes what a
|
|
1276
1503
|
deploy will accept.
|
|
@@ -1293,7 +1520,7 @@ Add a value instead, and let the old one die:
|
|
|
1293
1520
|
|
|
1294
1521
|
```sql
|
|
1295
1522
|
-- 1. Add the new label. This IS safe while the previous release serves.
|
|
1296
|
-
-- (Declare it in db/
|
|
1523
|
+
-- (Declare it in db/public.ts; the rail emits ALTER TYPE … ADD VALUE.)
|
|
1297
1524
|
-- 2. Move the data:
|
|
1298
1525
|
UPDATE posts SET status = 'review' WHERE status = 'onay';
|
|
1299
1526
|
-- 3. Stop naming the old value in the next release.
|
|
@@ -1314,19 +1541,15 @@ used to reject passed after the rename.
|
|
|
1314
1541
|
`indexes` declares plain (non-unique) btree indexes over an ordered column list:
|
|
1315
1542
|
|
|
1316
1543
|
```ts
|
|
1317
|
-
export
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
room_id: uuid().notNull().references("rooms", "id"),
|
|
1323
|
-
started_at: timestamp().defaultNow(),
|
|
1324
|
-
},
|
|
1325
|
-
indexes: [
|
|
1326
|
-
{ name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
|
|
1327
|
-
],
|
|
1328
|
-
},
|
|
1544
|
+
export const sessions = defineTable("sessions", {
|
|
1545
|
+
columns: {
|
|
1546
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1547
|
+
room_id: uuid().references(() => rooms.id),
|
|
1548
|
+
started_at: timestamp().defaultNow(),
|
|
1329
1549
|
},
|
|
1550
|
+
indexes: [
|
|
1551
|
+
{ name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
|
|
1552
|
+
],
|
|
1330
1553
|
});
|
|
1331
1554
|
```
|
|
1332
1555
|
|
|
@@ -1338,7 +1561,7 @@ name and every column are identifier-validated before any SQL is built.
|
|
|
1338
1561
|
knowing before you name an index:
|
|
1339
1562
|
|
|
1340
1563
|
- An index that exists in the database but is not in `indexes` is never dropped.
|
|
1341
|
-
|
|
1564
|
+
Your schema file does not own the database's indexes; it only adds the ones it
|
|
1342
1565
|
names.
|
|
1343
1566
|
- Removing an entry from `indexes` therefore does **not** drop the index. Drop it
|
|
1344
1567
|
in an explicit [migration](./migrations.md).
|
|
@@ -1364,10 +1587,9 @@ nothing. Rather than ship a half-working partial-index path, the typed field
|
|
|
1364
1587
|
stays columns-only and `raw()` carries the rest:
|
|
1365
1588
|
|
|
1366
1589
|
```ts
|
|
1367
|
-
import {
|
|
1590
|
+
import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
|
|
1368
1591
|
|
|
1369
|
-
|
|
1370
|
-
orders: {
|
|
1592
|
+
export const orders = defineTable("orders", {
|
|
1371
1593
|
columns: {
|
|
1372
1594
|
id: uuid().primaryKey().defaultRandom(),
|
|
1373
1595
|
status: text().notNull(),
|
|
@@ -1380,7 +1602,7 @@ orders: {
|
|
|
1380
1602
|
{ down: "DROP INDEX IF EXISTS orders_pending_idx" },
|
|
1381
1603
|
),
|
|
1382
1604
|
],
|
|
1383
|
-
}
|
|
1605
|
+
});
|
|
1384
1606
|
```
|
|
1385
1607
|
|
|
1386
1608
|
`raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
|
|
@@ -1390,12 +1612,34 @@ and an index is not one.
|
|
|
1390
1612
|
|
|
1391
1613
|
## Typed DB access — by default
|
|
1392
1614
|
|
|
1393
|
-
You do **not** wire anything per endpoint. Saving `db
|
|
1615
|
+
You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
|
|
1394
1616
|
`palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
|
|
1395
1617
|
of the schema, no generic, no cast:
|
|
1396
1618
|
|
|
1397
1619
|
```ts
|
|
1398
|
-
|
|
1620
|
+
// services/room.service.ts — the layer that touches the database.
|
|
1621
|
+
import { Database } from "@palbase/backend";
|
|
1622
|
+
|
|
1623
|
+
type RoomsTable = typeof Database.tables.rooms; // typed from db/schema.ts
|
|
1624
|
+
|
|
1625
|
+
export class RoomService {
|
|
1626
|
+
private readonly rooms: RoomsTable;
|
|
1627
|
+
constructor(rooms: RoomsTable) { this.rooms = rooms; }
|
|
1628
|
+
|
|
1629
|
+
async create(name: string) {
|
|
1630
|
+
const room = await this.rooms.insert({ name });
|
|
1631
|
+
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
1632
|
+
// room.nope ← compile error
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
export const roomService = new RoomService(Database.tables.rooms);
|
|
1637
|
+
```
|
|
1638
|
+
|
|
1639
|
+
```ts
|
|
1640
|
+
// controllers/rooms.controller.ts — HTTP only; no `Database` import here.
|
|
1641
|
+
import { Controller, Post, Body, z } from "@palbase/backend";
|
|
1642
|
+
import { roomService } from "../services/room.service.js";
|
|
1399
1643
|
|
|
1400
1644
|
const CreateRoomBody = z.object({ name: z.string() });
|
|
1401
1645
|
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
@@ -1405,10 +1649,8 @@ export default class RoomsController {
|
|
|
1405
1649
|
@Post("")
|
|
1406
1650
|
// The return type names the 200 schema — `z.infer<typeof RoomOut>` works
|
|
1407
1651
|
// inline, no separate `export type` needed.
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
1411
|
-
// room.nope ← compile error
|
|
1652
|
+
create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
|
|
1653
|
+
return roomService.create(body.name);
|
|
1412
1654
|
}
|
|
1413
1655
|
}
|
|
1414
1656
|
```
|
|
@@ -1506,36 +1748,37 @@ rows.
|
|
|
1506
1748
|
### Owner-scoped `todos` example
|
|
1507
1749
|
|
|
1508
1750
|
```ts
|
|
1509
|
-
import {
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
todos: {
|
|
1514
|
-
columns: {
|
|
1515
|
-
id: uuid().primaryKey().defaultRandom(),
|
|
1516
|
-
owner: text().notNull(), // palauth user id (TEXT)
|
|
1517
|
-
title: text().notNull(),
|
|
1518
|
-
done: boolean().default(false),
|
|
1519
|
-
created_at: timestamp().defaultNow(),
|
|
1520
|
-
},
|
|
1521
|
-
// `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
1522
|
-
policies: [
|
|
1523
|
-
// Read: a user sees only their own todos.
|
|
1524
|
-
policy("pb_todos_owner_select")
|
|
1525
|
-
.for("select")
|
|
1526
|
-
.to("authenticated")
|
|
1527
|
-
.using("owner = (select auth.uid())"),
|
|
1751
|
+
import {
|
|
1752
|
+
defineSchema, defineTable, policy, ownedByUser,
|
|
1753
|
+
uuid, text, boolean, timestamp,
|
|
1754
|
+
} from "@palbase/backend";
|
|
1528
1755
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
},
|
|
1756
|
+
export const todos = defineTable("todos", {
|
|
1757
|
+
columns: {
|
|
1758
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1759
|
+
owner: ownedByUser(), // text FK onto auth.users(id), NOT NULL, CASCADE
|
|
1760
|
+
title: text().notNull(),
|
|
1761
|
+
done: boolean().default(false),
|
|
1762
|
+
created_at: timestamp().defaultNow(),
|
|
1537
1763
|
},
|
|
1764
|
+
// `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
1765
|
+
policies: [
|
|
1766
|
+
// Read: a user sees only their own todos.
|
|
1767
|
+
policy("pb_todos_owner_select")
|
|
1768
|
+
.for("select")
|
|
1769
|
+
.to("authenticated")
|
|
1770
|
+
.using("owner = (select auth.uid())"),
|
|
1771
|
+
|
|
1772
|
+
// Write: a user can insert/update/delete only rows they own.
|
|
1773
|
+
policy("pb_todos_owner_write")
|
|
1774
|
+
.for("all")
|
|
1775
|
+
.to("authenticated")
|
|
1776
|
+
.using("owner = (select auth.uid())")
|
|
1777
|
+
.withCheck("owner = (select auth.uid())"),
|
|
1778
|
+
],
|
|
1538
1779
|
});
|
|
1780
|
+
|
|
1781
|
+
export default defineSchema("public", { tables: [todos] });
|
|
1539
1782
|
```
|
|
1540
1783
|
|
|
1541
1784
|
With this in place, `await Database.tables.todos.findMany({})` returns only the
|
|
@@ -1557,7 +1800,6 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
|
|
|
1557
1800
|
|
|
1558
1801
|
|
|
1559
1802
|
|
|
1560
|
-
|
|
1561
1803
|
<!-- ===== migrations.md ===== -->
|
|
1562
1804
|
|
|
1563
1805
|
# Migrations
|
|
@@ -2040,16 +2282,51 @@ stand-in and never needs a database.
|
|
|
2040
2282
|
|
|
2041
2283
|
```ts
|
|
2042
2284
|
// services/note.service.test.ts — `npm test`, no database
|
|
2043
|
-
import assert from "node:assert/strict";
|
|
2044
2285
|
import { test } from "node:test";
|
|
2286
|
+
import assert from "node:assert/strict";
|
|
2287
|
+
|
|
2045
2288
|
import { NoteService } from "./note.service.ts";
|
|
2046
2289
|
|
|
2047
|
-
|
|
2290
|
+
// WHY THIS TEST NEEDS NO DATABASE
|
|
2291
|
+
//
|
|
2292
|
+
// `NoteService` is handed the table it works on rather than reaching for the
|
|
2293
|
+
// singleton itself. That constructor is the seam: a stand-in goes in here, and
|
|
2294
|
+
// the logic — which rows, whose, in what order — is exercised without a
|
|
2295
|
+
// database. Test your own services the same way.
|
|
2296
|
+
//
|
|
2297
|
+
// When you want the whole database surface instead of one table, `fakeDatabase()`
|
|
2298
|
+
// from `@palbase/backend/test` is the stand-in.
|
|
2299
|
+
//
|
|
2300
|
+
// Node's ESM resolver wants the extension on a relative import inside a test
|
|
2301
|
+
// (`./note.service.ts`); this scaffold's `tsconfig.json` allows it.
|
|
2302
|
+
|
|
2303
|
+
test("list asks only for the caller's notes", async () => {
|
|
2048
2304
|
const seen: unknown[] = [];
|
|
2049
|
-
const
|
|
2050
|
-
|
|
2305
|
+
const notes = {
|
|
2306
|
+
findMany: async (where: unknown) => {
|
|
2307
|
+
seen.push(where);
|
|
2308
|
+
return [];
|
|
2309
|
+
},
|
|
2310
|
+
};
|
|
2311
|
+
|
|
2312
|
+
await new NoteService(notes as never).list("u_1");
|
|
2313
|
+
|
|
2051
2314
|
assert.deepEqual(seen, [{ user_id: "u_1" }]);
|
|
2052
2315
|
});
|
|
2316
|
+
|
|
2317
|
+
test("create writes ownership from the argument, never from the body", async () => {
|
|
2318
|
+
const written: unknown[] = [];
|
|
2319
|
+
const notes = {
|
|
2320
|
+
insert: async (row: unknown) => {
|
|
2321
|
+
written.push(row);
|
|
2322
|
+
return row;
|
|
2323
|
+
},
|
|
2324
|
+
};
|
|
2325
|
+
|
|
2326
|
+
await new NoteService(notes as never).create("u_1", "hello");
|
|
2327
|
+
|
|
2328
|
+
assert.deepEqual(written, [{ user_id: "u_1", body: "hello" }]);
|
|
2329
|
+
});
|
|
2053
2330
|
```
|
|
2054
2331
|
|
|
2055
2332
|
Node's ESM resolver wants the extension on a relative import inside a test
|