@palbase/backend 4.0.0 → 5.1.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.
@@ -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
@@ -19,22 +19,20 @@ method declares its verb + subpath; the real work lives in a `services/` class
19
19
 
20
20
  ```ts
21
21
  // controllers/places.controller.ts
22
- import { Controller, Get, Post, Returns, Body, User, z } from "@palbase/backend";
22
+ import { Controller, Get, Post, Body, User } from "@palbase/backend";
23
23
  import type { UserT } from "@palbase/backend";
24
24
  import { placeService } from "../services/place.service.js";
25
25
  import { ImportNearbyBody } from "../models/places/import.js";
26
- import { PlaceSchema } from "../models/places/shared.js";
26
+ import type { PlaceSchema } from "../models/places/shared.js"; // the return TYPE names the 200 schema
27
27
 
28
28
  @Controller("/places")
29
29
  export default class PlacesController {
30
30
  @Post("/import")
31
- @Returns(PlaceSchema)
32
31
  importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
33
32
  return placeService.importNearby(body.lat, body.lng, user.id);
34
33
  }
35
34
 
36
35
  @Get("/favorites", { auth: false })
37
- @Returns(z.array(PlaceSchema))
38
36
  listFavorites(): PlaceSchema[] {
39
37
  return placeService.listFavorites();
40
38
  }
@@ -52,8 +50,10 @@ Rules:
52
50
  - A `{segment}` in a path becomes a path param, injected via `@Param("segment")`.
53
51
  - Input is declared with the parameter decorators — `@Body(schema)`,
54
52
  `@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).
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.
57
57
  - The operationId is derived FLAT from method + full path (`postPlacesImport`),
58
58
  not from the method name. Change `@Post` → `@Put` — no file rename.
59
59
 
package/docs/schema.md CHANGED
@@ -72,7 +72,7 @@ You do **not** wire anything per endpoint. Saving `db/schema.ts` regenerates
72
72
  of the schema, no generic, no cast:
73
73
 
74
74
  ```ts
75
- import { Controller, Post, Returns, Body, Database, z } from "@palbase/backend";
75
+ import { Controller, Post, Body, Database, z } from "@palbase/backend";
76
76
 
77
77
  const CreateRoomBody = z.object({ name: z.string() });
78
78
  const RoomOut = z.object({ id: z.string(), name: z.string() });
@@ -80,7 +80,8 @@ const RoomOut = z.object({ id: z.string(), name: z.string() });
80
80
  @Controller("/rooms")
81
81
  export default class RoomsController {
82
82
  @Post("")
83
- @Returns(RoomOut)
83
+ // The return type names the 200 schema — `z.infer<typeof RoomOut>` works
84
+ // inline, no separate `export type` needed.
84
85
  async create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
85
86
  const room = await Database.tables.rooms.insert({ name: body.name });
86
87
  return { id: room.id, name: room.name }; // room.id: string ✓
package/docs/services.md CHANGED
@@ -90,7 +90,7 @@ await Notifications.sms.send({ /* PalbaseSmsSendParams */ });
90
90
  ## Flags
91
91
 
92
92
  ```ts
93
- import { Controller, Get, Returns, User, Flags, z } from "@palbase/backend";
93
+ import { Controller, Get, User, Flags, z } from "@palbase/backend";
94
94
  import type { UserT } from "@palbase/backend";
95
95
 
96
96
  const FlagsOut = z.object({ enabled: z.boolean() });
@@ -98,8 +98,7 @@ const FlagsOut = z.object({ enabled: z.boolean() });
98
98
  @Controller("/checkout")
99
99
  export default class CheckoutController {
100
100
  @Get("/flags") // auth omitted → required → user is non-null
101
- @Returns(FlagsOut)
102
- async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> {
101
+ async flags(@User() user: UserT): Promise<z.infer<typeof FlagsOut>> { // return type names the 200 schema
103
102
  const { data: enabled } = await Flags.isEnabled("new-checkout", { userId: user.id });
104
103
  const { data: variant } = await Flags.getVariant("button-color", { userId: user.id });
105
104
  return { enabled: enabled ?? false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "4.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@Query/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {