@palbase/backend 38.0.16 → 39.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/bin/palbase-backend.cjs +46 -1
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +5 -5
  4. package/dist/{chunk-LFUNRWUE.js → chunk-4J6I4KVQ.js} +67 -3
  5. package/dist/{chunk-LFUNRWUE.js.map → chunk-4J6I4KVQ.js.map} +1 -1
  6. package/dist/{chunk-7WAGQ3VR.js → chunk-AR2I4M7I.js} +497 -17
  7. package/dist/chunk-AR2I4M7I.js.map +1 -0
  8. package/dist/{chunk-YANGWAIM.js → chunk-H7EKL6HC.js} +2 -2
  9. package/dist/{chunk-EHKEMHB4.js → chunk-JJSR4BUX.js} +49 -5
  10. package/dist/chunk-JJSR4BUX.js.map +1 -0
  11. package/dist/{chunk-CS6NQ6PO.js → chunk-JPTFYHP3.js} +2 -2
  12. package/dist/{chunk-CS6NQ6PO.js.map → chunk-JPTFYHP3.js.map} +1 -1
  13. package/dist/{chunk-X7UR3VXA.js → chunk-XZWOMPD3.js} +3 -2
  14. package/dist/chunk-XZWOMPD3.js.map +1 -0
  15. package/dist/{chunk-PDD55QWN.js → chunk-ZNQDL466.js} +2 -2
  16. package/dist/db/index.cjs +378 -14
  17. package/dist/db/index.cjs.map +1 -1
  18. package/dist/db/index.d.cts +1 -1
  19. package/dist/db/index.d.ts +1 -1
  20. package/dist/db/index.js +3 -3
  21. package/dist/engine/index.cjs +46 -1
  22. package/dist/engine/index.cjs.map +1 -1
  23. package/dist/engine/index.d.cts +3 -3
  24. package/dist/engine/index.d.ts +3 -3
  25. package/dist/engine/index.js +5 -5
  26. package/dist/{index-CI7S0Wqv.d.cts → index-B0mjUnxy.d.cts} +25 -2
  27. package/dist/{index-CUy50OLU.d.ts → index-eN4KzGa5.d.ts} +555 -29
  28. package/dist/{index-DRx880KY.d.cts → index-pK2At5Yc.d.cts} +555 -29
  29. package/dist/{index-Ogi30dbt.d.ts → index-yDno9Ju9.d.ts} +25 -2
  30. package/dist/index.cjs +629 -17
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +216 -8
  33. package/dist/index.d.ts +216 -8
  34. package/dist/index.js +75 -6
  35. package/dist/index.js.map +1 -1
  36. package/dist/openapi/index.cjs.map +1 -1
  37. package/dist/openapi/index.d.cts +2 -2
  38. package/dist/openapi/index.d.ts +2 -2
  39. package/dist/openapi/index.js +2 -2
  40. package/dist/{registry-C7UCkQf0.d.cts → registry-9dNeVN5d.d.cts} +1 -1
  41. package/dist/{registry-DJcvbomD.d.ts → registry-B8ddZiMY.d.ts} +1 -1
  42. package/dist/test/index.cjs +19 -0
  43. package/dist/test/index.cjs.map +1 -1
  44. package/dist/test/index.d.cts +1 -1
  45. package/dist/test/index.d.ts +1 -1
  46. package/dist/test/index.js +20 -2
  47. package/dist/test/index.js.map +1 -1
  48. package/docs/README.md +1 -1
  49. package/docs/database.md +129 -0
  50. package/docs/llms-full.txt +512 -33
  51. package/docs/schema.md +382 -32
  52. package/package.json +1 -1
  53. package/template/package.json +1 -1
  54. package/dist/chunk-7WAGQ3VR.js.map +0 -1
  55. package/dist/chunk-EHKEMHB4.js.map +0 -1
  56. package/dist/chunk-X7UR3VXA.js.map +0 -1
  57. /package/dist/{chunk-YANGWAIM.js.map → chunk-H7EKL6HC.js.map} +0 -0
  58. /package/dist/{chunk-PDD55QWN.js.map → chunk-ZNQDL466.js.map} +0 -0
