@palbase/backend 2.0.2 → 4.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.
Files changed (53) hide show
  1. package/dist/chunk-EG7TTYHY.js +235 -0
  2. package/dist/chunk-EG7TTYHY.js.map +1 -0
  3. package/dist/chunk-WUQO76NW.js +101 -0
  4. package/dist/chunk-WUQO76NW.js.map +1 -0
  5. package/dist/db/env.cjs +19 -0
  6. package/dist/db/env.cjs.map +1 -0
  7. package/dist/db/env.d.cts +45 -0
  8. package/dist/db/env.d.ts +45 -0
  9. package/dist/db/env.js +1 -0
  10. package/dist/db/env.js.map +1 -0
  11. package/dist/db/index.cjs +143 -231
  12. package/dist/db/index.cjs.map +1 -1
  13. package/dist/db/index.d.cts +4 -20
  14. package/dist/db/index.d.ts +4 -20
  15. package/dist/db/index.js +13 -233
  16. package/dist/db/index.js.map +1 -1
  17. package/dist/{endpoint-Djk5L6G2.d.ts → endpoint-2d_DpASt.d.cts} +94 -96
  18. package/dist/{endpoint-BlcY2xNA.d.cts → endpoint-2d_DpASt.d.ts} +94 -96
  19. package/dist/index-DZW9CjiY.d.ts +463 -0
  20. package/dist/index-DzRFS3Tl.d.cts +463 -0
  21. package/dist/index.cjs +557 -60
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +278 -161
  24. package/dist/index.d.ts +278 -161
  25. package/dist/index.js +343 -12
  26. package/dist/index.js.map +1 -1
  27. package/dist/test/index.cjs +57 -2
  28. package/dist/test/index.cjs.map +1 -1
  29. package/dist/test/index.d.cts +1 -2
  30. package/dist/test/index.d.ts +1 -2
  31. package/dist/test/index.js +10 -2
  32. package/dist/test/index.js.map +1 -1
  33. package/docs/README.md +33 -12
  34. package/docs/background.md +19 -13
  35. package/docs/database.md +70 -17
  36. package/docs/endpoints.md +103 -79
  37. package/docs/errors.md +37 -31
  38. package/docs/events.md +25 -17
  39. package/docs/getting-started.md +38 -18
  40. package/docs/llms-full.txt +758 -267
  41. package/docs/llms.txt +3 -1
  42. package/docs/migrations.md +98 -0
  43. package/docs/resources.md +94 -0
  44. package/docs/routing.md +54 -27
  45. package/docs/schema.md +163 -42
  46. package/docs/services.md +17 -14
  47. package/package.json +12 -2
  48. package/dist/chunk-4J3F32SH.js +0 -96
  49. package/dist/chunk-4J3F32SH.js.map +0 -1
  50. package/dist/chunk-L36JLUPO.js +0 -97
  51. package/dist/chunk-L36JLUPO.js.map +0 -1
  52. package/dist/schema-BqfEhIC0.d.cts +0 -133
  53. package/dist/schema-BqfEhIC0.d.ts +0 -133
package/docs/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Palbase Backend SDK (`@palbase/backend`)
2
2
 
3
- > File-based TypeScript backend SDK. Endpoints use `req` (PBRequest) + imported service singletons (`Database`, `Cache`, …). Workers/jobs/hooks/webhooks use a `ctx` object. Not Express, not Supabase Edge Functions.
3
+ > File-based TypeScript backend SDK. All handler types import service singletons (`Database`, `Cache`, …). Trigger arg differs by type: endpoints use `req`, workers `(payload, meta)`, jobs `(meta)`, hooks/webhooks `(event, meta)`. Middleware is the one exception (`ctx`). Not Express, not Supabase Edge Functions.
4
4
 
5
5
  ## Docs
6
6
 
@@ -10,7 +10,9 @@
10
10
  - [endpoints](./endpoints.md)
11
11
  - [database](./database.md)
12
12
  - [schema](./schema.md)
13
+ - [migrations](./migrations.md)
13
14
  - [services](./services.md)
15
+ - [resources](./resources.md)
14
16
  - [errors](./errors.md)
15
17
  - [background](./background.md)
16
18
  - [events](./events.md)
