@palbase/backend 24.3.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 +212 -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 +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-DEneI8Mn.d.ts → index-BF1f0DfA.d.ts} +5 -2
- package/dist/{index-C-ALG22n.d.cts → index-CoaDN9dL.d.cts} +5 -2
- package/dist/{index-BTMYod_l.d.ts → index-Ct1iiB4N.d.ts} +203 -61
- package/dist/{index-BLAbr9ZH.d.cts → index-CwaWRhyc.d.cts} +203 -61
- package/dist/index.cjs +550 -297
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +122 -20
- package/dist/index.d.ts +122 -20
- package/dist/index.js +164 -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 +399 -153
- package/docs/schema.md +272 -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-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,9 +1323,93 @@ 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,
|
|
1326
|
+
`.defaultNow()` (timestamp → `now()`), `.references(() => table.column, opts?)`,
|
|
1327
|
+
`.selfReferences("column", opts?)`,
|
|
1239
1328
|
`.onDelete("cascade" | "set null" | "restrict" | "no action")`, `.ignored()`.
|
|
1240
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
|
+
|
|
1241
1413
|
## Removing a column
|
|
1242
1414
|
|
|
1243
1415
|
A deploy applies the schema while the PREVIOUS release is still answering requests, so
|
|
@@ -1254,23 +1426,47 @@ Removing a column is therefore two deploys:
|
|
|
1254
1426
|
|
|
1255
1427
|
```ts
|
|
1256
1428
|
// 1. Mark it. The column stays; nothing breaks; no DDL is produced.
|
|
1257
|
-
|
|
1258
|
-
|
|
1429
|
+
const notes = defineTable("notes", {
|
|
1430
|
+
columns: { id: uuid().primaryKey(), old_body: text().ignored() },
|
|
1259
1431
|
});
|
|
1432
|
+
export default defineSchema("public", { tables: [notes] });
|
|
1260
1433
|
```
|
|
1261
1434
|
|
|
1262
1435
|
```ts
|
|
1263
1436
|
// 2. Ship that. Then delete the column and ship again — this time the gate passes,
|
|
1264
1437
|
// because the release now serving promised it does not name the column.
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
});
|
|
1438
|
+
const notes = defineTable("notes", { columns: { id: uuid().primaryKey() } });
|
|
1439
|
+
export default defineSchema("public", { tables: [notes] });
|
|
1268
1440
|
```
|
|
1269
1441
|
|
|
1270
1442
|
`palbase db plan` tells you which step you are on before you push. Locally,
|
|
1271
1443
|
`palbase db apply` is not restricted — local is where you experiment, and there is no
|
|
1272
1444
|
traffic to protect.
|
|
1273
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
|
+
|
|
1274
1470
|
The word is `ignored` and not `deprecated` on purpose: deprecation is defined, in
|
|
1275
1471
|
RFC 9745 and in the GraphQL spec alike, as changing NO behaviour. This changes what a
|
|
1276
1472
|
deploy will accept.
|
|
@@ -1293,7 +1489,7 @@ Add a value instead, and let the old one die:
|
|
|
1293
1489
|
|
|
1294
1490
|
```sql
|
|
1295
1491
|
-- 1. Add the new label. This IS safe while the previous release serves.
|
|
1296
|
-
-- (Declare it in db/
|
|
1492
|
+
-- (Declare it in db/public.ts; the rail emits ALTER TYPE … ADD VALUE.)
|
|
1297
1493
|
-- 2. Move the data:
|
|
1298
1494
|
UPDATE posts SET status = 'review' WHERE status = 'onay';
|
|
1299
1495
|
-- 3. Stop naming the old value in the next release.
|
|
@@ -1314,19 +1510,15 @@ used to reject passed after the rename.
|
|
|
1314
1510
|
`indexes` declares plain (non-unique) btree indexes over an ordered column list:
|
|
1315
1511
|
|
|
1316
1512
|
```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
|
-
},
|
|
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(),
|
|
1329
1518
|
},
|
|
1519
|
+
indexes: [
|
|
1520
|
+
{ name: "sessions_room_started_idx", columns: ["room_id", "started_at"] },
|
|
1521
|
+
],
|
|
1330
1522
|
});
|
|
1331
1523
|
```
|
|
1332
1524
|
|
|
@@ -1338,7 +1530,7 @@ name and every column are identifier-validated before any SQL is built.
|
|
|
1338
1530
|
knowing before you name an index:
|
|
1339
1531
|
|
|
1340
1532
|
- An index that exists in the database but is not in `indexes` is never dropped.
|
|
1341
|
-
|
|
1533
|
+
Your schema file does not own the database's indexes; it only adds the ones it
|
|
1342
1534
|
names.
|
|
1343
1535
|
- Removing an entry from `indexes` therefore does **not** drop the index. Drop it
|
|
1344
1536
|
in an explicit [migration](./migrations.md).
|
|
@@ -1364,10 +1556,9 @@ nothing. Rather than ship a half-working partial-index path, the typed field
|
|
|
1364
1556
|
stays columns-only and `raw()` carries the rest:
|
|
1365
1557
|
|
|
1366
1558
|
```ts
|
|
1367
|
-
import {
|
|
1559
|
+
import { defineTable, raw, uuid, text, timestamp } from "@palbase/backend";
|
|
1368
1560
|
|
|
1369
|
-
|
|
1370
|
-
orders: {
|
|
1561
|
+
export const orders = defineTable("orders", {
|
|
1371
1562
|
columns: {
|
|
1372
1563
|
id: uuid().primaryKey().defaultRandom(),
|
|
1373
1564
|
status: text().notNull(),
|
|
@@ -1380,7 +1571,7 @@ orders: {
|
|
|
1380
1571
|
{ down: "DROP INDEX IF EXISTS orders_pending_idx" },
|
|
1381
1572
|
),
|
|
1382
1573
|
],
|
|
1383
|
-
}
|
|
1574
|
+
});
|
|
1384
1575
|
```
|
|
1385
1576
|
|
|
1386
1577
|
`raw()`'s `up` is emitted verbatim on the privileged DDL connection and, like
|
|
@@ -1390,12 +1581,34 @@ and an index is not one.
|
|
|
1390
1581
|
|
|
1391
1582
|
## Typed DB access — by default
|
|
1392
1583
|
|
|
1393
|
-
You do **not** wire anything per endpoint. Saving `db
|
|
1584
|
+
You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
|
|
1394
1585
|
`palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
|
|
1395
1586
|
of the schema, no generic, no cast:
|
|
1396
1587
|
|
|
1397
1588
|
```ts
|
|
1398
|
-
|
|
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";
|
|
1399
1612
|
|
|
1400
1613
|
const CreateRoomBody = z.object({ name: z.string() });
|
|
1401
1614
|
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
@@ -1405,10 +1618,8 @@ export default class RoomsController {
|
|
|
1405
1618
|
@Post("")
|
|
1406
1619
|
// The return type names the 200 schema — `z.infer<typeof RoomOut>` works
|
|
1407
1620
|
// inline, no separate `export type` needed.
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
return { id: room.id, name: room.name }; // room.id: string ✓
|
|
1411
|
-
// room.nope ← compile error
|
|
1621
|
+
create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
|
|
1622
|
+
return roomService.create(body.name);
|
|
1412
1623
|
}
|
|
1413
1624
|
}
|
|
1414
1625
|
```
|
|
@@ -1506,36 +1717,37 @@ rows.
|
|
|
1506
1717
|
### Owner-scoped `todos` example
|
|
1507
1718
|
|
|
1508
1719
|
```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())"),
|
|
1720
|
+
import {
|
|
1721
|
+
defineSchema, defineTable, policy, ownedByUser,
|
|
1722
|
+
uuid, text, boolean, timestamp,
|
|
1723
|
+
} from "@palbase/backend";
|
|
1528
1724
|
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
},
|
|
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(),
|
|
1537
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
|
+
],
|
|
1538
1748
|
});
|
|
1749
|
+
|
|
1750
|
+
export default defineSchema("public", { tables: [todos] });
|
|
1539
1751
|
```
|
|
1540
1752
|
|
|
1541
1753
|
With this in place, `await Database.tables.todos.findMany({})` returns only the
|
|
@@ -1557,7 +1769,6 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
|
|
|
1557
1769
|
|
|
1558
1770
|
|
|
1559
1771
|
|
|
1560
|
-
|
|
1561
1772
|
<!-- ===== migrations.md ===== -->
|
|
1562
1773
|
|
|
1563
1774
|
# Migrations
|
|
@@ -2040,16 +2251,51 @@ stand-in and never needs a database.
|
|
|
2040
2251
|
|
|
2041
2252
|
```ts
|
|
2042
2253
|
// services/note.service.test.ts — `npm test`, no database
|
|
2043
|
-
import assert from "node:assert/strict";
|
|
2044
2254
|
import { test } from "node:test";
|
|
2255
|
+
import assert from "node:assert/strict";
|
|
2256
|
+
|
|
2045
2257
|
import { NoteService } from "./note.service.ts";
|
|
2046
2258
|
|
|
2047
|
-
|
|
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 () => {
|
|
2048
2273
|
const seen: unknown[] = [];
|
|
2049
|
-
const
|
|
2050
|
-
|
|
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
|
+
|
|
2051
2283
|
assert.deepEqual(seen, [{ user_id: "u_1" }]);
|
|
2052
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
|
+
});
|
|
2053
2299
|
```
|
|
2054
2300
|
|
|
2055
2301
|
Node's ESM resolver wants the extension on a relative import inside a test
|