@palbase/backend 33.0.2 → 34.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 +2273 -358
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +9 -7
- package/dist/bin/palbase-backend.js.map +1 -1
- package/dist/{chunk-YIQ4RS4F.js → chunk-5UGPXKMJ.js} +331 -5
- package/dist/chunk-5UGPXKMJ.js.map +1 -0
- package/dist/chunk-GHNC2UHS.js +20 -0
- package/dist/chunk-GHNC2UHS.js.map +1 -0
- package/dist/{chunk-AAT5G7KY.js → chunk-JJW3L62T.js} +2 -2
- package/dist/chunk-JJW3L62T.js.map +1 -0
- package/dist/{chunk-SI4KGEM3.js → chunk-P7MGSAFT.js} +102 -96
- package/dist/chunk-P7MGSAFT.js.map +1 -0
- package/dist/{chunk-XOX6RFPZ.js → chunk-PRH45NZU.js} +46 -3
- package/dist/chunk-PRH45NZU.js.map +1 -0
- package/dist/{chunk-26GH54D2.js → chunk-RZLY4BOY.js} +5 -3
- package/dist/{chunk-26GH54D2.js.map → chunk-RZLY4BOY.js.map} +1 -1
- package/dist/{chunk-KATPXCJ5.js → chunk-UMESJJRS.js} +19 -40
- package/dist/chunk-UMESJJRS.js.map +1 -0
- package/dist/chunk-VXPNPVAG.js +40 -0
- package/dist/chunk-VXPNPVAG.js.map +1 -0
- package/dist/{chunk-WWUG2QXF.js → chunk-X2UYIXDS.js} +59 -4
- package/dist/chunk-X2UYIXDS.js.map +1 -0
- package/dist/{chunk-C6COAB3E.js → chunk-XZPDPYVY.js} +1905 -195
- package/dist/chunk-XZPDPYVY.js.map +1 -0
- package/dist/{chunk-TVCCR6SO.js → chunk-YO5GYM73.js} +58 -9
- package/dist/chunk-YO5GYM73.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +2 -0
- package/dist/db/env.d.ts +2 -0
- package/dist/db/index.cjs +285 -2
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +46 -3
- package/dist/db/index.d.ts +46 -3
- package/dist/db/index.js +14 -5
- package/dist/{index-DMZG3kpo.d.ts → endpoint-BXPdsTA5.d.ts} +370 -58
- package/dist/{index-DB_nW-AV.d.cts → endpoint-DqhUP7RT.d.cts} +370 -58
- package/dist/engine/index.cjs +2279 -356
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +5 -5
- package/dist/engine/index.d.ts +5 -5
- package/dist/engine/index.js +17 -7
- package/dist/{index-DLveQoOf.d.cts → index-Co0Hj_aO.d.cts} +108 -50
- package/dist/{index-D-3duy8Y.d.ts → index-MhCL7ll4.d.ts} +108 -50
- package/dist/index.cjs +806 -386
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -6
- package/dist/index.d.ts +7 -6
- package/dist/index.js +27 -15
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +7 -0
- package/dist/openapi/index.cjs.map +1 -1
- package/dist/openapi/index.d.cts +3 -3
- package/dist/openapi/index.d.ts +3 -3
- package/dist/openapi/index.js +4 -3
- package/dist/openapi/index.js.map +1 -1
- package/dist/{registry-DSTThhKf.d.cts → registry-BkIVHPzB.d.cts} +3 -1
- package/dist/{registry-JjF5lcj4.d.ts → registry-CvGLR5j1.d.ts} +3 -1
- package/dist/test/index.cjs +103 -66
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +3 -3
- package/dist/test/index.d.ts +3 -3
- package/dist/test/index.js +33 -7
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +1 -1
- package/docs/auth.md +58 -0
- package/docs/database.md +308 -0
- package/docs/examples/check-query-contracts.ts +5 -0
- package/docs/examples/database-dx.contract.json +65 -0
- package/docs/examples/database-dx.ts +63 -0
- package/docs/examples/money-transfer.ts +73 -0
- package/docs/llms-full.txt +382 -1
- package/docs/schema.md +15 -0
- package/package.json +1 -2
- package/template/db/public.ts +12 -0
- package/template/package.json +1 -1
- package/dist/chunk-AAT5G7KY.js.map +0 -1
- package/dist/chunk-C6COAB3E.js.map +0 -1
- package/dist/chunk-KATPXCJ5.js.map +0 -1
- package/dist/chunk-SI4KGEM3.js.map +0 -1
- package/dist/chunk-TVCCR6SO.js.map +0 -1
- package/dist/chunk-WWUG2QXF.js.map +0 -1
- package/dist/chunk-XOX6RFPZ.js.map +0 -1
- package/dist/chunk-YIQ4RS4F.js.map +0 -1
- package/stack-images.json +0 -24
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` 34.
|
|
86
86
|
|
|
87
87
|
### Complete CRUD example (copy-pasteable, compiles)
|
|
88
88
|
|
package/docs/auth.md
CHANGED
|
@@ -155,6 +155,64 @@ Two helpers are available in every policy body:
|
|
|
155
155
|
Both take the caller from `auth.uid()` and accept no user id, so a policy cannot
|
|
156
156
|
ask about somebody else.
|
|
157
157
|
|
|
158
|
+
### `can` — what a row says you may do
|
|
159
|
+
|
|
160
|
+
Every row a table hands back carries `can: string[]`: the names of the table's
|
|
161
|
+
named **update** policies whose `using` expression is true for THIS row and THIS
|
|
162
|
+
caller. It is computed in the same `SELECT` that fetched the row — the policy is
|
|
163
|
+
the only copy of the rule, and `can` is its echo.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const transfer = await Database.public.transfers.findById(id);
|
|
167
|
+
transfer?.can; // e.g. ["transfers_approve"] — typed from db/public.ts
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
To put it on the wire, name it in the response schema — the enum comes from the
|
|
171
|
+
policy declaration, never from a hand-typed list:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { z, can } from "@palbase/backend";
|
|
175
|
+
import publicSchema from "../../db/public";
|
|
176
|
+
|
|
177
|
+
export const TransferSchema = z.object({
|
|
178
|
+
id: z.string(),
|
|
179
|
+
status: z.string(),
|
|
180
|
+
can: can(publicSchema.tables.transfers),
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The contract, word for word: **`can` is for the interface; it cannot be used for
|
|
185
|
+
an authorization decision; it may be incomplete; the server decides again on
|
|
186
|
+
every request.** A policy whose `using` the engine cannot select as a column is
|
|
187
|
+
dropped from `can` with a `warn` line — it never becomes a silent `true`. In a
|
|
188
|
+
raw-string `using`, write column names unqualified (`status`, not
|
|
189
|
+
`transfers.status` or `public.transfers.status`) and wrap every
|
|
190
|
+
`auth.has_permission(...)` in `(select …)`; a comment, an unbalanced parenthesis
|
|
191
|
+
or an unterminated quote drops the policy the same way.
|
|
192
|
+
|
|
193
|
+
Rows read under `Database.$asService()` carry `can: []`: no policy applies to
|
|
194
|
+
the service role and every `TO` gate is false for it, so a row fetched there and
|
|
195
|
+
handed to a user says nothing about what that user may do — read it as the user.
|
|
196
|
+
|
|
197
|
+
Only `.for("update")` policies are echoed. A `.for("all")` policy such as
|
|
198
|
+
`notes_owner` above governs UPDATE too, but it is not a named update policy and
|
|
199
|
+
never appears in `can` — a table whose only policy is `for("all")` gets
|
|
200
|
+
`can: []` on every row, and `can(table)` refuses it by name. To get an
|
|
201
|
+
affordance, declare a separate `.for("update")` policy beside it (the template's
|
|
202
|
+
`notes_edit_own` next to `notes_owner`).
|
|
203
|
+
|
|
204
|
+
The name `can` is reserved for this echo. A table that declares a column named
|
|
205
|
+
`can` keeps its column and gets no echo (with a `warn`); a **relation** named
|
|
206
|
+
`can` — a table named `can`, a `can_id` foreign key, `as: "can"` or
|
|
207
|
+
`reverseAs: "can"` — is refused by `defineTable` itself, because `row.can` and
|
|
208
|
+
`relations.can` cannot both be true of one row.
|
|
209
|
+
|
|
210
|
+
When the server refuses, the 403 body names the cure: `required: "transfers.approve"`.
|
|
211
|
+
`pb.auth.onPermissionDenied { required in … }` (iOS) / `pb.auth.onPermissionDenied(required => …)`
|
|
212
|
+
(web) fires on it, so a screen drawn from a stale `can` refetches and repairs itself.
|
|
213
|
+
`pb.auth.permissions.holds("transfers.approve")` is the live, observable set of the
|
|
214
|
+
caller's permissions — pushed over realtime the moment a role changes.
|
|
215
|
+
|
|
158
216
|
## Email verification
|
|
159
217
|
|
|
160
218
|
The platform handles verification end to end. **You do not configure a sender**,
|
package/docs/database.md
CHANGED
|
@@ -29,6 +29,69 @@ 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
|
+
## Transaction boundaries and retries
|
|
33
|
+
|
|
34
|
+
Ordinary Database writes commit when the request completes. `$transaction(fn)`
|
|
35
|
+
builds a synchronous plan executed inside a savepoint of that same transaction.
|
|
36
|
+
A savepoint cannot refresh its parent snapshot or retry the parent's COMMIT;
|
|
37
|
+
passing a nonzero `retry` to `$transaction` is refused with a migration message.
|
|
38
|
+
|
|
39
|
+
Use `$atomic` for an explicit, independently committed transaction with ordinary
|
|
40
|
+
async reads and writes. Its promise resolves after COMMIT succeeds. Every retry
|
|
41
|
+
opens a fresh transaction and reruns the complete callback, including decision
|
|
42
|
+
reads. Choose `read committed`, `repeatable read` or `serializable` isolation.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { Conflict, NotFound, decrement } from "@palbase/backend";
|
|
46
|
+
|
|
47
|
+
const result = await Database.$atomic(async (tx) => {
|
|
48
|
+
const account = await tx.public.accounts.findById(accountId);
|
|
49
|
+
if (!account) throw new NotFound();
|
|
50
|
+
// All dependent writes, the ledger and an outbox event belong in this scope.
|
|
51
|
+
return tx.$transaction((plan) => {
|
|
52
|
+
return plan.public.accounts.updateWhere(
|
|
53
|
+
{ id: accountId, balance: { gte: amount } },
|
|
54
|
+
{ balance: decrement(amount) },
|
|
55
|
+
).expectOne(new Conflict("Insufficient balance"));
|
|
56
|
+
});
|
|
57
|
+
}, { isolation: "serializable", retry: 3, lockTimeoutMs: 1000, statementTimeoutMs: 5000 });
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`retry` defaults to 0 and is limited to 10. Only serialization and deadlock
|
|
61
|
+
failures are retried. A lost connection during COMMIT has an unknown outcome
|
|
62
|
+
and is not automatically replayed; use an idempotency key to reconcile it.
|
|
63
|
+
Keep external effects out of a retried callback: write an outbox record there
|
|
64
|
+
and deliver it after commit.
|
|
65
|
+
|
|
66
|
+
The complete [money-transfer.ts](./examples/money-transfer.ts) recipe uses the
|
|
67
|
+
public `DBClient.atomic` seam, typed guards, exact integer minor units, a payload
|
|
68
|
+
checked idempotency key, ordered account locks, balanced ledger entries and an
|
|
69
|
+
outbox. Its `transferSchema` declares the database constraints and tenant RLS;
|
|
70
|
+
export it from `db/public.ts`, review `palbase db plan` and run `palbase db apply`.
|
|
71
|
+
It is the same transfer body used by the real PostgreSQL verification suite;
|
|
72
|
+
importing it alone does not apply its constraints. Derive `input.tenant` from verified application scope. Use globally
|
|
73
|
+
unique request keys, or include the tenant in the key. An outbox worker delivers
|
|
74
|
+
after commit and consumers deduplicate by event id.
|
|
75
|
+
|
|
76
|
+
`$atomic` commits independently: a later handler error does not undo it. Avoid
|
|
77
|
+
writing the same data in the surrounding request transaction. The callback's
|
|
78
|
+
typed `tx` and ambient `Database` calls share its transaction. Handles saved from
|
|
79
|
+
another transaction, nested roots and `$asService()` inside it are refused.
|
|
80
|
+
For service-role work choose `Database.$asService().$atomic(...)` before entering
|
|
81
|
+
the callback; it keeps that one identity throughout every retry.
|
|
82
|
+
|
|
83
|
+
The request's default and service-role surfaces remain separate transactions;
|
|
84
|
+
their commits are not atomic together. Put a business operation's atomic writes
|
|
85
|
+
in one `$atomic` scope. `fakeDatabase` does not emulate PostgreSQL isolation or
|
|
86
|
+
commit/retry behavior; use a real PostgreSQL test for that boundary.
|
|
87
|
+
|
|
88
|
+
Schema-aware timestamp results use UTC ISO strings with six fractional digits,
|
|
89
|
+
preserving PostgreSQL microseconds through reads, returned writes, relations,
|
|
90
|
+
aggregates and transaction plan references. Infinities, BC dates and years outside
|
|
91
|
+
1–9999 retain PostgreSQL's text representation. These queries return the declared
|
|
92
|
+
column shape. Raw `$query` results follow the driver's types; request `column::text`
|
|
93
|
+
when a raw query must preserve timestamp precision rather than a JavaScript Date.
|
|
94
|
+
|
|
32
95
|
## Raw string-keyed operations
|
|
33
96
|
|
|
34
97
|
For a table name computed at runtime, or for SQL the typed helpers do not
|
|
@@ -706,3 +769,248 @@ Guidelines:
|
|
|
706
769
|
second surface waits for a lock the first cannot release until the handler
|
|
707
770
|
returns. The service side gives up after five seconds and tells you this;
|
|
708
771
|
the default side would wait until the edge cuts the request.
|
|
772
|
+
## Cursor pages and bulk counts
|
|
773
|
+
|
|
774
|
+
`page` returns `{ rows, pageInfo }`. A projection narrows `rows` in TypeScript;
|
|
775
|
+
the cursor keeps the ordering values independently, including fields omitted by
|
|
776
|
+
`select`.
|
|
777
|
+
|
|
778
|
+
```ts
|
|
779
|
+
const page = await Database.public.todos.page({
|
|
780
|
+
select: ["id", "title"],
|
|
781
|
+
orderBy: { column: "created_at", direction: "desc" },
|
|
782
|
+
first: 50,
|
|
783
|
+
after: cursor, // omit for the first page
|
|
784
|
+
});
|
|
785
|
+
// Next: after: page.pageInfo.endCursor
|
|
786
|
+
// Previous: last: 50, before: page.pageInfo.startCursor
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
Sizes are 1–1000 (default 50). Use `first`/`after` or `last`/`before` together.
|
|
790
|
+
The engine appends every missing primary-key column to the ordering, including
|
|
791
|
+
composite keys. `direction` and `nulls` are honored independently. Both directions
|
|
792
|
+
return rows in the requested order. Empty pages have null cursors and report
|
|
793
|
+
whether matching rows remain on the opposite side of the supplied boundary.
|
|
794
|
+
The page and its flags use one PostgreSQL statement snapshot. Across requests,
|
|
795
|
+
inserts, deletes and changes to ordering fields can change the list; a cursor is
|
|
796
|
+
not a frozen snapshot. Prefer immutable ordering columns for exports.
|
|
797
|
+
|
|
798
|
+
Install the table declaration and an index matching the tenant/filter columns
|
|
799
|
+
followed by the ordering columns and primary key. Keyset pagination avoids
|
|
800
|
+
scanning a skipped prefix; it does not provide arbitrary page-number jumps.
|
|
801
|
+
JSON/vector ordering is refused. Text ordering follows the database collation.
|
|
802
|
+
|
|
803
|
+
In the engine, cursor encryption uses `PALBASE_CURSOR_SECRET`, falling back to
|
|
804
|
+
`PALBASE_SERVICE_ROLE_KEY`. Keep this secret shared across replicas and restarts;
|
|
805
|
+
rotation invalidates existing cursors. Cursors are tied to the table declaration,
|
|
806
|
+
filter, projection, relations, ordering and authorization claims (JWT issuance/
|
|
807
|
+
expiry identifiers are excluded). Reusing one with another query or identity
|
|
808
|
+
returns `BadRequest`. A malformed token is rejected before database access.
|
|
809
|
+
Standalone engine consumers configure `setCursorKey(secret)` from the engine
|
|
810
|
+
entrypoint. `fakeDatabase` requires an explicit page test double; PostgreSQL
|
|
811
|
+
integration tests cover collation, RLS and cursor semantics.
|
|
812
|
+
|
|
813
|
+
```ts
|
|
814
|
+
const inserted = await Database.public.todos.insertMany(rows, {
|
|
815
|
+
returning: false,
|
|
816
|
+
});
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
`inserted` is a number. The engine omits `RETURNING` and reads PostgreSQL's affected
|
|
820
|
+
row count, including zero when `onConflict` with `action: "ignore"` skips every
|
|
821
|
+
row. `action: "update"` counts both inserted and updated rows. The default returns
|
|
822
|
+
rows as before; a dynamic boolean produces a `number | Row[]` type. This option
|
|
823
|
+
belongs to the asynchronous bulk surface; declarative transaction plan row handles
|
|
824
|
+
continue to return their rows. Custom SQL drivers must expose an integer `count`
|
|
825
|
+
on a non-returning result. Empty input opens no connection. The batch remains one
|
|
826
|
+
statement and participates in its enclosing transaction's rollback.
|
|
827
|
+
|
|
828
|
+
## Reusable queries and response contracts
|
|
829
|
+
|
|
830
|
+
`defineQuery` combines an explicit selection with a reusable input/filter. The
|
|
831
|
+
result is also a Zod array schema: a controller can return
|
|
832
|
+
`Promise<z.infer<typeof ListTodos>>`, and the existing build binder uses that
|
|
833
|
+
same value for response validation, OpenAPI and generated clients.
|
|
834
|
+
|
|
835
|
+
```ts
|
|
836
|
+
export const ListTodos = defineQuery("ListTodos", {
|
|
837
|
+
table: todos, // a defineTable value already registered by defineSchema
|
|
838
|
+
input: z.object({ prefix: z.string().default("") }),
|
|
839
|
+
select: ["id", "title"],
|
|
840
|
+
where: ({ prefix }) => ({ title: { startsWith: prefix } }),
|
|
841
|
+
orderBy: { column: "id" },
|
|
842
|
+
limit: 50,
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const rows = await ListTodos.run(Database, { prefix: "Buy" });
|
|
846
|
+
const compiled = ListTodos.sql(Database, { prefix: "Buy" }); // no connection
|
|
847
|
+
// compiled.sql and compiled.params describe the same statement run() executes.
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
`run` validates its input before SQL and its response before returning. SQL uses
|
|
851
|
+
the same SELECT compiler as `findMany`, under the caller's transaction and RLS
|
|
852
|
+
identity. Table names retain their declared schema. Selection is mandatory;
|
|
853
|
+
unselected database columns cannot silently expand the API. Numeric/bigint and
|
|
854
|
+
timestamp outputs retain their exact string representations, and named codecs
|
|
855
|
+
determine the surfaced type. A selected `jsonb<T>()` needs a matching Zod schema
|
|
856
|
+
in `fields: { payload: PayloadSchema }`, because its TypeScript payload is erased
|
|
857
|
+
at runtime. Free-form column transforms require migration to a named codec.
|
|
858
|
+
|
|
859
|
+
Keep a contract manifest in the application repository:
|
|
860
|
+
|
|
861
|
+
```ts
|
|
862
|
+
const generated = queryManifest([ListTodos]);
|
|
863
|
+
// Write generated JSON for the initial, reviewed baseline.
|
|
864
|
+
assertQueryManifest([ListTodos], committedManifest); // run in the CI/build check
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
The manifest contains input/response OpenAPI components, selected storage types,
|
|
868
|
+
nullability and codecs. A changed contract fails the check until its diff is
|
|
869
|
+
reviewed and the committed baseline is updated. The runtime also refuses a query
|
|
870
|
+
whose selected columns disagree with the installed declaration, before SQL.
|
|
871
|
+
This check compares declarations; migration validation still owns drift in the
|
|
872
|
+
physical PostgreSQL schema. Queries currently project one table; use `findMany`
|
|
873
|
+
or `page` for nested relation reads.
|
|
874
|
+
|
|
875
|
+
`await ListTodos.explain(Database, input)` executes plain `EXPLAIN (FORMAT JSON)`
|
|
876
|
+
with the same role and parameters. It never enables `ANALYZE`. Plans can contain
|
|
877
|
+
filter literals, so inspect them in development/operator tooling. No EXPLAIN is
|
|
878
|
+
run automatically on production requests. The complete controller/schema example
|
|
879
|
+
is [examples/database-dx.ts](./examples/database-dx.ts).
|
|
880
|
+
|
|
881
|
+
## Nested relations and unique lookups
|
|
882
|
+
|
|
883
|
+
`with` accepts three relation edges, with independent selection, filtering,
|
|
884
|
+
ordering and limits at each level. The whole read remains one SQL statement.
|
|
885
|
+
Each related table retains RLS; `many` returns an array, `one` returns a row or
|
|
886
|
+
null. Numeric/bigint strings, timestamp microseconds, vectors and named codecs
|
|
887
|
+
are restored at each level. Joins use the foreign key's declared target column,
|
|
888
|
+
including a non-primary unique column.
|
|
889
|
+
|
|
890
|
+
```ts
|
|
891
|
+
const customers = await Database.billing.customers.findMany({
|
|
892
|
+
select: ["id"],
|
|
893
|
+
with: { invoices: { select: ["id"], limit: 10,
|
|
894
|
+
with: { customer: { select: ["name"] } },
|
|
895
|
+
} },
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
const account = await Database.public.accounts.findUnique({
|
|
899
|
+
where: { tenant_id: tenantId, account_number: number },
|
|
900
|
+
select: ["balance"],
|
|
901
|
+
});
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
The lookup example requires a declared unique/primary key on
|
|
905
|
+
`["tenant_id", "account_number"]`. Regenerate `palbase-env.d.ts` to get exact key
|
|
906
|
+
types. Supply one entire key; partial composites, mixed keys, operators and null
|
|
907
|
+
values are refused. A missing or RLS-hidden row returns null. More than one match
|
|
908
|
+
raises a constraint-drift error. The direct `makeTypedDB` facade validates keys
|
|
909
|
+
at runtime; the generated `Database.<schema>.<table>` surface also checks them at
|
|
910
|
+
compile time. `findById` keeps its existing `id`-column contract.
|
|
911
|
+
|
|
912
|
+
## Commands and workload admission
|
|
913
|
+
|
|
914
|
+
`$command` accepts the synchronous transaction-plan callback used by
|
|
915
|
+
`$transaction`. It retains guards, dependent row references, sequential visibility
|
|
916
|
+
and savepoint rollback. Its default `mode: "auto"` compiles supported plans when
|
|
917
|
+
the helper is installed and uses the sequential path otherwise. Choose
|
|
918
|
+
`mode: "compiled"` to require compilation or `mode: "sequential"` to opt out.
|
|
919
|
+
It settles with its enclosing transaction; an independently durable command
|
|
920
|
+
belongs inside `$atomic`.
|
|
921
|
+
|
|
922
|
+
Enable the helper with `PALBASE_DB_COMMANDS=true`; standalone engine consumers
|
|
923
|
+
call `installCommandExecutor(driver, roles)`. Installation needs DDL rights.
|
|
924
|
+
Execution is `SECURITY INVOKER`, so the caller's role and RLS remain active.
|
|
925
|
+
Compilation currently excludes SQL fragments, column codecs/transforms, vector
|
|
926
|
+
tables, nullable WHERE references and certain JavaScript number/JSON scalar
|
|
927
|
+
bindings whose driver semantics would differ. Strict mode refuses these before
|
|
928
|
+
SQL; automatic mode preserves the sequential behavior. Plans with no row refs
|
|
929
|
+
send parameterized statements in order on one connection without waiting for
|
|
930
|
+
each result before submitting the next. SQL guards abort before later queued
|
|
931
|
+
writes run, and the engine drains every result before rollback. These are still
|
|
932
|
+
separate SQL statements, with sequential visibility. Plans with row refs use
|
|
933
|
+
one invoker helper call with sequential statements inside PostgreSQL. The helper
|
|
934
|
+
adds JSON/dynamic-SQL processing; measure the actual plan and network before
|
|
935
|
+
selecting it for throughput. Fewer round trips alone do not guarantee a speedup.
|
|
936
|
+
|
|
937
|
+
For contention on the same accounts/resources, declare a workload once:
|
|
938
|
+
|
|
939
|
+
```ts
|
|
940
|
+
const transfers = defineWorkload("transfers", {
|
|
941
|
+
concurrency: 16, queueLimit: 128,
|
|
942
|
+
tenantConcurrency: 8, tenantQueueLimit: 32, queueTimeoutMs: 1000,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
await Database.$atomic(async tx => {
|
|
946
|
+
// Decision reads, idempotency claim, writes, ledger and outbox belong here.
|
|
947
|
+
}, {
|
|
948
|
+
workload: transfers,
|
|
949
|
+
conflictKeys: [fromAccountId, toAccountId],
|
|
950
|
+
timeoutMs: 3000, signal, retry: 2,
|
|
951
|
+
});
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
Overlapping resource keys are admitted in order. Independent keys and tenants
|
|
955
|
+
can proceed while those requests wait. Queue size and per-tenant budgets are
|
|
956
|
+
bounded; full/expired queues return 429 before opening a transaction. Tenant
|
|
957
|
+
identity comes from the configured verified claim (`tenant_id`, then `sub`, then
|
|
958
|
+
one anonymous bucket). Keys are shared within a workload; include the tenant in
|
|
959
|
+
a key if resource IDs are only tenant-unique. Rejected/cancelled waiters leave the
|
|
960
|
+
queue. The lease spans every retry and transaction settlement.
|
|
961
|
+
|
|
962
|
+
These limits apply per engine/driver instance. Divide pool/admission budgets
|
|
963
|
+
across replicas; PostgreSQL locks and constraints remain authoritative across
|
|
964
|
+
instances. Admission is optional because independent work also pays its cost.
|
|
965
|
+
Each driver supports at most 64 named workloads.
|
|
966
|
+
|
|
967
|
+
`timeoutMs` includes queueing, callback work and retries. The default driver uses
|
|
968
|
+
a separate control pool of at most two connections to cancel active PostgreSQL
|
|
969
|
+
statements. Reserve those connections in the deployment budget. Standalone Bun
|
|
970
|
+
drivers can use `withQueryCancellation(applicationPool, controlPool)` with the
|
|
971
|
+
same server/login. The engine waits for cancellation and rollback before releasing
|
|
972
|
+
the application connection. JavaScript callbacks themselves are not preempted;
|
|
973
|
+
late DB handles are invalidated. Pool acquisition and an in-flight COMMIT still
|
|
974
|
+
have to settle, so the deadline is not a hard wall-clock response guarantee.
|
|
975
|
+
An acknowledged COMMIT stays successful; an unknown commit outcome is never
|
|
976
|
+
automatically replayed.
|
|
977
|
+
|
|
978
|
+
## Query diagnostics and budgets
|
|
979
|
+
|
|
980
|
+
Set `PALBASE_DB_DIAGNOSTICS=true` for request summaries, or provide
|
|
981
|
+
`EngineConfig.databaseDiagnostics` with `onQuery`/`onComplete` callbacks.
|
|
982
|
+
Standalone consumers pass the same options as the third argument to
|
|
983
|
+
`createRequestDatabase`. Diagnostics are disabled by default. For a route budget:
|
|
984
|
+
|
|
985
|
+
```ts
|
|
986
|
+
@Get("/", { databaseBudget: { maxQueries: 8, maxRows: 100, maxBytes: 65536 } })
|
|
987
|
+
```
|
|
988
|
+
|
|
989
|
+
Counts include application SQL, authorization reads, service queries and retries;
|
|
990
|
+
transaction setup is timed separately. Query count is checked before SQL. Row
|
|
991
|
+
and estimated JSON-byte budgets are checked after results arrive; use SQL limits
|
|
992
|
+
and projections to avoid producing excessive data. A caught budget error still
|
|
993
|
+
prevents COMMIT of the affected transaction. An earlier independent `$atomic`
|
|
994
|
+
commit remains durable.
|
|
995
|
+
|
|
996
|
+
Summaries correlate the route/request ID with query names and SQL fingerprints.
|
|
997
|
+
`captureSource: true` adds a bounded runtime stack; `includeSql: true` adds bounded
|
|
998
|
+
SQL text. Bind values are never recorded. Raw SQL text can itself contain inline
|
|
999
|
+
literals. `Database.$diagnostics()` returns the current snapshot, or null when
|
|
1000
|
+
disabled; the completion callback includes settlement timings. Only the first
|
|
1001
|
+
200 completed statements are retained, while totals cover every statement.
|
|
1002
|
+
|
|
1003
|
+
| Metric | Meaning |
|
|
1004
|
+
| --- | --- |
|
|
1005
|
+
| `queueMs` | Workload admission wait, including refused waits |
|
|
1006
|
+
| `poolMs` | Driver acquisition through transaction callback entry, including BEGIN |
|
|
1007
|
+
| `setupMs` | Isolation, cancellation ticket and role/claims setup |
|
|
1008
|
+
| `sqlMs` | Awaited driver time, including execution, lock and network waits |
|
|
1009
|
+
| `commitMs` / `rollbackMs` | Observed settlement waits |
|
|
1010
|
+
| `rows` / `bytes` | Returned root rows / estimated JSON result bytes, not wire bytes |
|
|
1011
|
+
| `commits` / `commitFailures` | Acknowledged commits / failed commit acknowledgements |
|
|
1012
|
+
| `rollbackRequests` / `retries` | Requested rollbacks / whole-transaction replays |
|
|
1013
|
+
|
|
1014
|
+
Durations can overlap across concurrent operations; summing them is not request
|
|
1015
|
+
wall time. The driver cannot separate lock wait from server execution. Telemetry
|
|
1016
|
+
callback failures do not change an acknowledged transaction outcome.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { assertQueryManifest } from "@palbase/backend";
|
|
3
|
+
import { ListTodos } from "./database-dx.js";
|
|
4
|
+
|
|
5
|
+
assertQueryManifest([ListTodos], JSON.parse(readFileSync(new URL("./database-dx.contract.json", import.meta.url), "utf8")));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"queries": [
|
|
3
|
+
{
|
|
4
|
+
"columns": {
|
|
5
|
+
"done": {
|
|
6
|
+
"nullable": false,
|
|
7
|
+
"type": "boolean"
|
|
8
|
+
},
|
|
9
|
+
"id": {
|
|
10
|
+
"nullable": false,
|
|
11
|
+
"type": "uuid"
|
|
12
|
+
},
|
|
13
|
+
"title": {
|
|
14
|
+
"nullable": false,
|
|
15
|
+
"type": "text"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"components": {
|
|
19
|
+
"parameters": {},
|
|
20
|
+
"schemas": {
|
|
21
|
+
"ListTodosInput": {
|
|
22
|
+
"properties": {
|
|
23
|
+
"prefix": {
|
|
24
|
+
"default": "",
|
|
25
|
+
"type": "string"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"type": "object"
|
|
29
|
+
},
|
|
30
|
+
"ListTodosResponse": {
|
|
31
|
+
"items": {
|
|
32
|
+
"additionalProperties": false,
|
|
33
|
+
"properties": {
|
|
34
|
+
"done": {
|
|
35
|
+
"type": "boolean"
|
|
36
|
+
},
|
|
37
|
+
"id": {
|
|
38
|
+
"type": "string"
|
|
39
|
+
},
|
|
40
|
+
"title": {
|
|
41
|
+
"type": "string"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"required": [
|
|
45
|
+
"id",
|
|
46
|
+
"title",
|
|
47
|
+
"done"
|
|
48
|
+
],
|
|
49
|
+
"type": "object"
|
|
50
|
+
},
|
|
51
|
+
"type": "array"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"name": "ListTodos",
|
|
56
|
+
"select": [
|
|
57
|
+
"id",
|
|
58
|
+
"title",
|
|
59
|
+
"done"
|
|
60
|
+
],
|
|
61
|
+
"table": "dx_todos"
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"version": 1
|
|
65
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
Controller, Get, QueryParams, Module, Injectable, Database, defineQuery, defineWorkload,
|
|
4
|
+
defineSchema, defineTable, uuid, text, boolean, timestamp, ownedByUser,
|
|
5
|
+
} from "@palbase/backend";
|
|
6
|
+
|
|
7
|
+
export const todos = defineTable("dx_todos", {
|
|
8
|
+
columns: {
|
|
9
|
+
id: uuid().primaryKey().defaultRandom(),
|
|
10
|
+
owner_id: ownedByUser(),
|
|
11
|
+
external_key: text(),
|
|
12
|
+
title: text(),
|
|
13
|
+
done: boolean().default(false),
|
|
14
|
+
created_at: timestamp().defaultNow(),
|
|
15
|
+
},
|
|
16
|
+
unique: [{ name: "dx_todos_owner_key", columns: ["owner_id", "external_key"] }],
|
|
17
|
+
});
|
|
18
|
+
export const schema = defineSchema("public", { tables: [todos] });
|
|
19
|
+
|
|
20
|
+
export const ListTodos = defineQuery("ListTodos", {
|
|
21
|
+
table: todos,
|
|
22
|
+
input: z.object({ prefix: z.string().default("") }),
|
|
23
|
+
select: ["id", "title", "done"],
|
|
24
|
+
where: ({ prefix }) => ({ title: { startsWith: prefix } }),
|
|
25
|
+
orderBy: { column: "id" },
|
|
26
|
+
limit: 50,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
@Injectable()
|
|
30
|
+
export class TodoQueries {
|
|
31
|
+
list(input: z.input<typeof ListTodos.input>) { return ListTodos.run(Database, input); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Controller("/dx-todos", { auth: true })
|
|
35
|
+
export class DatabaseDxController {
|
|
36
|
+
constructor(private readonly todos: TodoQueries) {}
|
|
37
|
+
|
|
38
|
+
@Get("/", { databaseBudget: { maxQueries: 8, maxRows: 50, maxBytes: 65536 } })
|
|
39
|
+
list(@QueryParams(ListTodos.input) input: z.input<typeof ListTodos.input>): Promise<z.infer<typeof ListTodos>> {
|
|
40
|
+
return this.todos.list(input);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Module({ controllers: [DatabaseDxController], providers: [TodoQueries] })
|
|
45
|
+
export class DatabaseDxModule {}
|
|
46
|
+
|
|
47
|
+
// The generated palbase-env.d.ts knows the complete composite unique key.
|
|
48
|
+
export function findExternalTodo(ownerId: string, key: string) {
|
|
49
|
+
return Database.public.dx_todos.findUnique({ where: { owner_id: ownerId, external_key: key }, select: ["id", "title"] });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Optional admission for repeated writes to the same resource.
|
|
53
|
+
const writes = defineWorkload("todo-writes", { concurrency: 8, queueLimit: 32, tenantConcurrency: 4, tenantQueueLimit: 8 });
|
|
54
|
+
export function finishTodo(id: string, signal?: AbortSignal) {
|
|
55
|
+
return Database.$atomic(tx => tx.public.dx_todos.update({ where: { id }, set: { done: true } }), {
|
|
56
|
+
workload: writes, conflictKeys: [id], timeoutMs: 1000, signal, retry: 2,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readPage(after?: string) {
|
|
61
|
+
return Database.public.dx_todos.page({ select: ["id", "title"], first: 20, after,
|
|
62
|
+
orderBy: { column: "created_at", direction: "desc" } });
|
|
63
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Exact integer transfer with idempotency, ordered locks, ledger and outbox.
|
|
2
|
+
* Export transferSchema from db/public.ts and review/apply it with palbase db.
|
|
3
|
+
* This raw DBClient recipe is also exercised by the real PostgreSQL verifiers.
|
|
4
|
+
*/
|
|
5
|
+
import { defineSchema, defineTable, uuid, text, bigint, makeTypedDB, increment, decrement, policy } from "@palbase/backend";
|
|
6
|
+
import type { DBClient, AtomicOptions } from "@palbase/backend";
|
|
7
|
+
|
|
8
|
+
const tenant = "tenant_id = (SELECT current_setting('request.jwt.claims',true)::jsonb->>'tenant_id')";
|
|
9
|
+
const tenantPolicy = () => policy("tenant_scope").for("all").to("authenticated").using(tenant).withCheck(tenant);
|
|
10
|
+
const accounts = defineTable("accounts", { policies: () => [tenantPolicy()],
|
|
11
|
+
checks: [{ name: "accounts_nonnegative", expr: "balance >= 0" }], columns: {
|
|
12
|
+
id: uuid().primaryKey(), tenant_id: text().notNull(), balance: bigint().notNull(),
|
|
13
|
+
} });
|
|
14
|
+
const transfers = defineTable("transfers", { policies: () => [tenantPolicy()], checks: [
|
|
15
|
+
{ name: "transfers_positive", expr: "amount > 0" }, { name: "transfers_distinct", expr: "from_id <> to_id" },
|
|
16
|
+
], columns: {
|
|
17
|
+
id: text().primaryKey(), tenant_id: text().notNull(),
|
|
18
|
+
from_id: uuid().notNull().references(() => accounts.id, { reverseAs: "outgoing" }),
|
|
19
|
+
to_id: uuid().notNull().references(() => accounts.id, { reverseAs: "incoming" }), amount: bigint().notNull(),
|
|
20
|
+
} });
|
|
21
|
+
const ledger = defineTable("ledger", { appendOnly: true, policies: () => [
|
|
22
|
+
policy("tenant_read").for("select").to("authenticated").using(tenant),
|
|
23
|
+
policy("tenant_insert").for("insert").to("authenticated").withCheck(tenant),
|
|
24
|
+
], unique: [{ name: "ledger_transfer_account", columns: ["transfer_id", "account_id"] }], columns: {
|
|
25
|
+
id: uuid().primaryKey(), tenant_id: text().notNull(),
|
|
26
|
+
transfer_id: text().notNull().references(() => transfers.id),
|
|
27
|
+
account_id: uuid().notNull().references(() => accounts.id), amount: bigint().notNull(),
|
|
28
|
+
} });
|
|
29
|
+
const outbox = defineTable("outbox", { policies: () => [tenantPolicy()], columns: {
|
|
30
|
+
id: text().primaryKey().references(() => transfers.id), tenant_id: text().notNull(), kind: text().notNull(),
|
|
31
|
+
} });
|
|
32
|
+
export const transferSchema = defineSchema("public", { tables: [accounts, transfers, ledger, outbox] });
|
|
33
|
+
|
|
34
|
+
export interface TransferInput {
|
|
35
|
+
/** Tenant must come from the authenticated application scope, not a trusted body field. */
|
|
36
|
+
tenant: string;
|
|
37
|
+
key: string;
|
|
38
|
+
from: string;
|
|
39
|
+
to: string;
|
|
40
|
+
/** Integer minor units; no JS floating point arithmetic. */
|
|
41
|
+
amount: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function transferAtomic(database: Pick<DBClient, "atomic">, input: TransferInput, options: AtomicOptions = {}) {
|
|
45
|
+
const amount = BigInt(input.amount);
|
|
46
|
+
if (amount <= 0n || input.from === input.to || input.key.length === 0) throw new Error("invalid_transfer");
|
|
47
|
+
const canonical = amount.toString();
|
|
48
|
+
return database.atomic(async raw => {
|
|
49
|
+
const claim = await raw.claim("transfers", { id: input.key }, {
|
|
50
|
+
tenant_id: input.tenant, from_id: input.from, to_id: input.to, amount: canonical,
|
|
51
|
+
});
|
|
52
|
+
if (!claim.inserted) {
|
|
53
|
+
if (claim.row.from_id !== input.from || claim.row.to_id !== input.to ||
|
|
54
|
+
claim.row.tenant_id !== input.tenant || claim.row.amount !== canonical) throw new Error("idempotency_conflict");
|
|
55
|
+
return { id: input.key, inserted: false };
|
|
56
|
+
}
|
|
57
|
+
await raw.lockRows("accounts", [input.from, input.to]);
|
|
58
|
+
const typed = makeTypedDB(transferSchema, raw);
|
|
59
|
+
await typed.transaction(tx => {
|
|
60
|
+
tx.tables.accounts.updateWhere({ id: input.from, balance: { gte: canonical } }, { balance: decrement(canonical) })
|
|
61
|
+
.expectOne(new Error("insufficient_or_hidden_source"));
|
|
62
|
+
tx.tables.accounts.updateWhere({ id: input.to }, { balance: increment(canonical) })
|
|
63
|
+
.expectOne(new Error("missing_or_hidden_destination"));
|
|
64
|
+
tx.tables.ledger.insertMany([
|
|
65
|
+
{ id: crypto.randomUUID(), tenant_id: input.tenant, transfer_id: input.key, account_id: input.from, amount: (-amount).toString() },
|
|
66
|
+
{ id: crypto.randomUUID(), tenant_id: input.tenant, transfer_id: input.key, account_id: input.to, amount: canonical },
|
|
67
|
+
]).expectAtLeast(2, new Error("ledger_missing"));
|
|
68
|
+
tx.tables.outbox.insert({ id: input.key, tenant_id: input.tenant, kind: "transfer.committed" })
|
|
69
|
+
.expectOne(new Error("outbox_missing"));
|
|
70
|
+
});
|
|
71
|
+
return { id: input.key, inserted: true };
|
|
72
|
+
}, options);
|
|
73
|
+
}
|