@palbase/backend 25.0.0 → 25.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/palbase-backend.cjs +2 -0
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +2 -2
- package/dist/{chunk-43A3KGWL.js → chunk-34I4GB7D.js} +4 -2
- package/dist/{chunk-43A3KGWL.js.map → chunk-34I4GB7D.js.map} +1 -1
- package/dist/{chunk-ZQRWW37O.js → chunk-35PNTIRN.js} +5 -1
- package/dist/chunk-35PNTIRN.js.map +1 -0
- package/dist/{chunk-OEQBHE2Z.js → chunk-7JSEN7UR.js} +20 -4
- package/dist/chunk-7JSEN7UR.js.map +1 -0
- package/dist/{chunk-5CMLOAEF.js → chunk-YOY5DFQS.js} +2 -2
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +2 -1
- package/dist/db/env.d.ts +2 -1
- package/dist/db/index.cjs +17 -3
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/{endpoint-DMwJjEHQ.d.ts → endpoint-CTEHhb7A.d.ts} +19 -8
- package/dist/{endpoint-BSGw1pTu.d.cts → endpoint-DYHMo6cC.d.cts} +19 -8
- package/dist/engine/index.cjs +2 -0
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +4 -4
- package/dist/engine/index.d.ts +4 -4
- package/dist/engine/index.js +2 -2
- package/dist/{index-BF1f0DfA.d.ts → index-CUomTA3e.d.ts} +3 -3
- package/dist/{index-Ct1iiB4N.d.ts → index-CW21M9Z3.d.ts} +24 -17
- package/dist/{index-CoaDN9dL.d.cts → index-ClpDeSos.d.cts} +3 -3
- package/dist/{index-CwaWRhyc.d.cts → index-CmBK76nx.d.cts} +24 -17
- package/dist/index.cjs +80 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -11
- package/dist/index.d.ts +13 -11
- package/dist/index.js +62 -51
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +1 -1
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +2 -2
- package/dist/openapi/index.d.ts +2 -2
- package/dist/openapi/index.js +3 -3
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-CgJvDYUW.d.cts → registry-CC0WBQq6.d.cts} +1 -1
- package/dist/{registry-B8pld3fj.d.ts → registry-dZZ5JKYg.d.ts} +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/docs/README.md +9 -8
- package/docs/database.md +2 -2
- package/docs/endpoints.md +6 -5
- package/docs/getting-started.md +1 -1
- package/docs/llms-full.txt +74 -38
- package/docs/migrations.md +16 -14
- package/docs/schema.md +38 -7
- package/docs/services.md +2 -1
- package/package.json +1 -1
- package/dist/chunk-OEQBHE2Z.js.map +0 -1
- package/dist/chunk-ZQRWW37O.js.map +0 -1
- /package/dist/{chunk-5CMLOAEF.js.map → chunk-YOY5DFQS.js.map} +0 -0
package/dist/test/index.d.cts
CHANGED
package/dist/test/index.d.ts
CHANGED
package/docs/README.md
CHANGED
|
@@ -20,7 +20,7 @@ deploy gate or the TypeScript compile.
|
|
|
20
20
|
controllers/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
|
|
21
21
|
models/<controller>/<endpoint>.ts # zod schemas — one folder per controller, one file per endpoint (+ shared.ts)
|
|
22
22
|
services/<name>.service.ts # plain class + singleton — the real logic (controllers stay thin)
|
|
23
|
-
db
|
|
23
|
+
db/<schema>.ts # one file per Postgres schema (db/public.ts in a fresh scaffold)
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
The four folders above are the daily surface. Three more are discovered by name,
|
|
@@ -40,7 +40,7 @@ service the controllers call.
|
|
|
40
40
|
that side effect alone — the shipped scaffold's own `HealthController` and
|
|
41
41
|
`NotesController` are not exported. Exporting is harmless and reads well, so
|
|
42
42
|
these examples do it; it is not a requirement. (`export default` **is**
|
|
43
|
-
required for `jobs/`, `webhooks/`, `hooks/` and `db
|
|
43
|
+
required for `jobs/`, `webhooks/`, `hooks/` and every `db/*.ts`, one class per
|
|
44
44
|
file.) What IS fatal is a `@Controller` class that collected zero routes —
|
|
45
45
|
usually `experimentalDecorators` missing from `tsconfig.json`.
|
|
46
46
|
2. **Methods that call a service are `async` and return `Promise<T>`.** Services
|
|
@@ -177,7 +177,7 @@ export default class TodosController {
|
|
|
177
177
|
```
|
|
178
178
|
|
|
179
179
|
```ts
|
|
180
|
-
// db/
|
|
180
|
+
// db/public.ts — config-as-code; the deploy auto-migrates additive changes.
|
|
181
181
|
import { defineSchema, uuid, text, boolean, timestamp, policy } from "@palbase/backend";
|
|
182
182
|
export default defineSchema({
|
|
183
183
|
tables: {
|
|
@@ -218,7 +218,7 @@ generated client surface) changes; the verb/path do not affect it.
|
|
|
218
218
|
(stage, bundle, extract controller metadata). Exits non-zero on a decorator,
|
|
219
219
|
return-type or version-skew error, so a push that would deploy zero endpoints
|
|
220
220
|
fails on your machine instead. It also regenerates `palbase-env.d.ts` from
|
|
221
|
-
`db
|
|
221
|
+
your `db/*.ts` files, which is what types `Database.tables.*` (no import, no
|
|
222
222
|
generic) — so run it after editing the schema. There is no separate command
|
|
223
223
|
for that: `build` regenerates everything derived.
|
|
224
224
|
- `palbase push` — deploy the current backend to the selected Environment. For a
|
|
@@ -261,9 +261,10 @@ The **only difference** is the trigger argument:
|
|
|
261
261
|
| **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
|
|
262
262
|
| **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
|
|
263
263
|
|
|
264
|
-
`defineMiddleware`
|
|
265
|
-
|
|
266
|
-
`ctx` anywhere on a path that runs
|
|
264
|
+
`defineMiddleware(fn)` **throws when called**: no bundler reads a `middleware/`
|
|
265
|
+
directory and the engine never calls one, so a handler written against it would
|
|
266
|
+
deploy and never run. There is no `ctx` anywhere on a path that runs — every
|
|
267
|
+
handler above imports its services.
|
|
267
268
|
|
|
268
269
|
`meta` carries non-service data: `env` (Environment variables),
|
|
269
270
|
`environmentId`, and for webhooks `requestId`. Services always come from
|
|
@@ -287,7 +288,7 @@ my-backend/
|
|
|
287
288
|
```
|
|
288
289
|
|
|
289
290
|
There is **no `db/migrations/`**. Nothing generates a migration file, nothing
|
|
290
|
-
commits one and nothing replays one: `db
|
|
291
|
+
commits one and nothing replays one: `db/*.ts` is diffed against the live
|
|
291
292
|
database and applied — see [migrations.md](./migrations.md).
|
|
292
293
|
|
|
293
294
|
HTTP endpoints are **not** file-path routed. You author a class controller
|
package/docs/database.md
CHANGED
|
@@ -12,7 +12,7 @@ and [events.md](./events.md) for worker/job/hook/webhook examples).
|
|
|
12
12
|
|
|
13
13
|
## Typed by default — `Database.tables`
|
|
14
14
|
|
|
15
|
-
When you declare `db
|
|
15
|
+
When you declare a schema under `db/*.ts`, `Database.tables.<name>` is typed everywhere
|
|
16
16
|
with no import and no generic. `insert` demands the right columns; rows come
|
|
17
17
|
back typed; nullable columns are `T | null`. This is the path you should use:
|
|
18
18
|
|
|
@@ -274,7 +274,7 @@ default.
|
|
|
274
274
|
|
|
275
275
|
The Postgres role it connects as is **`backend_authenticated`** (or
|
|
276
276
|
`backend_anon` when there is no signed-in user) — not `authenticated`. You
|
|
277
|
-
rarely need to know that, because a policy declared in `db
|
|
277
|
+
rarely need to know that, because a policy declared in `db/*.ts` is
|
|
278
278
|
deployed targeting both. It matters in exactly one place: **hand-written
|
|
279
279
|
`CREATE POLICY` SQL in a migration must name both roles**, or it applies to
|
|
280
280
|
nothing your code does. See
|
package/docs/endpoints.md
CHANGED
|
@@ -155,11 +155,12 @@ See [errors.md](./errors.md) for the full set + the wire envelope shape.
|
|
|
155
155
|
|
|
156
156
|
## There is no middleware
|
|
157
157
|
|
|
158
|
-
`defineMiddleware(async (ctx, next) => { … })`
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
`defineMiddleware(async (ctx, next) => { … })` **throws**. Nothing mounts a
|
|
159
|
+
`middleware/` directory and the engine has no middleware pipeline, so a handler
|
|
160
|
+
written against it used to deploy and then never run — silently. The call now
|
|
161
|
+
refuses and names where the work belongs, rather than handing you a shell.
|
|
162
|
+
|
|
163
|
+
There is no `ctx` object anywhere on a path that executes.
|
|
163
164
|
|
|
164
165
|
Cross-cutting work goes in a service the controllers call, and the route concerns
|
|
165
166
|
that used to live in a wrapper are route options instead: `auth` on `@Controller`
|
package/docs/getting-started.md
CHANGED
|
@@ -47,7 +47,7 @@ start. The loop is: edit, validate, push to a dev Environment.
|
|
|
47
47
|
own metadata extractor over the result, so a bad decorator, an illegal return
|
|
48
48
|
type or an SDK major skew fails here rather than shipping a deploy that
|
|
49
49
|
serves zero endpoints. It is wired into a `pre-push` git hook for you. It also
|
|
50
|
-
regenerates `palbase-env.d.ts` from `db
|
|
50
|
+
regenerates `palbase-env.d.ts` from your `db/*.ts` files, which is what types
|
|
51
51
|
`Database.tables.*` (no import, no generic), so run it after editing the
|
|
52
52
|
schema — there is no separate command for that. See
|
|
53
53
|
[migrations.md](./migrations.md) for the schema side.
|
package/docs/llms-full.txt
CHANGED
|
@@ -28,7 +28,7 @@ deploy gate or the TypeScript compile.
|
|
|
28
28
|
controllers/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
|
|
29
29
|
models/<controller>/<endpoint>.ts # zod schemas — one folder per controller, one file per endpoint (+ shared.ts)
|
|
30
30
|
services/<name>.service.ts # plain class + singleton — the real logic (controllers stay thin)
|
|
31
|
-
db
|
|
31
|
+
db/<schema>.ts # one file per Postgres schema (db/public.ts in a fresh scaffold)
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
The four folders above are the daily surface. Three more are discovered by name,
|
|
@@ -48,7 +48,7 @@ service the controllers call.
|
|
|
48
48
|
that side effect alone — the shipped scaffold's own `HealthController` and
|
|
49
49
|
`NotesController` are not exported. Exporting is harmless and reads well, so
|
|
50
50
|
these examples do it; it is not a requirement. (`export default` **is**
|
|
51
|
-
required for `jobs/`, `webhooks/`, `hooks/` and `db
|
|
51
|
+
required for `jobs/`, `webhooks/`, `hooks/` and every `db/*.ts`, one class per
|
|
52
52
|
file.) What IS fatal is a `@Controller` class that collected zero routes —
|
|
53
53
|
usually `experimentalDecorators` missing from `tsconfig.json`.
|
|
54
54
|
2. **Methods that call a service are `async` and return `Promise<T>`.** Services
|
|
@@ -185,7 +185,7 @@ export default class TodosController {
|
|
|
185
185
|
```
|
|
186
186
|
|
|
187
187
|
```ts
|
|
188
|
-
// db/
|
|
188
|
+
// db/public.ts — config-as-code; the deploy auto-migrates additive changes.
|
|
189
189
|
import { defineSchema, uuid, text, boolean, timestamp, policy } from "@palbase/backend";
|
|
190
190
|
export default defineSchema({
|
|
191
191
|
tables: {
|
|
@@ -226,7 +226,7 @@ generated client surface) changes; the verb/path do not affect it.
|
|
|
226
226
|
(stage, bundle, extract controller metadata). Exits non-zero on a decorator,
|
|
227
227
|
return-type or version-skew error, so a push that would deploy zero endpoints
|
|
228
228
|
fails on your machine instead. It also regenerates `palbase-env.d.ts` from
|
|
229
|
-
`db
|
|
229
|
+
your `db/*.ts` files, which is what types `Database.tables.*` (no import, no
|
|
230
230
|
generic) — so run it after editing the schema. There is no separate command
|
|
231
231
|
for that: `build` regenerates everything derived.
|
|
232
232
|
- `palbase push` — deploy the current backend to the selected Environment. For a
|
|
@@ -269,9 +269,10 @@ The **only difference** is the trigger argument:
|
|
|
269
269
|
| **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
|
|
270
270
|
| **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
|
|
271
271
|
|
|
272
|
-
`defineMiddleware`
|
|
273
|
-
|
|
274
|
-
`ctx` anywhere on a path that runs
|
|
272
|
+
`defineMiddleware(fn)` **throws when called**: no bundler reads a `middleware/`
|
|
273
|
+
directory and the engine never calls one, so a handler written against it would
|
|
274
|
+
deploy and never run. There is no `ctx` anywhere on a path that runs — every
|
|
275
|
+
handler above imports its services.
|
|
275
276
|
|
|
276
277
|
`meta` carries non-service data: `env` (Environment variables),
|
|
277
278
|
`environmentId`, and for webhooks `requestId`. Services always come from
|
|
@@ -295,7 +296,7 @@ my-backend/
|
|
|
295
296
|
```
|
|
296
297
|
|
|
297
298
|
There is **no `db/migrations/`**. Nothing generates a migration file, nothing
|
|
298
|
-
commits one and nothing replays one: `db
|
|
299
|
+
commits one and nothing replays one: `db/*.ts` is diffed against the live
|
|
299
300
|
database and applied — see [migrations.md](./migrations.md).
|
|
300
301
|
|
|
301
302
|
HTTP endpoints are **not** file-path routed. You author a class controller
|
|
@@ -375,7 +376,7 @@ start. The loop is: edit, validate, push to a dev Environment.
|
|
|
375
376
|
own metadata extractor over the result, so a bad decorator, an illegal return
|
|
376
377
|
type or an SDK major skew fails here rather than shipping a deploy that
|
|
377
378
|
serves zero endpoints. It is wired into a `pre-push` git hook for you. It also
|
|
378
|
-
regenerates `palbase-env.d.ts` from `db
|
|
379
|
+
regenerates `palbase-env.d.ts` from your `db/*.ts` files, which is what types
|
|
379
380
|
`Database.tables.*` (no import, no generic), so run it after editing the
|
|
380
381
|
schema — there is no separate command for that. See
|
|
381
382
|
[migrations.md](./migrations.md) for the schema side.
|
|
@@ -669,11 +670,12 @@ See [errors.md](./errors.md) for the full set + the wire envelope shape.
|
|
|
669
670
|
|
|
670
671
|
## There is no middleware
|
|
671
672
|
|
|
672
|
-
`defineMiddleware(async (ctx, next) => { … })`
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
673
|
+
`defineMiddleware(async (ctx, next) => { … })` **throws**. Nothing mounts a
|
|
674
|
+
`middleware/` directory and the engine has no middleware pipeline, so a handler
|
|
675
|
+
written against it used to deploy and then never run — silently. The call now
|
|
676
|
+
refuses and names where the work belongs, rather than handing you a shell.
|
|
677
|
+
|
|
678
|
+
There is no `ctx` object anywhere on a path that executes.
|
|
677
679
|
|
|
678
680
|
Cross-cutting work goes in a service the controllers call, and the route concerns
|
|
679
681
|
that used to live in a wrapper are route options instead: `auth` on `@Controller`
|
|
@@ -881,7 +883,7 @@ and [events.md](./events.md) for worker/job/hook/webhook examples).
|
|
|
881
883
|
|
|
882
884
|
## Typed by default — `Database.tables`
|
|
883
885
|
|
|
884
|
-
When you declare `db
|
|
886
|
+
When you declare a schema under `db/*.ts`, `Database.tables.<name>` is typed everywhere
|
|
885
887
|
with no import and no generic. `insert` demands the right columns; rows come
|
|
886
888
|
back typed; nullable columns are `T | null`. This is the path you should use:
|
|
887
889
|
|
|
@@ -1143,7 +1145,7 @@ default.
|
|
|
1143
1145
|
|
|
1144
1146
|
The Postgres role it connects as is **`backend_authenticated`** (or
|
|
1145
1147
|
`backend_anon` when there is no signed-in user) — not `authenticated`. You
|
|
1146
|
-
rarely need to know that, because a policy declared in `db
|
|
1148
|
+
rarely need to know that, because a policy declared in `db/*.ts` is
|
|
1147
1149
|
deployed targeting both. It matters in exactly one place: **hand-written
|
|
1148
1150
|
`CREATE POLICY` SQL in a migration must name both roles**, or it applies to
|
|
1149
1151
|
nothing your code does. See
|
|
@@ -1323,7 +1325,8 @@ export const invoices = defineTable("invoices", {
|
|
|
1323
1325
|
|
|
1324
1326
|
Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
|
|
1325
1327
|
`.default(value)`, `.defaultRandom()` (uuid → `gen_random_uuid()`),
|
|
1326
|
-
`.defaultNow()` (timestamp → `now()`),
|
|
1328
|
+
`.defaultNow()` (timestamp → `now()`),
|
|
1329
|
+
`.references(() => table.column, { as?, reverseAs?, onDelete? })`,
|
|
1327
1330
|
`.selfReferences("column", opts?)`,
|
|
1328
1331
|
`.onDelete("cascade" | "set null" | "restrict" | "no action")`, `.ignored()`.
|
|
1329
1332
|
|
|
@@ -1368,14 +1371,38 @@ export const orgs = defineTable("orgs", {
|
|
|
1368
1371
|
});
|
|
1369
1372
|
```
|
|
1370
1373
|
|
|
1371
|
-
|
|
1372
|
-
|
|
1374
|
+
### Relation names
|
|
1375
|
+
|
|
1376
|
+
Every foreign key produces **two** named relations, and the two names are derived
|
|
1377
|
+
separately:
|
|
1378
|
+
|
|
1379
|
+
| Direction | Where it appears | Default name | Option that changes it |
|
|
1380
|
+
|---|---|---|---|
|
|
1381
|
+
| forward (child → parent) | on the child | the column minus `_id` — `list_id` → `list` | `as` |
|
|
1382
|
+
| reverse (parent → children) | on the parent | the **child table's** name — `lists.todos` | `reverseAs` |
|
|
1383
|
+
|
|
1384
|
+
So `todos.list_id → lists` gives `todos.list` and `lists.todos`, and neither
|
|
1385
|
+
needs declaring. `as` renames the forward side only: `author_id` declared
|
|
1386
|
+
`{ as: "author" }` gives `posts.author` and still gives `users.posts`. That is
|
|
1387
|
+
why the two options exist separately — `posts` and `comments` can both call their
|
|
1388
|
+
forward relation `author` without colliding, because what lands on `users` is
|
|
1389
|
+
`posts` and `comments`.
|
|
1390
|
+
|
|
1391
|
+
**Two foreign keys from one table to the same parent** collide on the reverse
|
|
1392
|
+
side: both reverse relations want the child table's name. Name them:
|
|
1373
1393
|
|
|
1374
1394
|
```ts
|
|
1375
|
-
billing_address_id: uuid().references(() => addresses.id, {
|
|
1376
|
-
shipping_address_id: uuid().references(() => addresses.id, {
|
|
1395
|
+
billing_address_id: uuid().references(() => addresses.id, { reverseAs: "billed_orders" }),
|
|
1396
|
+
shipping_address_id: uuid().references(() => addresses.id, { reverseAs: "shipped_orders" }),
|
|
1377
1397
|
```
|
|
1378
1398
|
|
|
1399
|
+
The forward names here (`billing_address`, `shipping_address`) are already
|
|
1400
|
+
distinct, so no `as` is needed. Add one when two columns WOULD derive the same
|
|
1401
|
+
forward name.
|
|
1402
|
+
|
|
1403
|
+
Any two relations resolving to one name on one table are refused at push, with
|
|
1404
|
+
both relations named and the option that separates them.
|
|
1405
|
+
|
|
1379
1406
|
## Rows that belong to a user
|
|
1380
1407
|
|
|
1381
1408
|
There is no `public.users` table: auth users live in the `auth` schema of the same
|
|
@@ -1397,6 +1424,12 @@ device_id: installationRef({ onDelete: "cascade" }),
|
|
|
1397
1424
|
| Account erasure follows it | yes | no | no |
|
|
1398
1425
|
| Per table | **at most one** | unlimited | unlimited |
|
|
1399
1426
|
|
|
1427
|
+
Several `userRef` columns on one table are fine — they are ordinary foreign keys
|
|
1428
|
+
and each takes its own relation name from its column (`created_by`, `edited_by`).
|
|
1429
|
+
Only two of them resolving to the SAME name is refused, and `userRef({ onDelete,
|
|
1430
|
+
as })` is how you separate them. `auth.users` is not a table of your schema, so
|
|
1431
|
+
none of these produce a reverse relation to collide over.
|
|
1432
|
+
|
|
1400
1433
|
`ownedByUser()` takes no `onDelete` because there is only one correct answer:
|
|
1401
1434
|
ownership is what account erasure walks, so a row owned by an account has to go
|
|
1402
1435
|
when the account does. `"set null"` on a `userRef` needs a `.nullable()` column.
|
|
@@ -1489,7 +1522,7 @@ Add a value instead, and let the old one die:
|
|
|
1489
1522
|
|
|
1490
1523
|
```sql
|
|
1491
1524
|
-- 1. Add the new label. This IS safe while the previous release serves.
|
|
1492
|
-
-- (Declare it in
|
|
1525
|
+
-- (Declare it in the schema file; the rail emits ALTER TYPE … ADD VALUE.)
|
|
1493
1526
|
-- 2. Move the data:
|
|
1494
1527
|
UPDATE posts SET status = 'review' WHERE status = 'onay';
|
|
1495
1528
|
-- 3. Stop naming the old value in the next release.
|
|
@@ -1589,7 +1622,7 @@ of the schema, no generic, no cast:
|
|
|
1589
1622
|
// services/room.service.ts — the layer that touches the database.
|
|
1590
1623
|
import { Database } from "@palbase/backend";
|
|
1591
1624
|
|
|
1592
|
-
type RoomsTable = typeof Database.tables.rooms; // typed from db
|
|
1625
|
+
type RoomsTable = typeof Database.tables.rooms; // typed from your db/*.ts
|
|
1593
1626
|
|
|
1594
1627
|
export class RoomService {
|
|
1595
1628
|
private readonly rooms: RoomsTable;
|
|
@@ -1773,7 +1806,9 @@ so they apply without the `acceptDataLoss` confirmation that column drops need.
|
|
|
1773
1806
|
|
|
1774
1807
|
# Migrations
|
|
1775
1808
|
|
|
1776
|
-
`db
|
|
1809
|
+
Your `db/*.ts` files are the single source of truth for your Postgres schema — one
|
|
1810
|
+
file per schema, `db/public.ts` in a fresh scaffold. (It was a single
|
|
1811
|
+
`db/schema.ts` until 2026-08-31.) You change
|
|
1777
1812
|
the schema by editing that file — and **nothing generates a migration from it**.
|
|
1778
1813
|
There is no diff-to-file step: the change is computed against the database *as it
|
|
1779
1814
|
is right now*, by the stack that owns it, which is what lets it cover type
|
|
@@ -1794,7 +1829,7 @@ It is not where an ordinary column change goes any more.
|
|
|
1794
1829
|
## The workflow
|
|
1795
1830
|
|
|
1796
1831
|
```bash
|
|
1797
|
-
# 1. Edit db/
|
|
1832
|
+
# 1. Edit the schema file — db/public.ts in a fresh scaffold (add a column, a table, a policy, …)
|
|
1798
1833
|
|
|
1799
1834
|
# 2. See what it would take. THIS APPLIES NOTHING.
|
|
1800
1835
|
palbase db plan
|
|
@@ -1810,9 +1845,9 @@ palbase db apply
|
|
|
1810
1845
|
palbase push
|
|
1811
1846
|
```
|
|
1812
1847
|
|
|
1813
|
-
`palbase db plan` hands `db
|
|
1848
|
+
`palbase db plan` hands your `db/*.ts` declarations to the local stack, which computes the plan
|
|
1814
1849
|
against its own database and answers with what it would change. When there is
|
|
1815
|
-
nothing to do it says `✓ the database matches db
|
|
1850
|
+
nothing to do it says `✓ the database matches db/*.ts`. It writes nothing,
|
|
1816
1851
|
ever — neither a file nor a row — so it is safe to run at any moment, including
|
|
1817
1852
|
from a script (`--detailed-exitcode` exits 2 when the plan would change
|
|
1818
1853
|
something, 0 when it is in sync).
|
|
@@ -1854,19 +1889,19 @@ Code and schema move at different speeds, so three things keep them together:
|
|
|
1854
1889
|
validation the deploy runs, so a push that would produce a failed deploy is
|
|
1855
1890
|
caught on your machine first. (Bypass with `git push --no-verify` — the server
|
|
1856
1891
|
still gates it.)
|
|
1857
|
-
- On deploy, the migrations run and Palbase then asserts `db
|
|
1892
|
+
- On deploy, the migrations run and Palbase then asserts `db/*.ts` matches
|
|
1858
1893
|
the live database. Unresolved drift **fails the deploy** and keeps the previous
|
|
1859
1894
|
version live — a broken schema never goes out silently.
|
|
1860
1895
|
|
|
1861
1896
|
## Your schema change is not live anywhere you have not applied it
|
|
1862
1897
|
|
|
1863
1898
|
The local stack and every Environment hold their own database, and editing
|
|
1864
|
-
`db
|
|
1899
|
+
Editing `db/*.ts` changes none of them: `palbase db apply` is what moves the local
|
|
1865
1900
|
one, `palbase push` is what moves an Environment's. Until then the declaration is
|
|
1866
1901
|
ahead of the tables.
|
|
1867
1902
|
|
|
1868
1903
|
The TYPES move separately again. `palbase build` regenerates `palbase-env.d.ts`
|
|
1869
|
-
from `db
|
|
1904
|
+
from `db/*.ts`, which is what types `Database.tables.<name>` in your
|
|
1870
1905
|
services — so after a schema edit, run it. (There is no second command for this:
|
|
1871
1906
|
`build` regenerates everything derived, because one verb to remember is one verb
|
|
1872
1907
|
to forget.) Typed and applied are independent, and knowing which one you are
|
|
@@ -1881,7 +1916,7 @@ trigger, a `CHECK` constraint, an `EXCLUDE` constraint, a composite/multi-column
|
|
|
1881
1916
|
`UNIQUE`, an index (`CREATE INDEX`), or `CREATE EXTENSION` for an extension that
|
|
1882
1917
|
isn't in the DSL allowlist (e.g. `btree_gist`). The DSL column builders cover
|
|
1883
1918
|
single-column constraints only; everything above is hand-written SQL. Where the
|
|
1884
|
-
DSL *can* model the table, keep `db
|
|
1919
|
+
DSL *can* model the table, keep `db/*.ts` as the declared end-state and add
|
|
1885
1920
|
only the extra object in raw SQL so the drift gate passes (see below); a table
|
|
1886
1921
|
the DSL can't model at all lives entirely in raw SQL.
|
|
1887
1922
|
|
|
@@ -1889,7 +1924,7 @@ Migrations run on the privileged DDL connection, so `CREATE EXTENSION` and
|
|
|
1889
1924
|
`ALTER TABLE ... ADD CONSTRAINT` work. Example — race-free, DB-enforced
|
|
1890
1925
|
double-booking prevention (no instructor in two overlapping sessions), which the
|
|
1891
1926
|
DSL can't express. The `during` column is a `tstzrange` (no DSL builder), so the
|
|
1892
|
-
whole table is created in raw SQL — it can't come from `db
|
|
1927
|
+
whole table is created in raw SQL — it can't come from `db/*.ts`:
|
|
1893
1928
|
|
|
1894
1929
|
```sql
|
|
1895
1930
|
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
|
@@ -1910,7 +1945,7 @@ different instructor in the same slot are accepted. No application-level check.
|
|
|
1910
1945
|
### Drift gate and raw-SQL objects
|
|
1911
1946
|
|
|
1912
1947
|
The deploy-time drift gate only asserts that everything **declared** in
|
|
1913
|
-
`db
|
|
1948
|
+
`db/*.ts` declares exists in the live DB (declared ⊆ live), and it inspects tables,
|
|
1914
1949
|
columns, and column types **only** — not constraints or indexes. So a
|
|
1915
1950
|
hand-written constraint or index is invisible to the gate, and a live object you
|
|
1916
1951
|
never declared is tolerated (not drift, not dropped — it survives redeploys).
|
|
@@ -1918,12 +1953,12 @@ never declared is tolerated (not drift, not dropped — it survives redeploys).
|
|
|
1918
1953
|
Two patterns follow from this:
|
|
1919
1954
|
|
|
1920
1955
|
- **Extra constraint/index on a DSL-expressible table** — declare the table in
|
|
1921
|
-
`db
|
|
1956
|
+
`db/*.ts` and add **only** the constraint/index via the migration. The
|
|
1922
1957
|
base table stays modeled, the gate ignores the constraint, and `palbase db
|
|
1923
1958
|
plan` stays clean.
|
|
1924
1959
|
- **A whole object the DSL can't model** (like `instructor_sessions` above — its
|
|
1925
1960
|
`tstzrange` column has no DSL builder) — the table lives entirely in raw SQL,
|
|
1926
|
-
not in `db
|
|
1961
|
+
not in `db/*.ts`. The deploy still succeeds, but `palbase db plan` will
|
|
1927
1962
|
list it as a ⚠ would-be-drop for the local database; that is expected, and the
|
|
1928
1963
|
deploy-time gate does not block on it. Never approve that one.
|
|
1929
1964
|
|
|
@@ -1931,12 +1966,12 @@ Two patterns follow from this:
|
|
|
1931
1966
|
|
|
1932
1967
|
There's nothing to put in existing rows, so do it in two migrations: first add it
|
|
1933
1968
|
nullable (or with a default) and backfill, then a follow-up `ALTER ... SET NOT
|
|
1934
|
-
NULL`. `db
|
|
1969
|
+
NULL`. `db/*.ts` describes the end state; the migrations describe how
|
|
1935
1970
|
existing data gets there.
|
|
1936
1971
|
|
|
1937
1972
|
## Row-Level Security
|
|
1938
1973
|
|
|
1939
|
-
Add `rls: true` + `policies: [policy(...)]` to a table in `db
|
|
1974
|
+
Add `rls: true` + `policies: [policy(...)]` to a table in `db/*.ts`; the
|
|
1940
1975
|
generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
|
|
1941
1976
|
See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
|
|
1942
1977
|
`Database.tables.*` access.
|
|
@@ -2020,7 +2055,8 @@ const v = await Cache.get<{ hits: number }>("k"); // typed, null on miss
|
|
|
2020
2055
|
await Cache.incr("counter");
|
|
2021
2056
|
await Cache.del("k");
|
|
2022
2057
|
|
|
2023
|
-
// Stampede-safe read-through:
|
|
2058
|
+
// Stampede-safe read-through: concurrent callers wait on ONE execution of fn
|
|
2059
|
+
// and share its result, however many of them there are.
|
|
2024
2060
|
const profile = await Cache.getOrSet("user:42", 300, async () => {
|
|
2025
2061
|
return Database.findById("users", "42");
|
|
2026
2062
|
});
|
package/docs/migrations.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Migrations
|
|
2
2
|
|
|
3
|
-
`db
|
|
3
|
+
Your `db/*.ts` files are the single source of truth for your Postgres schema — one
|
|
4
|
+
file per schema, `db/public.ts` in a fresh scaffold. (It was a single
|
|
5
|
+
`db/schema.ts` until 2026-08-31.) You change
|
|
4
6
|
the schema by editing that file — and **nothing generates a migration from it**.
|
|
5
7
|
There is no diff-to-file step: the change is computed against the database *as it
|
|
6
8
|
is right now*, by the stack that owns it, which is what lets it cover type
|
|
@@ -21,7 +23,7 @@ It is not where an ordinary column change goes any more.
|
|
|
21
23
|
## The workflow
|
|
22
24
|
|
|
23
25
|
```bash
|
|
24
|
-
# 1. Edit db/
|
|
26
|
+
# 1. Edit the schema file — db/public.ts in a fresh scaffold (add a column, a table, a policy, …)
|
|
25
27
|
|
|
26
28
|
# 2. See what it would take. THIS APPLIES NOTHING.
|
|
27
29
|
palbase db plan
|
|
@@ -37,9 +39,9 @@ palbase db apply
|
|
|
37
39
|
palbase push
|
|
38
40
|
```
|
|
39
41
|
|
|
40
|
-
`palbase db plan` hands `db
|
|
42
|
+
`palbase db plan` hands your `db/*.ts` declarations to the local stack, which computes the plan
|
|
41
43
|
against its own database and answers with what it would change. When there is
|
|
42
|
-
nothing to do it says `✓ the database matches db
|
|
44
|
+
nothing to do it says `✓ the database matches db/*.ts`. It writes nothing,
|
|
43
45
|
ever — neither a file nor a row — so it is safe to run at any moment, including
|
|
44
46
|
from a script (`--detailed-exitcode` exits 2 when the plan would change
|
|
45
47
|
something, 0 when it is in sync).
|
|
@@ -81,19 +83,19 @@ Code and schema move at different speeds, so three things keep them together:
|
|
|
81
83
|
validation the deploy runs, so a push that would produce a failed deploy is
|
|
82
84
|
caught on your machine first. (Bypass with `git push --no-verify` — the server
|
|
83
85
|
still gates it.)
|
|
84
|
-
- On deploy, the migrations run and Palbase then asserts `db
|
|
86
|
+
- On deploy, the migrations run and Palbase then asserts `db/*.ts` matches
|
|
85
87
|
the live database. Unresolved drift **fails the deploy** and keeps the previous
|
|
86
88
|
version live — a broken schema never goes out silently.
|
|
87
89
|
|
|
88
90
|
## Your schema change is not live anywhere you have not applied it
|
|
89
91
|
|
|
90
92
|
The local stack and every Environment hold their own database, and editing
|
|
91
|
-
`db
|
|
93
|
+
Editing `db/*.ts` changes none of them: `palbase db apply` is what moves the local
|
|
92
94
|
one, `palbase push` is what moves an Environment's. Until then the declaration is
|
|
93
95
|
ahead of the tables.
|
|
94
96
|
|
|
95
97
|
The TYPES move separately again. `palbase build` regenerates `palbase-env.d.ts`
|
|
96
|
-
from `db
|
|
98
|
+
from `db/*.ts`, which is what types `Database.tables.<name>` in your
|
|
97
99
|
services — so after a schema edit, run it. (There is no second command for this:
|
|
98
100
|
`build` regenerates everything derived, because one verb to remember is one verb
|
|
99
101
|
to forget.) Typed and applied are independent, and knowing which one you are
|
|
@@ -108,7 +110,7 @@ trigger, a `CHECK` constraint, an `EXCLUDE` constraint, a composite/multi-column
|
|
|
108
110
|
`UNIQUE`, an index (`CREATE INDEX`), or `CREATE EXTENSION` for an extension that
|
|
109
111
|
isn't in the DSL allowlist (e.g. `btree_gist`). The DSL column builders cover
|
|
110
112
|
single-column constraints only; everything above is hand-written SQL. Where the
|
|
111
|
-
DSL *can* model the table, keep `db
|
|
113
|
+
DSL *can* model the table, keep `db/*.ts` as the declared end-state and add
|
|
112
114
|
only the extra object in raw SQL so the drift gate passes (see below); a table
|
|
113
115
|
the DSL can't model at all lives entirely in raw SQL.
|
|
114
116
|
|
|
@@ -116,7 +118,7 @@ Migrations run on the privileged DDL connection, so `CREATE EXTENSION` and
|
|
|
116
118
|
`ALTER TABLE ... ADD CONSTRAINT` work. Example — race-free, DB-enforced
|
|
117
119
|
double-booking prevention (no instructor in two overlapping sessions), which the
|
|
118
120
|
DSL can't express. The `during` column is a `tstzrange` (no DSL builder), so the
|
|
119
|
-
whole table is created in raw SQL — it can't come from `db
|
|
121
|
+
whole table is created in raw SQL — it can't come from `db/*.ts`:
|
|
120
122
|
|
|
121
123
|
```sql
|
|
122
124
|
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
|
@@ -137,7 +139,7 @@ different instructor in the same slot are accepted. No application-level check.
|
|
|
137
139
|
### Drift gate and raw-SQL objects
|
|
138
140
|
|
|
139
141
|
The deploy-time drift gate only asserts that everything **declared** in
|
|
140
|
-
`db
|
|
142
|
+
`db/*.ts` declares exists in the live DB (declared ⊆ live), and it inspects tables,
|
|
141
143
|
columns, and column types **only** — not constraints or indexes. So a
|
|
142
144
|
hand-written constraint or index is invisible to the gate, and a live object you
|
|
143
145
|
never declared is tolerated (not drift, not dropped — it survives redeploys).
|
|
@@ -145,12 +147,12 @@ never declared is tolerated (not drift, not dropped — it survives redeploys).
|
|
|
145
147
|
Two patterns follow from this:
|
|
146
148
|
|
|
147
149
|
- **Extra constraint/index on a DSL-expressible table** — declare the table in
|
|
148
|
-
`db
|
|
150
|
+
`db/*.ts` and add **only** the constraint/index via the migration. The
|
|
149
151
|
base table stays modeled, the gate ignores the constraint, and `palbase db
|
|
150
152
|
plan` stays clean.
|
|
151
153
|
- **A whole object the DSL can't model** (like `instructor_sessions` above — its
|
|
152
154
|
`tstzrange` column has no DSL builder) — the table lives entirely in raw SQL,
|
|
153
|
-
not in `db
|
|
155
|
+
not in `db/*.ts`. The deploy still succeeds, but `palbase db plan` will
|
|
154
156
|
list it as a ⚠ would-be-drop for the local database; that is expected, and the
|
|
155
157
|
deploy-time gate does not block on it. Never approve that one.
|
|
156
158
|
|
|
@@ -158,12 +160,12 @@ Two patterns follow from this:
|
|
|
158
160
|
|
|
159
161
|
There's nothing to put in existing rows, so do it in two migrations: first add it
|
|
160
162
|
nullable (or with a default) and backfill, then a follow-up `ALTER ... SET NOT
|
|
161
|
-
NULL`. `db
|
|
163
|
+
NULL`. `db/*.ts` describes the end state; the migrations describe how
|
|
162
164
|
existing data gets there.
|
|
163
165
|
|
|
164
166
|
## Row-Level Security
|
|
165
167
|
|
|
166
|
-
Add `rls: true` + `policies: [policy(...)]` to a table in `db
|
|
168
|
+
Add `rls: true` + `policies: [policy(...)]` to a table in `db/*.ts`; the
|
|
167
169
|
generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
|
|
168
170
|
See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
|
|
169
171
|
`Database.tables.*` access.
|
package/docs/schema.md
CHANGED
|
@@ -121,7 +121,8 @@ export const invoices = defineTable("invoices", {
|
|
|
121
121
|
|
|
122
122
|
Chainable modifiers: `.primaryKey()`, `.notNull()` (default), `.nullable()`,
|
|
123
123
|
`.default(value)`, `.defaultRandom()` (uuid → `gen_random_uuid()`),
|
|
124
|
-
`.defaultNow()` (timestamp → `now()`),
|
|
124
|
+
`.defaultNow()` (timestamp → `now()`),
|
|
125
|
+
`.references(() => table.column, { as?, reverseAs?, onDelete? })`,
|
|
125
126
|
`.selfReferences("column", opts?)`,
|
|
126
127
|
`.onDelete("cascade" | "set null" | "restrict" | "no action")`, `.ignored()`.
|
|
127
128
|
|
|
@@ -166,14 +167,38 @@ export const orgs = defineTable("orgs", {
|
|
|
166
167
|
});
|
|
167
168
|
```
|
|
168
169
|
|
|
169
|
-
|
|
170
|
-
|
|
170
|
+
### Relation names
|
|
171
|
+
|
|
172
|
+
Every foreign key produces **two** named relations, and the two names are derived
|
|
173
|
+
separately:
|
|
174
|
+
|
|
175
|
+
| Direction | Where it appears | Default name | Option that changes it |
|
|
176
|
+
|---|---|---|---|
|
|
177
|
+
| forward (child → parent) | on the child | the column minus `_id` — `list_id` → `list` | `as` |
|
|
178
|
+
| reverse (parent → children) | on the parent | the **child table's** name — `lists.todos` | `reverseAs` |
|
|
179
|
+
|
|
180
|
+
So `todos.list_id → lists` gives `todos.list` and `lists.todos`, and neither
|
|
181
|
+
needs declaring. `as` renames the forward side only: `author_id` declared
|
|
182
|
+
`{ as: "author" }` gives `posts.author` and still gives `users.posts`. That is
|
|
183
|
+
why the two options exist separately — `posts` and `comments` can both call their
|
|
184
|
+
forward relation `author` without colliding, because what lands on `users` is
|
|
185
|
+
`posts` and `comments`.
|
|
186
|
+
|
|
187
|
+
**Two foreign keys from one table to the same parent** collide on the reverse
|
|
188
|
+
side: both reverse relations want the child table's name. Name them:
|
|
171
189
|
|
|
172
190
|
```ts
|
|
173
|
-
billing_address_id: uuid().references(() => addresses.id, {
|
|
174
|
-
shipping_address_id: uuid().references(() => addresses.id, {
|
|
191
|
+
billing_address_id: uuid().references(() => addresses.id, { reverseAs: "billed_orders" }),
|
|
192
|
+
shipping_address_id: uuid().references(() => addresses.id, { reverseAs: "shipped_orders" }),
|
|
175
193
|
```
|
|
176
194
|
|
|
195
|
+
The forward names here (`billing_address`, `shipping_address`) are already
|
|
196
|
+
distinct, so no `as` is needed. Add one when two columns WOULD derive the same
|
|
197
|
+
forward name.
|
|
198
|
+
|
|
199
|
+
Any two relations resolving to one name on one table are refused at push, with
|
|
200
|
+
both relations named and the option that separates them.
|
|
201
|
+
|
|
177
202
|
## Rows that belong to a user
|
|
178
203
|
|
|
179
204
|
There is no `public.users` table: auth users live in the `auth` schema of the same
|
|
@@ -195,6 +220,12 @@ device_id: installationRef({ onDelete: "cascade" }),
|
|
|
195
220
|
| Account erasure follows it | yes | no | no |
|
|
196
221
|
| Per table | **at most one** | unlimited | unlimited |
|
|
197
222
|
|
|
223
|
+
Several `userRef` columns on one table are fine — they are ordinary foreign keys
|
|
224
|
+
and each takes its own relation name from its column (`created_by`, `edited_by`).
|
|
225
|
+
Only two of them resolving to the SAME name is refused, and `userRef({ onDelete,
|
|
226
|
+
as })` is how you separate them. `auth.users` is not a table of your schema, so
|
|
227
|
+
none of these produce a reverse relation to collide over.
|
|
228
|
+
|
|
198
229
|
`ownedByUser()` takes no `onDelete` because there is only one correct answer:
|
|
199
230
|
ownership is what account erasure walks, so a row owned by an account has to go
|
|
200
231
|
when the account does. `"set null"` on a `userRef` needs a `.nullable()` column.
|
|
@@ -287,7 +318,7 @@ Add a value instead, and let the old one die:
|
|
|
287
318
|
|
|
288
319
|
```sql
|
|
289
320
|
-- 1. Add the new label. This IS safe while the previous release serves.
|
|
290
|
-
-- (Declare it in
|
|
321
|
+
-- (Declare it in the schema file; the rail emits ALTER TYPE … ADD VALUE.)
|
|
291
322
|
-- 2. Move the data:
|
|
292
323
|
UPDATE posts SET status = 'review' WHERE status = 'onay';
|
|
293
324
|
-- 3. Stop naming the old value in the next release.
|
|
@@ -387,7 +418,7 @@ of the schema, no generic, no cast:
|
|
|
387
418
|
// services/room.service.ts — the layer that touches the database.
|
|
388
419
|
import { Database } from "@palbase/backend";
|
|
389
420
|
|
|
390
|
-
type RoomsTable = typeof Database.tables.rooms; // typed from db
|
|
421
|
+
type RoomsTable = typeof Database.tables.rooms; // typed from your db/*.ts
|
|
391
422
|
|
|
392
423
|
export class RoomService {
|
|
393
424
|
private readonly rooms: RoomsTable;
|
package/docs/services.md
CHANGED
|
@@ -42,7 +42,8 @@ const v = await Cache.get<{ hits: number }>("k"); // typed, null on miss
|
|
|
42
42
|
await Cache.incr("counter");
|
|
43
43
|
await Cache.del("k");
|
|
44
44
|
|
|
45
|
-
// Stampede-safe read-through:
|
|
45
|
+
// Stampede-safe read-through: concurrent callers wait on ONE execution of fn
|
|
46
|
+
// and share its result, however many of them there are.
|
|
46
47
|
const profile = await Cache.getOrSet("user:42", 300, async () => {
|
|
47
48
|
return Database.findById("users", "42");
|
|
48
49
|
});
|