@palbase/backend 32.0.0 → 33.0.1

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 (63) hide show
  1. package/dist/bin/palbase-backend.cjs +79 -26
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +5 -5
  4. package/dist/{chunk-AZJIKCOR.js → chunk-IKDONZ5D.js} +39 -23
  5. package/dist/{chunk-AZJIKCOR.js.map → chunk-IKDONZ5D.js.map} +1 -1
  6. package/dist/{chunk-IXAX5CON.js → chunk-SI4KGEM3.js} +1 -1
  7. package/dist/{chunk-IXAX5CON.js.map → chunk-SI4KGEM3.js.map} +1 -1
  8. package/dist/{chunk-XEGZ3S2Q.js → chunk-TVCCR6SO.js} +32 -7
  9. package/dist/chunk-TVCCR6SO.js.map +1 -0
  10. package/dist/{chunk-5C5UCILO.js → chunk-WWUG2QXF.js} +3 -3
  11. package/dist/{chunk-KGP6ALIU.js → chunk-XOX6RFPZ.js} +30 -8
  12. package/dist/chunk-XOX6RFPZ.js.map +1 -0
  13. package/dist/{chunk-Z4CZRMNF.js → chunk-YIQ4RS4F.js} +2 -2
  14. package/dist/db/index.cjs +14 -4
  15. package/dist/db/index.cjs.map +1 -1
  16. package/dist/db/index.d.cts +1 -1
  17. package/dist/db/index.d.ts +1 -1
  18. package/dist/db/index.js +3 -3
  19. package/dist/engine/index.cjs +79 -26
  20. package/dist/engine/index.cjs.map +1 -1
  21. package/dist/engine/index.d.cts +3 -3
  22. package/dist/engine/index.d.ts +3 -3
  23. package/dist/engine/index.js +5 -5
  24. package/dist/{index-BILC9WwS.d.ts → index-D-3duy8Y.d.ts} +2 -2
  25. package/dist/{index-DgYkdNT9.d.cts → index-DB_nW-AV.d.cts} +87 -30
  26. package/dist/{index-CWGiJ2Up.d.cts → index-DLveQoOf.d.cts} +2 -2
  27. package/dist/{index-BgWnP07a.d.ts → index-DMZG3kpo.d.ts} +87 -30
  28. package/dist/index.cjs +59 -12
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +6 -6
  31. package/dist/index.d.ts +6 -6
  32. package/dist/index.js +5 -5
  33. package/dist/openapi/index.d.cts +2 -2
  34. package/dist/openapi/index.d.ts +2 -2
  35. package/dist/{registry-BcRYIQ-R.d.cts → registry-DSTThhKf.d.cts} +1 -1
  36. package/dist/{registry-ClzjxIWy.d.ts → registry-JjF5lcj4.d.ts} +1 -1
  37. package/dist/test/index.cjs +1155 -12
  38. package/dist/test/index.cjs.map +1 -1
  39. package/dist/test/index.d.cts +1 -1
  40. package/dist/test/index.d.ts +1 -1
  41. package/dist/test/index.js +1126 -8
  42. package/dist/test/index.js.map +1 -1
  43. package/docs/README.md +124 -80
  44. package/docs/auth.md +112 -26
  45. package/docs/background.md +16 -7
  46. package/docs/database.md +22 -18
  47. package/docs/endpoints.md +26 -20
  48. package/docs/errors.md +2 -3
  49. package/docs/events.md +63 -29
  50. package/docs/getting-started.md +8 -7
  51. package/docs/llms-full.txt +519 -244
  52. package/docs/migrations.md +6 -5
  53. package/docs/routing.md +34 -11
  54. package/docs/schema.md +78 -27
  55. package/docs/services.md +30 -13
  56. package/package.json +2 -1
  57. package/stack-images.json +24 -0
  58. package/template/db/public.ts +23 -0
  59. package/template/package.json +1 -1
  60. package/dist/chunk-KGP6ALIU.js.map +0 -1
  61. package/dist/chunk-XEGZ3S2Q.js.map +0 -1
  62. /package/dist/{chunk-5C5UCILO.js.map → chunk-WWUG2QXF.js.map} +0 -0
  63. /package/dist/{chunk-Z4CZRMNF.js.map → chunk-YIQ4RS4F.js.map} +0 -0
@@ -90,7 +90,7 @@ Code and schema move at different speeds, so three things keep them together:
90
90
  ## Your schema change is not live anywhere you have not applied it
91
91
 
92
92
  The local stack and every Environment hold their own database, and editing
93
- Editing `db/*.ts` changes none of them: `palbase db apply` is what moves the local
93
+ `db/*.ts` changes none of them: `palbase db apply` is what moves the local
94
94
  one, `palbase push` is what moves an Environment's. Until then the declaration is
95
95
  ahead of the tables.
96
96
 
@@ -165,10 +165,11 @@ existing data gets there.
165
165
 
166
166
  ## Row-Level Security
167
167
 
168
- Add `rls: true` + `policies: [policy(...)]` to a table in `db/*.ts`; the
169
- generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
170
- See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
171
- `Database.public.*` access.
168
+ Add `policies: () => [policy(...)]` to a table in `db/*.ts` — a CALLBACK, not an
169
+ array — and the generated migration emits the `ENABLE ROW LEVEL SECURITY` +
170
+ `CREATE POLICY` DDL. RLS is on by default, so `rls: true` is only worth writing
171
+ as a deliberate deny-all with no policies. See [schema.md](./schema.md) for the
172
+ column builders, the policy DSL, and typed `Database.public.*` access.
172
173
 
173
174
  ### Hand-writing a policy
174
175
 
package/docs/routing.md CHANGED
@@ -4,8 +4,11 @@ Routes are declared in code with **class controllers**. A controller is a class
4
4
  decorated with `@Controller(basePath)`; each route is a method decorated with
5
5
  `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`/`@Query`. Request input + context are
6
6
  injected into the method via **parameter decorators** (`@Body`/`@QueryParams`/
7
- `@Param`/`@User`/…). Putting a controller file under `controllers/` mounts it
8
- there is no central router and no manual registration.
7
+ `@Param`/`@User`/…). A controller is mounted by being LISTED: its module names
8
+ it in `controllers`, and that list is the only place ownership is decided. A
9
+ class no module lists does not exist — the build refuses it by name, and it
10
+ never reaches the route table or the OpenAPI document. Being in a particular
11
+ folder grants nothing.
9
12
 
10
13
  ```ts
11
14
  import { Controller, Get, Post, Body, QueryParams, Param, User } from "@palbase/backend";
@@ -14,31 +17,51 @@ import { Controller, Get, Post, Body, QueryParams, Param, User } from "@palbase/
14
17
  ## Controllers — class + method decorators
15
18
 
16
19
  `@Controller(basePath)` marks the class and sets the mount path. Each route
17
- method declares its verb + subpath; the real work lives in a `services/` class
18
- (the controller method is thin).
20
+ method declares its verb + subpath; the real work lives in an `@Injectable()`
21
+ service that arrives through the CONSTRUCTOR (the controller method is thin).
19
22
 
20
23
  ```ts
21
- // controllers/places.controller.ts
24
+ // modules/places/places.controller.ts
22
25
  import { Controller, Get, Post, Body, User } from "@palbase/backend";
23
26
  import type { UserT } from "@palbase/backend";
24
- import { placeService } from "../services/place.service.js";
25
- import { ImportNearbyBody } from "../models/places/import.js";
26
- import type { PlaceSchema } from "../models/places/shared.js"; // the return TYPE names the 200 schema
27
+ import { PlaceService } from "./place.service.js";
28
+ import { ImportNearbyBody } from "./dto/import.js";
29
+ import { PlaceSchema } from "./dto/shared.js"; // the return TYPE names the 200 schema
27
30
 
28
31
  @Controller("/places")
29
- export default class PlacesController {
32
+ export class PlacesController {
33
+ constructor(private readonly places: PlaceService) {}
34
+
30
35
  @Post("/import")
31
36
  importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
32
- return placeService.importNearby(body.lat, body.lng, user.id);
37
+ return this.places.importNearby(body.lat, body.lng, user.id);
33
38
  }
34
39
 
35
40
  @Get("/favorites", { auth: false })
36
41
  listFavorites(): PlaceSchema[] {
37
- return placeService.listFavorites();
42
+ return this.places.listFavorites();
38
43
  }
39
44
  }
40
45
  ```
41
46
 
47
+ The class is mounted by being LISTED, and nothing else mounts it:
48
+
49
+ ```ts
50
+ // modules/places/places.module.ts
51
+ import { Module, type Token } from "@palbase/backend";
52
+
53
+ import { PlacesController } from "./places.controller.js";
54
+ import { PlaceService } from "./place.service.js";
55
+
56
+ @Module({
57
+ controllers: [PlacesController as Token],
58
+ providers: [PlaceService as Token],
59
+ exports: [],
60
+ imports: [],
61
+ })
62
+ export class PlacesModule {}
63
+ ```
64
+
42
65
  | Method name | Verb | Full path | operationId (dotted) |
43
66
  |---|---|---|---|
44
67
  | `importNearby` | POST | `/places/import` | `places.importNearby` |
package/docs/schema.md CHANGED
@@ -19,9 +19,10 @@ those values — never a dictionary, because a name in a dictionary key is a sec
19
19
  place the name is written, and a table built under a key does not yet know what
20
20
  to call itself when a sibling references it.
21
21
 
22
- Each table's only required field is `columns`; `rls` and `policies` enable
23
- [Row-Level Security](#row-level-security-rls), and `indexes` declares plain
24
- btree [indexes](#indexes).
22
+ Each table's only required field is `columns`; `policies` (a callback — see
23
+ [Row-Level Security](#row-level-security-rls)) declares the RLS rules, `rls`
24
+ toggles enforcement (default `true`), and `indexes` declares plain btree
25
+ [indexes](#indexes).
25
26
 
26
27
  ```ts