@@ -0,0 +1,98 @@
1
+ # Migrations
2
+
3
+ `db/schema.ts` is the single source of truth for your Postgres schema. On every
4
+ deploy, Palbase diffs your declared schema against the live branch database and
5
+ reconciles it — but *how* it reconciles depends on whether the change is safe to
6
+ apply automatically.
7
+
8
+ ## Two kinds of change
9
+
10
+ ### 1. Additive — auto-applied, no migration file
11
+
12
+ A new table, or a new **nullable** or **defaulted** column, is additive: the
13
+ deploy applies it automatically (`CREATE TABLE` / `ADD COLUMN`) with no manual
14
+ step and no backfill risk. Just edit `db/schema.ts` and deploy.
15
+
16
+ ```ts
17
+ // before
18
+ todos: {
19
+ id: uuid().primaryKey().defaultRandom(),
20
+ title: text().notNull(),
21
+ }
22
+
23
+ // after — additive: `notes` (nullable) + `priority` (defaulted) auto-apply on deploy
24
+ todos: {
25
+ id: uuid().primaryKey().defaultRandom(),
26
+ title: text().notNull(),
27
+ notes: text().nullable(),
28
+ priority: text().nullable().default("normal"),
29
+ }
30
+ ```
31
+
32
+ > A new **NOT NULL column without a default** is NOT additive-safe on a table
33
+ > that already has rows (there is nothing to put in the existing rows). Make it
34
+ > `.nullable()`, give it a `.default(...)`, or apply it as an explicit migration
35
+ > (add nullable → backfill → set NOT NULL).
36
+
37
+ ### 2. Destructive / type-changing — needs an explicit migration
38
+
39
+ Renaming or dropping a column, changing a column's type, or adding a NOT NULL
40
+ constraint can lose or corrupt existing data — so the deploy's **drift-gate
41
+ blocks them** and the deploy fails until you provide an explicit migration.
42
+ Write the SQL yourself in `db/migrations/`:
43
+
44
+ ```
45
+ db/migrations/
46
+ 001_user_id_to_text.up.sql
47
+ 001_user_id_to_text.down.sql
48
+ ```
49
+
50
+ ```sql
51
+ -- 001_user_id_to_text.up.sql
52
+ ALTER TABLE todos ALTER COLUMN user_id TYPE text USING user_id::text;
53
+ ```
54
+
55
+ ```sql
56
+ -- 001_user_id_to_text.down.sql
57
+ ALTER TABLE todos ALTER COLUMN user_id TYPE uuid USING user_id::uuid;
58
+ ```
59
+
60
+ Migrations are golang-migrate style: numbered `NNN_name.up.sql` / `.down.sql`
61
+ pairs, applied in order and tracked so each runs exactly once (idempotent).
62
+ `db/schema.ts` always describes the **end state**; the migration describes **how
63
+ existing data gets there**. Keep the two in sync — after the migration lands,
64
+ `schema.ts` should already reflect the new column type.
65
+
66
+ ## The drift-gate
67
+
68
+ On deploy, Palbase compares `db/schema.ts` to the live database:
69
+
70
+ - **Additive** diffs → auto-applied.
71
+ - **Type-changing / destructive** diffs **with** a matching migration → the
72
+ migration runs.
73
+ - **Type-changing / destructive** diffs **without** a migration → the deploy
74
+ **aborts** and your currently-running version keeps serving.
75
+
76
+ This is deliberate: it stops an accidental column-type change from silently
77
+ dropping production data. A blocked deploy is a prompt to write the migration,
78
+ not a failure to work around.
79
+
80
+ ## Local dev: `palbase serve` uses the deployed database
81
+
82
+ `palbase serve` runs your controllers locally but proxies `Database` and
83
+ `ctx.*` to the **deployed** branch — it does **not** spin up a local Postgres or
84
+ apply migrations locally. So when your local `db/schema.ts` or `db/migrations/`
85
+ is ahead of what's deployed, serve prints a note: new tables/columns won't exist
86
+ until you push. Deploy to apply them.
87
+
88
+ ## Workflow
89
+
90
+ 1. Edit `db/schema.ts`.
91
+ 2. **Additive** change? → `git push`. It auto-migrates on deploy.
92
+ 3. **Type change / rename / drop?** → add `db/migrations/NNN_*.up.sql` (+
93
+ `.down.sql`), then `git push`. The runner applies it; without it the
94
+ drift-gate blocks the deploy.
95
+ 4. `palbase serve` warns locally until the change is deployed.
96
+
97
+ See [schema.md](./schema.md) for the column builders and typed
98
+ `Database.tables.*` access.
@@ -0,0 +1,94 @@
1
+ # Resources
2
+
3
+ A `Resource` models one external connection — a pooled datastore, a stateless
4
+ API client, or a per-user factory. You put it in `resources/`, export an
5
+ instance, and **do not register it**: the framework discovers it, sets it up
6
+ once at boot, and drains it on shutdown. On top of that lifecycle you expose
7
+ your own clean facade.
8
+
9
+ ```ts
10
+ import { Resource } from "@palbase/backend";
11
+ ```
12
+
13
+ ## Lifecycle (boot scope — not per request)
14
+
15
+ A resource is created once at process boot — NOT per request. The framework:
16
+
17
+ 1. calls `init(env)` **once**, with only the secrets the resource declared;
18
+ 2. (optionally) calls `shutdown()` on SIGTERM, in reverse boot order.
19
+
20
+ The instance lives for the whole process; your facade methods are called
21
+ per-request. This makes "reconnect on every request" structurally impossible.
22
+
23
+ ## Pooled datastore — `init` + `shutdown`
24
+
25
+ ```ts
26
+ import { Resource } from "@palbase/backend";
27
+ import neo4j, { type Driver, type Session } from "neo4j-driver";
28
+
29
+ export class Neo4jResource extends Resource {
30
+ static secrets = ["NEO4J_URL", "NEO4J_USER", "NEO4J_PASSWORD"] as const;
31
+ private driver!: Driver;
32
+ async init(env: { NEO4J_URL: string; NEO4J_USER: string; NEO4J_PASSWORD: string }) {
33
+ this.driver = neo4j.driver(env.NEO4J_URL, neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD));
34
+ }
35
+ async shutdown() {
36
+ await this.driver.close();
37
+ }
38
+ session(): Session {
39
+ return this.driver.session();
40
+ }
41
+ }
42
+
43
+ export const graph = new Neo4jResource();
44
+ ```
45
+
46
+ ## Stateless API client — `init` only
47
+
48
+ ```ts
49
+ import { Resource } from "@palbase/backend";
50
+ import { Client } from "@googlemaps/google-maps-services-js";
51
+
52
+ export class GoogleResource extends Resource {
53
+ static secrets = ["GOOGLE_MAPS_KEY"] as const;
54
+ private client = new Client();
55
+ private key = "";
56
+ init(env: { GOOGLE_MAPS_KEY: string }) {
57
+ this.key = env.GOOGLE_MAPS_KEY;
58
+ }
59
+ nearby(lat: number, lng: number) {
60
+ return this.client.placesNearby({ params: { location: { lat, lng }, radius: 1500, key: this.key } });
61
+ }
62
+ }
63
+
64
+ export const google = new GoogleResource();
65
+ ```
66
+
67
+ A per-user (OAuth) resource adds a factory method on the base, e.g.
68
+ `github.forUser(token)` — the same single model covers pooled, stateless, and
69
+ per-user.
70
+
71
+ ## Secrets
72
+
73
+ `static secrets` is the contract:
74
+
75
+ - It **types** the `env` passed to `init` — only the declared names are
76
+ present, each a `string`. An undeclared key is a compile error.
77
+ - A declared secret that is **missing at boot fails the deploy**, naming the
78
+ secret. Secrets are branch-scoped; set them with `palbase secret set NAME ...`
79
+ or in Studio. A resource is initialised once at boot, so rotating a secret
80
+ needs a redeploy/restart.
81
+
82
+ `secrets` is optional — a resource that needs none simply omits it and gets an
83
+ empty `env`.
84
+
85
+ ## Using a resource
86
+
87
+ Import the singleton and call your facade — services and handlers reach
88
+ resources the same way they reach `Database`:
89
+
90
+ ```ts
91
+ import { google } from "../resources/google.js";
92
+
93
+ const results = (await google.nearby(41.0, 29.0)).data.results;
94
+ ```
package/docs/routing.md CHANGED
@@ -1,34 +1,61 @@
1
- # File-based routing
1
+ # Routing
2
2
 
3
- The path of a file under `endpoints/` plus its filename determine the route.
4
- The filename is the HTTP method.
3
+ Routes are declared in code with **class controllers**. A controller is a class
4
+ decorated with `@Controller(basePath)`; each route is a method decorated with
5
+ `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`. Request input + context are injected
6
+ into the method via **parameter decorators** (`@Body`/`@Query`/`@Param`/`@User`/
7
+ …). Putting a controller file under `controllers/` mounts it — there is no
8
+ central router and no manual registration.
5
9
 
6
- | File | Route |
7
- |------|-------|
8
- | `endpoints/hello/get.ts` | `GET /hello` |
9
- | `endpoints/items/post.ts` | `POST /items` |
10
- | `endpoints/posts/[id]/get.ts` | `GET /posts/:id` |
11
- | `endpoints/posts/[id]/patch.ts` | `PATCH /posts/:id` |
12
- | `endpoints/rooms/[id]/sessions/post.ts` | `POST /rooms/:id/sessions` |
10
+ ```ts
11
+ import { Controller, Get, Post, Body, Query, Param, User } from "@palbase/backend";
12
+ ```
13
13
 
14
- Rules:
14
+ ## Controllers — class + method decorators
15
15
 
16
- - The method file name is one of `get`, `post`, `put`, `patch`, `delete` (`.ts`).
17
- - A `[segment]` directory becomes a `:segment` path param, read via `req.params.segment`.
18
- - Each method file `export default defineEndpoint({...})` — one endpoint per file.
19
- - There is no central router file and no manual registration. Adding a file adds a route.
16
+ `@Controller(basePath)` marks the class and sets the mount path. Each route
17
+ method declares its verb + subpath; the real work lives in a `services/` class
18
+ (the controller method is thin).
20
19
 
21
20
  ```ts
22
- // endpoints/posts/[id]/get.ts → GET /posts/:id
23
- import { defineEndpoint, z, Database, HttpError } from "@palbase/backend";
24
-
25
- export default defineEndpoint({
26
- method: "GET",
27
- output: z.object({ id: z.string(), title: z.string() }),
28
- handler: async (req) => {
29
- const post = await Database.findById("posts", req.params.id!);
30
- if (!post) throw new HttpError(404, "post_not_found", "No such post");
31
- return { id: post.id as string, title: post.title as string };
32
- },
33
- });
21
+ // controllers/places.controller.ts
22
+ import { Controller, Get, Post, Returns, Body, User, z } from "@palbase/backend";
23
+ import type { UserT } from "@palbase/backend";
24
+ import { placeService } from "../services/place.service.js";
25
+ import { ImportNearbyBody } from "../models/places/import.js";
26
+ import { PlaceSchema } from "../models/places/shared.js";
27
+
28
+ @Controller("/places")
29
+ export default class PlacesController {
30
+ @Post("/import")
31
+ @Returns(PlaceSchema)
32
+ importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
33
+ return placeService.importNearby(body.lat, body.lng, user.id);
34
+ }
35
+
36
+ @Get("/favorites", { auth: false })
37
+ @Returns(z.array(PlaceSchema))
38
+ listFavorites(): PlaceSchema[] {
39
+ return placeService.listFavorites();
40
+ }
41
+ }
34
42
  ```
43
+
44
+ | Method name (sugar) | Verb | Full path | operationId (flat) |
45
+ |---|---|---|---|
46
+ | `importNearby` | POST | `/places/import` | `postPlacesImport` |
47
+ | `listFavorites` | GET | `/places/favorites` | `getPlacesFavorites` |
48
+
49
+ Rules:
50
+
51
+ - The full path of a route is `basePath + subpath` (`"/places" + "/import"`).
52
+ - A `{segment}` in a path becomes a path param, injected via `@Param("segment")`.
53
+ - Input is declared with the parameter decorators — `@Body(schema)`,
54
+ `@Query(schema)`, `@Param("id")`, `@Headers(schema?)`. The success response is
55
+ the method's RETURN TYPE; pair it with `@Returns(schema)` so the zod value
56
+ drives the OpenAPI 200 response (or annotate `: void` for no body).
57
+ - The operationId is derived FLAT from method + full path (`postPlacesImport`),
58
+ not from the method name. Change `@Post` → `@Put` — no file rename.
59
+
60
+ See [endpoints.md](./endpoints.md) for the full decorator reference (`@Controller`
61
+ options, the parameter decorators, auth cascade, and error classes).
package/docs/schema.md CHANGED
@@ -1,36 +1,50 @@
1
1
  # Schema & typed database access