package/docs/README.md CHANGED
@@ -82,7 +82,7 @@ service the controllers call.
82
82
 
83
83
  > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
84
84
  > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
85
- > and will not compile against `@palbase/backend` 38.
85
+ > and will not compile against `@palbase/backend` 39.
86
86
 
87
87
  ### Complete CRUD example (copy-pasteable, compiles)
88
88
 
package/docs/database.md CHANGED
@@ -29,6 +29,56 @@ await Database.public.todos.delete(todo.id);
29
29
 
30
30
  See [schema.md](./schema.md) for the full typed-table surface.
31
31
 
32
+ ## Tenant-scoped CRUD — `defineRepository`
33
+
34
+ Most repository classes are the same two lines per method: add the tenant column
35
+ to the predicate, then unwrap the result. Writing that by hand is what makes it
36
+ forgettable, and one forgotten predicate shows one tenant's row to another.
37
+ `defineRepository` produces a BASE CLASS that writes the predicate once.
38
+
39
+ ```ts
40
+ import { defineRepository } from "@palbase/backend";
41
+
42
+ export class TodoRepository extends defineRepository(
43
+ Database.public.todos,
44
+ { tenant: "household_id" },
45
+ ) {}
46
+ ```
47
+
48
+ The subclass gets six typed methods, all scoped to the tenant you pass first:
49
+ `list(tenant)`, `find(tenant, id)`, `insert(tenant, values)`,
50
+ `update(tenant, id, patch)`, `updateScoped(tenant, id, patch)` and
51
+ `delete(tenant, id)`. Add your own methods in the subclass body as usual.
52
+
53
+ `insert` does NOT take the tenant column in its payload — the repository writes
54
+ it. `update` throws `NotFound` when nothing matches, so the caller does not need
55
+ a `null` branch; `updateScoped` runs the same predicate and returns `null`
56
+ instead, for when absence is a value.
57
+
58
+ The base class constructor takes no arguments, so the DI container resolves the
59
+ subclass by its own name with no extra metadata. `Database` stays ambient rather
60
+ than injected: it is request-scoped, so a reference captured in a constructor
61
+ would carry one request's client into another.
62
+
63
+ ### A row key that is not `id`
64
+
65
+ The row key defaults to `id`, but it is not fixed — `defineTable` does not
66
+ require an `id` column, and a primary key can sit on any column. Name it:
67
+
68
+ ```ts
69
+ export class DocRepository extends defineRepository(
70
+ Database.public.docs,
71
+ { tenant: "org_id", key: "slug" },
72
+ ) {}
73
+
74
+ await new DocRepository().find("org_1", "getting-started");
75
+ ```
76
+
77
+ The key parameter carries the column's OWN type, so a numeric key is typed
78
+ `number` rather than assumed to be a string. If the table has no `id` column and
79
+ you do not name a key, the surface refuses at compile time and the refusal says
80
+ what to do (`MissingRowKey`).
81
+
32
82
  ## Transaction boundaries and retries
33
83
 
34
84
  Ordinary Database writes commit when the request completes. `$transaction(fn)`
