@palbase/backend 24.2.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/palbase-backend.cjs +101 -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-43A3KGWL.js} +80 -49
- package/dist/chunk-43A3KGWL.js.map +1 -0
- package/dist/{chunk-ERDL5VAE.js → chunk-5CMLOAEF.js} +2 -2
- package/dist/chunk-OEQBHE2Z.js +825 -0
- package/dist/chunk-OEQBHE2Z.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-UWSYTUGM.js → chunk-ZQRWW37O.js} +44 -1
- package/dist/chunk-ZQRWW37O.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +29 -13
- package/dist/db/env.d.ts +29 -13
- package/dist/db/index.cjs +233 -110
- 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 +87 -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-C0PMn5jl.d.ts → index-BF1f0DfA.d.ts} +5 -2
- package/dist/{index-DAwHMppB.d.cts → index-CoaDN9dL.d.cts} +5 -2
- package/dist/{index-ByBMibIJ.d.ts → index-Ct1iiB4N.d.ts} +232 -60
- package/dist/{index-D4rts8T7.d.cts → index-CwaWRhyc.d.cts} +232 -60
- package/dist/index.cjs +572 -296
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -20
- package/dist/index.d.ts +124 -20
- package/dist/index.js +164 -216
- 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 +465 -148
- package/docs/schema.md +338 -86
- 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-EIXCY4SS.js.map +0 -1
- package/dist/chunk-LCL7TUAI.js +0 -534
- package/dist/chunk-LCL7TUAI.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-5CMLOAEF.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,27 +1323,202 @@ 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()`), `.references(table,
|
|
1239
|
-
`.
|
|
1326
|
+
`.defaultNow()` (timestamp → `now()`), `.references(() => table.column, opts?)`,
|
|
1327
|
+
`.selfReferences("column", opts?)`,
|
|
1328
|
+
`.onDelete("cascade" | "set null" | "restrict" | "no action")`, `.ignored()`.
|
|
1329
|
+
|
|
1330
|
+
## Foreign keys
|
|
1331
|
+
|
|
1332
|
+
The target of `references` is a **thunk**, not a direct reference. The callback is
|
|
1333
|
+
invoked inside `defineSchema`, where every binding exists and every table already
|
|
1334
|
+
knows its name — which is what makes a cycle expressible at all: in `x → y, y → x`
|
|
1335
|
+
the second table does not exist yet when the first is built.
|
|
1336
|
+
|
|
1337
|
+
```ts
|
|
1338
|
+
list_id: uuid().references(() => lists.id),
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
**Pointing at this same table** takes no thunk and no annotation — the target
|
|
1342
|
+
table is the one being declared, so there is nothing to defer:
|
|
1343
|
+
|
|
1344
|
+
```ts
|
|
1345
|
+
parent_id: uuid().nullable().selfReferences("id"),
|
|
1346
|
+
```
|
|
1347
|
+
|
|
1348
|
+
Naming a column the table does not have is refused where you declare it.
|
|
1349
|
+
|
|
1350
|
+
**Two tables that point at each other** need an explicit return type on ONE side,
|
|
1351
|
+
and one is enough — measured. Without it TypeScript chases its own tail (TS7022):
|
|
1352
|
+
|
|
1353
|
+
```ts
|
|
1354
|
+
import { type AnyColumn } from "@palbase/backend";
|
|
1355
|
+
|
|
1356
|
+
export const users = defineTable("users", {
|
|
1357
|
+
columns: {
|
|
1358
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1359
|
+
primary_org_id: uuid().nullable().references((): AnyColumn => orgs.id),
|
|
1360
|
+
},
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
export const orgs = defineTable("orgs", {
|
|
1364
|
+
columns: {
|
|
1365
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1366
|
+
owner_id: uuid().nullable().references(() => users.id),
|
|
1367
|
+
},
|
|
1368
|
+
});
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
**Two foreign keys to the same table** would derive the same relation name from
|
|
1372
|
+
their columns, so name one of them:
|
|
1373
|
+
|
|
1374
|
+
```ts
|
|
1375
|
+
billing_address_id: uuid().references(() => addresses.id, { as: "billing_address" }),
|
|
1376
|
+
shipping_address_id: uuid().references(() => addresses.id, { as: "shipping_address" }),
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
## Rows that belong to a user
|
|
1380
|
+
|
|
1381
|
+
There is no `public.users` table: auth users live in the `auth` schema of the same
|
|
1382
|
+
Postgres. Three column factories declare a real foreign key onto it. They are
|
|
1383
|
+
factories rather than chain methods because the column type, its nullability and
|
|
1384
|
+
its `ON DELETE` are part of what each one MEANS — so they cannot be written wrong.
|
|
1385
|
+
|
|
1386
|
+
```ts
|
|
1387
|
+
user_id: ownedByUser(), // text, NOT NULL, ON DELETE CASCADE
|
|
1388
|
+
edited_by: userRef({ onDelete: "set null" }).nullable(),
|
|
1389
|
+
device_id: installationRef({ onDelete: "cascade" }),
|
|
1390
|
+
```
|
|
1391
|
+
|
|
1392
|
+
| | `ownedByUser()` | `userRef({ onDelete })` | `installationRef({ onDelete })` |
|
|
1393
|
+
|---|---|---|---|
|
|
1394
|
+
| References | `auth.users(id)` | `auth.users(id)` | `auth.installations(id)` |
|
|
1395
|
+
| Means | the row **belongs to** that user | the row **points at** a user | the row is scoped to an app install |
|
|
1396
|
+
| `ON DELETE` | `cascade`, no argument | required: `cascade` / `set null` | required: `cascade` / `set null` |
|
|
1397
|
+
| Account erasure follows it | yes | no | no |
|
|
1398
|
+
| Per table | **at most one** | unlimited | unlimited |
|
|
1399
|
+
|
|
1400
|
+
`ownedByUser()` takes no `onDelete` because there is only one correct answer:
|
|
1401
|
+
ownership is what account erasure walks, so a row owned by an account has to go
|
|
1402
|
+
when the account does. `"set null"` on a `userRef` needs a `.nullable()` column.
|
|
1403
|
+
|
|
1404
|
+
**One `ownedByUser()` per table, enforced.** Two on one table are refused at push
|
|
1405
|
+
with both column names in the error. The rule exists because the alternative was
|
|
1406
|
+
worse than a refusal: when several columns could reference `auth.users`, the owner
|
|
1407
|
+
was whichever came FIRST IN DECLARATION ORDER — so moving a `created_by` above a
|
|
1408
|
+
`user_id` silently changed which rows an account deletion took with it.
|
|
1409
|
+
|
|
1410
|
+
An installation reference is **not** ownership. A user-owned row still needs its
|
|
1411
|
+
own `ownedByUser()`, or erasing the account leaves it behind.
|
|
1412
|
+
|
|
1413
|
+
## Removing a column
|
|
1414
|
+
|
|
1415
|
+
A deploy applies the schema while the PREVIOUS release is still answering requests, so
|
|
1416
|
+
dropping a column that release still names breaks it the instant the change lands. The
|
|
1417
|
+
gate therefore refuses the drop — and until now it refused every drop, with no way
|
|
1418
|
+
through: deleting the code that used the column changed nothing, because the gate never
|
|
1419
|
+
read your code.
|
|
1420
|
+
|
|
1421
|
+
`.ignored()` is how you tell it. The mark is a PROMISE about the release that carries
|
|
1422
|
+
it: **this release neither reads nor writes this column, and never names it in a filter,
|
|
1423
|
+
a sort or a SET.**
|
|
1424
|
+
|
|
1425
|
+
Removing a column is therefore two deploys:
|
|
1426
|
+
|
|
1427
|
+
```ts
|
|
1428
|
+
// 1. Mark it. The column stays; nothing breaks; no DDL is produced.
|
|
1429
|
+
const notes = defineTable("notes", {
|
|
1430
|
+
columns: { id: uuid().primaryKey(), old_body: text().ignored() },
|
|
1431
|
+
});
|
|
1432
|
+
export default defineSchema("public", { tables: [notes] });
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
```ts
|
|
1436
|
+
// 2. Ship that. Then delete the column and ship again — this time the gate passes,
|
|
1437
|
+
// because the release now serving promised it does not name the column.
|
|
1438
|
+
const notes = defineTable("notes", { columns: { id: uuid().primaryKey() } });
|
|
1439
|
+
export default defineSchema("public", { tables: [notes] });
|
|
1440
|
+
```
|
|
1441
|
+
|
|
1442
|
+
`palbase db plan` tells you which step you are on before you push. Locally,
|
|
1443
|
+
`palbase db apply` is not restricted — local is where you experiment, and there is no
|
|
1444
|
+
traffic to protect.
|
|
1445
|
+
|
|
1446
|
+
### When you cannot wait two deploys
|
|
1447
|
+
|
|
1448
|
+
There is an escape, and it is deliberately loud:
|
|
1449
|
+
|
|
1450
|
+
```
|
|
1451
|
+
palbase push --accept-breaking
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
It opens the gate for one push. Use it when the running release is ALREADY broken and
|
|
1455
|
+
the fix is the very change the gate refuses — an incident, not an inconvenience. Outside
|
|
1456
|
+
that, two deploys cost less than the one this can break.
|
|
1457
|
+
|
|
1458
|
+
It is not silent, and that is the whole design: the push prints the consents it is
|
|
1459
|
+
sending, and the server records a `BREAK-GLASS` line naming the digest that was serving
|
|
1460
|
+
and every object the gate had refused. So the decision has an author and a time, and
|
|
1461
|
+
whoever asks later why a column disappeared finds the answer instead of a normal-looking
|
|
1462
|
+
push.
|
|
1463
|
+
|
|
1464
|
+
Two things it will not do. It does not apply to a cloud push — `--accept-breaking` there
|
|
1465
|
+
is refused by name rather than ignored, because the gate needs to know what is serving
|
|
1466
|
+
and only a linked checkout can tell it. And it does not skip the data-loss consent:
|
|
1467
|
+
`--approve` is a separate question about erasing rows, and answering one does not answer
|
|
1468
|
+
the other.
|
|
1469
|
+
|
|
1470
|
+
The word is `ignored` and not `deprecated` on purpose: deprecation is defined, in
|
|
1471
|
+
RFC 9745 and in the GraphQL spec alike, as changing NO behaviour. This changes what a
|
|
1472
|
+
deploy will accept.
|
|
1473
|
+
|
|
1474
|
+
**What the mark cannot do.** No static mark can PROVE your code does not name the
|
|
1475
|
+
column — that question is only answerable from traffic. `.ignored()` is your promise.
|
|
1476
|
+
What the gate adds is that it reads the promise from the release that is ACTUALLY
|
|
1477
|
+
SERVING, not from the file in front of you.
|
|
1478
|
+
|
|
1479
|
+
## Renaming an enum value
|
|
1480
|
+
|
|
1481
|
+
There is no `renamedFrom` for enum values, and the reason is measured rather than
|
|
1482
|
+
stylistic: on PostgreSQL 16, `ALTER TYPE … RENAME VALUE` is an **atomic cutover**. While
|
|
1483
|
+
the rename is uncommitted a writer using the OLD label succeeds and one using the NEW
|
|
1484
|
+
label fails; at commit that flips. The two names are never both valid, so there is no
|
|
1485
|
+
window in which a running release can be migrated across — the instant the rename
|
|
1486
|
+
commits, that release's writes fail with `invalid input value for enum`.
|
|
1487
|
+
|
|
1488
|
+
Add a value instead, and let the old one die:
|
|
1489
|
+
|
|
1490
|
+
```sql
|
|
1491
|
+
-- 1. Add the new label. This IS safe while the previous release serves.
|
|
1492
|
+
-- (Declare it in db/public.ts; the rail emits ALTER TYPE … ADD VALUE.)
|
|
1493
|
+
-- 2. Move the data:
|
|
1494
|
+
UPDATE posts SET status = 'review' WHERE status = 'onay';
|
|
1495
|
+
-- 3. Stop naming the old value in the next release.
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
Postgres cannot delete an enum label, so `'onay'` stays — unused and harmless.
|
|
1499
|
+
|
|
1500
|
+
**One trap, measured.** A rename is followed automatically by everything Postgres stores
|
|
1501
|
+
as a parse tree: column defaults, enum-typed CHECKs, views, materialized-view
|
|
1502
|
+
definitions, partial-index predicates, RLS policies, partition bounds, generated columns.
|
|
1503
|
+
It is NOT followed by anything stored as TEXT. A constraint written
|
|
1504
|
+
`CHECK (status::text = 'onay')` keeps its text after a rename and now checks a label that
|
|
1505
|
+
no longer exists — a dead constraint, silently. Measured on PG16: an INSERT the check
|
|
1506
|
+
used to reject passed after the rename.
|
|
1240
1507
|
|
|
1241
1508
|
## Indexes
|
|
1242
1509
|
|
|
1243
1510
|
`indexes` declares plain (non-unique) btree indexes over an ordered column list:
|
|
1244
1511
|
|
|
1245
1512
|
```ts
|
|
1246
|
-
export
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
room_id: uuid().notNull().references("rooms", "id"),
|
|
1252
|
-
started_at: timestamp().defaultNow(),
|
|
1253
|
-
},
|
|
1254
|
-
indexes: [
|
|
1255
|
-
{ name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
|
|
1256
|
-
],
|
|
1257
|
-
},
|
|
1513
|
+
export const sessions = defineTable("sessions", {
|
|
1514
|
+
columns: {
|
|
1515
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1516
|
+
room_id: uuid().references(() => rooms.id),
|
|
1517
|
+
started_at: timestamp().defaultNow(),
|
|
1258
1518
|
},
|
|
1519
|
+
indexes: [
|
|
1520
|
+
{ name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
|
|
1521
|
+
],
|
|
1259
1522
|
});
|
|
1260
1523
|
```
|
|
1261
1524
|
|
|
@@ -1267,7 +1530,7 @@ name and every column are identifier-validated before any SQL is built.
|
|
|
1267
1530
|
knowing before you name an index:
|
|
1268
1531
|
|
|
1269
1532
|
- An index that exists in the database but is not in `indexes` is never dropped.
|
|
1270
|
-
|
|
1533
|
+
Your schema file does not own the database's indexes; it only adds the ones it
|
|
1271
1534
|
names.
|
|
1272
1535
|
- Removing an entry from `indexes` therefore does **not** drop the index. Drop it
|
|
1273
1536
|
in an explicit [migration](./migrations.md).
|
|
@@ -1293,10 +1556,9 @@ nothing. Rather than ship a half-working partial-index path, the typed field
|
|
|
1293
1556
|
stays columns-only and `raw()` carries the rest:
|
|
1294
1557
|
|
|
1295
1558
|
```ts
|
|
1296
|
-
import {
|
|
1559
|
+
import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
|
|
1297
1560
|
|
|
1298
|
-
|
|
1299
|
-
orders: {
|
|
1561
|
+
export const orders = defineTable("orders", {
|
|
1300
1562
|
columns: {
|
|
1301
1563
|
id: uuid().primaryKey().defaultRandom(),
|
|
1302
1564
|
status: text().notNull(),
|
|
@@ -1309,7 +1571,7 @@ orders: {
|
|
|
1309
1571
|
{ down: "DROP INDEX IF EXISTS orders_pending_idx" },
|
|
1310
1572
|
),
|
|
1311
1573
|
],
|
|
1312
|
-
}
|
|
1574
|
+
});
|
|
1313
1575
|
```
|
|
1314
1576
|
|
|
1315
1577
|
`raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
|
|
@@ -1319,12 +1581,34 @@ and an index is not one.
|
|
|
1319
1581
|
|
|
1320
1582
|
## Typed DB access — by default
|
|
1321
1583
|
|
|
1322
|
-
You do **not** wire anything per endpoint. Saving `db
|
|
1584
|
+
You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
|
|
1323
1585
|
`palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
|
|
1324
1586
|
of the schema, no generic, no cast:
|
|
1325
1587
|
|
|
1326
1588
|
```ts
|
|
1327
|
-
|
|
1589
|
+
// services/room.service.ts — the layer that touches the database.
|
|
1590
|
+
import { Database } from "@palbase/backend";
|
|
1591
|
+
|
|
1592
|
+
type RoomsTable = typeof Database.tables.rooms; // typed from db/schema.ts
|
|
1593
|
+
|
|
1594
|
+
export class RoomService {
|
|
1595
|
+
private readonly rooms: RoomsTable;
|
|
1596
|
+
constructor(rooms: RoomsTable) { this.rooms = rooms; }
|
|
1597
|
+
|
|
1598
|
+
async create(name: string) {
|
|
1599
|
+
const room = await this.rooms.insert({ name });
|
|
1600
|
+
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
1601
|
+
// room.nope ← compile error
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
export const roomService = new RoomService(Database.tables.rooms);
|
|
1606
|
+
```
|
|
1607
|
+
|
|
1608
|
+
```ts
|
|
1609
|
+
// controllers/rooms.controller.ts — HTTP only; no `Database` import here.
|
|
1610
|
+
import { Controller, Post, Body, z } from "@palbase/backend";
|
|
1611
|
+
import { roomService } from "../services/room.service.js";
|
|
1328
1612
|
|
|
1329
1613
|
const CreateRoomBody = z.object({ name: z.string() });
|
|
1330
1614
|
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
@@ -1334,10 +1618,8 @@ export default class RoomsController {
|
|
|
1334
1618
|
@Post("")
|
|
1335
1619
|
// The return type names the 200 schema — `z.infer<typeof RoomOut>` works
|
|
1336
1620
|
// inline, no separate `export type` needed.
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
1340
|
-
// room.nope ← compile error
|
|
1621
|
+
create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
|
|
1622
|
+
return roomService.create(body.name);
|
|
1341
1623
|
}
|
|
1342
1624
|
}
|
|
1343
1625
|
```
|
|
@@ -1435,36 +1717,37 @@ rows.
|
|
|
1435
1717
|
### Owner-scoped `todos` example
|
|
1436
1718
|
|
|
1437
1719
|
```ts
|
|
1438
|
-
import {
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
todos: {
|
|
1443
|
-
columns: {
|
|
1444
|
-
id: uuid().primaryKey().defaultRandom(),
|
|
1445
|
-
owner: text().notNull(), // palauth user id (TEXT)
|
|
1446
|
-
title: text().notNull(),
|
|
1447
|
-
done: boolean().default(false),
|
|
1448
|
-
created_at: timestamp().defaultNow(),
|
|
1449
|
-
},
|
|
1450
|
-
// `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
1451
|
-
policies: [
|
|
1452
|
-
// Read: a user sees only their own todos.
|
|
1453
|
-
policy("pb_todos_owner_select")
|
|
1454
|
-
.for("select")
|
|
1455
|
-
.to("authenticated")
|
|
1456
|
-
.using("owner = (select auth.uid())"),
|
|
1720
|
+
import {
|
|
1721
|
+
defineSchema, defineTable, policy, ownedByUser,
|
|
1722
|
+
uuid, text, boolean, timestamp,
|
|
1723
|
+
} from "@palbase/backend";
|
|
1457
1724
|
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
},
|
|
1725
|
+
export const todos = defineTable("todos", {
|
|
1726
|
+
columns: {
|
|
1727
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
1728
|
+
owner: ownedByUser(), // text FK onto auth.users(id), NOT NULL, CASCADE
|
|
1729
|
+
title: text().notNull(),
|
|
1730
|
+
done: boolean().default(false),
|
|
1731
|
+
created_at: timestamp().defaultNow(),
|
|
1466
1732
|
},
|
|
1733
|
+
// `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
1734
|
+
policies: [
|
|
1735
|
+
// Read: a user sees only their own todos.
|
|
1736
|
+
policy("pb_todos_owner_select")
|
|
1737
|
+
.for("select")
|
|
1738
|
+
.to("authenticated")
|
|
1739
|
+
.using("owner = (select auth.uid())"),
|
|
1740
|
+
|
|
1741
|
+
// Write: a user can insert/update/delete only rows they own.
|
|
1742
|
+
policy("pb_todos_owner_write")
|
|
1743
|
+
.for("all")
|
|
1744
|
+
.to("authenticated")
|
|
1745
|
+
.using("owner = (select auth.uid())")
|
|
1746
|
+
.withCheck("owner = (select auth.uid())"),
|
|
1747
|
+
],
|
|
1467
1748
|
});
|
|
1749
|
+
|
|
1750
|
+
export default defineSchema("public", { tables: [todos] });
|
|
1468
1751
|
```
|
|
1469
1752
|
|
|
1470
1753
|
With this in place, `await Database.tables.todos.findMany({})` returns only the
|
|
@@ -1486,7 +1769,6 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
|
|
|
1486
1769
|
|
|
1487
1770
|
|
|
1488
1771
|
|
|
1489
|
-
|
|
1490
1772
|
<!-- ===== migrations.md ===== -->
|
|
1491
1773
|
|
|
1492
1774
|
# Migrations
|
|
@@ -1969,16 +2251,51 @@ stand-in and never needs a database.
|
|
|
1969
2251
|
|
|
1970
2252
|
```ts
|
|
1971
2253
|
// services/note.service.test.ts — `npm test`, no database
|
|
1972
|
-
import assert from "node:assert/strict";
|
|
1973
2254
|
import { test } from "node:test";
|
|
2255
|
+
import assert from "node:assert/strict";
|
|
2256
|
+
|
|
1974
2257
|
import { NoteService } from "./note.service.ts";
|
|
1975
2258
|
|
|
1976
|
-
|
|
2259
|
+
// WHY THIS TEST NEEDS NO DATABASE
|
|
2260
|
+
//
|
|
2261
|
+
// `NoteService` is handed the table it works on rather than reaching for the
|
|
2262
|
+
// singleton itself. That constructor is the seam: a stand-in goes in here, and
|
|
2263
|
+
// the logic — which rows, whose, in what order — is exercised without a
|
|
2264
|
+
// database. Test your own services the same way.
|
|
2265
|
+
//
|
|
2266
|
+
// When you want the whole database surface instead of one table, `fakeDatabase()`
|
|
2267
|
+
// from `@palbase/backend/test` is the stand-in.
|
|
2268
|
+
//
|
|
2269
|
+
// Node's ESM resolver wants the extension on a relative import inside a test
|
|
2270
|
+
// (`./note.service.ts`); this scaffold's `tsconfig.json` allows it.
|
|
2271
|
+
|
|
2272
|
+
test("list asks only for the caller's notes", async () => {
|
|
1977
2273
|
const seen: unknown[] = [];
|
|
1978
|
-
const
|
|
1979
|
-
|
|
2274
|
+
const notes = {
|
|
2275
|
+
findMany: async (where: unknown) => {
|
|
2276
|
+
seen.push(where);
|
|
2277
|
+
return [];
|
|
2278
|
+
},
|
|
2279
|
+
};
|
|
2280
|
+
|
|
2281
|
+
await new NoteService(notes as never).list("u_1");
|
|
2282
|
+
|
|
1980
2283
|
assert.deepEqual(seen, [{ user_id: "u_1" }]);
|
|
1981
2284
|
});
|
|
2285
|
+
|
|
2286
|
+
test("create writes ownership from the argument, never from the body", async () => {
|
|
2287
|
+
const written: unknown[] = [];
|
|
2288
|
+
const notes = {
|
|
2289
|
+
insert: async (row: unknown) => {
|
|
2290
|
+
written.push(row);
|
|
2291
|
+
return row;
|
|
2292
|
+
},
|
|
2293
|
+
};
|
|
2294
|
+
|
|
2295
|
+
await new NoteService(notes as never).create("u_1", "hello");
|
|
2296
|
+
|
|
2297
|
+
assert.deepEqual(written, [{ user_id: "u_1", body: "hello" }]);
|
|
2298
|
+
});
|
|
1982
2299
|
```
|
|
1983
2300
|
|
|
1984
2301
|
Node's ESM resolver wants the extension on a relative import inside a test
|