@palbase/backend 3.0.0 → 5.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 (41) hide show
  1. package/dist/{chunk-B7EUJP5W.js → chunk-2N4YNN6F.js} +113 -3
  2. package/dist/chunk-2N4YNN6F.js.map +1 -0
  3. package/dist/{chunk-PHAFZGHN.js → chunk-WUQO76NW.js} +26 -19
  4. package/dist/chunk-WUQO76NW.js.map +1 -0
  5. package/dist/db/index.cjs +117 -2
  6. package/dist/db/index.cjs.map +1 -1
  7. package/dist/db/index.d.cts +2 -2
  8. package/dist/db/index.d.ts +2 -2
  9. package/dist/db/index.js +11 -1
  10. package/dist/{endpoint-DJ98tQd6.d.cts → endpoint-BEHjfvFH.d.cts} +99 -57
  11. package/dist/{endpoint-DJ98tQd6.d.ts → endpoint-BEHjfvFH.d.ts} +99 -57
  12. package/dist/index-BTVdhfsb.d.ts +469 -0
  13. package/dist/index-mr3Co63T.d.cts +469 -0
  14. package/dist/index.cjs +356 -42
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +80 -219
  17. package/dist/index.d.ts +80 -219
  18. package/dist/index.js +203 -21
  19. package/dist/index.js.map +1 -1
  20. package/dist/test/index.cjs +34 -19
  21. package/dist/test/index.cjs.map +1 -1
  22. package/dist/test/index.d.cts +1 -1
  23. package/dist/test/index.d.ts +1 -1
  24. package/dist/test/index.js +10 -2
  25. package/dist/test/index.js.map +1 -1
  26. package/docs/README.md +212 -14
  27. package/docs/database.md +40 -0
  28. package/docs/endpoints.md +110 -92
  29. package/docs/errors.md +37 -30
  30. package/docs/getting-started.md +64 -26
  31. package/docs/llms-full.txt +724 -312
  32. package/docs/llms.txt +1 -1
  33. package/docs/migrations.md +75 -73
  34. package/docs/routing.md +39 -45
  35. package/docs/schema.md +135 -23
  36. package/docs/services.md +13 -10
  37. package/package.json +2 -2
  38. package/dist/chunk-B7EUJP5W.js.map +0 -1
  39. package/dist/chunk-PHAFZGHN.js.map +0 -1
  40. package/dist/index-CXUs9iTQ.d.ts +0 -294
  41. package/dist/index-CZAwpQE1.d.cts +0 -294
package/docs/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Palbase Backend SDK (`@palbase/backend`)
2
2
 
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.
3
+ > TypeScript backend SDK. NestJS-style class controllers: `@Controller` classes with `@Get`/`@Post`/… methods and `@Body`/`@Query`/`@Param`/`@User` parameter decorators. All handler types import service singletons (`Database`, `Cache`, …). Trigger arg differs by type: endpoints use parameter decorators, 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
 
@@ -1,98 +1,100 @@
1
1
  # Migrations
2
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
- ```
3
+ `db/schema.ts` is the single source of truth for your Postgres schema. You change
4
+ the schema by editing that file, then generating a **migration** from the diff
5
+ with `palbase db diff`. Every schema change additive or destructive flows
6
+ through a reviewable migration file committed to git. The deploy applies the
7
+ migrations in `db/migrations/`; nothing is auto-applied behind your back.
31
8
 
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).
9
+ ## The workflow
36
10
 
37
- ### 2. Destructive / type-changing — needs an explicit migration
11
+ ```bash
12
+ # 1. Edit db/schema.ts (add a column, a table, a policy, …)
38
13
 
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/`:
14
+ # 2. Generate the migration from the diff (declared schema vs the live branch)
15
+ palbase db diff -f add_priority
16
+ # → writes db/migrations/<timestamp>_add_priority.sql
43
17
 
18
+ # 3. Review the generated SQL (especially destructive changes — see below),
19
+ # then commit + push. git push deploys; the migration runs on deploy.
20
+ git add db/migrations && git commit -m "add priority column" && git push
44
21
  ```
45
- db/migrations/
46
- 001_user_id_to_text.up.sql
47
- 001_user_id_to_text.down.sql
22
+
23
+ `palbase db diff` introspects your **live branch database** (there is no local
24
+ database — every branch runs on the server), diffs it against `db/schema.ts`, and
25
+ writes one migration SQL file. If the schema is already in sync it writes nothing
26
+ and tells you so.
27
+
28
+ ## Additive vs destructive
29
+
30
+ The generated SQL labels what it does. An additive change (new table, new column)
31
+ is plain DDL:
32
+
33
+ ```sql
34
+ -- palbase db diff: add_priority
35
+ -- generated 20260605T142233
36
+
37
+ ALTER TABLE todos ADD COLUMN IF NOT EXISTS priority text;
48
38
  ```
49
39
 
40
+ A **destructive** change (dropping a column or table — losing data) is generated
41
+ with a clear warning comment, and `palbase db diff` prints a warning. Review it
42
+ before committing:
43
+
50
44
  ```sql
51
- -- 001_user_id_to_text.up.sql
52
- ALTER TABLE todos ALTER COLUMN user_id TYPE text USING user_id::text;
45
+ -- DESTRUCTIVE: dropping todos.notes loses its data
46
+ ALTER TABLE todos DROP COLUMN notes;
53
47
  ```
54
48
 
49
+ A **column type change** is emitted as a commented stub — auto-migration never
50
+ alters types, so you write the real `ALTER ... TYPE` with whatever `USING` cast
51
+ and backfill your data needs:
52
+
55
53
  ```sql
56
- -- 001_user_id_to_text.down.sql
57
- ALTER TABLE todos ALTER COLUMN user_id TYPE uuid USING user_id::uuid;
54
+ -- TYPE CHANGE: todos.priority text -> integer (review; auto-migrate does not ALTER types)
55
+ -- ALTER TABLE todos ALTER COLUMN priority TYPE integer;
58
56
  ```
59
57
 
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.
58
+ ## The drift gate
59
+
60
+ You can't push a schema change without its migration:
65
61
 
66
- ## The drift-gate
62
+ - **`palbase db check`** exits non-zero when `db/schema.ts` declares something the
63
+ database lacks (i.e. you edited the schema but didn't run `palbase db diff`).
64
+ - The scaffold installs a **git pre-push hook** that runs `palbase db check`, so a
65
+ plain `git push` is **blocked** until you generate + commit the migration.
66
+ (Bypass with `git push --no-verify` — but the deploy-time gate still rejects it.)
67
+ - On deploy, after migrations run, Palbase asserts `db/schema.ts` matches the live
68
+ database. Any unresolved drift **fails the deploy** and keeps the previous
69
+ version live — a broken schema never goes out silently.
67
70
 
68
- On deploy, Palbase compares `db/schema.ts` to the live database:
71
+ ## `palbase serve` uses the deployed database
69
72
 
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.
73
+ `palbase serve` runs your controllers locally but proxies `Database` and `ctx.*`
74
+ to the **deployed** branch it does not spin up a local Postgres. So a schema
75
+ change in `db/schema.ts` doesn't exist in the database until you generate the
76
+ migration and push. Run `palbase gen-types` (or just `palbase serve`, which
77
+ regenerates on change) after editing the schema to refresh `palbase-env.d.ts` so
78
+ `Database.tables.<name>` is fully typed in your services.
75
79
 
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.
80
+ ## Hand-written migrations
79
81
 
80
- ## Local dev: `palbase serve` uses the deployed database
82
+ `db/migrations/*.sql` is plain SQL applied in filename order and tracked in
83
+ `schema_migrations` (idempotent — a migration runs once). `palbase db diff`
84
+ generates them for you, but you can also hand-write one for anything the diff
85
+ can't express (a data backfill, a complex type change with a `USING` cast, a
86
+ trigger). Keep `db/schema.ts` as the declared end-state so the drift gate passes.
81
87
 
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.
88
+ ## Adding a NOT NULL column to a table with rows
87
89
 
