@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.
- package/dist/bin/palbase-backend.cjs +46 -1
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +5 -5
- package/dist/{chunk-LFUNRWUE.js → chunk-4J6I4KVQ.js} +67 -3
- package/dist/{chunk-LFUNRWUE.js.map → chunk-4J6I4KVQ.js.map} +1 -1
- package/dist/{chunk-7WAGQ3VR.js → chunk-AR2I4M7I.js} +497 -17
- package/dist/chunk-AR2I4M7I.js.map +1 -0
- package/dist/{chunk-YANGWAIM.js → chunk-H7EKL6HC.js} +2 -2
- package/dist/{chunk-EHKEMHB4.js → chunk-JJSR4BUX.js} +49 -5
- package/dist/chunk-JJSR4BUX.js.map +1 -0
- package/dist/{chunk-CS6NQ6PO.js → chunk-JPTFYHP3.js} +2 -2
- package/dist/{chunk-CS6NQ6PO.js.map → chunk-JPTFYHP3.js.map} +1 -1
- package/dist/{chunk-X7UR3VXA.js → chunk-XZWOMPD3.js} +3 -2
- package/dist/chunk-XZWOMPD3.js.map +1 -0
- package/dist/{chunk-PDD55QWN.js → chunk-ZNQDL466.js} +2 -2
- package/dist/db/index.cjs +378 -14
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +3 -3
- package/dist/engine/index.cjs +46 -1
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +3 -3
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +5 -5
- package/dist/{index-CI7S0Wqv.d.cts → index-B0mjUnxy.d.cts} +25 -2
- package/dist/{index-CUy50OLU.d.ts → index-eN4KzGa5.d.ts} +555 -29
- package/dist/{index-DRx880KY.d.cts → index-pK2At5Yc.d.cts} +555 -29
- package/dist/{index-Ogi30dbt.d.ts → index-yDno9Ju9.d.ts} +25 -2
- package/dist/index.cjs +629 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -8
- package/dist/index.d.ts +216 -8
- package/dist/index.js +75 -6
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +2 -2
- package/dist/openapi/index.d.ts +2 -2
- package/dist/openapi/index.js +2 -2
- package/dist/{registry-C7UCkQf0.d.cts → registry-9dNeVN5d.d.cts} +1 -1
- package/dist/{registry-DJcvbomD.d.ts → registry-B8ddZiMY.d.ts} +1 -1
- package/dist/test/index.cjs +19 -0
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/dist/test/index.js +20 -2
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +1 -1
- package/docs/database.md +129 -0
- package/docs/llms-full.txt +512 -33
- package/docs/schema.md +382 -32
- package/package.json +1 -1
- package/template/package.json +1 -1
- package/dist/chunk-7WAGQ3VR.js.map +0 -1
- package/dist/chunk-EHKEMHB4.js.map +0 -1
- package/dist/chunk-X7UR3VXA.js.map +0 -1
- /package/dist/{chunk-YANGWAIM.js.map → chunk-H7EKL6HC.js.map} +0 -0
- /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`
|
|
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
|