@@ -455,6 +505,85 @@ const rows = await Database.$query(
455
505
  A JavaScript array parameter is encoded as a Postgres array literal, so
456
506
  `= ANY($1::uuid[])` works.
457
507
 
508
+ **Do not reach for `$query` to take a lock.** That is the single most common
509
+ reason this escape hatch gets used, and there are typed primitives for it —
510
+ see the next section.
511
+
512
+ ### Locks — `$lockRows`, `$lockRowsWhere`, `$advisoryXactLock`, `lock: "update"`
513
+
514
+ Four surfaces, for four different things.
515
+
516
+ **`Database.$lockRows(table, ids)` — lock these rows, and let the layer order
517
+ them.** You declare the set you are about to touch; the ordering is not yours to
518
+ choose. Trusting the caller's order means two callers entering the same two rows
519
+ in opposite orders, and the bill for a deadlock is not cheap: `deadlock_timeout`
520
+ defaults to **1 second**, so the loser waits a full second before it errors.
521
+
522
+ ```ts
523
+ await Database.$lockRows("accounts", [input.from, input.to]);
524
+ ```
525
+
526
+ It de-duplicates and sorts the ids, then emits
527
+ `SELECT pk … WHERE pk IN (…) ORDER BY pk FOR NO KEY UPDATE` — the same lock mode
528
+ `updateMany`'s CTE takes, because two write paths must share one order and one
529
+ mode or each will wait on the other. It requires a single-column primary key and
530
+ throws if the table has none. An empty list locks nothing and returns.
531
+
532
+ **`Database.$lockRowsWhere(table, where, { mode })` — lock what a FILTER
533
+ matches.** `$lockRows` asks you to enumerate the set. "Lock every pending entry
534
+ on this account" cannot: the id list only comes out of a query, and the gap
535
+ between that query and the lock is exactly the race the lock exists to close.
536
+ This form selects and locks the set in ONE statement.
537
+
538
+ ```ts
539
+ await Database.$lockRowsWhere("entries", { account_id: id, status: "pending" });
540
+ const rows = await Database.$findMany("entries", { account_id: id, status: "pending" });
541
+ ```
542
+
543
+ The filter is the ordinary one — same compiler as `findMany` and `updateMany`,
544
+ so there is no second filter dialect and no `$query`. It emits
545
+ `SELECT pk … WHERE … ORDER BY pk <lock clause>`, and that `ORDER BY pk` is the
546
+ **same order `updateMany`'s CTE takes**: two transactions entering the same rows
547
+ by different routes cannot take them in opposite orders. Measured against live
548
+ Postgres in both directions.
549
+
550
+ The lock MODE is yours, and it is deliberately not equalised with `updateMany`'s:
551
+
552
+ | `mode` | Postgres clause | when |
553
+ |---|---|---|
554
+ | `"update"` (default) | `FOR UPDATE` | you are about to change these rows |
555
+ | `"share"` | `FOR SHARE` | "nobody may change these while I read them" |
556
+ | `"noKeyUpdate"` | `FOR NO KEY UPDATE` | `updateMany`'s own mode — non-key columns |
557
+
558
+ Forcing everyone onto one mode would make a reader take a stronger lock than it
559
+ needs. What prevents the deadlock is the ORDER, not the mode. An unrecognised
560
+ mode is refused rather than quietly downgraded — a silently weaker lock is
561
+ fail-open.
562
+
563
+ Two refusals worth knowing before you reach for it: an **empty filter** is
564
+ rejected (locking the whole table is not somewhere you should arrive by
565
+ accident — every writer waits until your transaction ends), and a table with no
566
+ single-column primary key is rejected, because the lock ORDER needs a key.
567
+
568
+ **`Database.$advisoryXactLock(key)` — lock a NAME, not a row.** For work that
569
+ has no row to lock: "only one statement close runs at a time", or serializing
570
+ retries of the same idempotency key before reading its result.
571
+
572
+ ```ts
573
+ await tx.$advisoryXactLock(`billing.close:${householdId}:${period}`);
574
+ ```
575
+
576
+ The key is text; Postgres wants a 64-bit integer, so it is hashed with
577
+ `hashtextextended` and **the text never enters the SQL**. Only the
578
+ transaction-scoped family is offered (`pg_advisory_xact_lock`): Postgres releases
579
+ it on commit or rollback, so it cannot be forgotten. The session-scoped version
580
+ is deliberately absent — the connection returns to the pool with the lock still
581
+ held, and the next request waits forever.
582
+
583
+ **`select(where, { lock: "update" })` — a real `FOR UPDATE`**, inside a
584
+ `$transaction` plan. Use it when the rows you want to lock are the rows you were
585
+ about to select anyway.
586
+
458
587
  ### Bulk writes with a conflict rule
459
588
 
460
589
  Inside a transaction, `insertMany` takes an optional conflict rule — which is how