88
- ## Workflow
90
+ There's nothing to put in existing rows, so do it in two migrations: first add it
91
+ nullable (or with a default) and backfill, then a follow-up `ALTER ... SET NOT
92
+ NULL`. `db/schema.ts` describes the end state; the migrations describe how
93
+ existing data gets there.
89
94
 
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.
95
+ ## Row-Level Security
96
96
 
97
- See [schema.md](./schema.md) for the column builders and typed
97
+ Add `rls: true` + `policies: [policy(...)]` to a table in `db/schema.ts`; the
98
+ generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
99
+ See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
98
100
  `Database.tables.*` access.
package/docs/routing.md CHANGED
@@ -1,67 +1,61 @@
1
1
  # Routing
2
2
 
3
- Routes are declared in code. A **handler** is one endpoint unit (schema + thin
4
- logic) with NO route on it; a **controller** is a route map (method+path →
5
- handler). Putting a controller file under `controllers/` mounts it — there is no
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
6
8
  central router and no manual registration.
7
9
 
8
10
  ```ts
9
- import { defineController, route } from "@palbase/backend";
11
+ import { Controller, Get, Post, Body, Query, Param, User } from "@palbase/backend";
10
12
  ```
11
13
 
12
- ## Handlersone endpoint per file, no route
14
+ ## Controllersclass + method decorators
13
15
 
14
- A handler declares everything that types `req` (`auth`/`input`/`output`/
15
- `errors`) and the logic; it has no method or path.
16
-
17
- ```ts
18
- // handlers/places/import-nearby.ts
19
- import { defineHandler, z } from "@palbase/backend";
20
- import { placeService } from "../../services/place.service.js";
21
-
22
- export default defineHandler({
23
- auth: { required: true },
24
- input: z.object({ lat: z.number(), lng: z.number() }),
25
- output: z.object({ imported: z.number() }),
26
- errors: { quotaExceeded: { status: 429, code: "quota_exceeded" } },
27
- handler: (req) => placeService.importNearby(req.input.lat, req.input.lng),
28
- });
29
- ```
30
-
31
- ## Controllers — the route map
32
-
33
- A controller maps method+path to handlers with `route.get|post|put|patch|delete`.
34
- The route-map KEY is authoring sugar only (it is NOT the operationId).
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).
35
19
 
36
20
  ```ts
37
21
  // controllers/places.controller.ts
38
- import { defineController, route } from "@palbase/backend";
39
- import importNearby from "../handlers/places/import-nearby.js";
40
- import addFavorite from "../handlers/places/add-favorite.js";
41
- import listFavorites from "../handlers/places/list-favorites.js";
42
-
43
- export default defineController("/places", {
44
- importNearby: route.post("/import", importNearby),
45
- addFavorite: route.post("/favorites", addFavorite),
46
- listFavorites: route.get ("/favorites", listFavorites),
47
- });
22
+ import { Controller, Get, Post, Body, User } 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 type { PlaceSchema } from "../models/places/shared.js"; // the return TYPE names the 200 schema
27
+
28
+ @Controller("/places")
29
+ export default class PlacesController {
30
+ @Post("/import")
31
+ importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
32
+ return placeService.importNearby(body.lat, body.lng, user.id);
33
+ }
34
+
35
+ @Get("/favorites", { auth: false })
36
+ listFavorites(): PlaceSchema[] {
37
+ return placeService.listFavorites();
38
+ }
39
+ }
48
40
  ```
49
41
 
50
- | Map key (sugar) | Method | Full path | operationId (flat) |
42
+ | Method name (sugar) | Verb | Full path | operationId (flat) |
51
43
  |---|---|---|---|
52
44
  | `importNearby` | POST | `/places/import` | `postPlacesImport` |
53
- | `addFavorite` | POST | `/places/favorites` | `postPlacesFavorites` |
54
45
  | `listFavorites` | GET | `/places/favorites` | `getPlacesFavorites` |
55
46
 
56
47
  Rules:
57
48
 
58
49
  - The full path of a route is `basePath + subpath` (`"/places" + "/import"`).
59
- - A `{segment}` in a path becomes a route param, read via `req.params.segment`.
60
- - Each route value MUST be a `route.*(...)` result a bare handler is a
61
- compile error, which keeps logic out of controllers.
50
+ - A `{segment}` in a path becomes a path param, injected via `@Param("segment")`.
51
+ - Input is declared with the parameter decorators`@Body(schema)`,
52
+ `@Query(schema)`, `@Param("id")`, `@Headers(schema?)`. The success response is
53
+ the method's RETURN TYPE (`: PlaceSchema` or `: z.infer<typeof PlaceSchema>`):
54
+ codegen + the runtime read that named type to drive the OpenAPI 200 response.
55
+ A body route with no named return type is a build error — annotate `: void`
56
+ (or `: Promise<void>`) for no body.
62
57
  - The operationId is derived FLAT from method + full path (`postPlacesImport`),
63
- not from the map key. Change a verb with `route.post` → `route.put` — no file
64
- rename.
58
+ not from the method name. Change `@Post` → `@Put` — no file rename.
65
59
 
66
- See [endpoints.md](./endpoints.md) for the full `defineHandler` config (`req`,
67
- auth, input/output, errors, middleware) reference.
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
@@ -7,8 +7,9 @@ everywhere — by default, with no import and no generic.
7
7
 
8
8
  ## Defining a schema
9
9
 
10
- The table NAME comes from the object key under `tables` there is one
11
- canonical form.
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).
12
13
 
13
14
  ```ts
14
15
  import {
@@ -19,23 +20,29 @@ import {
19
20
  export default defineSchema({
20
21
  tables: {
21
22
  rooms: {
22
- id: uuid().primaryKey().defaultRandom(),
23
- name: text().notNull(),
24
- capacity: integer().nullable(),
25
- is_active: boolean().default(true),
26
- created_at: timestamp().defaultNow(),
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
+ },
27
30
  },
28
31
  sessions: {
29
- id: uuid().primaryKey().defaultRandom(),
30
- room_id: uuid().notNull().references("rooms", "id").onDelete("cascade"),
31
- user_id: uuid().notNull(),
32
- data: jsonb().nullable(),
33
- started_at: timestamp().defaultNow(),
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
+ },
34
39
  },
35
40
  orders: {
36
- id: uuid().primaryKey().defaultRandom(),
37
- status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
38
- amount: integer().notNull(),
41
+ columns: {
42
+ id: uuid().primaryKey().defaultRandom(),
43
+ status: enumType("order_status", ["pending", "paid", "shipped", "cancelled"]),
44
+ amount: integer().notNull(),
45
+ },
39
46
  },
40
47
  },
41
48
  });
@@ -65,17 +72,22 @@ You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
65
72
  of the schema, no generic, no cast:
66
73
 
67
74
  ```ts
68
- import { defineHandler, z, Database } from "@palbase/backend";
75
+ import { Controller, Post, Body, Database, z } from "@palbase/backend";
69
76
 
70
- export default defineHandler({
71
- input: z.object({ name: z.string() }),
72
- output: z.object({ id: z.string(), name: z.string() }),
73
- handler: async (req) => {
74
- const room = await Database.tables.rooms.insert({ name: req.input.name });
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
+ // The return type names the 200 schema — `z.infer<typeof RoomOut>` works
84
+ // inline, no separate `export type` needed.
85
+ async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
86
+ const room = await Database.tables.rooms.insert({ name: body.name });
75
87
  return { id: room.id, name: room.name }; // room.id: string ✓
76
88
  // room.nope ← compile error
77
- },
78
- });
89
+ }
90
+ }
79
91
  ```
80
92
 
81
93
  `Database.tables.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
@@ -90,3 +102,103 @@ If you want a row type explicitly, import it from the generated env module:
90
102
  import type { Tables } from "@palbase/backend/env";
