@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.
- package/dist/chunk-EG7TTYHY.js +235 -0
- package/dist/chunk-EG7TTYHY.js.map +1 -0
- package/dist/chunk-WUQO76NW.js +101 -0
- package/dist/chunk-WUQO76NW.js.map +1 -0
- package/dist/db/env.cjs +19 -0
- package/dist/db/env.cjs.map +1 -0
- package/dist/db/env.d.cts +45 -0
- package/dist/db/env.d.ts +45 -0
- package/dist/db/env.js +1 -0
- package/dist/db/env.js.map +1 -0
- package/dist/db/index.cjs +143 -231
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +4 -20
- package/dist/db/index.d.ts +4 -20
- package/dist/db/index.js +13 -233
- package/dist/db/index.js.map +1 -1
- package/dist/{endpoint-Djk5L6G2.d.ts → endpoint-2d_DpASt.d.cts} +94 -96
- package/dist/{endpoint-BlcY2xNA.d.cts → endpoint-2d_DpASt.d.ts} +94 -96
- package/dist/index-DZW9CjiY.d.ts +463 -0
- package/dist/index-DzRFS3Tl.d.cts +463 -0
- package/dist/index.cjs +557 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +278 -161
- package/dist/index.d.ts +278 -161
- package/dist/index.js +343 -12
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +57 -2
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -2
- package/dist/test/index.d.ts +1 -2
- package/dist/test/index.js +10 -2
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +33 -12
- package/docs/background.md +19 -13
- package/docs/database.md +70 -17
- package/docs/endpoints.md +103 -79
- package/docs/errors.md +37 -31
- package/docs/events.md +25 -17
- package/docs/getting-started.md +38 -18
- package/docs/llms-full.txt +758 -267
- package/docs/llms.txt +3 -1
- package/docs/migrations.md +98 -0
- package/docs/resources.md +94 -0
- package/docs/routing.md +54 -27
- package/docs/schema.md +163 -42
- package/docs/services.md +17 -14
- package/package.json +12 -2
- package/dist/chunk-4J3F32SH.js +0 -96
- package/dist/chunk-4J3F32SH.js.map +0 -1
- package/dist/chunk-L36JLUPO.js +0 -97
- package/dist/chunk-L36JLUPO.js.map +0 -1
- package/dist/schema-BqfEhIC0.d.cts +0 -133
- 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.
|
|
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
|
-
#
|
|
1
|
+
# Routing
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
14
|
+
## Controllers — class + method decorators
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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,
|
|
16
|
+
defineSchema,
|
|
11
17
|
uuid, text, integer, boolean, timestamp, jsonb, enumType,
|
|
12
18
|
} from "@palbase/backend";
|
|
13
19
|
|
|
14
20
|
export default defineSchema({
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
`ctx.
|
|
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 {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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": "
|
|
4
|
-
"description": "Palbase Backend SDK —
|
|
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": [
|