@palbase/backend 33.0.0 → 33.0.2
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.map +1 -1
- package/dist/bin/palbase-backend.js +5 -5
- package/dist/{chunk-BRLJOXWS.js → chunk-AAT5G7KY.js} +2 -2
- package/dist/{chunk-BRLJOXWS.js.map → chunk-AAT5G7KY.js.map} +1 -1
- package/dist/{chunk-DCDHAKF3.js → chunk-C6COAB3E.js} +5 -5
- package/dist/{chunk-C4ZA5AT2.js → chunk-SI4KGEM3.js} +1 -1
- package/dist/{chunk-C4ZA5AT2.js.map → chunk-SI4KGEM3.js.map} +1 -1
- package/dist/{chunk-QMFOL2K6.js → chunk-TVCCR6SO.js} +2 -2
- package/dist/{chunk-N54QZER3.js → chunk-WWUG2QXF.js} +2 -2
- package/dist/{chunk-GOPZPM2A.js → chunk-YIQ4RS4F.js} +2 -2
- 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 +2 -2
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +3 -3
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +5 -5
- package/dist/{index-Bi74dcOu.d.ts → index-D-3duy8Y.d.ts} +2 -2
- package/dist/{index-BWgnGj68.d.cts → index-DB_nW-AV.d.cts} +19 -10
- package/dist/{index-g6iQyYci.d.cts → index-DLveQoOf.d.cts} +2 -2
- package/dist/{index-Dg10RnZO.d.ts → index-DMZG3kpo.d.ts} +19 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +5 -5
- package/dist/openapi/index.cjs +1 -1
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +2 -2
- package/dist/openapi/index.d.ts +2 -2
- package/dist/openapi/index.js +1 -1
- package/dist/{registry-6VT5RPeO.d.cts → registry-DSTThhKf.d.cts} +1 -1
- package/dist/{registry-B-sxJJN0.d.ts → registry-JjF5lcj4.d.ts} +1 -1
- package/dist/test/index.cjs +1120 -4
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/dist/test/index.js +1121 -5
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +123 -79
- package/docs/auth.md +11 -7
- package/docs/background.md +16 -7
- package/docs/database.md +22 -18
- package/docs/endpoints.md +26 -20
- package/docs/errors.md +2 -3
- package/docs/events.md +63 -29
- package/docs/getting-started.md +8 -7
- package/docs/llms-full.txt +399 -232
- package/docs/migrations.md +6 -5
- package/docs/routing.md +34 -11
- package/docs/schema.md +61 -36
- package/docs/services.md +30 -13
- package/package.json +2 -1
- package/stack-images.json +24 -0
- package/template/AGENTS.md +7 -7
- package/template/modules/health/health.controller.ts +7 -4
- package/template/modules/notes/dto/create.ts +4 -3
- package/template/modules/notes/notes.controller.ts +8 -6
- /package/dist/{chunk-DCDHAKF3.js.map → chunk-C6COAB3E.js.map} +0 -0
- /package/dist/{chunk-QMFOL2K6.js.map → chunk-TVCCR6SO.js.map} +0 -0
- /package/dist/{chunk-N54QZER3.js.map → chunk-WWUG2QXF.js.map} +0 -0
- /package/dist/{chunk-GOPZPM2A.js.map → chunk-YIQ4RS4F.js.map} +0 -0
package/docs/migrations.md
CHANGED
|
@@ -90,7 +90,7 @@ Code and schema move at different speeds, so three things keep them together:
|
|
|
90
90
|
## Your schema change is not live anywhere you have not applied it
|
|
91
91
|
|
|
92
92
|
The local stack and every Environment hold their own database, and editing
|
|
93
|
-
|
|
93
|
+
`db/*.ts` changes none of them: `palbase db apply` is what moves the local
|
|
94
94
|
one, `palbase push` is what moves an Environment's. Until then the declaration is
|
|
95
95
|
ahead of the tables.
|
|
96
96
|
|
|
@@ -165,10 +165,11 @@ existing data gets there.
|
|
|
165
165
|
|
|
166
166
|
## Row-Level Security
|
|
167
167
|
|
|
168
|
-
Add `
|
|
169
|
-
generated migration emits the `ENABLE ROW LEVEL SECURITY` +
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
Add `policies: () => [policy(...)]` to a table in `db/*.ts` — a CALLBACK, not an
|
|
169
|
+
array — and the generated migration emits the `ENABLE ROW LEVEL SECURITY` +
|
|
170
|
+
`CREATE POLICY` DDL. RLS is on by default, so `rls: true` is only worth writing
|
|
171
|
+
as a deliberate deny-all with no policies. See [schema.md](./schema.md) for the
|
|
172
|
+
column builders, the policy DSL, and typed `Database.public.*` access.
|
|
172
173
|
|
|
173
174
|
### Hand-writing a policy
|
|
174
175
|
|
package/docs/routing.md
CHANGED
|
@@ -4,8 +4,11 @@ Routes are declared in code with **class controllers**. A controller is a class
|
|
|
4
4
|
decorated with `@Controller(basePath)`; each route is a method decorated with
|
|
5
5
|
`@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`/`@Query`. Request input + context are
|
|
6
6
|
injected into the method via **parameter decorators** (`@Body`/`@QueryParams`/
|
|
7
|
-
`@Param`/`@User`/…).
|
|
8
|
-
|
|
7
|
+
`@Param`/`@User`/…). A controller is mounted by being LISTED: its module names
|
|
8
|
+
it in `controllers`, and that list is the only place ownership is decided. A
|
|
9
|
+
class no module lists does not exist — the build refuses it by name, and it
|
|
10
|
+
never reaches the route table or the OpenAPI document. Being in a particular
|
|
11
|
+
folder grants nothing.
|
|
9
12
|
|
|
10
13
|
```ts
|
|
11
14
|
import { Controller, Get, Post, Body, QueryParams, Param, User } from "@palbase/backend";
|
|
@@ -14,31 +17,51 @@ import { Controller, Get, Post, Body, QueryParams, Param, User } from "@palbase/
|
|
|
14
17
|
## Controllers — class + method decorators
|
|
15
18
|
|
|
16
19
|
`@Controller(basePath)` marks the class and sets the mount path. Each route
|
|
17
|
-
method declares its verb + subpath; the real work lives in
|
|
18
|
-
(the controller method is thin).
|
|
20
|
+
method declares its verb + subpath; the real work lives in an `@Injectable()`
|
|
21
|
+
service that arrives through the CONSTRUCTOR (the controller method is thin).
|
|
19
22
|
|
|
20
23
|
```ts
|
|
21
|
-
//
|
|
24
|
+
// modules/places/places.controller.ts
|
|
22
25
|
import { Controller, Get, Post, Body, User } from "@palbase/backend";
|
|
23
26
|
import type { UserT } from "@palbase/backend";
|
|
24
|
-
import {
|
|
25
|
-
import { ImportNearbyBody } from "
|
|
26
|
-
import
|
|
27
|
+
import { PlaceService } from "./place.service.js";
|
|
28
|
+
import { ImportNearbyBody } from "./dto/import.js";
|
|
29
|
+
import { PlaceSchema } from "./dto/shared.js"; // the return TYPE names the 200 schema
|
|
27
30
|
|
|
28
31
|
@Controller("/places")
|
|
29
|
-
export
|
|
32
|
+
export class PlacesController {
|
|
33
|
+
constructor(private readonly places: PlaceService) {}
|
|
34
|
+
|
|
30
35
|
@Post("/import")
|
|
31
36
|
importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
|
|
32
|
-
return
|
|
37
|
+
return this.places.importNearby(body.lat, body.lng, user.id);
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
@Get("/favorites", { auth: false })
|
|
36
41
|
listFavorites(): PlaceSchema[] {
|
|
37
|
-
return
|
|
42
|
+
return this.places.listFavorites();
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
```
|
|
41
46
|
|
|
47
|
+
The class is mounted by being LISTED, and nothing else mounts it:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// modules/places/places.module.ts
|
|
51
|
+
import { Module, type Token } from "@palbase/backend";
|
|
52
|
+
|
|
53
|
+
import { PlacesController } from "./places.controller.js";
|
|
54
|
+
import { PlaceService } from "./place.service.js";
|
|
55
|
+
|
|
56
|
+
@Module({
|
|
57
|
+
controllers: [PlacesController as Token],
|
|
58
|
+
providers: [PlaceService as Token],
|
|
59
|
+
exports: [],
|
|
60
|
+
imports: [],
|
|
61
|
+
})
|
|
62
|
+
export class PlacesModule {}
|
|
63
|
+
```
|
|
64
|
+
|
|
42
65
|
| Method name | Verb | Full path | operationId (dotted) |
|
|
43
66
|
|---|---|---|---|
|
|
44
67
|
| `importNearby` | POST | `/places/import` | `places.importNearby` |
|
package/docs/schema.md
CHANGED
|
@@ -19,9 +19,10 @@ those values — never a dictionary, because a name in a dictionary key is a sec
|
|
|
19
19
|
place the name is written, and a table built under a key does not yet know what
|
|
20
20
|
to call itself when a sibling references it.
|
|
21
21
|
|
|
22
|
-
Each table's only required field is `columns`; `
|
|
23
|
-
[Row-Level Security](#row-level-security-rls),
|
|
24
|
-
|
|
22
|
+
Each table's only required field is `columns`; `policies` (a callback — see
|
|
23
|
+
[Row-Level Security](#row-level-security-rls)) declares the RLS rules, `rls`
|
|
24
|
+
toggles enforcement (default `true`), and `indexes` declares plain btree
|
|
25
|
+
[indexes](#indexes).
|
|
25
26
|
|
|
26
27
|
```ts
|
|
27
28
|
import {
|
|
@@ -447,14 +448,28 @@ You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
|
|
|
447
448
|
of the schema, no generic, no cast:
|
|
448
449
|
|
|
449
450
|
```ts
|
|
450
|
-
//
|
|
451
|
-
import { Database } from "@palbase/backend";
|
|
451
|
+
// modules/rooms/room.service.ts — the layer that touches the database.
|
|
452
|
+
import { Database, Injectable } from "@palbase/backend";
|
|
453
|
+
import type { Tables } from "@palbase/backend/env";
|
|
454
|
+
|
|
455
|
+
type Room = Tables["rooms"]["row"]; // typed from your db/*.ts
|
|
452
456
|
|
|
453
|
-
|
|
457
|
+
/** `Database.public.rooms` is a VALUE, and a dependency is named by its
|
|
458
|
+
* parameter's TYPE — so the seam is an `abstract class`, one table wide. */
|
|
459
|
+
export abstract class RoomRepo {
|
|
460
|
+
abstract insert(row: { name: string }): Promise<Room>;
|
|
461
|
+
}
|
|
454
462
|
|
|
463
|
+
@Injectable()
|
|
464
|
+
export class DbRoomRepo extends RoomRepo {
|
|
465
|
+
insert(row: { name: string }): Promise<Room> {
|
|
466
|
+
return Database.public.rooms.insert(row); // typed: rooms.nope ← compile error
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
@Injectable()
|
|
455
471
|
export class RoomService {
|
|
456
|
-
private readonly rooms:
|
|
457
|
-
constructor(rooms: RoomsTable) { this.rooms = rooms; }
|
|
472
|
+
constructor(private readonly rooms: RoomRepo) {}
|
|
458
473
|
|
|
459
474
|
async create(name: string) {
|
|
460
475
|
const room = await this.rooms.insert({ name });
|
|
@@ -462,41 +477,48 @@ export class RoomService {
|
|
|
462
477
|
// room.nope ← compile error
|
|
463
478
|
}
|
|
464
479
|
}
|
|
465
|
-
|
|
466
480
|
```
|
|
467
481
|
|
|
468
482
|
```ts
|
|
469
|
-
// rooms.module.ts
|
|
483
|
+
// modules/rooms/rooms.module.ts — the four lists that make these classes exist.
|
|
470
484
|
import { Module, type Token } from "@palbase/backend";
|
|
471
|
-
import { RoomsController } from "./
|
|
472
|
-
import { RoomService } from "./
|
|
473
|
-
|
|
474
|
-
@Module({
|
|
485
|
+
import { RoomsController } from "./rooms.controller.ts";
|
|
486
|
+
import { DbRoomRepo, RoomService } from "./room.service.ts";
|
|
487
|
+
|
|
488
|
+
@Module({
|
|
489
|
+
controllers: [RoomsController as Token],
|
|
490
|
+
providers: [RoomService as Token, DbRoomRepo as Token],
|
|
491
|
+
exports: [],
|
|
492
|
+
imports: [],
|
|
493
|
+
})
|
|
475
494
|
export class RoomsModule {}
|
|
476
495
|
```
|
|
477
496
|
|
|
478
497
|
```ts
|
|
479
|
-
//
|
|
498
|
+
// modules/rooms/rooms.controller.ts — HTTP only; no `Database` import here.
|
|
480
499
|
import { Controller, Post, Body, z } from "@palbase/backend";
|
|
481
|
-
import { RoomService } from "
|
|
500
|
+
import { RoomService } from "./room.service";
|
|
482
501
|
|
|
483
502
|
const CreateRoomBody = z.object({ name: z.string() });
|
|
484
503
|
const RoomOut = z.object({ id: z.string(), name: z.string() });
|
|
485
504
|
|
|
486
505
|
@Controller("/rooms")
|
|
487
|
-
export
|
|
506
|
+
export class RoomsController {
|
|
507
|
+
constructor(private readonly rooms: RoomService) {}
|
|
508
|
+
|
|
488
509
|
@Post("")
|
|
489
510
|
// The return type names the 200 schema — `z.infer<typeof RoomOut>` works
|
|
490
511
|
// inline, no separate `export type` needed.
|
|
491
512
|
create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
|
|
492
|
-
return
|
|
513
|
+
return this.rooms.create(body.name);
|
|
493
514
|
}
|
|
494
515
|
}
|
|
495
516
|
```
|
|
496
517
|
|
|
497
|
-
`Database.public.<name>` exposes `insert
|
|
498
|
-
`
|
|
499
|
-
|
|
518
|
+
`Database.public.<name>` exposes `insert(values)`,
|
|
519
|
+
`update({ where: { id }, set })`, `delete(id)`, `findById(id)`,
|
|
520
|
+
`findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
|
|
521
|
+
whose `tx.public.<name>` is typed from the same schema, but carries plan
|
|
500
522
|
operations (`insert`/`insertMany`/`updateWhere`/`deleteWhere`/`select`) rather
|
|
501
523
|
than awaited calls — see [database.md](./database.md#transactions). The raw
|
|
502
524
|
string-keyed ops (`Database.$insert("rooms", …)`, `Database.$query(…)`) are still
|
|
@@ -518,10 +540,11 @@ missing `WHERE user_id = …` in your handler can no longer leak another user's
|
|
|
518
540
|
rows — the policy enforces it. This is the recommended way to scope data per
|
|
519
541
|
user.
|
|
520
542
|
|
|
521
|
-
Add `policies`
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
deliberate deny-all
|
|
543
|
+
Add `policies` to a table — a **callback** returning the list, `policies: () =>
|
|
544
|
+
[…]`, not a bare array. RLS defaults to `true`, and a non-empty `policies` forces
|
|
545
|
+
it on regardless (a table with policies must have RLS enabled or the policies are
|
|
546
|
+
inert). Write `rls: true` with no policies only as a deliberate deny-all
|
|
547
|
+
intermediate step.
|
|
525
548
|
|
|
526
549
|
### The `policy()` builder
|
|
527
550
|
|
|
@@ -588,15 +611,17 @@ from `auth.uid()`, so a policy cannot ask about somebody else. A revoked role is
|
|
|
588
611
|
gone from the very next statement; there is nothing cached.
|
|
589
612
|
|
|
590
613
|
```ts
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
614
|
+
policies: () => [
|
|
615
|
+
policy("notes_owner").for("all")
|
|
616
|
+
.using("user_id = (select auth.uid())")
|
|
617
|
+
.withCheck("user_id = (select auth.uid())"),
|
|
618
|
+
|
|
619
|
+
// Moderation. Permissive policies are OR'd, so these widen only.
|
|
620
|
+
policy("notes_moderate_read").for("select")
|
|
621
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
622
|
+
policy("notes_delete_any").for("delete")
|
|
623
|
+
.using("(select auth.has_permission('notes.delete_any'))"),
|
|
624
|
+
],
|
|
600
625
|
```
|
|
601
626
|
|
|
602
627
|
> **The read policy is not optional.** `DELETE … WHERE id = $1` has to FIND the
|
|
@@ -626,8 +651,8 @@ export const todos = defineTable("todos", {
|
|
|
626
651
|
done: boolean().default(false),
|
|
627
652
|
created_at: timestamp().defaultNow(),
|
|
628
653
|
},
|
|
629
|
-
// `policies`
|
|
630
|
-
policies: [
|
|
654
|
+
// `policies` is a CALLBACK. Non-empty ⇒ RLS is enabled + FORCEd automatically.
|
|
655
|
+
policies: () => [
|
|
631
656
|
// Read: a user sees only their own todos.
|
|
632
657
|
policy("pb_todos_owner_select")
|
|
633
658
|
.for("select")
|
package/docs/services.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Two different things are called "services" here, and this page is the second
|
|
4
4
|
one.** Read the first paragraph before searching this page for a layout rule.
|
|
5
5
|
|
|
6
|
-
- **YOUR service layer** — `
|
|
6
|
+
- **YOUR service layer** — `modules/<domain>/<name>.service.ts`, the `@Injectable()` classes your
|
|
7
7
|
own business logic lives in. The scaffold ships one, and the contract is at
|
|
8
8
|
the bottom of this page: [Your own service layer](#your-own-service-layer).
|
|
9
9
|
- **THE service singletons** — the platform objects you import from
|
|
@@ -26,9 +26,26 @@ turns out to be empty — it fails at the import.
|
|
|
26
26
|
a channel, but cannot subscribe (a stateless request can't hold a socket).
|
|
27
27
|
Subscription lives on the client SDK (`pb.realtime`).
|
|
28
28
|
|
|
29
|
+
`Auth` is available here for exactly one thing: **granting and revoking roles**.
|
|
30
|
+
Signing in, signing up and session handling run on the client SDK — a backend
|
|
31
|
+
handler never holds a user's credentials — but assignment is server work, so
|
|
32
|
+
`assignRole`, `revokeRole` and `rolesOf` are on this side:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { Auth } from "@palbase/backend";
|
|
36
|
+
|
|
37
|
+
await Auth.assignRole(userId, "moderator"); // takes effect on the next request
|
|
38
|
+
await Auth.revokeRole(userId, "moderator"); // and so does this
|
|
39
|
+
const held = await Auth.rolesOf(userId); // string[]
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A role that does not exist throws `RoleNotDefined` naming it — declare it first
|
|
43
|
+
with `palbase roles create`. A failed read throws rather than answering `[]`:
|
|
44
|
+
"nobody could say" and "holds nothing" are different claims, and a handler that
|
|
45
|
+
confuses them denies a user everything they hold.
|
|
46
|
+
|
|
29
47
|
**Not available to backend handlers** (do not import them here): Functions, CMS,
|
|
30
|
-
Links
|
|
31
|
-
scope for backend endpoints.
|
|
48
|
+
Links and Analytics — out of scope for backend endpoints.
|
|
32
49
|
|
|
33
50
|
## Cache
|
|
34
51
|
|
|
@@ -158,7 +175,7 @@ Writes mirror the `Database` / `Database.$asService()` model:
|
|
|
158
175
|
- `Flags.setOverride(key, value)` (default) writes an override for the **current
|
|
159
176
|
request user** — no `userId` argument, no admin power. It errors on an
|
|
160
177
|
anonymous request (no signed-in user).
|
|
161
|
-
- `Flags
|
|
178
|
+
- `Flags.$asService()` returns the cross-user admin surface
|
|
162
179
|
(`setOverrideForUser`, `setOverridesForUser`, `clearOverrideForUser`,
|
|
163
180
|
`clearAllOverridesForUser`, `batchSetOverrides`) for writing overrides for an
|
|
164
181
|
**arbitrary** user. Explicit and greppable, just like `Database.$asService()`.
|
|
@@ -167,8 +184,8 @@ Writes mirror the `Database` / `Database.$asService()` model:
|
|
|
167
184
|
// Current request user — no userId needed:
|
|
168
185
|
await Flags.setOverride("new-checkout", true);
|
|
169
186
|
|
|
170
|
-
// Cross-user admin write — explicit target, via asService():
|
|
171
|
-
await Flags
|
|
187
|
+
// Cross-user admin write — explicit target, via $asService():
|
|
188
|
+
await Flags.$asService().setOverrideForUser("user_123", "new-checkout", true);
|
|
172
189
|
```
|
|
173
190
|
|
|
174
191
|
## Realtime
|
|
@@ -219,8 +236,8 @@ to drive live chat, presence, dashboards, and other push features.
|
|
|
219
236
|
## Your own service layer
|
|
220
237
|
|
|
221
238
|
The singletons above are what a service CALLS. This section is the layer that
|
|
222
|
-
calls them — `
|
|
223
|
-
example of (`
|
|
239
|
+
calls them — `modules/<domain>/<name>.service.ts`, which the scaffold ships a working
|
|
240
|
+
example of (`modules/notes/note.service.ts` and the controller that uses it).
|
|
224
241
|
|
|
225
242
|
The contract is three rules, and the scaffold's own test enforces all three:
|
|
226
243
|
|
|
@@ -235,7 +252,7 @@ it.** Mark the class `@Injectable()` and name what it needs as ordinary
|
|
|
235
252
|
constructor parameters:
|
|
236
253
|
|
|
237
254
|
```ts
|
|
238
|
-
//
|
|
255
|
+
// modules/notes/note.service.ts
|
|
239
256
|
import { Database, Injectable } from "@palbase/backend";
|
|
240
257
|
|
|
241
258
|
type NotesTable = typeof Database.public.notes;
|
|
@@ -251,7 +268,7 @@ export class NoteService {
|
|
|
251
268
|
```
|
|
252
269
|
|
|
253
270
|
```ts
|
|
254
|
-
//
|
|
271
|
+
// modules/reports/report.service.ts — a service that depends on another service
|
|
255
272
|
import { Injectable } from "@palbase/backend";
|
|
256
273
|
import { NoteService } from "./note.service.ts";
|
|
257
274
|
|
|
@@ -268,7 +285,7 @@ export class ReportService {
|
|
|
268
285
|
A controller asks the same way, and nothing wires it by hand:
|
|
269
286
|
|
|
270
287
|
```ts
|
|
271
|
-
//
|
|
288
|
+
// modules/notes/notes.controller.ts
|
|
272
289
|
import { Controller, Get } from "@palbase/backend";
|
|
273
290
|
import { NoteService } from "../services/note.service.ts";
|
|
274
291
|
|
|
@@ -310,7 +327,7 @@ never reaches the route table, the dispatcher or the OpenAPI document.
|
|
|
310
327
|
```ts
|
|
311
328
|
// notes.module.ts — beside the domain it owns, not in a directory we name
|
|
312
329
|
import { Module, type Token } from "@palbase/backend";
|
|
313
|
-
import { NotesController } from "./
|
|
330
|
+
import { NotesController } from "./modules/notes/notes.controller.ts";
|
|
314
331
|
import { NoteService } from "./services/note.service.ts";
|
|
315
332
|
import { ReportService } from "./services/report.service.ts";
|
|
316
333
|
|
|
@@ -363,7 +380,7 @@ NoteService())` is ordinary TypeScript, and the container is not required for it
|
|
|
363
380
|
to work.
|
|
364
381
|
|
|
365
382
|
```ts
|
|
366
|
-
//
|
|
383
|
+
// modules/notes/note.service.test.ts — `npm test`, no database
|
|
367
384
|
import { test } from "node:test";
|
|
368
385
|
import assert from "node:assert/strict";
|
|
369
386
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@palbase/backend",
|
|
3
|
-
"version": "33.0.
|
|
3
|
+
"version": "33.0.2",
|
|
4
4
|
"description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"files": [
|
|
92
92
|
"dist",
|
|
93
93
|
"docs",
|
|
94
|
+
"stack-images.json",
|
|
94
95
|
"stager",
|
|
95
96
|
"template"
|
|
96
97
|
],
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"33": [
|
|
3
|
+
{
|
|
4
|
+
"env": "PALBASE_PALSVC_IMAGE",
|
|
5
|
+
"ref": "ghcr.io/palgroup/palbase/palsvc:0.42.0",
|
|
6
|
+
"build": "cd v2 && DOCKER_BUILDKIT=1 docker build -t palbase-palsvc -f Dockerfile ."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"env": "PALBASE_RUNTIME_IMAGE",
|
|
10
|
+
"ref": "ghcr.io/palgroup/palbase/runtime-dev:0.42.0",
|
|
11
|
+
"build": "cd v2/runtime && DOCKER_BUILDKIT=1 docker build --target dev -t palbase-runtime-dev -f Dockerfile ."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"env": "PALBASE_EDGE_IMAGE",
|
|
15
|
+
"ref": "ghcr.io/palgroup/palbase/edge:0.42.0",
|
|
16
|
+
"build": "cd v2/deploy/envoy && DOCKER_BUILDKIT=1 docker build -t palbase-edge ."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"env": "PALBASE_POSTGRES_IMAGE",
|
|
20
|
+
"ref": "pgvector/pgvector:pg16",
|
|
21
|
+
"build": ""
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
package/template/AGENTS.md
CHANGED
|
@@ -44,7 +44,7 @@ shape you find there is the shape to copy.
|
|
|
44
44
|
| A business rule, a calculation, an ordering | the service |
|
|
45
45
|
| Return an HTTP status | `throw new NotFound(…)` in the controller (or the service — no request object needed) |
|
|
46
46
|
| Call another feature | name its class in your constructor — its module must `exports` it and yours must `imports` that module |
|
|
47
|
-
| Scheduled or background work | a `@Job` class in `
|
|
47
|
+
| Scheduled or background work | a `@Job` class listed in a module's `providers` — **there is no queue** |
|
|
48
48
|
| Read a setting or a secret | `Secrets.get(name)` / `Flags.isEnabled(name)` — the names are checked by the compiler |
|
|
49
49
|
| Share code between controllers | a plain module they import; not a base class, not middleware |
|
|
50
50
|
|
|
@@ -86,12 +86,12 @@ wrong in this runtime:
|
|
|
86
86
|
- **An inline return type** — `Promise<{ ok: boolean }>`, a union, or a bare
|
|
87
87
|
`interface`. The deploy REFUSES it; name a zod schema.
|
|
88
88
|
|
|
89
|
-
**A controller is exported by NAME, and no file is default-exported.** Its module
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
89
|
+
**A controller is exported by NAME, and no file is default-exported.** Its module imports
|
|
90
|
+
it (`import { NotesController } from "./notes.controller"`) to list it in `controllers`;
|
|
91
|
+
that list is the registration. `@Job`, `@Webhook`, `@Hook` and `@Room` go in the same
|
|
92
|
+
module's `providers`. There is no `jobs/`, `webhooks/` or `hooks/` directory and nothing
|
|
93
|
+
reads one — a surface class no module lists is never imported, so its decorator never
|
|
94
|
+
runs and nothing says so. Only the schema is read by location: `db/*.ts`, one per schema.
|
|
95
95
|
|
|
96
96
|
**Class and method names are your public API.** `NotesController.list` generates
|
|
97
97
|
`pb.notes.list()`. Renaming either renames the call in every app; the verb and the
|
|
@@ -3,15 +3,18 @@ import { Controller, Get, z } from "@palbase/backend";
|
|
|
3
3
|
// A route's success response IS its return type — a NAMED zod schema, read by
|
|
4
4
|
// both the runtime (output validation) and the client codegen. Small projects
|
|
5
5
|
// declare it beside the route; once a controller grows, move the schemas to
|
|
6
|
-
// `
|
|
6
|
+
// `dto/<endpoint>.ts` inside the same module and import them — the way
|
|
7
|
+
// `modules/notes/dto/create.ts` does.
|
|
7
8
|
export const HealthResponse = z.object({
|
|
8
9
|
status: z.string(),
|
|
9
10
|
});
|
|
10
11
|
export type HealthResponse = z.infer<typeof HealthResponse>;
|
|
11
12
|
|
|
12
|
-
// A controller is a CLASS whose methods are routes
|
|
13
|
-
//
|
|
14
|
-
// and
|
|
13
|
+
// A controller is a CLASS whose methods are routes, and `health.module.ts`
|
|
14
|
+
// beside this file is what REGISTERS it: the module imports the class by name
|
|
15
|
+
// and lists it in `controllers`. That list is the registration — a class no
|
|
16
|
+
// module names is refused at build, by name. `@Controller` describes the class;
|
|
17
|
+
// it does not enrol it, and neither does the folder the file sits in.
|
|
15
18
|
//
|
|
16
19
|
// The full path is the controller base + the method subpath, so this serves
|
|
17
20
|
// `GET /health`. The generated clients call it `pb.health.check()` —
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from "@palbase/backend";
|
|
2
2
|
|
|
3
|
-
// SCHEMAS LIVE HERE once a controller has more than a screen of them:
|
|
4
|
-
// `
|
|
5
|
-
// an ordinary import path — so the shape is
|
|
3
|
+
// SCHEMAS LIVE HERE once a controller has more than a screen of them: the
|
|
4
|
+
// module's own `dto/<endpoint>.ts`, which is where this file sits. Nothing
|
|
5
|
+
// discovers this directory — it is an ordinary import path — so the shape is
|
|
6
|
+
// yours. What is NOT optional is the
|
|
6
7
|
// double export below.
|
|
7
8
|
//
|
|
8
9
|
// Each name is exported TWICE, under the SAME name:
|
|
@@ -4,7 +4,7 @@ import type { UserT } from "@palbase/backend";
|
|
|
4
4
|
import { CreateNoteBody, NoteSchema } from "./dto/create";
|
|
5
5
|
import { NoteService } from "./note.service";
|
|
6
6
|
|
|
7
|
-
// The other half of the vertical that starts in
|
|
7
|
+
// The other half of the vertical that starts in `./note.service.ts`.
|
|
8
8
|
//
|
|
9
9
|
// Everything a controller does here is HTTP: validate the body through a named
|
|
10
10
|
// schema, name the 200 shape as the return type, turn a missing row into a
|
|
@@ -22,12 +22,14 @@ import { NoteService } from "./note.service";
|
|
|
22
22
|
// container builds the graph once at boot.
|
|
23
23
|
//
|
|
24
24
|
// A class no module lists does not exist — it is refused at build, by name, and
|
|
25
|
-
// never reaches the route table or the OpenAPI document.
|
|
26
|
-
// grants nothing
|
|
25
|
+
// never reaches the route table or the OpenAPI document. WHERE the file sits
|
|
26
|
+
// grants nothing: the bundler walks the tree for `*.module.ts` and reads
|
|
27
|
+
// nothing else, so a class reached by no module is never even imported. Being
|
|
28
|
+
// in a module's list is what does it.
|
|
27
29
|
|
|
28
|
-
// The schemas are in
|
|
29
|
-
//
|
|
30
|
-
// `
|
|
30
|
+
// The schemas are in `./dto/create.ts`. A controller with one endpoint can keep
|
|
31
|
+
// them beside the routes; the moment there are two, they go to the module's
|
|
32
|
+
// `dto/`, and this scaffold ships them already moved — because the shape you
|
|
31
33
|
// find here is the shape the next file copies.
|
|
32
34
|
|
|
33
35
|
// Auth is required unless a route opts out, so `@User()` is non-null here and
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|