91
103
  type Room = Tables["rooms"]["row"];
92
104
  ```
105
+
106
+ ## Row-Level Security (RLS)
107
+
108
+ RLS pushes per-user access control **into Postgres**: every `Database.*` query
109
+ runs as the request's verified user (the `authenticated` role with that user's
110
+ claims), and the database itself filters rows your policies don't allow. A
111
+ missing `WHERE user_id = …` in your handler can no longer leak another user's
112
+ rows — the policy enforces it. This is the recommended way to scope data per
113
+ user.
114
+
115
+ Add `policies` (and optionally `rls`) to a table. `policies` being non-empty
116
+ implies `rls: true` automatically (a table with policies must have RLS enabled
117
+ or the policies are inert). Set `rls: true` with no policies only as a
118
+ deliberate deny-all intermediate step.
119
+
120
+ ### The `policy()` builder
121
+
122
+ `policy(name)` is a fluent builder, just like the column builders:
123
+
124
+ ```ts
125
+ import { policy } from "@palbase/backend";
126
+
127
+ policy("pb_owner_all")
128
+ .for("all") // "all" | "select" | "insert" | "update" | "delete"
129
+ .to("authenticated") // one or more DB roles; .to() with no args = PUBLIC
130
+ .using("owner = (select auth.uid())") // row-visibility filter (SELECT/UPDATE/DELETE)
131
+ .withCheck("owner = (select auth.uid())"); // write-validation (INSERT/UPDATE)
132
+ ```
133
+
134
+ | Method | Default | Meaning |
135
+ |--------|---------|---------|
136
+ | `.for(cmd)` | `"all"` | The SQL command the policy governs. |
137
+ | `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. |
138
+ | `.using(sql)` | none | `USING (...)` — which existing rows are visible (SELECT/UPDATE/DELETE). |
139
+ | `.withCheck(sql)` | none | `WITH CHECK (...)` — which rows may be written (INSERT/UPDATE). |
140
+ | `.as(mode)` | `"permissive"` | `"permissive"` (policies OR together) or `"restrictive"` (AND together). |
141
+
142
+ **`auth.uid()`** returns the verified user's id (palauth user id, TEXT) from the
143
+ request's JWT claims. Wrap it as `(select auth.uid())` — Postgres evaluates that
144
+ once per statement (an initPlan) instead of once per row. `auth.role()` and
145
+ `auth.jwt()` are also available. With no user on the request (anon/public),
146
+ `auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
147
+ rows.
148
+
149
+ > Name policies with a `pb_` prefix. Palbase reconciliation only manages
150
+ > policies it authored (`pb_`-prefixed) and never touches policies created by
151
+ > other modules (storage, cron, …).
152
+
153
+ ### Owner-scoped `todos` example
154
+
155
+ ```ts
156
+ import { defineSchema, policy, uuid, text, boolean, timestamp } from "@palbase/backend";
157
+
158
+ export default defineSchema({
159
+ tables: {
160
+ todos: {
161
+ columns: {
162
+ id: uuid().primaryKey().defaultRandom(),
163
+ owner: text().notNull(), // palauth user id (TEXT)
164
+ title: text().notNull(),
165
+ done: boolean().default(false),
166
+ created_at: timestamp().defaultNow(),
167
+ },
168
+ // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
169
+ policies: [
170
+ // Read: a user sees only their own todos.
171
+ policy("pb_todos_owner_select")
172
+ .for("select")
173
+ .to("authenticated")
174
+ .using("owner = (select auth.uid())"),
175
+
176
+ // Write: a user can insert/update/delete only rows they own.
177
+ policy("pb_todos_owner_write")
178
+ .for("all")
179
+ .to("authenticated")
180
+ .using("owner = (select auth.uid())")
181
+ .withCheck("owner = (select auth.uid())"),
182
+ ],
183
+ },
184
+ },
185
+ });
186
+ ```
187
+
188
+ With this in place, `await Database.tables.todos.findMany({})` returns only the
189
+ calling user's rows — no `WHERE owner = …` needed in the handler. To read or
190
+ write across all users (e.g. an admin job), use the explicit bypass:
191
+ `Database.asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
192
+
193
+ ### How policies are applied
194
+
195
+ On deploy, Palbase diffs your declared schema against the live database and
196
+ applies RLS **additively**: it emits `ENABLE`/`FORCE ROW LEVEL SECURITY` only
197
+ when the table doesn't already have it, and `CREATE POLICY` only for policies
198
+ that don't already exist (keyed by `(table, name)`). These are non-destructive,
199
+ so they apply without the `acceptDataLoss` confirmation that column drops need.
200
+
201
+ > Changing a policy's body (its `USING`/`WITH CHECK` SQL) in place is not yet
202
+ > auto-applied — rename the policy (new `(table, name)`) or drop the old one in
203
+ > a hand-written migration. Policy DROP/rewrite churn is a documented TODO.
204
+
package/docs/services.md CHANGED
@@ -90,15 +90,18 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
90
90
  ## Flags
91
91
 
