@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.
Files changed (57) hide show
  1. package/dist/bin/palbase-backend.cjs +2 -0
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +2 -2
  4. package/dist/{chunk-43A3KGWL.js → chunk-34I4GB7D.js} +4 -2
  5. package/dist/{chunk-43A3KGWL.js.map → chunk-34I4GB7D.js.map} +1 -1
  6. package/dist/{chunk-ZQRWW37O.js → chunk-35PNTIRN.js} +5 -1
  7. package/dist/chunk-35PNTIRN.js.map +1 -0
  8. package/dist/{chunk-OEQBHE2Z.js → chunk-7JSEN7UR.js} +20 -4
  9. package/dist/chunk-7JSEN7UR.js.map +1 -0
  10. package/dist/{chunk-5CMLOAEF.js → chunk-YOY5DFQS.js} +2 -2
  11. package/dist/db/env.cjs.map +1 -1
  12. package/dist/db/env.d.cts +2 -1
  13. package/dist/db/env.d.ts +2 -1
  14. package/dist/db/index.cjs +17 -3
  15. package/dist/db/index.cjs.map +1 -1
  16. package/dist/db/index.d.cts +2 -2
  17. package/dist/db/index.d.ts +2 -2
  18. package/dist/db/index.js +1 -1
  19. package/dist/{endpoint-DMwJjEHQ.d.ts → endpoint-CTEHhb7A.d.ts} +19 -8
  20. package/dist/{endpoint-BSGw1pTu.d.cts → endpoint-DYHMo6cC.d.cts} +19 -8
  21. package/dist/engine/index.cjs +2 -0
  22. package/dist/engine/index.cjs.map +1 -1
  23. package/dist/engine/index.d.cts +4 -4
  24. package/dist/engine/index.d.ts +4 -4
  25. package/dist/engine/index.js +2 -2
  26. package/dist/{index-BF1f0DfA.d.ts → index-CUomTA3e.d.ts} +3 -3
  27. package/dist/{index-Ct1iiB4N.d.ts → index-CW21M9Z3.d.ts} +24 -17
  28. package/dist/{index-CoaDN9dL.d.cts → index-ClpDeSos.d.cts} +3 -3
  29. package/dist/{index-CwaWRhyc.d.cts → index-CmBK76nx.d.cts} +24 -17
  30. package/dist/index.cjs +80 -51
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +13 -11
  33. package/dist/index.d.ts +13 -11
  34. package/dist/index.js +62 -51
  35. package/dist/index.js.map +1 -1
  36. package/dist/openapi/index.cjs +1 -1
  37. package/dist/openapi/index.cjs.map +1 -1
  38. package/dist/openapi/index.d.cts +2 -2
  39. package/dist/openapi/index.d.ts +2 -2
  40. package/dist/openapi/index.js +3 -3
  41. package/dist/openapi/index.js.map +1 -1
  42. package/dist/{registry-CgJvDYUW.d.cts → registry-CC0WBQq6.d.cts} +1 -1
  43. package/dist/{registry-B8pld3fj.d.ts → registry-dZZ5JKYg.d.ts} +1 -1
  44. package/dist/test/index.d.cts +1 -1
  45. package/dist/test/index.d.ts +1 -1
  46. package/docs/README.md +9 -8
  47. package/docs/database.md +2 -2
  48. package/docs/endpoints.md +6 -5
  49. package/docs/getting-started.md +1 -1
  50. package/docs/llms-full.txt +74 -38
  51. package/docs/migrations.md +16 -14
  52. package/docs/schema.md +38 -7
  53. package/docs/services.md +2 -1
  54. package/package.json +1 -1
  55. package/dist/chunk-OEQBHE2Z.js.map +0 -1
  56. package/dist/chunk-ZQRWW37O.js.map +0 -1
  57. /package/dist/{chunk-5CMLOAEF.js.map → chunk-YOY5DFQS.js.map} +0 -0
@@ -1,4 +1,4 @@
1
- import { A as AuthSpec, R as RateLimitConfig } from './endpoint-BSGw1pTu.cjs';
1
+ import { A as AuthSpec, R as RateLimitConfig } from './endpoint-DYHMo6cC.cjs';
2
2
  import { ZodTypeAny } from 'zod';
3
3
  import { PalbaseBucketName } from './stack.cjs';
4
4
 
@@ -1,4 +1,4 @@
1
- import { A as AuthSpec, R as RateLimitConfig } from './endpoint-DMwJjEHQ.js';
1
+ import { A as AuthSpec, R as RateLimitConfig } from './endpoint-CTEHhb7A.js';
2
2
  import { ZodTypeAny } from 'zod';
3
3
  import { PalbaseBucketName } from './stack.js';
4
4
 
@@ -1,4 +1,4 @@
1
- import { D as DBClient } from '../endpoint-BSGw1pTu.cjs';
1
+ import { D as DBClient } from '../endpoint-DYHMo6cC.cjs';
2
2
  import 'zod';