2
2
 
3
3
  Declare your tables in `db/schema.ts` with `defineSchema`. This drives
4
- migrations and, via `typedDatabase`, a fully-typed `.tables.*` API.
4
+ [migrations](./migrations.md) (additive changes auto-apply on deploy; type
5
+ changes need an explicit migration) and makes `Database.tables.*` typed
6
+ everywhere — by default, with no import and no generic.
5
7
 
6
8
  ## Defining a schema
7
9
 
10
+ The table NAME comes from the object key under `tables`. Each table value is an
11
+ object `{ columns, rls?, policies? }` — `columns` is required; `rls` and
12
+ `policies` enable [Row-Level Security](#row-level-security-rls).
13
+
8
14
  ```ts
9
15
  import {
10
- defineSchema, table,
16
+ defineSchema,
11
17
  uuid, text, integer, boolean, timestamp, jsonb, enumType,
12
18
  } from "@palbase/backend";
13
19
 
14
20
  export default defineSchema({
15
- rooms: table("rooms", {
16
- id: uuid().primaryKey().defaultRandom(),
17
- name: text().notNull(),
18
- capacity: integer().nullable(),
19
- is_active: boolean().default(true),
20
- created_at: timestamp().defaultNow(),
21
- }),
22
- sessions: table("sessions", {
23
- id: uuid().primaryKey().defaultRandom(),
24
- room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
25
- user_id: uuid().notNull(),
26
- data: jsonb().nullable(),
27
- started_at: timestamp().defaultNow(),
28
- }),
29
- orders: table("orders", {
30
- id: uuid().primaryKey().defaultRandom(),
31
- status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
32
- amount: integer().notNull(),
33
- }),
21
+ tables: {
22
+ rooms: {
23
+ columns: {
24
+ id: uuid().primaryKey().defaultRandom(),
25
+ name: text().notNull(),
26
+ capacity: integer().nullable(),
27
+ is_active: boolean().default(true),
28
+ created_at: timestamp().defaultNow(),
29
+ },
30
+ },
31
+ sessions: {
32
+ columns: {
33
+ id: uuid().primaryKey().defaultRandom(),
34
+ room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
35
+ user_id: uuid().notNull(),
36
+ data: jsonb().nullable(),
37
+ started_at: timestamp().defaultNow(),
38
+ },
39
+ },
40
+ orders: {
41
+ columns: {
42
+ id: uuid().primaryKey().defaultRandom(),
43
+ status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
44
+ amount: integer().notNull(),
45
+ },
46
+ },
47
+ },
34
48
  });
35
49
  ```
36
50
 
@@ -51,32 +65,139 @@ Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
51
65
  `.defaultNow()` (timestamp → `now()`), `.references(table, column)`,
52
66
  `.onDelete("cascade" | "set null" | "restrict" | "no action")`.
53
67
 
54
- ## Typed DB access
68
+ ## Typed DB access — by default
69
+
70
+ You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
71
+ `palbase-env.d.ts`, which types `Database.tables.<name>` everywhere — no import
72
+ of the schema, no generic, no cast:
73
+
74
+ ```ts
75
+ import { Controller, Post, Returns, Body, Database, z } from "@palbase/backend";
76
+
77
+ const CreateRoomBody = z.object({ name: z.string() });
78
+ const RoomOut = z.object({ id: z.string(), name: z.string() });
79
+
80
+ @Controller("/rooms")
81
+ export default class RoomsController {
82
+ @Post("")
83
+ @Returns(RoomOut)
84
+ async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
85
+ const room = await Database.tables.rooms.insert({ name: body.name });
86
+ return { id: room.id, name: room.name }; // room.id: string ✓
87
+ // room.nope ← compile error
88
+ }
89
+ }
90
+ ```
91
+
92
+ `Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
93
+ `findById(id)`, `findMany(query?)`, and `Database.transaction(fn)` yields a `tx`
94
+ with the same typed tables. The raw string-keyed ops
95
+ (`Database.insert("rooms", …)`, `Database.query(…)`) are still available for
96
+ dynamic table names and read-only SQL.
55
97
 
56
- `typedDatabase(schema)` returns a typed facade. `insert` demands the right
57
- columns; rows come back typed; nullable columns are `T | null`.
98
+ If you want a row type explicitly, import it from the generated env module:
58
99
 
59
100
  ```ts
60
- import { defineEndpoint, z, typedDatabase } from "@palbase/backend";
61
- import schema from "../../db/schema.js";
62
-
63
- const Db = typedDatabase(schema);
64
-
65
- export default defineEndpoint({
66
- method: "POST",
67
- input: z.object({ name: z.string() }),
68
- output: z.object({ id: z.string(), name: z.string() }),
69
- handler: async (req) => {
70
- const room = await Db.tables.rooms.insert({ name: req.input.name });
71
- return { id: room.id, name: room.name };
101
+ import type { Tables } from "@palbase/backend/env";
102
+ type Room = Tables["rooms"]["row"];
103
+ ```
104
+
105
+ ## Row-Level Security (RLS)
106
+
107
+ RLS pushes per-user access control **into Postgres**: every `Database.*` query
108
+ runs as the request's verified user (the `authenticated` role with that user's
109
+ claims), and the database itself filters rows your policies don't allow. A
110
+ missing `WHERE user_id = …` in your handler can no longer leak another user's
111
+ rows the policy enforces it. This is the recommended way to scope data per
112
+ user.
113
+
114
+ Add `policies` (and optionally `rls`) to a table. `policies` being non-empty
115
+ implies `rls: true` automatically (a table with policies must have RLS enabled
116
+ or the policies are inert). Set `rls: true` with no policies only as a
117
+ deliberate deny-all intermediate step.
118
+
119
+ ### The `policy()` builder
120
+
121
+ `policy(name)` is a fluent builder, just like the column builders:
122
+
123
+ ```ts
124
+ import { policy } from "@palbase/backend";
125
+
126
+ policy("pb_owner_all")
127
+ .for("all") // "all" | "select" | "insert" | "update" | "delete"
128
+ .to("authenticated") // one or more DB roles; .to() with no args = PUBLIC
129
+ .using("owner = (select auth.uid())") // row-visibility filter (SELECT/UPDATE/DELETE)
130
+ .withCheck("owner = (select auth.uid())"); // write-validation (INSERT/UPDATE)
131
+ ```
132
+
133
+ | Method | Default | Meaning |
134
+ |--------|---------|---------|
135
+ | `.for(cmd)` | `"all"` | The SQL command the policy governs. |
136
+ | `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. |
137
+ | `.using(sql)` | none | `USING (...)` — which existing rows are visible (SELECT/UPDATE/DELETE). |
138
+ | `.withCheck(sql)` | none | `WITH CHECK (...)` — which rows may be written (INSERT/UPDATE). |
139
+ | `.as(mode)` | `"permissive"` | `"permissive"` (policies OR together) or `"restrictive"` (AND together). |
140
+
141
+ **`auth.uid()`** returns the verified user's id (palauth user id, TEXT) from the
142
+ request's JWT claims. Wrap it as `(select auth.uid())` — Postgres evaluates that
143
+ once per statement (an initPlan) instead of once per row. `auth.role()` and
144
+ `auth.jwt()` are also available. With no user on the request (anon/public),
145
+ `auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
146
+ rows.
147
+
148
+ > Name policies with a `pb_` prefix. Palbase reconciliation only manages
149
+ > policies it authored (`pb_`-prefixed) and never touches policies created by
150
+ > other modules (storage, cron, …).
151
+
152
+ ### Owner-scoped `todos` example
153
+
154
+ ```ts
155
+ import { defineSchema, policy, uuid, text, boolean, timestamp } from "@palbase/backend";
156
+
157
+ export default defineSchema({
158
+ tables: {
159
+ todos: {
160
+ columns: {
161
+ id: uuid().primaryKey().defaultRandom(),
162
+ owner: text().notNull(), // palauth user id (TEXT)
163
+ title: text().notNull(),
164
+ done: boolean().default(false),
165
+ created_at: timestamp().defaultNow(),
166
+ },
167
+ // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
168
+ policies: [
169
+ // Read: a user sees only their own todos.
170
+ policy("pb_todos_owner_select")
171
+ .for("select")
172
+ .to("authenticated")
173
+ .using("owner = (select auth.uid())"),
174
+
175
+ // Write: a user can insert/update/delete only rows they own.
176
+ policy("pb_todos_owner_write")
177
+ .for("all")
178
+ .to("authenticated")
179
+ .using("owner = (select auth.uid())")
180
+ .withCheck("owner = (select auth.uid())"),
181
+ ],
182
+ },
72
183
  },
73
184
  });
74
185
  ```
75
186
 
76
- Note the `.js` extension on `../../db/schema.js` even though the file is
77
- `db/schema.ts`this is standard ESM module resolution; you still author the
78
- file as `.ts`.
187
+ With this in place, `await Database.tables.todos.findMany({})` returns only the
188
+ calling user's rows no `WHERE owner = …` needed in the handler. To read or
189
+ write across all users (e.g. an admin job), use the explicit bypass:
190
+ `Database.asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
191
+
192
+ ### How policies are applied
193
+
194
+ On deploy, Palbase diffs your declared schema against the live database and
195
+ applies RLS **additively**: it emits `ENABLE`/`FORCE ROW LEVEL SECURITY` only
196
+ when the table doesn't already have it, and `CREATE POLICY` only for policies
197
+ that don't already exist (keyed by `(table, name)`). These are non-destructive,
198
+ so they apply without the `acceptDataLoss` confirmation that column drops need.
199
+
200
+ > Changing a policy's body (its `USING`/`WITH CHECK` SQL) in place is not yet
201
+ > auto-applied — rename the policy (new `(table, name)`) or drop the old one in
202
+ > a hand-written migration. Policy DROP/rewrite churn is a documented TODO.
79
203
 
80
- `Db.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
81
- `findById(id)`, `findMany(query?)`, and `Db.transaction(fn)` mirrors the
82
- untyped transaction with typed tables.
package/docs/services.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Services
2
2
 
3
- In **endpoints**, import service singletons from `@palbase/backend`. In
4
- **workers/jobs/hooks/webhooks**, the equivalents live on `ctx` (`ctx.cache`,
5
- `ctx.queue`, `ctx.log`, `ctx.db`).
3
+ Import service singletons from `@palbase/backend` in every handler type —
4
+ endpoints, workers, jobs, hooks, and webhooks all use the same imports. Only
5
+ **middleware** uses a `ctx` argument (`ctx.db`, `ctx.log`, etc.).
6
6
 
7
7
  Available singletons: `Database`, `Documents`, `Storage`, `Cache`, `Queue`,
8
8
  `Log`, `Notifications`, `Flags`.
@@ -90,16 +90,19 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
90
90
  ## Flags
91
91
 
92
92
  ```ts
93
- import { defineEndpoint, z, Flags } from "@palbase/backend";
94
-
95
- export default defineEndpoint({
96
- method: "GET",
97
- auth: { required: true }, // req.user is non-null here
98
- output: z.object({ enabled: z.boolean() }),
99
- handler: async (req) => {
100
- const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: req.user.id });
101
- const { data: variant } = await Flags.getVariant("button-color", { userId: req.user.id });
93
+ import { Controller, Get, Returns, User, Flags, z } from "@palbase/backend";
94
+ import type { UserT } from "@palbase/backend";
95
+
96
+ const FlagsOut = z.object({ enabled: z.boolean() });
97
+
98
+ @Controller("/checkout")
99
+ export default class CheckoutController {
100
+ @Get("/flags") // auth omitted required user is non-null
101
+ @Returns(FlagsOut)
102
+ async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> {
103
+ const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: user.id });
104
+ const { data: variant } = await Flags.getVariant("button-color", { userId: user.id });
102
105
  return { enabled: enabled ?? false };
103
- },
104
- });
106
+ }
107
+ }
105
108
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "2.0.2",
4
- "description": "Palbase Backend SDK — defineEndpoint, context types, schema DSL",
3
+ "version": "4.0.0",
4
+ "description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@Query/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -42,6 +42,16 @@
42
42
  "types": "./dist/db/index.d.cts",
43
43
  "default": "./dist/db/index.cjs"
44
44
  }
45
+ },
46
+ "./env": {
47
+ "import": {
48
+ "types": "./dist/db/env.d.ts",
49
+ "default": "./dist/db/env.js"
50
+ },
51
+ "require": {
52
+ "types": "./dist/db/env.d.cts",
53
+ "default": "./dist/db/env.cjs"
54
+ }
45
55
  }
46
56
  },
47
57
  "files": [