92
92
  ```ts
93
- import { defineHandler, z, Flags } from "@palbase/backend";
94
-
95
- export default defineHandler({
96
- auth: { required: true }, // req.user is non-null here
97
- output: z.object({ enabled: z.boolean() }),
98
- handler: async (req) => {
99
- const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: req.user.id });
100
- const { data: variant } = await Flags.getVariant("button-color", { userId: req.user.id });
93
+ import { Controller, Get, 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
+ async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> { // return type names the 200 schema
102
+ const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: user.id });
103
+ const { data: variant } = await Flags.getVariant("button-color", { userId: user.id });
101
104
  return { enabled: enabled ?? false };
102
- },
103
- });
105
+ }
106
+ }
104
107
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "3.0.0",
4
- "description": "Palbase Backend SDK — defineEndpoint, context types, schema DSL",
3
+ "version": "5.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",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/db/schema.ts","../src/db/columns.ts","../src/db/typed-db.ts"],"sourcesContent":["import type { ColumnBuilder } from \"./columns.js\";\n\n/**\n * A map of column builders keyed by column name — the value you write under\n * each key of `defineSchema({ tables: { <name>: <columns> } })`.\n *\n * The default `Record<string, ColumnBuilder>` keeps bare references compiling\n * without a type argument.\n */\nexport type ColumnMap = Record<string, ColumnBuilder>;\n\n/**\n * A table definition with its name and columns.\n *\n * The runtime value shape — `{ name, columns }` — is the contract the Go\n * runtime's `schema_extract.js` reads (it keys tables by `tableDef.name`).\n * `defineSchema` derives `name` from the object key, so authors never repeat\n * the table name.\n *\n * The `C` type parameter preserves the precise per-column phantom types so\n * that downstream mapped types (InsertShape, RowShape) can discriminate on\n * them.\n */\nexport interface TableDef<C extends ColumnMap = ColumnMap> {\n name: string;\n columns: C;\n}\n\n/**\n * A schema definition containing multiple tables, keyed by table name.\n *\n * The `T` type parameter preserves the exact `TableDef<...>` type for each\n * table so that `SchemaDef[\"tables\"][\"rooms\"]` resolves to the precise\n * `TableDef<{ id: ColumnBuilder<'uuid', false, true, never>; ... }>`.\n */\nexport interface SchemaDef<\n T extends Record<string, TableDef> = Record<string, TableDef>,\n> {\n tables: T;\n}\n\n/** The author-facing input to `defineSchema` — a `tables` map whose keys are\n * the table names and whose values are the column maps. */\nexport interface SchemaInput<T extends Record<string, ColumnMap> = Record<string, ColumnMap>> {\n tables: T;\n}\n\n/** Map the author's `{ tables: { <name>: <columns> } }` input to the\n * `{ tables: { <name>: TableDef<columns> } }` runtime/type shape. */\ntype TablesFromInput<T extends Record<string, ColumnMap>> = {\n [K in keyof T]: TableDef<T[K]>;\n};\n\n/**\n * Define a schema. The table NAME comes from the object key — there is one\n * canonical form:\n *\n * export default defineSchema({\n * tables: {\n * todos: {\n * id: uuid().primaryKey().defaultRandom(),\n * title: text().notNull(),\n * },\n * },\n * });\n *\n * The returned value is `{ tables: { todos: { name: \"todos\", columns: {...} } } }`\n * — the exact shape the runtime schema extractor parses. Per-column phantom\n * types are preserved so `Database.tables.todos.insert({...})` is typed.\n *\n * @example\n * import { defineSchema, uuid, text, timestamp } from \"@palbase/backend\";\n *\n * export default defineSchema({\n * tables: {\n * todos: {\n * id: uuid().primaryKey().defaultRandom(),\n * title: text().notNull(),\n * done: boolean().default(false),\n * created_at: timestamp().defaultNow(),\n * },\n * },\n * });\n */\nexport function defineSchema<T extends Record<string, ColumnMap>>(\n input: SchemaInput<T>,\n): SchemaDef<TablesFromInput<T>> {\n const tables = {} as TablesFromInput<T>;\n for (const name of Object.keys(input.tables) as (keyof T)[]) {\n tables[name] = { name: name as string, columns: input.tables[name] };\n }\n return { tables };\n}\n","/** On delete action for foreign key references. */\nexport type OnDeleteAction = 'cascade' | 'set null' | 'restrict' | 'no action';\n\n/** Column type identifiers. */\nexport type ColumnType = 'uuid' | 'text' | 'integer' | 'boolean' | 'timestamp' | 'jsonb' | 'enum';\n\n/** Base column definition shared by all column types. */\nexport interface ColumnDef {\n type: ColumnType;\n nullable: boolean;\n primaryKey: boolean;\n defaultValue?: unknown;\n defaultRandom?: boolean;\n defaultNow?: boolean;\n references?: { table: string; column: string };\n onDeleteAction?: OnDeleteAction;\n enumName?: string;\n enumValues?: string[];\n}\n\n// Phantom brand symbols — never have runtime values; exist only to force\n// TypeScript's structural type system to distinguish ColumnBuilder instances\n// with different type-param combinations. Without these, TS sees all\n// ColumnBuilder<K,...> as structurally identical and the first branch of\n// ColValue matches everything.\ndeclare const __colKind: unique symbol;\ndeclare const __colNullable: unique symbol;\ndeclare const __colHasDefault: unique symbol;\ndeclare const __colEnumValues: unique symbol;\n\n/**\n * Fluent column builder with phantom type params:\n * K — ColumnType literal (e.g. \"text\", \"integer\")\n * N — boolean: true when nullable() has been called last (false = NOT NULL)\n * D — boolean: true when a default has been set\n * E — enum value union (never for non-enum columns)\n *\n * All four params have defaults so bare `ColumnBuilder` (no args) still\n * satisfies `Record<string, ColumnBuilder>` in schema.ts without modification.\n *\n * The four `declare readonly` brand fields carry the phantom types into the\n * structural shape so that conditional types like ColValue<C> can discriminate\n * on K without requiring runtime values on those fields.\n */\nexport class ColumnBuilder<\n K extends ColumnType = ColumnType,\n N extends boolean = boolean,\n D extends boolean = boolean,\n E = unknown,\n> {\n // These fields exist only in the type layer (declared, never initialised at\n // runtime — TypeScript allows declared class members without an initializer\n // in strict mode as long as they're never read at runtime).\n declare readonly [__colKind]: K;\n declare readonly [__colNullable]: N;\n declare readonly [__colHasDefault]: D;\n declare readonly [__colEnumValues]: E;\n\n readonly _def: ColumnDef;\n\n constructor(type: K, existingDef?: ColumnDef) {\n this._def = existingDef ?? {\n type,\n nullable: false,\n primaryKey: false,\n };\n }\n\n /** Mark this column as the primary key. */\n primaryKey(): ColumnBuilder<K, N, D, E> {\n this._def.primaryKey = true;\n return new ColumnBuilder<K, N, D, E>(this._def.type as K, this._def);\n }\n\n /** Mark this column as NOT NULL (default). */\n notNull(): ColumnBuilder<K, false, D, E> {\n this._def.nullable = false;\n return new ColumnBuilder<K, false, D, E>(this._def.type as K, this._def);\n }\n\n /** Allow NULL values. */\n nullable(): ColumnBuilder<K, true, D, E> {\n this._def.nullable = true;\n return new ColumnBuilder<K, true, D, E>(this._def.type as K, this._def);\n }\n\n /** Set a default value. */\n default(value: unknown): ColumnBuilder<K, N, true, E> {\n this._def.defaultValue = value;\n return new ColumnBuilder<K, N, true, E>(this._def.type as K, this._def);\n }\n\n /** UUID: generate a random default (gen_random_uuid()). */\n defaultRandom(): ColumnBuilder<K, N, true, E> {\n this._def.defaultRandom = true;\n return new ColumnBuilder<K, N, true, E>(this._def.type as K, this._def);\n }\n\n /** Timestamp: default to now(). */\n defaultNow(): ColumnBuilder<K, N, true, E> {\n this._def.defaultNow = true;\n return new ColumnBuilder<K, N, true, E>(this._def.type as K, this._def);\n }\n\n /** Add a foreign key reference. */\n references(table: string, column: string): ColumnBuilder<K, N, D, E> {\n this._def.references = { table, column };\n return new ColumnBuilder<K, N, D, E>(this._def.type as K, this._def);\n }\n\n /** Set the ON DELETE action for a foreign key reference. */\n onDelete(action: OnDeleteAction): ColumnBuilder<K, N, D, E> {\n this._def.onDeleteAction = action;\n return new ColumnBuilder<K, N, D, E>(this._def.type as K, this._def);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Type extractors — imported by Task 2 to derive insert/row shapes.\n// ---------------------------------------------------------------------------\n\n/**\n * Extracts the TypeScript value type for a column, respecting nullability.\n * - \"uuid\" | \"text\" | \"timestamp\" → string (or string | null when N = true)\n * - \"integer\" → number\n * - \"boolean\" → boolean\n * - \"jsonb\" → unknown (opaque JSON)\n * - \"enum\" → E (the union of literal values)\n */\nexport type ColValue<C> =\n C extends ColumnBuilder<'uuid' | 'text' | 'timestamp', infer N, infer _D, infer _E>\n ? N extends true\n ? string | null\n : string\n : C extends ColumnBuilder<'integer', infer N, infer _D, infer _E>\n ? N extends true\n ? number | null\n : number\n : C extends ColumnBuilder<'boolean', infer N, infer _D, infer _E>\n ? N extends true\n ? boolean | null\n : boolean\n : C extends ColumnBuilder<'jsonb', infer _N, infer _D, infer _E>\n ? unknown\n : C extends ColumnBuilder<'enum', infer N, infer _D, infer E>\n ? N extends true\n ? E | null\n : E\n : never;\n\n/**\n * True when a column is optional on INSERT:\n * - nullable columns (N = true) — the DB allows NULL so the field may be omitted\n * - columns with a default (D = true) — the DB fills in the value when absent\n */\nexport type ColIsOptionalOnInsert<C> =\n C extends ColumnBuilder<infer _K, true, infer _D, infer _E>\n ? true\n : C extends ColumnBuilder<infer _K, infer _N, true, infer _E>\n ? true\n : false;\n\n// ---------------------------------------------------------------------------\n// Factory functions\n// ---------------------------------------------------------------------------\n\n/** Create a UUID column. */\nexport function uuid(): ColumnBuilder<'uuid', false, false, never> {\n return new ColumnBuilder('uuid');\n}\n\n/** Create a TEXT column. */\nexport function text(): ColumnBuilder<'text', false, false, never> {\n return new ColumnBuilder('text');\n}\n\n/** Create an INTEGER column. */\nexport function integer(): ColumnBuilder<'integer', false, false, never> {\n return new ColumnBuilder('integer');\n}\n\n/** Create a BOOLEAN column. */\nexport function boolean(): ColumnBuilder<'boolean', false, false, never> {\n return new ColumnBuilder('boolean');\n}\n\n/** Create a TIMESTAMP column. */\nexport function timestamp(): ColumnBuilder<'timestamp', false, false, never> {\n return new ColumnBuilder('timestamp');\n}\n\n/** Create a JSONB column. */\nexport function jsonb(): ColumnBuilder<'jsonb', false, false, never> {\n return new ColumnBuilder('jsonb');\n}\n\n/**\n * Create an ENUM column.\n * @param name The PostgreSQL enum type name (used in DDL).\n * @param values A readonly tuple of valid string values — kept `const` so the\n * union `V[number]` is as narrow as possible.\n */\nexport function enumType<const V extends readonly string[]>(\n name: string,\n values: V,\n): ColumnBuilder<'enum', false, false, V[number]> {\n const builder = new ColumnBuilder<'enum', false, false, V[number]>('enum');\n builder._def.enumName = name;\n builder._def.enumValues = [...values];\n return builder;\n}\n","/**\n * typed-db.ts — Task 2: TypedDB schema-derived insert/row shapes.\n *\n * Derives INSERT and full-row TypeScript types from a `defineSchema()` result\n * and wraps the untyped runtime `DBClient` with a typed facade.\n *\n * No value-any. No `as unknown as X`. The two narrow `as` casts in\n * `makeTypedTable` are safe because:\n * - `data as Record<string, unknown>`: InsertShape<T> maps string keys to\n * typed values; all value types are subsets of `unknown`, so the cast is\n * structurally sound.\n * - `result as RowShape<T>`: The runtime DBClient returns `Record<string,\n * unknown>` which is the erased form of the typed row; we're narrowing back\n * to the precise shape that the schema declared.\n * Both casts are narrowing only (not widening) and correctness is guaranteed\n * by the schema the caller provides.\n */\n\nimport type { ColValue, ColIsOptionalOnInsert, ColumnBuilder } from \"./columns.js\";\nimport type { TableDef, SchemaDef } from \"./schema.js\";\nimport type { Tables, TableTypes } from \"./env.js\";\nimport type { DBClient, TxClient } from \"../endpoint.js\";\n\n// ---------------------------------------------------------------------------\n// Key discriminators — split a column map into required vs optional keys.\n// ---------------------------------------------------------------------------\n\n/** Keys of C whose columns are required on INSERT (not nullable, no default). */\ntype RequiredKeys<C> = {\n [K in keyof C]: ColIsOptionalOnInsert<C[K]> extends true ? never : K;\n}[keyof C];\n\n/** Keys of C whose columns are optional on INSERT (nullable or has a default). */\ntype OptionalKeys<C> = {\n [K in keyof C]: ColIsOptionalOnInsert<C[K]> extends true ? K : never;\n}[keyof C];\n\n// ---------------------------------------------------------------------------\n// Public shape types — exported so callers can reference them directly.\n// ---------------------------------------------------------------------------\n\n/**\n * The TypeScript type for an INSERT payload for table `T`.\n * - Required: columns that are NOT NULL and have no DB-level default.\n * - Optional: columns that are nullable or carry a default.\n *\n * When all columns are optional, `RequiredKeys<C>` resolves to `never` and\n * the first part becomes `{}`, which is a neutral element for `&`.\n */\nexport type InsertShape<T extends TableDef> = {\n [K in RequiredKeys<T[\"columns\"]>]: ColValue<T[\"columns\"][K]>;\n} & {\n [K in OptionalKeys<T[\"columns\"]>]?: ColValue<T[\"columns\"][K]>;\n};\n\n/**\n * The TypeScript type for a full row returned by the DB for table `T`.\n * Every column is present; nullable columns resolve to `T | null`.\n */\nexport type RowShape<T extends TableDef> = {\n [K in keyof T[\"columns\"]]: ColValue<T[\"columns\"][K]>;\n};\n\n// ---------------------------------------------------------------------------\n// TypedTable + TypedDB interfaces.\n// ---------------------------------------------------------------------------\n\n/** A typed table accessor that mirrors the runtime DBClient surface. */\nexport interface TypedTable<T extends TableDef> {\n insert(data: InsertShape<T>): Promise<RowShape<T>>;\n update(id: string, data: Partial<InsertShape<T>>): Promise<RowShape<T>>;\n delete(id: string): Promise<void>;\n findById(id: string): Promise<RowShape<T> | null>;\n findMany(query?: Partial<RowShape<T>>): Promise<RowShape<T>[]>;\n}\n\n/** A typed DB facade covering all tables declared in schema `S`. */\nexport interface TypedDB<S extends SchemaDef> {\n tables: {\n [K in keyof S[\"tables\"]]: TypedTable<S[\"tables\"][K]>;\n };\n transaction<T>(fn: (tx: TypedTx<S>) => Promise<T>): Promise<T>;\n}\n\n/** Transaction-scoped typed facade: same typed tables, no nested transaction. */\nexport interface TypedTx<S extends SchemaDef> {\n tables: {\n [K in keyof S[\"tables\"]]: TypedTable<S[\"tables\"][K]>;\n };\n}\n\n// ---------------------------------------------------------------------------\n// Runtime factory.\n// ---------------------------------------------------------------------------\n\n/**\n * Builds a typed table accessor that delegates every call to `raw` using the\n * runtime table name string. Two narrow `as` casts bridge the mapped-type\n * shapes to/from `Record<string, unknown>` — see module-level doc comment.\n *\n * The `raw` param is typed `TxClient` (the op surface shared by `DBClient` and\n * the transaction-scoped client) because this only ever calls the five\n * insert/update/delete/findById/findMany ops — never `transaction`. This lets\n * the same factory wrap both the top-level db and a tx without any cast.\n */\nfunction makeTypedTable<T extends TableDef<Record<string, ColumnBuilder>>>(\n name: string,\n raw: TxClient,\n): TypedTable<T> {\n return {\n insert: (data: InsertShape<T>) =>\n raw.insert(name, data as Record<string, unknown>) as Promise<RowShape<T>>,\n\n update: (id: string, data: Partial<InsertShape<T>>) =>\n raw.update(name, id, data as Record<string, unknown>) as Promise<RowShape<T>>,\n\n delete: (id: string) => raw.delete(name, id),\n\n findById: (id: string) =>\n raw.findById(name, id) as Promise<RowShape<T> | null>,\n\n findMany: (query?: Partial<RowShape<T>>) =>\n raw.findMany(name, query as Record<string, unknown> | undefined) as Promise<RowShape<T>[]>,\n };\n}\n\n/**\n * Wraps a raw `DBClient` with the type-safe `TypedDB<S>` facade derived from\n * the provided schema. No behavior change — all calls delegate to `raw` with\n * the table name as a plain string.\n *\n * `buildTables` is the reusable factory that wraps any op-bearing client\n * (`TxClient` — the surface shared by `DBClient` and the transaction-scoped\n * client) into the typed tables map. It is used both for the top-level db\n * (wrapping `raw`) and inside `transaction`, where it wraps the raw `TxClient`\n * the runtime yields so the callback sees the same typed `.tables` API.\n *\n * `transaction` delegates straight to `raw.transaction`; the two narrow\n * `as TypedTx<S>` / `as TypedDB<S>` casts are single structural narrowings\n * from the dynamically-built tables object to the precise mapped type (TS\n * cannot infer through `Object.keys` iteration) — see module-level doc comment.\n */\nexport function makeTypedDB<S extends SchemaDef>(\n schema: S,\n raw: DBClient,\n): TypedDB<S> {\n function buildTables(client: TxClient): Record<string, TypedTable<TableDef>> {\n const tables = {} as Record<string, TypedTable<TableDef>>;\n for (const key of Object.keys(schema.tables)) {\n const tableDef = schema.tables[key];\n if (tableDef !== undefined) {\n tables[key] = makeTypedTable(tableDef.name, client);\n }\n }\n return tables;\n }\n\n const result = {\n tables: buildTables(raw),\n transaction: <T>(fn: (tx: TypedTx<S>) => Promise<T>): Promise<T> =>\n raw.transaction((rawTx) => fn({ tables: buildTables(rawTx) } as TypedTx<S>)),\n };\n\n // Narrow cast: `result.tables` is structurally identical to\n // TypedDB<S>[\"tables\"] — each key maps to a TypedTable for the matching\n // TableDef. TS cannot infer the mapped-type result through Object.keys\n // iteration, so a single `as` bridges the gap.\n return result as TypedDB<S>;\n}\n\n// ---------------------------------------------------------------------------\n// Env-augmentation-driven typed surface — the typed-by-default `Database`.\n//\n// These types read the globally-augmented `Tables` interface from\n// `@palbase/backend/env` (filled by the generated `palbase-env.d.ts`). They\n// back `Database.tables.<name>` so handler code is typed with no import and no\n// generic (C5). They DELIBERATELY do not reference `ColumnBuilder` — the env\n// `Tables` interface carries flat `row`/`insert` object types.\n// ---------------------------------------------------------------------------\n\n/** A typed table accessor derived from one env `Tables` entry's flat shapes. */\nexport interface EnvTypedTable<T extends TableTypes> {\n insert(data: T[\"insert\"]): Promise<T[\"row\"]>;\n update(id: string, data: Partial<T[\"insert\"]>): Promise<T[\"row\"]>;\n delete(id: string): Promise<void>;\n findById(id: string): Promise<T[\"row\"] | null>;\n findMany(query?: Partial<T[\"row\"]>): Promise<T[\"row\"][]>;\n}\n\n/** The `tables` map exposed on `Database`/`tx`, keyed by the env `Tables`\n * interface. When no schema is declared `Tables` is empty, so `tables` is an\n * empty object — accessing `.tables.foo` is then a compile error (no member). */\nexport type EnvTables = {\n [K in keyof Tables]: EnvTypedTable<Tables[K]>;\n};\n\n/** Transaction-scoped typed facade for the env-augmented surface: same typed\n * tables, no nested transaction. */\nexport interface EnvTypedTx {\n tables: EnvTables;\n}\n\n/**\n * The typed-by-default Database surface: the raw string-keyed `DBClient` ops\n * PLUS a `tables` map typed against the project's generated `palbase-env.d.ts`\n * and a `transaction` whose callback receives the typed tables.\n *\n * `transaction` is declared here (overriding `DBClient[\"transaction\"]`) so the\n * `tx` the callback receives carries the typed `.tables` API.\n */\nexport interface EnvTypedDatabase extends Omit<DBClient, \"transaction\"> {\n tables: EnvTables;\n transaction<T>(fn: (tx: EnvTypedTx) => Promise<T>): Promise<T>;\n}\n"],"mappings":";AAoFO,SAAS,aACd,OAC+B;AAC/B,QAAM,SAAS,CAAC;AAChB,aAAW,QAAQ,OAAO,KAAK,MAAM,MAAM,GAAkB;AAC3D,WAAO,IAAI,IAAI,EAAE,MAAsB,SAAS,MAAM,OAAO,IAAI,EAAE;AAAA,EACrE;AACA,SAAO,EAAE,OAAO;AAClB;;;AChDO,IAAM,gBAAN,MAAM,eAKX;AAAA,EASS;AAAA,EAET,YAAY,MAAS,aAAyB;AAC5C,SAAK,OAAO,eAAe;AAAA,MACzB;AAAA,MACA,UAAU;AAAA,MACV,YAAY;AAAA,IACd;AAAA,EACF;AAAA;AAAA,EAGA,aAAwC;AACtC,SAAK,KAAK,aAAa;AACvB,WAAO,IAAI,eAA0B,KAAK,KAAK,MAAW,KAAK,IAAI;AAAA,EACrE;AAAA;AAAA,EAGA,UAAyC;AACvC,SAAK,KAAK,WAAW;AACrB,WAAO,IAAI,eAA8B,KAAK,KAAK,MAAW,KAAK,IAAI;AAAA,EACzE;AAAA;AAAA,EAGA,WAAyC;AACvC,SAAK,KAAK,WAAW;AACrB,WAAO,IAAI,eAA6B,KAAK,KAAK,MAAW,KAAK,IAAI;AAAA,EACxE;AAAA;AAAA,EAGA,QAAQ,OAA8C;AACpD,SAAK,KAAK,eAAe;AACzB,WAAO,IAAI,eAA6B,KAAK,KAAK,MAAW,KAAK,IAAI;AAAA,EACxE;AAAA;AAAA,EAGA,gBAA8C;AAC5C,SAAK,KAAK,gBAAgB;AAC1B,WAAO,IAAI,eAA6B,KAAK,KAAK,MAAW,KAAK,IAAI;AAAA,EACxE;AAAA;AAAA,EAGA,aAA2C;AACzC,SAAK,KAAK,aAAa;AACvB,WAAO,IAAI,eAA6B,KAAK,KAAK,MAAW,KAAK,IAAI;AAAA,EACxE;AAAA;AAAA,EAGA,WAAW,OAAe,QAA2C;AACnE,SAAK,KAAK,aAAa,EAAE,OAAO,OAAO;AACvC,WAAO,IAAI,eAA0B,KAAK,KAAK,MAAW,KAAK,IAAI;AAAA,EACrE;AAAA;AAAA,EAGA,SAAS,QAAmD;AAC1D,SAAK,KAAK,iBAAiB;AAC3B,WAAO,IAAI,eAA0B,KAAK,KAAK,MAAW,KAAK,IAAI;AAAA,EACrE;AACF;AAoDO,SAAS,OAAmD;AACjE,SAAO,IAAI,cAAc,MAAM;AACjC;AAGO,SAAS,OAAmD;AACjE,SAAO,IAAI,cAAc,MAAM;AACjC;AAGO,SAAS,UAAyD;AACvE,SAAO,IAAI,cAAc,SAAS;AACpC;AAGO,SAAS,UAAyD;AACvE,SAAO,IAAI,cAAc,SAAS;AACpC;AAGO,SAAS,YAA6D;AAC3E,SAAO,IAAI,cAAc,WAAW;AACtC;AAGO,SAAS,QAAqD;AACnE,SAAO,IAAI,cAAc,OAAO;AAClC;AAQO,SAAS,SACd,MACA,QACgD;AAChD,QAAM,UAAU,IAAI,cAA+C,MAAM;AACzE,UAAQ,KAAK,WAAW;AACxB,UAAQ,KAAK,aAAa,CAAC,GAAG,MAAM;AACpC,SAAO;AACT;;;ACzGA,SAAS,eACP,MACA,KACe;AACf,SAAO;AAAA,IACL,QAAQ,CAAC,SACP,IAAI,OAAO,MAAM,IAA+B;AAAA,IAElD,QAAQ,CAAC,IAAY,SACnB,IAAI,OAAO,MAAM,IAAI,IAA+B;AAAA,IAEtD,QAAQ,CAAC,OAAe,IAAI,OAAO,MAAM,EAAE;AAAA,IAE3C,UAAU,CAAC,OACT,IAAI,SAAS,MAAM,EAAE;AAAA,IAEvB,UAAU,CAAC,UACT,IAAI,SAAS,MAAM,KAA4C;AAAA,EACnE;AACF;AAkBO,SAAS,YACd,QACA,KACY;AACZ,WAAS,YAAY,QAAwD;AAC3E,UAAM,SAAS,CAAC;AAChB,eAAW,OAAO,OAAO,KAAK,OAAO,MAAM,GAAG;AAC5C,YAAM,WAAW,OAAO,OAAO,GAAG;AAClC,UAAI,aAAa,QAAW;AAC1B,eAAO,GAAG,IAAI,eAAe,SAAS,MAAM,MAAM;AAAA,MACpD;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,QAAM,SAAS;AAAA,IACb,QAAQ,YAAY,GAAG;AAAA,IACvB,aAAa,CAAI,OACf,IAAI,YAAY,CAAC,UAAU,GAAG,EAAE,QAAQ,YAAY,KAAK,EAAE,CAAe,CAAC;AAAA,EAC/E;AAMA,SAAO;AACT;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/runtime.ts"],"sourcesContent":["/**\n * runtime.ts — request-scoped service singletons.\n *\n * The backend SDK no longer threads a `ctx` god-object through every handler.\n * Instead, endpoint authors import PascalCase service singletons directly:\n *\n * import { Database, Documents, Cache } from \"@palbase/backend\";\n *\n * export default defineHandler({\n * handler: async (req) => {\n * const row = await Database.insert(\"todos\", { title: req.input.title });\n * return row;\n * },\n * });\n *\n * The singletons are thin Proxies. Every property access forwards to the live\n * client for the CURRENT request scope, resolved through {@link __getRuntime}.\n *\n * # Request-scope resolution (persistent app-server)\n *\n * The runtime is a long-running Node process that serves many concurrent\n * requests on one event loop (NOT a fresh subprocess per request). A single\n * module-global slot would let one in-flight request's services bleed into\n * another's. So the services are carried in an {@link AsyncLocalStorage} store\n * ({@link __requestALS}) that the runtime sets per request with\n * {@link __runWithRuntime}; every async continuation of that request reads its\n * own store. `__getRuntime` reads the ALS store first; the module-global slot\n * (set by {@link __setRuntime}) is only a fallback for callers that run OUTSIDE\n * an ALS scope (dev-server, unit tests, the legacy single-shot path). Because\n * each `br-<ref>` pod is single-tenant, there is no cross-tenant leakage; the\n * ALS store is what prevents cross-REQUEST leakage within the shared process.\n *\n * The seam that makes `import { Database } from \"@palbase/backend\"` resolve to\n * the runtime-injected client: `@palbase/backend` is marked esbuild-EXTERNAL\n * when the tenant bundle is built, and the package is installed globally in the\n * pod (NODE_PATH=/usr/local/lib/node_modules). So worker.js's\n * `require('@palbase/backend')` and the bundle's `import` resolve to ONE shared\n * module instance — the ALS store and `__setRuntime` slot on that instance are\n * visible to the singletons the bundle imported.\n */\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nimport type {\n DBClient,\n TxClient,\n CacheClient,\n QueueClient,\n Logger,\n PalbaseDocsClient,\n} from \"./endpoint.js\";\nimport type {\n PalbaseStorageClient,\n PalbaseNotificationsClient,\n PalbaseFlagsClient,\n} from \"./clients.js\";\nimport type { EnvTypedDatabase, EnvTypedTx, EnvTables } from \"./db/typed-db.js\";\n\n/** The set of live clients the runtime injects per request scope.\n *\n * EXCLUDED on purpose: Realtime, Functions, CMS, Links, Analytics, Auth. They\n * are not exposed as backend handler singletons (auth lives on the client SDK;\n * the rest are out of scope for backend endpoints). */\nexport interface RuntimeServices {\n Database: DBClient;\n Documents: PalbaseDocsClient;\n Storage: PalbaseStorageClient;\n Cache: CacheClient;\n Queue: QueueClient;\n Log: Logger;\n Notifications: PalbaseNotificationsClient;\n Flags: PalbaseFlagsClient;\n}\n\n/**\n * Per-request store. The persistent runtime runs each request inside\n * {@link __runWithRuntime}, so every async continuation of that request reads\n * its OWN `runtime` (and any other request-scoped fields the runtime adds).\n *\n * Exported with a `__` prefix so the runtime (worker.js) shares the SAME ALS\n * instance across the one module instance — two ALS instances would silently\n * not see each other's stores. NOT part of the public author-facing API.\n */\nexport const __requestALS = new AsyncLocalStorage<{ runtime: RuntimeServices }>();\n\n/** Process-global fallback slot. Used only OUTSIDE an ALS scope (dev-server,\n * unit tests, legacy single-shot worker). Inside the persistent server every\n * request runs in {@link __requestALS}, which takes precedence. */\nlet runtime: RuntimeServices | null = null;\n\n/** Install the live clients in the process-global fallback slot.\n *\n * Persistent-server requests should use {@link __runWithRuntime} instead; this\n * remains for dev-server / tests / the legacy single-shot path that run without\n * an ALS scope. NOT part of the public author-facing API. */\nexport function __setRuntime(services: RuntimeServices): void {\n runtime = services;\n}\n\n/** Run `fn` with `services` bound as the request-scoped runtime.\n *\n * The persistent worker calls this once per request so concurrent requests\n * never share a services slot. NOT part of the public author-facing API. */\nexport function __runWithRuntime<T>(services: RuntimeServices, fn: () => T): T {\n return __requestALS.run({ runtime: services }, fn);\n}\n\n/** Read the live clients, throwing if accessed outside a request scope.\n *\n * Resolves the ALS store first (persistent server, per-request), then the\n * process-global fallback (dev-server / tests). NOT part of the public\n * author-facing API — used by the runtime and the singleton Proxies. */\nexport function __getRuntime(): RuntimeServices {\n const scoped = __requestALS.getStore();\n if (scoped) return scoped.runtime;\n if (runtime === null) {\n throw new Error(\n \"Palbase services accessed outside a request scope. The Database/Documents/… \" +\n \"singletons are only available inside an endpoint handler (or after the \" +\n \"runtime has called __runWithRuntime / __setRuntime).\",\n );\n }\n return runtime;\n}\n\n/**\n * Build a Proxy singleton that forwards every property access to the live\n * client named `key` on the current runtime.\n *\n * The single `as RuntimeServices[K]` is the only contained cast in the surface:\n * `Reflect.get` on a typed object returns `unknown` for a `string | symbol`\n * key, but `prop` is constrained to keys of the client interface at the call\n * sites (the exported singletons are typed below), so the forward is sound.\n */\nfunction makeServiceProxy<K extends keyof RuntimeServices>(key: K): RuntimeServices[K] {\n const handler: ProxyHandler<RuntimeServices[K]> = {\n get(_target, prop, receiver) {\n const client = __getRuntime()[key];\n const value = Reflect.get(client as object, prop, receiver) as unknown;\n // Bind methods to their owning client so `this` stays correct when the\n // author destructures or calls `Database.query(...)`.\n return typeof value === \"function\" ? value.bind(client) : value;\n },\n };\n // The Proxy target is irrelevant (all access goes through `get`); the cast\n // names the surface type the singleton presents to authors.\n return new Proxy({} as RuntimeServices[K], handler);\n}\n\n/**\n * Build the `.tables` accessor for an op-bearing client (the top-level\n * `Database` or a transaction-scoped `tx`). Each `tables.<name>` access\n * returns a small object that forwards the five CRUD ops to the underlying\n * client using `name` as the string table identifier. The shapes are typed\n * against the generated `palbase-env.d.ts` (`EnvTables`); at runtime they are\n * plain string-keyed calls, so no schema value is needed here.\n *\n * Returns `EnvTables` — TS cannot infer the mapped type through the Proxy, so\n * a single structural narrowing names the surface (the proxy returns a\n * correctly-shaped accessor for whatever string member is read).\n */\nfunction makeTablesAccessor(ops: () => TxClient): EnvTables {\n const tablesProxy = new Proxy(\n {},\n {\n get(_t, prop: string | symbol) {\n if (typeof prop !== \"string\") return undefined;\n const name = prop;\n return {\n insert: (data: Record<string, unknown>) => ops().insert(name, data),\n update: (id: string, data: Record<string, unknown>) => ops().update(name, id, data),\n delete: (id: string) => ops().delete(name, id),\n findById: (id: string) => ops().findById(name, id),\n findMany: (query?: Record<string, unknown>) => ops().findMany(name, query),\n };\n },\n },\n );\n return tablesProxy as EnvTables;\n}\n\n/** The raw string-keyed `DBClient` for the current request scope. */\nconst rawDatabase: DBClient = makeServiceProxy(\"Database\");\n\n/**\n * The project's own Postgres (pgx, schema `env_<envId>`).\n *\n * Typed by default: `Database.tables.<name>.insert({...})` is typed against\n * the project's generated `palbase-env.d.ts` with NO import and NO generic.\n * The raw string ops (`query`/`insert`/`update`/`delete`/`findById`/`findMany`)\n * are also available for dynamic table names and read-only SQL.\n *\n * @example\n * import { Database } from \"@palbase/backend\";\n *\n * const todo = await Database.tables.todos.insert({ title: req.input.title });\n * todo.id; // string ✓\n * const rows = await Database.query(\"SELECT id FROM todos WHERE done = $1\", [false]);\n */\nexport const Database: EnvTypedDatabase = Object.assign(\n // Spread the raw ops onto a fresh object so the added `tables`/typed\n // `transaction` members live alongside them. Each op still forwards through\n // the request-scoped runtime Proxy.\n {\n query: (sql: string, params?: unknown[]) => rawDatabase.query(sql, params),\n insert: (table: string, data: Record<string, unknown>) => rawDatabase.insert(table, data),\n update: (table: string, id: string, data: Record<string, unknown>) =>\n rawDatabase.update(table, id, data),\n delete: (table: string, id: string) => rawDatabase.delete(table, id),\n findById: (table: string, id: string) => rawDatabase.findById(table, id),\n findMany: (table: string, query?: Record<string, unknown>) =>\n rawDatabase.findMany(table, query),\n } satisfies Omit<DBClient, \"transaction\">,\n {\n tables: makeTablesAccessor(() => rawDatabase),\n transaction<T>(fn: (tx: EnvTypedTx) => Promise<T>): Promise<T> {\n return rawDatabase.transaction((rawTx) =>\n fn({ tables: makeTablesAccessor(() => rawTx) }),\n );\n },\n },\n);\n\n/** Firestore-like document client (PalDocs). */\nexport const Documents: PalbaseDocsClient = makeServiceProxy(\"Documents\");\n\n/** Object storage client (buckets, signed URLs). */\nexport const Storage: PalbaseStorageClient = makeServiceProxy(\"Storage\");\n\n/** JSON-typed cache (get/set/incr/getOrSet). */\nexport const Cache: CacheClient = makeServiceProxy(\"Cache\");\n\n/** Background job queue. */\nexport const Queue: QueueClient = makeServiceProxy(\"Queue\");\n\n/** Structured logger. */\nexport const Log: Logger = makeServiceProxy(\"Log\");\n\n/** Push / email / SMS / in-app notifications. */\nexport const Notifications: PalbaseNotificationsClient = makeServiceProxy(\"Notifications\");\n\n/** Feature flags. */\nexport const Flags: PalbaseFlagsClient = makeServiceProxy(\"Flags\");\n"],"mappings":";AAyCA,SAAS,yBAAyB;AA0C3B,IAAM,eAAe,IAAI,kBAAgD;AAKhF,IAAI,UAAkC;AAO/B,SAAS,aAAa,UAAiC;AAC5D,YAAU;AACZ;AAMO,SAAS,iBAAoB,UAA2B,IAAgB;AAC7E,SAAO,aAAa,IAAI,EAAE,SAAS,SAAS,GAAG,EAAE;AACnD;AAOO,SAAS,eAAgC;AAC9C,QAAM,SAAS,aAAa,SAAS;AACrC,MAAI,OAAQ,QAAO,OAAO;AAC1B,MAAI,YAAY,MAAM;AACpB,UAAM,IAAI;AAAA,MACR;AAAA,IAGF;AAAA,EACF;AACA,SAAO;AACT;AAWA,SAAS,iBAAkD,KAA4B;AACrF,QAAM,UAA4C;AAAA,IAChD,IAAI,SAAS,MAAM,UAAU;AAC3B,YAAM,SAAS,aAAa,EAAE,GAAG;AACjC,YAAM,QAAQ,QAAQ,IAAI,QAAkB,MAAM,QAAQ;AAG1D,aAAO,OAAO,UAAU,aAAa,MAAM,KAAK,MAAM,IAAI;AAAA,IAC5D;AAAA,EACF;AAGA,SAAO,IAAI,MAAM,CAAC,GAAyB,OAAO;AACpD;AAcA,SAAS,mBAAmB,KAAgC;AAC1D,QAAM,cAAc,IAAI;AAAA,IACtB,CAAC;AAAA,IACD;AAAA,MACE,IAAI,IAAI,MAAuB;AAC7B,YAAI,OAAO,SAAS,SAAU,QAAO;AACrC,cAAM,OAAO;AACb,eAAO;AAAA,UACL,QAAQ,CAAC,SAAkC,IAAI,EAAE,OAAO,MAAM,IAAI;AAAA,UAClE,QAAQ,CAAC,IAAY,SAAkC,IAAI,EAAE,OAAO,MAAM,IAAI,IAAI;AAAA,UAClF,QAAQ,CAAC,OAAe,IAAI,EAAE,OAAO,MAAM,EAAE;AAAA,UAC7C,UAAU,CAAC,OAAe,IAAI,EAAE,SAAS,MAAM,EAAE;AAAA,UACjD,UAAU,CAAC,UAAoC,IAAI,EAAE,SAAS,MAAM,KAAK;AAAA,QAC3E;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAGA,IAAM,cAAwB,iBAAiB,UAAU;AAiBlD,IAAM,WAA6B,OAAO;AAAA;AAAA;AAAA;AAAA,EAI/C;AAAA,IACE,OAAO,CAAC,KAAa,WAAuB,YAAY,MAAM,KAAK,MAAM;AAAA,IACzE,QAAQ,CAAC,OAAe,SAAkC,YAAY,OAAO,OAAO,IAAI;AAAA,IACxF,QAAQ,CAAC,OAAe,IAAY,SAClC,YAAY,OAAO,OAAO,IAAI,IAAI;AAAA,IACpC,QAAQ,CAAC,OAAe,OAAe,YAAY,OAAO,OAAO,EAAE;AAAA,IACnE,UAAU,CAAC,OAAe,OAAe,YAAY,SAAS,OAAO,EAAE;AAAA,IACvE,UAAU,CAAC,OAAe,UACxB,YAAY,SAAS,OAAO,KAAK;AAAA,EACrC;AAAA,EACA;AAAA,IACE,QAAQ,mBAAmB,MAAM,WAAW;AAAA,IAC5C,YAAe,IAAgD;AAC7D,aAAO,YAAY;AAAA,QAAY,CAAC,UAC9B,GAAG,EAAE,QAAQ,mBAAmB,MAAM,KAAK,EAAE,CAAC;AAAA,MAChD;AAAA,IACF;AAAA,EACF;AACF;AAGO,IAAM,YAA+B,iBAAiB,WAAW;AAGjE,IAAM,UAAgC,iBAAiB,SAAS;AAGhE,IAAM,QAAqB,iBAAiB,OAAO;AAGnD,IAAM,QAAqB,iBAAiB,OAAO;AAGnD,IAAM,MAAc,iBAAiB,KAAK;AAG1C,IAAM,gBAA4C,iBAAiB,eAAe;AAGlF,IAAM,QAA4B,iBAAiB,OAAO;","names":[]}