3
3
  import '../stack.cjs';
4
4
 
@@ -1,4 +1,4 @@
1
- import { D as DBClient } from '../endpoint-DMwJjEHQ.js';
1
+ import { D as DBClient } from '../endpoint-CTEHhb7A.js';
2
2
  import 'zod';
3
3
  import '../stack.js';
4
4
 
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/schema.ts # config-as-code Postgres schema (tables, columns, RLS) auto-migrated on deploy
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/public.ts`, one class per
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/schema.ts — config-as-code; the deploy auto-migrates additive changes.
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/schema.ts`, which is what types `Database.tables.*` (no import, no
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` is still exported and takes `(ctx, next)`, but **no bundler
265
- reads a `middleware/` directory and the engine never calls one** there is no
266
- `ctx` anywhere on a path that runs. Every handler above imports its services.
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/schema.ts` is diffed against the live
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/schema.ts`, `Database.tables.<name>` is typed everywhere
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/schema.ts` is
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) => { … })` is still exported and still
159
- type-checks, but **nothing mounts a `middleware/` directory and the engine has no
160
- middleware pipeline** a handler written against it deploys, never runs, and
161
- nothing reports it. There is no `ctx` object anywhere on a path that executes, so
162
- treat the export as a leftover rather than a seam.
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`
@@ -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/schema.ts`, which is what types
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.
@@ -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/schema.ts # config-as-code Postgres schema (tables, columns, RLS) auto-migrated on deploy
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/public.ts`, one class per
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/schema.ts — config-as-code; the deploy auto-migrates additive changes.
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/schema.ts`, which is what types `Database.tables.*` (no import, no
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` is still exported and takes `(ctx, next)`, but **no bundler
273
- reads a `middleware/` directory and the engine never calls one** there is no
274
- `ctx` anywhere on a path that runs. Every handler above imports its services.
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/schema.ts` is diffed against the live
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/schema.ts`, which is what types
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) => { … })` is still exported and still
673
- type-checks, but **nothing mounts a `middleware/` directory and the engine has no
674
- middleware pipeline** a handler written against it deploys, never runs, and
675
- nothing reports it. There is no `ctx` object anywhere on a path that executes, so
676
- treat the export as a leftover rather than a seam.
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/schema.ts`, `Database.tables.<name>` is typed everywhere
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/schema.ts` is
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()`), `.references(() => table.column, opts?)`,
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
- **Two foreign keys to the same table** would derive the same relation name from
1372
- their columns, so name one of them:
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, { as: "billing_address" }),
1376
- shipping_address_id: uuid().references(() => addresses.id, { as: "shipping_address" }),
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 db/public.ts; the rail emits ALTER TYPE … ADD VALUE.)
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/schema.ts
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/schema.ts` is the single source of truth for your Postgres schema. You change
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/schema.ts (add a column, a table, a policy, …)
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/schema.ts` to the local stack, which computes the plan
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/schema.ts`. It writes nothing,
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/schema.ts` matches
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/schema.ts` changes none of them: `palbase db apply` is what moves the local
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/schema.ts`, which is what types `Database.tables.<name>` in your
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/schema.ts` as the declared end-state and add
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/schema.ts`:
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/schema.ts` exists in the live DB (declared ⊆ live), and it inspects tables,
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/schema.ts` and add **only** the constraint/index via the migration. The
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/schema.ts`. The deploy still succeeds, but `palbase db plan` will
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/schema.ts` describes the end state; the migrations describe how
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/schema.ts`; the
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: only one caller across all pods runs fn.
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
  });
@@ -1,6 +1,8 @@
1
1
  # Migrations
2
2
 
3
- `db/schema.ts` is the single source of truth for your Postgres schema. You change
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/schema.ts (add a column, a table, a policy, …)
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/schema.ts` to the local stack, which computes the plan
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/schema.ts`. It writes nothing,
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/schema.ts` matches
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/schema.ts` changes none of them: `palbase db apply` is what moves the local
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/schema.ts`, which is what types `Database.tables.<name>` in your
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/schema.ts` as the declared end-state and add
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/schema.ts`:
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/schema.ts` exists in the live DB (declared ⊆ live), and it inspects tables,
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/schema.ts` and add **only** the constraint/index via the migration. The
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/schema.ts`. The deploy still succeeds, but `palbase db plan` will
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/schema.ts` describes the end state; the migrations describe how
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/schema.ts`; the
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()`), `.references(() => table.column, opts?)`,
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
- **Two foreign keys to the same table** would derive the same relation name from
170
- their columns, so name one of them:
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, { as: "billing_address" }),
174
- shipping_address_id: uuid().references(() => addresses.id, { as: "shipping_address" }),
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 db/public.ts; the rail emits ALTER TYPE … ADD VALUE.)
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/schema.ts
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: only one caller across all pods runs fn.
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
  });