27
28
  import {
@@ -447,14 +448,28 @@ You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
447
448
  of the schema, no generic, no cast:
448
449
 
449
450
  ```ts
450
- // services/room.service.ts — the layer that touches the database.
451
- import { Database } from "@palbase/backend";
451
+ // modules/rooms/room.service.ts — the layer that touches the database.
452
+ import { Database, Injectable } from "@palbase/backend";
453
+ import type { Tables } from "@palbase/backend/env";
454
+
455
+ type Room = Tables["rooms"]["row"]; // typed from your db/*.ts
452
456
 
453
- type RoomsTable = typeof Database.public.rooms; // typed from your db/*.ts
457
+ /** `Database.public.rooms` is a VALUE, and a dependency is named by its
458
+ * parameter's TYPE — so the seam is an `abstract class`, one table wide. */
459
+ export abstract class RoomRepo {
460
+ abstract insert(row: { name: string }): Promise<Room>;
461
+ }
454
462
 
463
+ @Injectable()
464
+ export class DbRoomRepo extends RoomRepo {
465
+ insert(row: { name: string }): Promise<Room> {
466
+ return Database.public.rooms.insert(row); // typed: rooms.nope ← compile error
467
+ }
468
+ }
469
+
470
+ @Injectable()
455
471
  export class RoomService {
456
- private readonly rooms: RoomsTable;
457
- constructor(rooms: RoomsTable) { this.rooms = rooms; }
472
+ constructor(private readonly rooms: RoomRepo) {}
458
473
 
459
474
  async create(name: string) {
460
475
  const room = await this.rooms.insert({ name });
@@ -462,41 +477,48 @@ export class RoomService {
462
477
  // room.nope ← compile error
463
478
  }
464
479
  }
465
-
466
480
  ```
467
481
 
468
482
  ```ts
469
- // rooms.module.ts
483
+ // modules/rooms/rooms.module.ts — the four lists that make these classes exist.
470
484
  import { Module, type Token } from "@palbase/backend";
471
- import { RoomsController } from "./controllers/rooms.controller.ts";
472
- import { RoomService } from "./services/room.service.ts";
473
-
474
- @Module({ controllers: [RoomsController as Token], providers: [RoomService as Token] })
485
+ import { RoomsController } from "./rooms.controller.ts";
486
+ import { DbRoomRepo, RoomService } from "./room.service.ts";
487
+
488
+ @Module({
489
+ controllers: [RoomsController as Token],
490
+ providers: [RoomService as Token, DbRoomRepo as Token],
491
+ exports: [],
492
+ imports: [],
493
+ })
475
494
  export class RoomsModule {}
476
495
  ```
477
496
 
478
497
  ```ts
479
- // controllers/rooms.controller.ts — HTTP only; no `Database` import here.
498
+ // modules/rooms/rooms.controller.ts — HTTP only; no `Database` import here.
480
499
  import { Controller, Post, Body, z } from "@palbase/backend";
481
- import { RoomService } from "../services/room.service.js";
500
+ import { RoomService } from "./room.service";
482
501
 
483
502
  const CreateRoomBody = z.object({ name: z.string() });
484
503
  const RoomOut = z.object({ id: z.string(), name: z.string() });
485
504
 
486
505
  @Controller("/rooms")
487
- export default class RoomsController {
506
+ export class RoomsController {
507
+ constructor(private readonly rooms: RoomService) {}
508
+
488
509
  @Post("")
489
510
  // The return type names the 200 schema — `z.infer<typeof RoomOut>` works
490
511
  // inline, no separate `export type` needed.
491
512
  create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
492
- return roomService.create(body.name);
513
+ return this.rooms.create(body.name);
493
514
  }
494
515
  }
495
516
  ```
496
517
 
497
- `Database.public.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
498
- `findById(id)`, `findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
499
- whose `tx.tables.<name>` is typed from the same schema, but carries plan
518
+ `Database.public.<name>` exposes `insert(values)`,
519
+ `update({ where: { id }, set })`, `delete(id)`, `findById(id)`,
520
+ `findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
521
+ whose `tx.public.<name>` is typed from the same schema, but carries plan
500
522
  operations (`insert`/`insertMany`/`updateWhere`/`deleteWhere`/`select`) rather
501
523
  than awaited calls — see [database.md](./database.md#transactions). The raw
502
524
  string-keyed ops (`Database.$insert("rooms", …)`, `Database.$query(…)`) are still
@@ -518,10 +540,11 @@ missing `WHERE user_id = …` in your handler can no longer leak another user's
518
540
  rows — the policy enforces it. This is the recommended way to scope data per
519
541
  user.
520
542
 
521
- Add `policies` (and optionally `rls`) to a table. `policies` being non-empty
522
- implies `rls: true` automatically (a table with policies must have RLS enabled
523
- or the policies are inert). Set `rls: true` with no policies only as a
524
- deliberate deny-all intermediate step.
543
+ Add `policies` to a table a **callback** returning the list, `policies: () =>
544
+ […]`, not a bare array. RLS defaults to `true`, and a non-empty `policies` forces
545
+ it on regardless (a table with policies must have RLS enabled or the policies are
546
+ inert). Write `rls: true` with no policies only as a deliberate deny-all
547
+ intermediate step.
525
548
 
526
549
  ### The `policy()` builder
527
550
 
@@ -580,6 +603,34 @@ once per statement (an initPlan) instead of once per row. `auth.role()` and
580
603
  `auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
581
604
  rows.
582
605
 
606
+ **`auth.has_permission('resource.action')`** answers whether the caller holds
607
+ that permission through any role they carry, and **`auth.app_roles()`** returns
608
+ their roles as a sorted `text[]` (`{}` when they hold none). Both read
609
+ `auth.user_roles` — the table `palbase roles` writes — and both take the caller
610
+ from `auth.uid()`, so a policy cannot ask about somebody else. A revoked role is
611
+ gone from the very next statement; there is nothing cached.
612
+
613
+ ```ts
614
+ policies: () => [
615
+ policy("notes_owner").for("all")
616
+ .using("user_id = (select auth.uid())")
617
+ .withCheck("user_id = (select auth.uid())"),
618
+
619
+ // Moderation. Permissive policies are OR'd, so these widen only.
620
+ policy("notes_moderate_read").for("select")
621
+ .using("(select auth.has_permission('notes.delete_any'))"),
622
+ policy("notes_delete_any").for("delete")
623
+ .using("(select auth.has_permission('notes.delete_any'))"),
624
+ ],
625
+ ```
626
+
627
+ > **The read policy is not optional.** `DELETE … WHERE id = $1` has to FIND the
628
+ > row first, and that read is governed by the SELECT policies. With only the
629
+ > DELETE policy above, a moderator holding the permission deletes **nothing** —
630
+ > the row is invisible to them, so the `WHERE` matches nothing and the statement
631
+ > answers `DELETE 0`. No error, no log. If a permission writes to somebody
632
+ > else's row, a SELECT policy has to show them that row.
633
+
583
634
  > Name policies with a `pb_` prefix. Palbase reconciliation only manages
584
635
  > policies it authored (`pb_`-prefixed) and never touches policies created by
585
636
  > other modules (storage, cron, …).
@@ -600,8 +651,8 @@ export const todos = defineTable("todos", {
600
651
  done: boolean().default(false),
601
652
  created_at: timestamp().defaultNow(),
602
653
  },
603
- // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
604
- policies: [
654
+ // `policies` is a CALLBACK. Non-empty ⇒ RLS is enabled + FORCEd automatically.
655
+ policies: () => [
605
656
  // Read: a user sees only their own todos.
606
657
  policy("pb_todos_owner_select")
607
658
  .for("select")
package/docs/services.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **Two different things are called "services" here, and this page is the second
4
4
  one.** Read the first paragraph before searching this page for a layout rule.
5
5
 
6
- - **YOUR service layer** — `services/<name>.service.ts`, the plain classes your
6
+ - **YOUR service layer** — `modules/<domain>/<name>.service.ts`, the `@Injectable()` classes your
7
7
  own business logic lives in. The scaffold ships one, and the contract is at
8
8
  the bottom of this page: [Your own service layer](#your-own-service-layer).
9
9
  - **THE service singletons** — the platform objects you import from
@@ -26,9 +26,26 @@ turns out to be empty — it fails at the import.
26
26
  a channel, but cannot subscribe (a stateless request can't hold a socket).
27
27
  Subscription lives on the client SDK (`pb.realtime`).
28
28
 
29
+ `Auth` is available here for exactly one thing: **granting and revoking roles**.
30
+ Signing in, signing up and session handling run on the client SDK — a backend
31
+ handler never holds a user's credentials — but assignment is server work, so
32
+ `assignRole`, `revokeRole` and `rolesOf` are on this side:
33
+
34
+ ```ts
35
+ import { Auth } from "@palbase/backend";
36
+
37
+ await Auth.assignRole(userId, "moderator"); // takes effect on the next request
38
+ await Auth.revokeRole(userId, "moderator"); // and so does this
39
+ const held = await Auth.rolesOf(userId); // string[]
40
+ ```
41
+
42
+ A role that does not exist throws `RoleNotDefined` naming it — declare it first
43
+ with `palbase roles create`. A failed read throws rather than answering `[]`:
44
+ "nobody could say" and "holds nothing" are different claims, and a handler that
45
+ confuses them denies a user everything they hold.
46
+
29
47
  **Not available to backend handlers** (do not import them here): Functions, CMS,
30
- Links, Analytics, and Auth. Auth runs on the client SDK; the others are out of
31
- scope for backend endpoints.
48
+ Links and Analytics out of scope for backend endpoints.
32
49
 
33
50
  ## Cache
34
51
 
@@ -158,7 +175,7 @@ Writes mirror the `Database` / `Database.$asService()` model:
158
175
  - `Flags.setOverride(key, value)` (default) writes an override for the **current
159
176
  request user** — no `userId` argument, no admin power. It errors on an
160
177
  anonymous request (no signed-in user).
161
- - `Flags.asService()` returns the cross-user admin surface
178
+ - `Flags.$asService()` returns the cross-user admin surface
162
179
  (`setOverrideForUser`, `setOverridesForUser`, `clearOverrideForUser`,
163
180
  `clearAllOverridesForUser`, `batchSetOverrides`) for writing overrides for an
164
181
  **arbitrary** user. Explicit and greppable, just like `Database.$asService()`.
@@ -167,8 +184,8 @@ Writes mirror the `Database` / `Database.$asService()` model:
167
184
  // Current request user — no userId needed:
168
185
  await Flags.setOverride("new-checkout", true);
169
186
 
170
- // Cross-user admin write — explicit target, via asService():
171
- await Flags.asService().setOverrideForUser("user_123", "new-checkout", true);
187
+ // Cross-user admin write — explicit target, via $asService():
188
+ await Flags.$asService().setOverrideForUser("user_123", "new-checkout", true);
172
189
  ```
173
190
 
174
191
  ## Realtime
@@ -219,8 +236,8 @@ to drive live chat, presence, dashboards, and other push features.
219
236
  ## Your own service layer
220
237
 
221
238
  The singletons above are what a service CALLS. This section is the layer that
222
- calls them — `services/<name>.service.ts`, which the scaffold ships a working
223
- example of (`services/note.service.ts` and the controller that uses it).
239
+ calls them — `modules/<domain>/<name>.service.ts`, which the scaffold ships a working
240
+ example of (`modules/notes/note.service.ts` and the controller that uses it).
224
241
 
225
242
  The contract is three rules, and the scaffold's own test enforces all three:
226
243
 
@@ -235,7 +252,7 @@ it.** Mark the class `@Injectable()` and name what it needs as ordinary
235
252
  constructor parameters:
236
253
 
237
254
  ```ts
238
- // services/note.service.ts
255
+ // modules/notes/note.service.ts
239
256
  import { Database, Injectable } from "@palbase/backend";
240
257
 
241
258
  type NotesTable = typeof Database.public.notes;
@@ -251,7 +268,7 @@ export class NoteService {
251
268
  ```
252
269
 
253
270
  ```ts
254
- // services/report.service.ts — a service that depends on another service
271
+ // modules/reports/report.service.ts — a service that depends on another service
255
272
  import { Injectable } from "@palbase/backend";
256
273
  import { NoteService } from "./note.service.ts";
257
274
 
@@ -268,7 +285,7 @@ export class ReportService {
268
285
  A controller asks the same way, and nothing wires it by hand:
269
286
 
270
287
  ```ts
271
- // controllers/notes.controller.ts
288
+ // modules/notes/notes.controller.ts
272
289
  import { Controller, Get } from "@palbase/backend";
273
290
  import { NoteService } from "../services/note.service.ts";
274
291
 
@@ -310,7 +327,7 @@ never reaches the route table, the dispatcher or the OpenAPI document.
310
327
  ```ts
311
328
  // notes.module.ts — beside the domain it owns, not in a directory we name
312
329
  import { Module, type Token } from "@palbase/backend";
313
- import { NotesController } from "./controllers/notes.controller.ts";
330
+ import { NotesController } from "./modules/notes/notes.controller.ts";
314
331
  import { NoteService } from "./services/note.service.ts";
315
332
  import { ReportService } from "./services/report.service.ts";
316
333
 
@@ -363,7 +380,7 @@ NoteService())` is ordinary TypeScript, and the container is not required for it
363
380
  to work.
364
381
 
365
382
  ```ts
366
- // services/note.service.test.ts — `npm test`, no database
383
+ // modules/notes/note.service.test.ts — `npm test`, no database
367
384
  import { test } from "node:test";
368
385
  import assert from "node:assert/strict";
369
386
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "32.0.0",
3
+ "version": "33.0.1",
4
4
  "description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -91,6 +91,7 @@
91
91
  "files": [
92
92
  "dist",
93
93
  "docs",
94
+ "stack-images.json",
94
95
  "stager",
95
96
  "template"
96
97
  ],
@@ -0,0 +1,24 @@
1
+ {
2
+ "33": [
3
+ {
4
+ "env": "PALBASE_PALSVC_IMAGE",
5
+ "ref": "ghcr.io/palgroup/palbase/palsvc:0.42.0",
6
+ "build": "cd v2 && DOCKER_BUILDKIT=1 docker build -t palbase-palsvc -f Dockerfile ."
7
+ },
8
+ {
9
+ "env": "PALBASE_RUNTIME_IMAGE",
10
+ "ref": "ghcr.io/palgroup/palbase/runtime-dev:0.42.0",
11
+ "build": "cd v2/runtime && DOCKER_BUILDKIT=1 docker build --target dev -t palbase-runtime-dev -f Dockerfile ."
12
+ },
13
+ {
14
+ "env": "PALBASE_EDGE_IMAGE",
15
+ "ref": "ghcr.io/palgroup/palbase/edge:0.42.0",
16
+ "build": "cd v2/deploy/envoy && DOCKER_BUILDKIT=1 docker build -t palbase-edge ."
17
+ },
18
+ {
19
+ "env": "PALBASE_POSTGRES_IMAGE",
20
+ "ref": "pgvector/pgvector:pg16",
21
+ "build": ""
22
+ }
23
+ ]
24
+ }
@@ -30,6 +30,29 @@ const notes = defineTable("notes", {
30
30
  .to("authenticated")
31
31
  .using("user_id = (select auth.uid())")
32
32
  .withCheck("user_id = (select auth.uid())"),
33
+
34
+ // MODERATION, when you want it — delete these two if you do not.
35
+ //
36
+ // Roles are yours and there is no built-in "admin": you declare them from
37
+ // the command line (`palbase roles create moderator --permissions
38
+ // notes.delete_any`) and `auth.has_permission` answers from the roles the
39
+ // caller actually holds right now. Permissive policies are OR'd, so these
40
+ // widen only: the owner rule above keeps working untouched.
41
+ //
42
+ // THE READ POLICY IS NOT OPTIONAL. `DELETE … WHERE id = $1` has to FIND the
43
+ // row first, and that read is governed by the SELECT policies — with only
44
+ // the delete rule below, a moderator holding the permission deletes NOTHING
45
+ // and gets no error, because the row is invisible to them and the WHERE
46
+ // matches nothing. If a permission writes to somebody else's row, a SELECT
47
+ // policy has to show them that row.
48
+ policy("notes_moderate_read")
49
+ .for("select")
50
+ .to("authenticated")
51
+ .using("(select auth.has_permission('notes.delete_any'))"),
52
+ policy("notes_delete_any")
53
+ .for("delete")
54
+ .to("authenticated")
55
+ .using("(select auth.has_permission('notes.delete_any'))"),
33
56
  ],
34
57
  });
35
58
 
@@ -9,7 +9,7 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@palbase/backend": "^32.0.0",
12
+ "@palbase/backend": "^33.0.0",
13
13
  "reflect-metadata": "^0.2.2"
14
14
  },
15
15
  "engines": {
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/db/columns.ts"],"sourcesContent":["/** On delete action for foreign key references. */\nexport type OnDeleteAction = 'cascade' | 'set null' | 'restrict' | 'no action';\n\n/**\n * The ON DELETE actions permitted on a foreign key to the built-in auth users\n * (`auth.users`). Both let a user's rows be removed (`cascade`) or detached\n * (`set null`) when the account is erased; `restrict` / `no action` would BLOCK\n * erasure and are therefore excluded. This is the CLIENT-SIDE mirror of the\n * server's auth-FK deletion policy — the server (validateAuthUserFK) is the real\n * boundary, this narrows the type so the common mistake is caught at compile time.\n */\nexport type AuthUserOnDelete = Extract<OnDeleteAction, 'cascade' | 'set null'>;\n\n/** Column type identifiers. */\nexport type ColumnType =\n | 'uuid'\n | 'text'\n | 'integer'\n | 'bigint'\n | 'numeric'\n | 'boolean'\n | 'timestamp'\n | 'jsonb'\n | 'enum'\n | 'vector';\n\n/** Base column definition shared by all column types. */\nexport interface ColumnDef {\n type: ColumnType;\n nullable: boolean;\n primaryKey: boolean;\n defaultValue?: unknown;\n defaultRandom?: boolean;\n defaultNow?: boolean;\n references?: { table: string; column: string };\n /** Pending FK target, resolved by `defineSchema` once every binding exists. */\n referencesThunk?: () => ColumnBuilder;\n /** FK onto THIS table — no thunk needed, the target is the declaring table. */\n selfRefColumn?: string;\n /** This column OWNS the row: erasure, RLS and the `owner` relation read it. */\n owns?: true;\n /** Explicit name for the FORWARD relation (child → parent), when the one\n * derived from the column would be ambiguous or unclear. */\n refAs?: string;\n /**\n * Explicit name for the REVERSE relation (parent → children).\n *\n * Separate from {@link refAs} because the two directions are different facts:\n * the forward name describes the parent this row points at (`author`), the\n * reverse one describes the rows hanging off the parent (`posts`). One option\n * naming both made an ordinary schema undeclarable — `posts.author_id` and\n * `comments.author_id` both named `{ as: \"author\" }` collided on `users`, and\n * the refusal asked for the `{ as }` they had both already written.\n */\n reverseAs?: string;\n /** The table this column belongs to; set by `defineTable`. */\n ownerTable?: { name: string; columns: Record<string, ColumnBuilder> };\n /**\n * The name this column used to have. A diff cannot tell a rename from a drop and\n * an add — both leave one name gone and another present — so the intent has to be\n * declared. Without it, renaming a column loses its data.\n */\n renamedFrom?: string;\n /**\n * This release's PROMISE that it does not reference this column — neither\n * reads it nor writes it, and never names it in a filter, a sort or a SET.\n *\n * The contraction gate reads it and nothing else does: dropping a column is\n * refused while the RUNNING release's declaration still lacks this mark, so\n * removing a column is two deploys — mark it, ship, then drop it.\n *\n * The word is `ignored` and not `deprecated` deliberately: RFC 9745 defines\n * deprecation as changing NO behaviour, and this changes what a deploy will\n * accept. Rails calls the same thing `ignored_columns`.\n */\n ignored?: boolean;\n onDeleteAction?: OnDeleteAction;\n /** FR-044: yalnız AÇIKÇA `false` bildirilince var. Türev FK index'ini kapatır. */\n index?: boolean;\n /** FR-049: kolon `increment()` ile güncelleniyor. Plan HOT çakışmasını uyarır. */\n counter?: boolean;\n enumName?: string;\n enumValues?: string[];\n unique?: boolean;\n /**\n * The value is written by the DATABASE — a trigger, a rule, an identity — not by\n * the author and not by a DEFAULT this schema declares. It makes the column\n * optional on INSERT without putting a DEFAULT in the DDL.\n *\n * Before this existed the only way to keep a trigger-filled column off the\n * INSERT type was to give it a fake `default()`: a value the schema claimed to\n * write and the trigger immediately overwrote. That made the schema lie about\n * its own data.\n */\n dbAssigned?: boolean;\n /** vector(n): the declared dimension count — part of the TYPE (typmod), read\n * by the wire serializer and the deploy's auto-index (FR-001). */\n dimensions?: number;\n /**\n * How the stored value is projected in and out of this process (FR-009).\n *\n * NOT part of the DDL: the column's Postgres type is unchanged and this pair\n * is never serialized into a migration. It exists so the row surface can hand\n * back the type the application actually works with.\n */\n /**\n * The named codec this column declares (FR-001). Unlike `transform`, the NAME\n * survives into the runtime object, so `makeEnvDts` can emit the matching\n * TypeScript type instead of falling back to the storage type. Not part of\n * the DDL: the column's Postgres type is unchanged.\n */\n codec?: CodecName;\n transform?: ColumnTransform;\n}\n\n/**\n * The read/write pair a column may declare (FR-009).\n *\n * `fromDb` takes whatever the driver produced for this column and returns the\n * value the application sees; `toDb` is its inverse on the way out. Kept\n * deliberately unexported — a column declares one inline, nobody needs to name\n * the shape.\n */\ninterface ColumnTransform<T = unknown> {\n fromDb: (value: unknown) => T;\n toDb: (value: T) => unknown;\n}\n\n/**\n * The closed set of named column codecs (FR-001).\n *\n * WHY A CLOSED SET rather than `.transform<T>()`. A transform's target type is\n * a TYPE parameter — erased at runtime — so the generator that reads the\n * bundled schema object cannot learn it. `palbase-env.d.ts` therefore said\n * `string` while the engine handed the application a number, and a consumer\n * project wrote a hand-rolled codec module plus 141 call sites to compensate.\n *\n * A codec is NAMED instead of typed: the name lands in `_def.codec`, travels in\n * the runtime object to every reader, and BOTH the conversion and the emitted\n * TypeScript type are derived from it. One declaration, one truth, and no way\n * for the two to disagree.\n */\nexport type CodecName = 'number' | 'decimal';\n\nexport const CODECS: Record<\n CodecName,\n { fromDb: (value: unknown) => unknown; toDb: (value: unknown) => unknown; tsType: 'number' | 'string' }\n> = {\n // Exact-precision column → JS number. Safe below 2^53; a value above it is\n // not representable and the author wants `asDecimal()` instead.\n number: { fromDb: (v) => Number(v), toDb: (v) => String(v), tsType: 'number' },\n // Exact-precision column → the string Postgres sent. It exists so the\n // DECLARATION is explicit: \"this column is a decimal I handle as text\",\n // rather than the ABSENCE of a declaration meaning the same thing by default.\n decimal: { fromDb: (v) => String(v), toDb: (v) => String(v), tsType: 'string' },\n};\n\n/** FR-002: a vector column cannot carry keys/defaults/references — the modifier\n * is named in the error so the author fixes the right line. */\nfunction refuseOnVector(def: ColumnDef, modifier: string): void {\n if (def.type === 'vector') {\n throw new Error(`vector column: .${modifier}() is not supported (FR-002 — allowed: nullable()/notNull())`);\n }\n}\n\n// Phantom brand symbols — never have runtime values; exist only to force\n// TypeScript's structural type system to distinguish ColumnBuilder instances\n// with different type-param combinations. Without these, TS sees all\n// ColumnBuilder<K,...> as structurally identical and the first branch of\n// ColValue matches everything.\ndeclare const __colKind: unique symbol;\ndeclare const __colNullable: unique symbol;\ndeclare const __colHasDefault: unique symbol;\ndeclare const __colEnumValues: unique symbol;\ndeclare const __colPayload: unique symbol;\ndeclare const __colTransform: unique symbol;\n\n/**\n * Fluent column builder with phantom type params:\n * K — ColumnType literal (e.g. \"text\", \"integer\")\n * N — boolean: true when nullable() has been called last (false = NOT NULL)\n * D — boolean: true when a default has been set\n * E — enum value union (never for non-enum columns)\n * P — jsonb payload shape (unknown unless jsonb<T>() supplied one)\n * T — transform target type (`never` when the column declares no transform;\n * `never` is the sentinel because it is the only type that survives\n * `[T] extends [never]` and never collides with a real target type)\n *\n * All six params have defaults so bare `ColumnBuilder` (no args) still\n * satisfies `Record<string, ColumnBuilder>` in schema.ts without modification.\n *\n * The six `declare readonly` brand fields carry the phantom types into the\n * structural shape so that conditional types like ColValue<C> can discriminate\n * on K without requiring runtime values on those fields.\n */\nexport class ColumnBuilder<\n K extends ColumnType = ColumnType,\n N extends boolean = boolean,\n D extends boolean = boolean,\n E = unknown,\n P = unknown,\n // `unknown`, not `never`: the schema's own constraint is a BARE\n // `ColumnBuilder`, whose T lands on this default. With `never` there, a\n // column that declares `.transform<number>()` is not assignable to the\n // constraint at all — `number` does not extend `never` — so a transform\n // could not appear in a schema and the whole table's `RowShape` collapsed.\n // Measured: TS2322 on `defineSchema`.\n T = unknown,\n> {\n // These fields exist only in the type layer (declared, never initialised at\n // runtime — TypeScript allows declared class members without an initializer\n // in strict mode as long as they're never read at runtime).\n declare readonly [__colKind]: K;\n declare readonly [__colNullable]: N;\n declare readonly [__colHasDefault]: D;\n declare readonly [__colEnumValues]: E;\n declare readonly [__colPayload]: P;\n declare readonly [__colTransform]: T;\n\n readonly _def: ColumnDef;\n\n constructor(type: K, existingDef?: ColumnDef) {\n this._def = existingDef ?? {\n type,\n nullable: false,\n primaryKey: false,\n };\n }\n\n /** Mark this column as the primary key. */\n primaryKey(): ColumnBuilder<K, N, D, E, P, T> {\n refuseOnVector(this._def, 'primaryKey');\n this._def.primaryKey = true;\n return new ColumnBuilder<K, N, D, E, P, T>(this._def.type as K, this._def);\n }\n\n /** Mark this column as NOT NULL (default). */\n notNull(): ColumnBuilder<K, false, D, E, P, T> {\n this._def.nullable = false;\n return new ColumnBuilder<K, false, D, E, P, T>(this._def.type as K, this._def);\n }\n\n /** Allow NULL values. */\n nullable(): ColumnBuilder<K, true, D, E, P, T> {\n this._def.nullable = true;\n return new ColumnBuilder<K, true, D, E, P, T>(this._def.type as K, this._def);\n }\n\n /** Set a default value. */\n default(value: unknown): ColumnBuilder<K, N, true, E, P, T> {\n refuseOnVector(this._def, 'default');\n this._def.defaultValue = value;\n return new ColumnBuilder<K, N, true, E, P, T>(this._def.type as K, this._def);\n }\n\n /** UUID: generate a random default (gen_random_uuid()). */\n defaultRandom(): ColumnBuilder<K, N, true, E, P, T> {\n refuseOnVector(this._def, 'defaultRandom');\n this._def.defaultRandom = true;\n return new ColumnBuilder<K, N, true, E, P, T>(this._def.type as K, this._def);\n }\n\n /** Timestamp: default to now(). */\n defaultNow(): ColumnBuilder<K, N, true, E, P, T> {\n refuseOnVector(this._def, 'defaultNow');\n this._def.defaultNow = true;\n return new ColumnBuilder<K, N, true, E, P, T>(this._def.type as K, this._def);\n }\n\n /**\n * The DATABASE assigns this column's value — a trigger, a rule, an identity.\n *\n * The column becomes optional on INSERT (the author has nothing to send) while\n * the DDL stays free of a DEFAULT this schema would not honour. It is NOT\n * `default()`: that declares a value the schema promises to write.\n *\n * Naming: deliberately not `generated()`. Postgres has GENERATED columns and\n * they are a different thing; borrowing the word would send a reader — or a\n * model writing a schema — to the wrong feature.\n */\n dbAssigned(): ColumnBuilder<K, N, true, E, P, T> {\n this._def.dbAssigned = true;\n return new ColumnBuilder<K, N, true, E, P, T>(this._def.type as K, this._def);\n }\n\n /** Add a foreign key reference. */\n /**\n * Declares that this column used to be called `previous`.\n *\n * A schema diff sees one name gone and another present; it cannot know whether\n * you renamed a column or dropped one and added another, and the two are very\n * different — the second loses every value. Saying so here turns the plan into\n * `ALTER TABLE … RENAME COLUMN` instead.\n *\n * Once the rename has been applied the annotation is inert (the old name is no\n * longer there to rename), so it can be deleted at your leisure.\n */\n renamedFrom(previous: string): ColumnBuilder<K, N, D, E, P, T> {\n this._def.renamedFrom = previous;\n return this as unknown as ColumnBuilder<K, N, D, E, P, T>;\n }\n\n /**\n * See {@link ColumnDef.ignored}.\n *\n * COPIES the def rather than mutating it. The constructor takes an existing\n * def BY REFERENCE, so every builder derived from another shares one object —\n * `const a = slug.unique()` leaves `a._def === slug._def`. An in-place\n * `ignored = true` therefore marks every column sharing that def, including\n * one another table actively reads, and the gate would let THAT column be\n * dropped. Measured before this copy existed.\n *\n * The aliasing is older than this method and other fields leak through it too.\n * The reason this one cannot wait: every other leak produces a VISIBLE schema\n * difference — the plan shows it, the DDL shows it. This one is invisible by\n * design (no DDL, no diff, no plan line), so its only effect is to disarm a\n * safety gate in silence.\n */\n ignored(): ColumnBuilder<K, N, D, E, P, T> {\n return new ColumnBuilder<K, N, D, E, P, T>(this._def.type as K, {\n ...this._def,\n ignored: true,\n });\n }\n\n /**\n * Foreign key onto another table's column.\n *\n * The target is a THUNK, not a direct reference. In a cycle (`x → y`, `y → x`)\n * the second table does not exist yet when the first is built; a direct\n * reference makes TypeScript chase its own tail (TS7022 — measured, and making\n * the return type independent of the target does NOT help). The thunk is\n * invoked in `defineSchema`, where every binding exists and every table\n * already knows its name.\n *\n * In a cycle, ONE side needs an explicit return type:\n * `references((): AnyColumn => y.id)`. One side is enough — measured.\n * For a self-reference use `selfReferences(column)`: no thunk, no annotation.\n *\n * `as` names the FORWARD relation (`author_id` → `author` by default);\n * `reverseAs` names the REVERSE one on the parent, whose default is this\n * table's own name (`users.posts`). Two foreign keys from one table onto one\n * parent therefore need a `reverseAs` on at least one of them — the reverse\n * names would otherwise both be this table's name.\n */\n references(\n target: () => AnyColumn,\n opts?: {\n as?: string;\n reverseAs?: string;\n onDelete?: OnDeleteAction;\n /**\n * Bu FK kolonu için TÜREVİ index üretilsin mi (FR-044). Varsayılan açık.\n *\n * Postgres bir foreign key'i otomatik indekslemez — yalnız hedef\n * taraftaki unique kısıt vardır. Bedeli FK üzerinden her JOIN'de ve her\n * `ON DELETE CASCADE`'de ödenir: bir parent silinirken child tablo tam\n * taranır.\n *\n * `false` demek gerçek bir ihtiyaç, nezaket değil (D-028): FK kolonu aynı\n * zamanda SIK GÜNCELLENEN bir kolonsa index HOT güncellemeyi kırar — ve\n * HOT, DEĞİŞEN kolon indeksliyse kırılır, tablo indeksli diye değil.\n */\n index?: boolean;\n },\n ): ColumnBuilder<K, N, D, E, P, T> {\n refuseOnVector(this._def, 'references');\n if (typeof target !== 'function') {\n // Fail where the mistake is. Storing a non-thunk here surfaces three\n // layers later as \"def.referencesThunk is not a function\", inside\n // defineSchema, naming neither the column nor the call that was wrong.\n throw new Error(\n `references(...) takes a callback: write references(() => otherTable.column). ` +\n `The two-string form references(\"table\", \"column\") is gone — a string cannot be type-checked ` +\n `and cannot point at a table that does not exist yet.`,\n );\n }\n this._def.referencesThunk = target;\n if (opts?.as !== undefined) this._def.refAs = opts.as;\n if (opts?.reverseAs !== undefined) this._def.reverseAs = opts.reverseAs;\n if (opts?.onDelete !== undefined) this._def.onDeleteAction = opts.onDelete;\n // Yalnız AÇIKÇA false bildirilince taşınıyor: `undefined` \"bildirilmedi,\n // türet\" demek ve wire'da hiç görünmemeli (bayt-aynılık).\n if (opts?.index === false) this._def.index = false;\n return new ColumnBuilder<K, N, D, E, P, T>(this._def.type as K, this._def);\n }\n\n /**\n * Bu kolon `increment()` / `decrement()` ile güncelleniyor (FR-049).\n *\n * Deploy kontrolcü kodunu OKUMAZ, o yüzden bildirimin söylemesi gerekiyor —\n * ve söylediği anda plan bir şeyi görebiliyor: aynı kolon hem sayaç hem\n * indeksliyse her güncelleme HOT'u kaybeder.\n *\n * ZİNCİR (D-028+D-030): HOT kaybı → ölü tuple → autovacuum yükü → ve\n * autovacuum worker'ları KÜME GENELİNDE bir kaynak, yani bedeli başka\n * kiracıların tabloları da öder.\n *\n * Sektörde çare \"dokümana uyarı yaz\"dır, çünkü index'i ekleyen kişi\n * `increment()`'i yazan kişi değildir. Bu bildirimde ikisi de YAN YANA\n * duruyor.\n *\n * Uyarı, HATA DEĞİL: sayaç kolonunu indekslemek bazen doğru karardır.\n */\n counter(): ColumnBuilder<K, N, D, E, P, T> {\n this._def.counter = true;\n return new ColumnBuilder<K, N, D, E, P, T>(this._def.type as K, this._def);\n }\n\n /**\n * Foreign key onto THIS table (`parent_id → id`) — category trees, comment\n * replies, org charts.\n *\n * No thunk and no type annotation: the target table is the one being declared,\n * so there is nothing to defer and nothing for TypeScript to chase in a circle.\n * Drizzle forces an explicit `(): AnyPgColumn =>` here because its reference\n * always goes through a callback; measured, we do not need one.\n */\n selfReferences(\n column: string,\n opts?: { as?: string; onDelete?: OnDeleteAction },\n ): ColumnBuilder<K, N, D, E, P, T> {\n refuseOnVector(this._def, 'selfReferences');\n this._def.selfRefColumn = column;\n if (opts?.as !== undefined) this._def.refAs = opts.as;\n if (opts?.onDelete !== undefined) this._def.onDeleteAction = opts.onDelete;\n return new ColumnBuilder<K, N, D, E, P, T>(this._def.type as K, this._def);\n }\n\n\n\n /** Set the ON DELETE action for a foreign key reference. */\n onDelete(action: OnDeleteAction): ColumnBuilder<K, N, D, E, P, T> {\n this._def.onDeleteAction = action;\n return new ColumnBuilder<K, N, D, E, P, T>(this._def.type as K, this._def);\n }\n\n /** Add a single-column UNIQUE constraint. */\n unique(): ColumnBuilder<K, N, D, E, P, T> {\n refuseOnVector(this._def, 'unique');\n this._def.unique = true;\n return new ColumnBuilder<K, N, D, E, P, T>(this._def.type as K, this._def);\n }\n\n /**\n * Declare how this column's value is projected in and out of the process.\n *\n * The DDL does not move: `numeric` stays `numeric`, and the driver still hands\n * back what Postgres sent. What changes is the type the row surface exposes —\n * it becomes `Target`:\n *\n * amount: numeric().transform<number>({ fromDb: Number, toDb: String })\n *\n * `numeric` surfacing as `string` is CORRECT (a JS number cannot hold\n * arbitrary precision), and that is exactly why this exists: application code\n * that does arithmetic on the column otherwise rewrites the same\n * `Number(row.amount)` / `String(x)` pair in every controller that touches it,\n * and each rewrite is a place the two directions can drift apart.\n *\n * A transform is a PROJECTION, never a constraint: it lives only in this\n * process, so it can neither validate nor migrate what is stored.\n */\n /**\n * Surface this exact-precision column as a JS `number` (FR-001).\n *\n * `numeric`/`bigint` arrive as strings because a JS number cannot hold their\n * full range — correct, and exactly why this exists: application code that\n * does arithmetic on the column otherwise rewrites the same `Number(row.x)` /\n * `String(v)` pair in every caller, and each rewrite is a place the two\n * directions can drift apart.\n */\n asNumber(): ColumnBuilder<K, N, D, E, P, number> {\n return this.withCodec<number>('number', 'asNumber');\n }\n\n /**\n * Surface this exact-precision column as a `string` — the value Postgres\n * sent, DECLARED rather than defaulted (FR-001).\n */\n asDecimal(): ColumnBuilder<K, N, D, E, P, string> {\n return this.withCodec<string>('decimal', 'asDecimal');\n }\n\n private withCodec<Target>(codec: CodecName, method: string): ColumnBuilder<K, N, D, E, P, Target> {\n if (this._def.type !== 'bigint' && this._def.type !== 'numeric') {\n throw new Error(\n `${this._def.type} column: .${method}() is only available on bigint()/numeric() — ` +\n `those are the exact-precision types that surface as string.`,\n );\n }\n if (this._def.transform !== undefined) {\n throw new Error(\n `.${method}() and .transform() are two ways to say the same thing on one column — keep .${method}().`,\n );\n }\n this._def.codec = codec;\n return new ColumnBuilder<K, N, D, E, P, Target>(this._def.type as K, this._def);\n }\n\n transform<Target>(fns: ColumnTransform<Target>): ColumnBuilder<K, N, D, E, P, Target> {\n // The cast is the variance, not a shortcut: `toDb` takes `Target`, and a\n // `ColumnTransform<unknown>` would have to accept anything. The stored pair\n // is only ever called with this column's own values.\n this._def.transform = fns as ColumnTransform;\n return new ColumnBuilder<K, N, D, E, P, Target>(this._def.type as K, this._def);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Type extractors — imported by Task 2 to derive insert/row shapes.\n// ---------------------------------------------------------------------------\n\n/**\n * Extracts the TypeScript value type for a column, respecting nullability.\n * - \"uuid\" | \"text\" | \"timestamp\" | \"bigint\" | \"numeric\" → string (or string | null when N = true)\n * Note: bigint/numeric surface as string — JS number loses precision past 2^53,\n * and pgx/PostgREST serialize int8/numeric as strings. App code uses\n * BigInt(row.amount) for bigint, or a decimal lib for numeric.\n * - \"integer\" → number\n * - \"boolean\" → boolean\n * - \"jsonb\" → P (the dev-supplied payload shape from jsonb<T>(), else unknown)\n * - \"enum\" → E (the union of literal values)\n *\n * A declared `.transform<T>()` OVERRIDES the table above: the column then\n * surfaces as T (or T | null when nullable), because that is the value the\n * application is handed. Nullability is still the column's, not the\n * transform's — `fromDb` is not called for a NULL.\n */\nexport type ColValue<C> =\n C extends ColumnBuilder<ColumnType, infer N, boolean, unknown, unknown, infer T>\n ? [unknown] extends [T]\n ? ColStoredValue<C>\n : N extends true\n ? T | null\n : T\n : never;\n\n/** The value as the DATABASE hands it over — the branch table above, before any\n * transform. This is what a column's `fromDb` receives. */\ntype ColStoredValue<C> =\n C extends ColumnBuilder<'uuid' | 'text' | 'timestamp' | 'bigint' | 'numeric', infer N, infer _D, infer _E, infer _P>\n ? N extends true\n ? string | null\n : string\n : C extends ColumnBuilder<'integer', infer N, infer _D, infer _E, infer _P>\n ? N extends true\n ? number | null\n : number\n : C extends ColumnBuilder<'boolean', infer N, infer _D, infer _E, infer _P>\n ? N extends true\n ? boolean | null\n : boolean\n : C extends ColumnBuilder<'jsonb', infer N, infer _D, infer _E, infer P>\n ? N extends true\n ? P | null\n : P\n : C extends ColumnBuilder<'vector', infer N, infer _D, infer _E, infer _P>\n ? N extends true\n ? number[] | null\n : number[]\n : C extends ColumnBuilder<'enum', infer N, infer _D, infer E, infer _P>\n ? N extends true\n ? E | null\n : E\n : never;\n\n/**\n * True when a column is optional on INSERT:\n * - nullable columns (N = true) — the DB allows NULL so the field may be omitted\n * - columns with a default (D = true) — the DB fills in the value when absent\n */\nexport type ColIsOptionalOnInsert<C> =\n C extends ColumnBuilder<ColumnType, true, boolean, unknown, unknown, unknown>\n ? true\n : C extends ColumnBuilder<ColumnType, boolean, true, unknown, unknown, unknown>\n ? true\n : false;\n\n// ---------------------------------------------------------------------------\n// Factory functions\n// ---------------------------------------------------------------------------\n\n/** Create a UUID column. */\nexport function uuid(): ColumnBuilder<'uuid', false, false, never> {\n return new ColumnBuilder('uuid');\n}\n\n/** Create a TEXT column. */\nexport function text(): ColumnBuilder<'text', false, false, never> {\n return new ColumnBuilder('text');\n}\n\n/** Create an INTEGER column. Emits int4 (max ~2.1B). */\nexport function integer(): ColumnBuilder<'integer', false, false, never> {\n return new ColumnBuilder('integer');\n}\n\n/**\n * Create a BIGINT column (Postgres int8, max ~9.2×10^18).\n * Surfaces as `string` in row/insert types — JS number loses precision past 2^53\n * and pgx/PostgREST serialize int8 as a JSON string. Use BigInt(row.column) in app code.\n */\nexport function bigint(): ColumnBuilder<'bigint', false, false, never> {\n return new ColumnBuilder('bigint');\n}\n\n/**\n * Create a NUMERIC column (Postgres `numeric`/`decimal`, arbitrary precision).\n * For exact fractional values (money with cents as a decimal, rates, weights)\n * where int4/int8 don't fit. Surfaces as `string` in row/insert types — JS\n * number can't hold arbitrary-precision decimals without rounding, and\n * pgx/PostgREST serialize numeric as a JSON string. Parse with a decimal lib\n * (or BigInt for scaled integers) in app code.\n */\nexport function numeric(): ColumnBuilder<'numeric', false, false, never> {\n return new ColumnBuilder('numeric');\n}\n\n/** Create a BOOLEAN column. */\nexport function boolean(): ColumnBuilder<'boolean', false, false, never> {\n return new ColumnBuilder('boolean');\n}\n\n/** Create a TIMESTAMP column. */\nexport function timestamp(): ColumnBuilder<'timestamp', false, false, never> {\n return new ColumnBuilder('timestamp');\n}\n\n/**\n * Create a JSONB column. Pass a payload type to make the generated row/insert\n * type concrete instead of `unknown`:\n *\n * tags: jsonb<string[]>() // row.tags: string[]\n * meta: jsonb<{ tier: string }>() // row.meta: { tier: string }\n * raw: jsonb() // row.raw: unknown (back-compat)\n *\n * The runtime accepts a plain JS object/array directly (no JSON.stringify); the\n * generic only refines the TYPE the env codegen emits.\n */\nexport function jsonb<T = unknown>(): ColumnBuilder<'jsonb', false, false, never, T> {\n return new ColumnBuilder('jsonb');\n}\n\n/**\n * Create an ENUM column.\n * @param name The PostgreSQL enum type name (used in DDL).\n * @param values A readonly tuple of valid string values — kept `const` so the\n * union `V[number]` is as narrow as possible.\n */\nexport function enumType<const V extends readonly string[]>(\n name: string,\n values: V,\n): ColumnBuilder<'enum', false, false, V[number]> {\n const builder = new ColumnBuilder<'enum', false, false, V[number]>('enum');\n builder._def.enumName = name;\n builder._def.enumValues = [...values];\n return builder;\n}\n\n/** vector(n) — pgvector kolonu. n TİPİN parçasıdır (typmod) ve [1, 2000] —\n * 2000 = pgvector'ün HNSW-indekslenebilir tavanı; auto-index bu beyanla bağlı\n * (spec FR-001, D-3). */\nexport function vector(dimensions: number): ColumnBuilder<'vector', false, false, unknown, number[]> {\n if (!Number.isInteger(dimensions) || dimensions < 1 || dimensions > 2000) {\n throw new Error(`vector(): dimensions must be an integer in [1, 2000], got ${String(dimensions)}`);\n }\n const b = new ColumnBuilder('vector') as ColumnBuilder<'vector', false, false, unknown, number[]>;\n (b._def as { dimensions?: number }).dimensions = dimensions;\n return b;\n}\n\n/**\n * Any column, whatever its type parameters.\n *\n * Exported so a cycle can be broken from ONE side:\n * `references((): AnyColumn => y.id)`.\n */\nexport type AnyColumn = ColumnBuilder;\n\n/**\n * The column that OWNS this row: a `text` FK onto `auth.users(id)`, NOT NULL,\n * ON DELETE CASCADE.\n *\n * Ownership drives account erasure, so cascade is the only correct action and\n * takes no argument. The referencing column must be `text` (palauth ids are\n * `usr_<uuid>`) and NOT NULL — both are implied here rather than left to the\n * caller, so three rules the type could not express before become UNWRITABLE.\n *\n * At most ONE per table. The old shape let several columns reference\n * `auth.users` and picked the FIRST IN DECLARATION ORDER as the owner — moving a\n * `created_by` above a `user_id` silently changed which rows an account deletion\n * took with it. A second one is now rejected at push.\n *\n * For a column that merely POINTS at a user without owning the row\n * (`created_by`, `edited_by`), use `userRef({ onDelete })`.\n */\nexport function ownedByUser(): ColumnBuilder<'text', false, false, never> {\n const b = new ColumnBuilder<'text', false, false, never>('text');\n b._def.nullable = false;\n b._def.references = { table: 'auth.users', column: 'id' };\n b._def.onDeleteAction = 'cascade';\n b._def.owns = true;\n return b;\n}\n\n/**\n * A plain FK onto `auth.users(id)` that does NOT own the row.\n *\n * `created_by` / `edited_by`: deleting that user must not delete the row. ON\n * DELETE is required and limited to `cascade | set null` so an erasure request\n * is never blocked by a lingering FK; `set null` needs a nullable column.\n */\nexport function userRef(opts: { onDelete: AuthUserOnDelete; as?: string }): ColumnBuilder<'text', boolean, false, never> {\n const b = new ColumnBuilder<'text', boolean, false, never>('text');\n b._def.references = { table: 'auth.users', column: 'id' };\n b._def.onDeleteAction = opts.onDelete;\n if (opts.as !== undefined) b._def.refAs = opts.as;\n return b;\n}\n\n/**\n * A plain FK onto `auth.installations(id)` — the app-scoped verified-device\n * anchor.\n *\n * An installation is an APP INSTALL, not a user: this is NOT ownership. A\n * user-owned row still needs its own `ownedByUser()` so account erasure removes\n * it; an installation reference alone does not tie a row to a user's deletion.\n */\nexport function installationRef(opts: { onDelete: AuthUserOnDelete; as?: string }): ColumnBuilder<'text', boolean, false, never> {\n const b = new ColumnBuilder<'text', boolean, false, never>('text');\n b._def.references = { table: 'auth.installations', column: 'id' };\n b._def.onDeleteAction = opts.onDelete;\n if (opts.as !== undefined) b._def.refAs = opts.as;\n return b;\n}\n"],"mappings":"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","names":["CODECS","number","fromDb","v","Number","toDb","String","tsType","decimal","refuseOnVector","def","modifier","type","Error","ColumnBuilder","_def","existingDef","nullable","primaryKey","notNull","default","value","defaultValue","defaultRandom","defaultNow","dbAssigned","renamedFrom","previous","ignored","references","target","opts","referencesThunk","as","undefined","refAs","reverseAs","onDelete","onDeleteAction","index","counter","selfReferences","column","selfRefColumn","action","unique","asNumber","withCodec","asDecimal","codec","method","transform","fns","uuid","text","integer","bigint","numeric","boolean","timestamp","jsonb","enumType","name","values","builder","enumName","enumValues","vector","dimensions","isInteger","b","ownedByUser","table","owns","userRef","installationRef"]}