@palbase/backend 35.0.0 → 36.0.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 +556 -487
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +6 -6
- package/dist/{chunk-L4R7WXLH.js → chunk-7WAGQ3VR.js} +4 -171
- package/dist/chunk-7WAGQ3VR.js.map +1 -0
- package/dist/{chunk-UMESJJRS.js → chunk-CS6NQ6PO.js} +2 -1
- package/dist/chunk-CS6NQ6PO.js.map +1 -0
- package/dist/{chunk-BOD3DT6D.js → chunk-EHKEMHB4.js} +560 -430
- package/dist/chunk-EHKEMHB4.js.map +1 -0
- package/dist/{chunk-PRH45NZU.js → chunk-FFO36MQQ.js} +2 -45
- package/dist/chunk-FFO36MQQ.js.map +1 -0
- package/dist/{chunk-P7MGSAFT.js → chunk-LFUNRWUE.js} +1 -40
- package/dist/chunk-LFUNRWUE.js.map +1 -0
- package/dist/{chunk-RZLY4BOY.js → chunk-PDD55QWN.js} +2 -2
- package/dist/{chunk-YO5GYM73.js → chunk-X7UR3VXA.js} +5 -6
- package/dist/chunk-X7UR3VXA.js.map +1 -0
- package/dist/{chunk-6FEHCEB7.js → chunk-YANGWAIM.js} +3 -3
- package/dist/db/index.cjs +1 -232
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +3 -46
- package/dist/db/index.d.ts +3 -46
- package/dist/db/index.js +4 -12
- package/dist/engine/index.cjs +556 -487
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +4 -4
- package/dist/engine/index.d.ts +4 -4
- package/dist/engine/index.js +6 -6
- package/dist/{index-DllNjpTl.d.cts → index-CI7S0Wqv.d.cts} +61 -8
- package/dist/{endpoint-BI0yGuEL.d.ts → index-CUy50OLU.d.ts} +6 -82
- package/dist/{endpoint-DaGnjt42.d.cts → index-DRx880KY.d.cts} +6 -82
- package/dist/{index-E8Sd6NFy.d.ts → index-Ogi30dbt.d.ts} +61 -8
- package/dist/index.cjs +5 -237
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -7
- package/dist/index.d.ts +6 -7
- package/dist/index.js +7 -15
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +1 -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 +2 -2
- package/dist/{registry-BnKvIyve.d.cts → registry-C7UCkQf0.d.cts} +1 -1
- package/dist/{registry-zDCj0eOU.d.ts → registry-DJcvbomD.d.ts} +1 -1
- package/dist/test/index.cjs +3 -11
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +2 -2
- package/dist/test/index.d.ts +2 -2
- package/dist/test/index.js +2 -9
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +1 -1
- package/docs/database.md +84 -67
- package/docs/examples/database-dx.ts +12 -17
- package/docs/llms-full.txt +82 -65
- package/package.json +1 -1
- package/stager/inferred_returns.js +153 -0
- package/stager/return_types.js +9 -4
- package/stager/stage.js +3 -1
- package/template/AGENTS.md +5 -5
- package/template/package.json +1 -1
- package/dist/chunk-BOD3DT6D.js.map +0 -1
- package/dist/chunk-L4R7WXLH.js.map +0 -1
- package/dist/chunk-P7MGSAFT.js.map +0 -1
- package/dist/chunk-PRH45NZU.js.map +0 -1
- package/dist/chunk-UMESJJRS.js.map +0 -1
- package/dist/chunk-YO5GYM73.js.map +0 -1
- package/docs/examples/check-query-contracts.ts +0 -5
- package/docs/examples/database-dx.contract.json +0 -65
- /package/dist/{chunk-RZLY4BOY.js.map → chunk-PDD55QWN.js.map} +0 -0
- /package/dist/{chunk-6FEHCEB7.js.map → chunk-YANGWAIM.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` 36.
|
|
86
86
|
|
|
87
87
|
### Complete CRUD example (copy-pasteable, compiles)
|
|
88
88
|
|
package/docs/database.md
CHANGED
|
@@ -825,58 +825,51 @@ continue to return their rows. Custom SQL drivers must expose an integer `count`
|
|
|
825
825
|
on a non-returning result. Empty input opens no connection. The batch remains one
|
|
826
826
|
statement and participates in its enclosing transaction's rollback.
|
|
827
827
|
|
|
828
|
-
##
|
|
828
|
+
## Query results and response contracts
|
|
829
829
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
same value for response validation, OpenAPI and generated clients.
|
|
830
|
+
Use the ordinary CRUD expression. A controller without a return annotation gets
|
|
831
|
+
its response validator, OpenAPI response and generated client type from the
|
|
832
|
+
TypeScript return type, following calls through injected services:
|
|
834
833
|
|
|
835
834
|
```ts
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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:
|
|
835
|
+
// Store/service
|
|
836
|
+
list(prefix: string) {
|
|
837
|
+
return Database.public.todos.findMany({
|
|
838
|
+
where: { title: { startsWith: prefix } },
|
|
839
|
+
select: ["id", "title"],
|
|
840
|
+
limit: 50,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
860
843
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
844
|
+
// Controller
|
|
845
|
+
@Get("/")
|
|
846
|
+
list(@QueryParams(ListInput) input: ListInput) {
|
|
847
|
+
return this.todos.list(input.prefix);
|
|
848
|
+
}
|
|
865
849
|
```
|
|
866
850
|
|
|
867
|
-
The
|
|
868
|
-
nullability
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
851
|
+
The generated contract contains only `id` and `title`. `findUnique` retains
|
|
852
|
+
nullability, `page` retains its rows/pageInfo shape, and `insertMany` with
|
|
853
|
+
`returning: false` returns a count. Use `updateMany` for one bulk UPDATE; a
|
|
854
|
+
command plan is useful when several dependent operations actually belong together.
|
|
855
|
+
|
|
856
|
+
Inference does not execute application code or capture a Database/transaction
|
|
857
|
+
handle. Reuse a normal service method; inside an atomic callback, use its `tx`
|
|
858
|
+
argument. A service-role handle still owns a separate transaction and identity.
|
|
859
|
+
|
|
860
|
+
The build refuses `any`, `unknown`, unresolved imports, recursive/class/tuple
|
|
861
|
+
responses and unsupported JSON shapes. Supply a named Zod response schema when
|
|
862
|
+
needed. Existing named Zod annotations remain supported. Inference describes
|
|
863
|
+
the static JSON shape; an explicit schema remains useful for semantic constraints
|
|
864
|
+
such as UUID formats or a domain-specific numeric range. Input validation still
|
|
865
|
+
uses the existing `@Body`/`@QueryParams` schema.
|
|
866
|
+
|
|
867
|
+
Invalid response values and serialization failures roll the request transaction
|
|
868
|
+
back before COMMIT. A committed independent `$atomic` call retains its existing
|
|
869
|
+
independent commit semantics; response validation cannot undo an earlier commit.
|
|
870
|
+
An explicit nullable response returns HTTP 200 with JSON `null`; a response with
|
|
871
|
+
no body remains 204. This fixes nullable client contracts previously receiving an
|
|
872
|
+
empty body.
|
|
880
873
|
|
|
881
874
|
## Nested relations and unique lookups
|
|
882
875
|
|
|
@@ -934,35 +927,58 @@ one invoker helper call with sequential statements inside PostgreSQL. The helper
|
|
|
934
927
|
adds JSON/dynamic-SQL processing; measure the actual plan and network before
|
|
935
928
|
selecting it for throughput. Fewer round trips alone do not guarantee a speedup.
|
|
936
929
|
|
|
937
|
-
|
|
930
|
+
Core owns admission for ordinary CRUD, raw queries, request transactions,
|
|
931
|
+
`$atomic`, service-role operations, upload authorization and scheduled database
|
|
932
|
+
work. Nested savepoints reuse their parent's admitted connection. Application
|
|
933
|
+
code does not declare concurrency or queue capacities:
|
|
938
934
|
|
|
939
935
|
```ts
|
|
940
|
-
const transfers = defineWorkload("transfers", {
|
|
941
|
-
concurrency: 16, queueLimit: 128,
|
|
942
|
-
tenantConcurrency: 8, tenantQueueLimit: 32, queueTimeoutMs: 1000,
|
|
943
|
-
});
|
|
944
|
-
|
|
945
936
|
await Database.$atomic(async tx => {
|
|
946
937
|
// Decision reads, idempotency claim, writes, ledger and outbox belong here.
|
|
947
938
|
}, {
|
|
948
|
-
workload: transfers,
|
|
949
|
-
conflictKeys: [fromAccountId, toAccountId],
|
|
950
939
|
timeoutMs: 3000, signal, retry: 2,
|
|
951
940
|
});
|
|
952
941
|
```
|
|
953
942
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
943
|
+
The core process shares one physical pool across live and candidate applications.
|
|
944
|
+
Reloading or discarding an application does not create another pool. A retiring
|
|
945
|
+
application drains its requests/jobs through transaction settlement before
|
|
946
|
+
closing its own resources.
|
|
947
|
+
|
|
948
|
+
Before independent `$atomic` work, core releases an implicit transaction that
|
|
949
|
+
contains only its authorization reads. This prevents requests holding the whole
|
|
950
|
+
pool while each waits for a second connection. Role/permission decisions still
|
|
951
|
+
come from verified identity and current database state, once per request. Once
|
|
952
|
+
application SQL runs, its enclosing transaction is retained; atomic work never
|
|
953
|
+
silently commits an application's earlier write or changes its identity.
|
|
954
|
+
|
|
955
|
+
Admission happens before BEGIN and its lease lasts through COMMIT/ROLLBACK.
|
|
956
|
+
Full/expired queues return an explicit 429; cancellation removes queued work.
|
|
957
|
+
The private runtime `/_internal/database` probe exposes current admission counts,
|
|
958
|
+
rejection reasons and PostgreSQL connection groups. It is on the internal probe
|
|
959
|
+
port, not the public edge.
|
|
960
|
+
|
|
961
|
+
These are resource protections, not product quotas or a transactions-per-second
|
|
962
|
+
limit. `DB_POOL_MAX` remains a deployment setting, not a business-code parameter.
|
|
963
|
+
It is a per-process maximum; PostgreSQL's server limit is shared by every
|
|
964
|
+
process. Operators must account for every runtime application pool, control
|
|
965
|
+
pool, palsvc pool, migration/admin connection and replication connection in
|
|
966
|
+
their server resource plan. An instance-local queue is not a cluster-wide
|
|
967
|
+
admission guarantee. Core contains no Free/Pro tier logic.
|
|
968
|
+
|
|
969
|
+
Optional deployment settings are `PALBASE_DB_QUEUE_LIMIT` (default: eight times
|
|
970
|
+
the application pool size, bounded to 32–512), `PALBASE_DB_QUEUE_TIMEOUT_MS`
|
|
971
|
+
(default: 1000), and `PALBASE_DB_TENANT_CLAIM`. These defaults bound transient
|
|
972
|
+
memory/waiting; they are not a measured optimum for every installation. Fairness
|
|
973
|
+
uses verified `sub` by default, with round robin under contention. One identity
|
|
974
|
+
can use the entire available pool. A custom tenant claim must be signed and
|
|
975
|
+
issuer-controlled; do not use editable user metadata. Missing metadata does not
|
|
976
|
+
reduce an installation to a fraction of its pool.
|
|
977
|
+
|
|
978
|
+
Sorted PostgreSQL row locks and constraints coordinate conflicting writes across
|
|
979
|
+
all runtime instances. No application workload declaration is needed.
|
|
980
|
+
Do not place external service calls in a retryable callback. Use an idempotency
|
|
981
|
+
claim and transactional outbox for effects that must survive retries.
|
|
966
982
|
|
|
967
983
|
`timeoutMs` includes queueing, callback work and retries. The default driver uses
|
|
968
984
|
a separate control pool of at most two connections to cancel active PostgreSQL
|
|
@@ -1002,8 +1018,9 @@ disabled; the completion callback includes settlement timings. Only the first
|
|
|
1002
1018
|
|
|
1003
1019
|
| Metric | Meaning |
|
|
1004
1020
|
| --- | --- |
|
|
1005
|
-
| `queueMs` |
|
|
1021
|
+
| `queueMs` | Core admission and legacy workload wait, including refused waits |
|
|
1006
1022
|
| `poolMs` | Driver acquisition through transaction callback entry, including BEGIN |
|
|
1023
|
+
| `connectionMs` | Connection held from transaction callback entry through COMMIT/ROLLBACK settlement |
|
|
1007
1024
|
| `setupMs` | Isolation, cancellation ticket and role/claims setup |
|
|
1008
1025
|
| `sqlMs` | Awaited driver time, including execution, lock and network waits |
|
|
1009
1026
|
| `commitMs` / `rollbackMs` | Observed settlement waits |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import {
|
|
3
|
-
Controller, Get, QueryParams, Module, Injectable, Database,
|
|
3
|
+
Controller, Get, QueryParams, Module, Injectable, Database,
|
|
4
4
|
defineSchema, defineTable, uuid, text, boolean, timestamp, ownedByUser,
|
|
5
5
|
} from "@palbase/backend";
|
|
6
6
|
|
|
@@ -17,18 +17,16 @@ export const todos = defineTable("dx_todos", {
|
|
|
17
17
|
});
|
|
18
18
|
export const schema = defineSchema("public", { tables: [todos] });
|
|
19
19
|
|
|
20
|
-
export const
|
|
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
|
-
});
|
|
20
|
+
export const ListInput = z.object({ prefix: z.string().default("") });
|
|
28
21
|
|
|
29
22
|
@Injectable()
|
|
30
23
|
export class TodoQueries {
|
|
31
|
-
list(input: z.
|
|
24
|
+
list(input: z.output<typeof ListInput>) {
|
|
25
|
+
return Database.public.dx_todos.findMany({
|
|
26
|
+
select: ["id", "title", "done"], where: { title: { startsWith: input.prefix } },
|
|
27
|
+
orderBy: { column: "id" }, limit: 50,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
@Controller("/dx-todos", { auth: true })
|
|
@@ -36,7 +34,7 @@ export class DatabaseDxController {
|
|
|
36
34
|
constructor(private readonly todos: TodoQueries) {}
|
|
37
35
|
|
|
38
36
|
@Get("/", { databaseBudget: { maxQueries: 8, maxRows: 50, maxBytes: 65536 } })
|
|
39
|
-
list(@QueryParams(
|
|
37
|
+
list(@QueryParams(ListInput) input: z.output<typeof ListInput>) {
|
|
40
38
|
return this.todos.list(input);
|
|
41
39
|
}
|
|
42
40
|
}
|
|
@@ -49,12 +47,9 @@ export function findExternalTodo(ownerId: string, key: string) {
|
|
|
49
47
|
return Database.public.dx_todos.findUnique({ where: { owner_id: ownerId, external_key: key }, select: ["id", "title"] });
|
|
50
48
|
}
|
|
51
49
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
});
|
|
50
|
+
// One batch UPDATE uses the ordinary CRUD surface and core admission.
|
|
51
|
+
export function finishTodos(ids: string[]) {
|
|
52
|
+
return Database.public.dx_todos.updateMany({ where: { id: { in: ids } }, set: { done: true }, returning: false });
|
|
58
53
|
}
|
|
59
54
|
|
|
60
55
|
export function readPage(after?: string) {
|
package/docs/llms-full.txt
CHANGED
|
@@ -90,7 +90,7 @@ service the controllers call.
|
|
|
90
90
|
|
|
91
91
|
> **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
|
|
92
92
|
> `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
|
|
93
|
-
> and will not compile against `@palbase/backend`
|
|
93
|
+
> and will not compile against `@palbase/backend` 36.
|
|
94
94
|
|
|
95
95
|
### Complete CRUD example (copy-pasteable, compiles)
|
|
96
96
|
|
|
@@ -1929,58 +1929,51 @@ continue to return their rows. Custom SQL drivers must expose an integer `count`
|
|
|
1929
1929
|
on a non-returning result. Empty input opens no connection. The batch remains one
|
|
1930
1930
|
statement and participates in its enclosing transaction's rollback.
|
|
1931
1931
|
|
|
1932
|
-
##
|
|
1932
|
+
## Query results and response contracts
|
|
1933
1933
|
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
same value for response validation, OpenAPI and generated clients.
|
|
1934
|
+
Use the ordinary CRUD expression. A controller without a return annotation gets
|
|
1935
|
+
its response validator, OpenAPI response and generated client type from the
|
|
1936
|
+
TypeScript return type, following calls through injected services:
|
|
1938
1937
|
|
|
1939
1938
|
```ts
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
}
|
|
1939
|
+
// Store/service
|
|
1940
|
+
list(prefix: string) {
|
|
1941
|
+
return Database.public.todos.findMany({
|
|
1942
|
+
where: { title: { startsWith: prefix } },
|
|
1943
|
+
select: ["id", "title"],
|
|
1944
|
+
limit: 50,
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1948
1947
|
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1948
|
+
// Controller
|
|
1949
|
+
@Get("/")
|
|
1950
|
+
list(@QueryParams(ListInput) input: ListInput) {
|
|
1951
|
+
return this.todos.list(input.prefix);
|
|
1952
|
+
}
|
|
1952
1953
|
```
|
|
1953
1954
|
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
timestamp outputs retain their exact string representations, and named codecs
|
|
1959
|
-
determine the surfaced type. A selected `jsonb<T>()` needs a matching Zod schema
|
|
1960
|
-
in `fields: { payload: PayloadSchema }`, because its TypeScript payload is erased
|
|
1961
|
-
at runtime. Free-form column transforms require migration to a named codec.
|
|
1955
|
+
The generated contract contains only `id` and `title`. `findUnique` retains
|
|
1956
|
+
nullability, `page` retains its rows/pageInfo shape, and `insertMany` with
|
|
1957
|
+
`returning: false` returns a count. Use `updateMany` for one bulk UPDATE; a
|
|
1958
|
+
command plan is useful when several dependent operations actually belong together.
|
|
1962
1959
|
|
|
1963
|
-
|
|
1960
|
+
Inference does not execute application code or capture a Database/transaction
|
|
1961
|
+
handle. Reuse a normal service method; inside an atomic callback, use its `tx`
|
|
1962
|
+
argument. A service-role handle still owns a separate transaction and identity.
|
|
1964
1963
|
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
The manifest contains input/response OpenAPI components, selected storage types,
|
|
1972
|
-
nullability and codecs. A changed contract fails the check until its diff is
|
|
1973
|
-
reviewed and the committed baseline is updated. The runtime also refuses a query
|
|
1974
|
-
whose selected columns disagree with the installed declaration, before SQL.
|
|
1975
|
-
This check compares declarations; migration validation still owns drift in the
|
|
1976
|
-
physical PostgreSQL schema. Queries currently project one table; use `findMany`
|
|
1977
|
-
or `page` for nested relation reads.
|
|
1964
|
+
The build refuses `any`, `unknown`, unresolved imports, recursive/class/tuple
|
|
1965
|
+
responses and unsupported JSON shapes. Supply a named Zod response schema when
|
|
1966
|
+
needed. Existing named Zod annotations remain supported. Inference describes
|
|
1967
|
+
the static JSON shape; an explicit schema remains useful for semantic constraints
|
|
1968
|
+
such as UUID formats or a domain-specific numeric range. Input validation still
|
|
1969
|
+
uses the existing `@Body`/`@QueryParams` schema.
|
|
1978
1970
|
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1971
|
+
Invalid response values and serialization failures roll the request transaction
|
|
1972
|
+
back before COMMIT. A committed independent `$atomic` call retains its existing
|
|
1973
|
+
independent commit semantics; response validation cannot undo an earlier commit.
|
|
1974
|
+
An explicit nullable response returns HTTP 200 with JSON `null`; a response with
|
|
1975
|
+
no body remains 204. This fixes nullable client contracts previously receiving an
|
|
1976
|
+
empty body.
|
|
1984
1977
|
|
|
1985
1978
|
## Nested relations and unique lookups
|
|
1986
1979
|
|
|
@@ -2038,35 +2031,58 @@ one invoker helper call with sequential statements inside PostgreSQL. The helper
|
|
|
2038
2031
|
adds JSON/dynamic-SQL processing; measure the actual plan and network before
|
|
2039
2032
|
selecting it for throughput. Fewer round trips alone do not guarantee a speedup.
|
|
2040
2033
|
|
|
2041
|
-
|
|
2034
|
+
Core owns admission for ordinary CRUD, raw queries, request transactions,
|
|
2035
|
+
`$atomic`, service-role operations, upload authorization and scheduled database
|
|
2036
|
+
work. Nested savepoints reuse their parent's admitted connection. Application
|
|
2037
|
+
code does not declare concurrency or queue capacities:
|
|
2042
2038
|
|
|
2043
2039
|
```ts
|
|
2044
|
-
const transfers = defineWorkload("transfers", {
|
|
2045
|
-
concurrency: 16, queueLimit: 128,
|
|
2046
|
-
tenantConcurrency: 8, tenantQueueLimit: 32, queueTimeoutMs: 1000,
|
|
2047
|
-
});
|
|
2048
|
-
|
|
2049
2040
|
await Database.$atomic(async tx => {
|
|
2050
2041
|
// Decision reads, idempotency claim, writes, ledger and outbox belong here.
|
|
2051
2042
|
}, {
|
|
2052
|
-
workload: transfers,
|
|
2053
|
-
conflictKeys: [fromAccountId, toAccountId],
|
|
2054
2043
|
timeoutMs: 3000, signal, retry: 2,
|
|
2055
2044
|
});
|
|
2056
2045
|
```
|
|
2057
2046
|
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2047
|
+
The core process shares one physical pool across live and candidate applications.
|
|
2048
|
+
Reloading or discarding an application does not create another pool. A retiring
|
|
2049
|
+
application drains its requests/jobs through transaction settlement before
|
|
2050
|
+
closing its own resources.
|
|
2051
|
+
|
|
2052
|
+
Before independent `$atomic` work, core releases an implicit transaction that
|
|
2053
|
+
contains only its authorization reads. This prevents requests holding the whole
|
|
2054
|
+
pool while each waits for a second connection. Role/permission decisions still
|
|
2055
|
+
come from verified identity and current database state, once per request. Once
|
|
2056
|
+
application SQL runs, its enclosing transaction is retained; atomic work never
|
|
2057
|
+
silently commits an application's earlier write or changes its identity.
|
|
2058
|
+
|
|
2059
|
+
Admission happens before BEGIN and its lease lasts through COMMIT/ROLLBACK.
|
|
2060
|
+
Full/expired queues return an explicit 429; cancellation removes queued work.
|
|
2061
|
+
The private runtime `/_internal/database` probe exposes current admission counts,
|
|
2062
|
+
rejection reasons and PostgreSQL connection groups. It is on the internal probe
|
|
2063
|
+
port, not the public edge.
|
|
2064
|
+
|
|
2065
|
+
These are resource protections, not product quotas or a transactions-per-second
|
|
2066
|
+
limit. `DB_POOL_MAX` remains a deployment setting, not a business-code parameter.
|
|
2067
|
+
It is a per-process maximum; PostgreSQL's server limit is shared by every
|
|
2068
|
+
process. Operators must account for every runtime application pool, control
|
|
2069
|
+
pool, palsvc pool, migration/admin connection and replication connection in
|
|
2070
|
+
their server resource plan. An instance-local queue is not a cluster-wide
|
|
2071
|
+
admission guarantee. Core contains no Free/Pro tier logic.
|
|
2072
|
+
|
|
2073
|
+
Optional deployment settings are `PALBASE_DB_QUEUE_LIMIT` (default: eight times
|
|
2074
|
+
the application pool size, bounded to 32–512), `PALBASE_DB_QUEUE_TIMEOUT_MS`
|
|
2075
|
+
(default: 1000), and `PALBASE_DB_TENANT_CLAIM`. These defaults bound transient
|
|
2076
|
+
memory/waiting; they are not a measured optimum for every installation. Fairness
|
|
2077
|
+
uses verified `sub` by default, with round robin under contention. One identity
|
|
2078
|
+
can use the entire available pool. A custom tenant claim must be signed and
|
|
2079
|
+
issuer-controlled; do not use editable user metadata. Missing metadata does not
|
|
2080
|
+
reduce an installation to a fraction of its pool.
|
|
2081
|
+
|
|
2082
|
+
Sorted PostgreSQL row locks and constraints coordinate conflicting writes across
|
|
2083
|
+
all runtime instances. No application workload declaration is needed.
|
|
2084
|
+
Do not place external service calls in a retryable callback. Use an idempotency
|
|
2085
|
+
claim and transactional outbox for effects that must survive retries.
|
|
2070
2086
|
|
|
2071
2087
|
`timeoutMs` includes queueing, callback work and retries. The default driver uses
|
|
2072
2088
|
a separate control pool of at most two connections to cancel active PostgreSQL
|
|
@@ -2106,8 +2122,9 @@ disabled; the completion callback includes settlement timings. Only the first
|
|
|
2106
2122
|
|
|
2107
2123
|
| Metric | Meaning |
|
|
2108
2124
|
| --- | --- |
|
|
2109
|
-
| `queueMs` |
|
|
2125
|
+
| `queueMs` | Core admission and legacy workload wait, including refused waits |
|
|
2110
2126
|
| `poolMs` | Driver acquisition through transaction callback entry, including BEGIN |
|
|
2127
|
+
| `connectionMs` | Connection held from transaction callback entry through COMMIT/ROLLBACK settlement |
|
|
2111
2128
|
| `setupMs` | Isolation, cancellation ticket and role/claims setup |
|
|
2112
2129
|
| `sqlMs` | Awaited driver time, including execution, lock and network waits |
|
|
2113
2130
|
| `commitMs` / `rollbackMs` | Observed settlement waits |
|
package/package.json
CHANGED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Build-time only. The actual TypeScript return type follows calls through DI
|
|
4
|
+
// services and the generated Database tables, including literal projections.
|
|
5
|
+
// No application code is executed and no database/identity handle is rebound.
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
function createReturnInference(projectRoot) {
|
|
9
|
+
let program, checker, ts;
|
|
10
|
+
function initialize(file) {
|
|
11
|
+
ts = require('typescript');
|
|
12
|
+
const configFile = ts.findConfigFile(projectRoot, ts.sys.fileExists);
|
|
13
|
+
let config = { compilerOptions: {}, include: ['**/*.ts'] };
|
|
14
|
+
if (configFile) {
|
|
15
|
+
const read = ts.readConfigFile(configFile, ts.sys.readFile);
|
|
16
|
+
if (read.error) throw new Error(ts.flattenDiagnosticMessageText(read.error.messageText, '\n'));
|
|
17
|
+
config = read.config;
|
|
18
|
+
}
|
|
19
|
+
const parsed = ts.parseJsonConfigFileContent(config, ts.sys, configFile ? path.dirname(configFile) : projectRoot);
|
|
20
|
+
// Nullability must survive even in a project that has disabled strict mode.
|
|
21
|
+
// Check the server response graph, plus the project's ambient declarations.
|
|
22
|
+
// A generated web client or an unrelated test is not a server entrypoint.
|
|
23
|
+
// Imports from a controller still pull in (and check) every service it uses.
|
|
24
|
+
const roots = parsed.fileNames.filter(name => /\.d\.[cm]?ts$|\.controller\.[cm]?tsx?$/i.test(name));
|
|
25
|
+
program = ts.createProgram([...new Set([...roots, file])], {
|
|
26
|
+
...parsed.options, strictNullChecks: true, noEmit: true, skipLibCheck: true,
|
|
27
|
+
});
|
|
28
|
+
checker = program.getTypeChecker();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return function infer(file, className, methodName) {
|
|
32
|
+
file = path.resolve(file);
|
|
33
|
+
if (!program) initialize(file);
|
|
34
|
+
const sf = program.getSourceFile(file);
|
|
35
|
+
const cls = sf?.statements.find(s => ts.isClassDeclaration(s) && s.name?.text === className);
|
|
36
|
+
const method = cls?.members.find(m => ts.isMethodDeclaration(m) && m.name.getText(sf) === methodName);
|
|
37
|
+
const refuse = detail => {
|
|
38
|
+
throw new Error(`${file} — ${className}.${methodName}: cannot infer a safe response contract (${detail}). ` +
|
|
39
|
+
'Use a named Zod return schema for this response; any/unknown is never emitted as a successful contract.');
|
|
40
|
+
};
|
|
41
|
+
if (!method) return refuse('method is outside the TypeScript project');
|
|
42
|
+
// Unresolved imports or bad calls can otherwise leave a plausible but wrong
|
|
43
|
+
// result type. Check the project's source graph once, not once per route.
|
|
44
|
+
if (!infer.checked) {
|
|
45
|
+
const errors = program.getSemanticDiagnostics().filter(d => d.category === ts.DiagnosticCategory.Error);
|
|
46
|
+
if (errors.length) return refuse(ts.formatDiagnostics(errors.slice(0, 5), {
|
|
47
|
+
getCanonicalFileName: f => f, getCurrentDirectory: () => projectRoot, getNewLine: () => '\n',
|
|
48
|
+
}));
|
|
49
|
+
infer.checked = true;
|
|
50
|
+
}
|
|
51
|
+
const signature = checker.getSignatureFromDeclaration(method);
|
|
52
|
+
const result = signature && checker.getAwaitedType(checker.getReturnTypeOfSignature(signature));
|
|
53
|
+
if (!result) return refuse('unresolved return type');
|
|
54
|
+
if (result.flags & ts.TypeFlags.Void) return null;
|
|
55
|
+
const ancestors = new Set();
|
|
56
|
+
let nodes = 0;
|
|
57
|
+
function schema(type, location) {
|
|
58
|
+
if (++nodes > 2000 || ancestors.size > 24) return refuse(`${location}: contract is too large; name its schema`);
|
|
59
|
+
const f = type.flags, F = ts.TypeFlags;
|
|
60
|
+
if (f & (F.Any | F.Unknown | F.Never | F.TypeParameter)) return refuse(`${location}: ${checker.typeToString(type)}`);
|
|
61
|
+
// Generated palbase-env.d.ts preserves PostgreSQL families using
|
|
62
|
+
// T & { readonly __pg?: "uuid" }. This optional phantom is not JSON.
|
|
63
|
+
const pgBrand = part => {
|
|
64
|
+
const props = checker.getPropertiesOfType(part);
|
|
65
|
+
if (!(part.flags & F.Object) || props.length !== 1 || props[0].name !== '__pg' ||
|
|
66
|
+
!(props[0].flags & ts.SymbolFlags.Optional) || checker.getIndexInfosOfType(part).length) return false;
|
|
67
|
+
const tag = checker.getTypeOfSymbolAtLocation(props[0], method);
|
|
68
|
+
return (tag.isUnion() ? tag.types : [tag]).every(t => t.flags & (F.StringLiteral | F.Undefined));
|
|
69
|
+
};
|
|
70
|
+
if (type.isIntersection()) {
|
|
71
|
+
const data = type.types.filter(t => !pgBrand(t));
|
|
72
|
+
if (data.length === 1 && data.length < type.types.length) return schema(data[0], location);
|
|
73
|
+
}
|
|
74
|
+
// Pg<unknown, "jsonb"> simplifies to the phantom alone. Never mistake
|
|
75
|
+
// that erased payload for an object containing an optional __pg field.
|
|
76
|
+
if (pgBrand(type)) return refuse(`${location}: unknown PostgreSQL payload; name its response schema`);
|
|
77
|
+
if (f & F.StringLiteral) return `z.literal(${JSON.stringify(type.value)})`;
|
|
78
|
+
if (f & F.NumberLiteral) return `z.literal(${JSON.stringify(type.value)})`;
|
|
79
|
+
if (f & F.BooleanLiteral) return `z.literal(${type.intrinsicName === 'true'})`;
|
|
80
|
+
if (f & F.String) return 'z.string()';
|
|
81
|
+
if (f & F.TemplateLiteral) {
|
|
82
|
+
if (type.types.length > 8 || !type.types.every(t => t.flags & F.String)) return refuse(`${location}: template literal requires an explicit schema`);
|
|
83
|
+
const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
84
|
+
// Each intermediate delimiter takes its first occurrence; an arbitrary
|
|
85
|
+
// string interpolation can absorb later occurrences. This avoids a
|
|
86
|
+
// chain of overlapping .* groups with explosive rejection cost.
|
|
87
|
+
let pattern = '^' + escape(type.texts[0]);
|
|
88
|
+
for (const text of type.texts.slice(1, -1)) if (text) {
|
|
89
|
+
const delimiter = escape(text);
|
|
90
|
+
pattern += `(?:(?!${delimiter})[\\s\\S])*${delimiter}`;
|
|
91
|
+
}
|
|
92
|
+
pattern += '[\\s\\S]*' + escape(type.texts.at(-1)) + '$(?![\\s\\S])';
|
|
93
|
+
if (pattern.length > 4096) return refuse(`${location}: template pattern is too large`);
|
|
94
|
+
return `z.string().regex(new RegExp(${JSON.stringify(pattern)}))`;
|
|
95
|
+
}
|
|
96
|
+
if (f & F.Number) return 'z.number().finite()';
|
|
97
|
+
if (f & F.Boolean) return 'z.boolean()';
|
|
98
|
+
if (f & F.Null) return 'z.null()';
|
|
99
|
+
if (f & F.Undefined) return 'z.undefined()';
|
|
100
|
+
if (ancestors.has(type)) return refuse(`${location}: recursive response`);
|
|
101
|
+
ancestors.add(type);
|
|
102
|
+
try {
|
|
103
|
+
if (type.isUnion()) {
|
|
104
|
+
if (type.types.length > 32) return refuse(`${location}: union has more than 32 alternatives`);
|
|
105
|
+
const nullable = type.types.some(t => t.flags & F.Null);
|
|
106
|
+
const optional = type.types.some(t => t.flags & F.Undefined);
|
|
107
|
+
const members = type.types.filter(t => !(t.flags & (F.Null | F.Undefined)));
|
|
108
|
+
const base = members.length === 0 ? nullable ? 'z.null()' : 'z.undefined()'
|
|
109
|
+
: members.length === 1 ? schema(members[0], location)
|
|
110
|
+
: `z.union([${members.map(t => schema(t, location)).join(',')}])`;
|
|
111
|
+
// OpenAPI represents nullability on the value schema. A standalone
|
|
112
|
+
// null union arm becomes {} in older client generators.
|
|
113
|
+
return base + (nullable && members.length ? '.nullable()' : '') + (optional ? '.optional()' : '');
|
|
114
|
+
}
|
|
115
|
+
if (checker.isTupleType(type)) return refuse(`${location}: tuple requires an explicit schema`);
|
|
116
|
+
if (checker.isArrayType(type)) {
|
|
117
|
+
const item = checker.getTypeArguments(type)[0];
|
|
118
|
+
// never[] has one possible JSON value: []. Preserve that constraint
|
|
119
|
+
// without emitting an any/unknown item contract to client generators.
|
|
120
|
+
if (item.flags & F.Never) return 'z.array(z.string()).length(0)';
|
|
121
|
+
return `z.array(${schema(item, `${location}[]`)})`;
|
|
122
|
+
}
|
|
123
|
+
const objectIntersection = type.isIntersection() && type.types.every(t => t.flags & F.Object);
|
|
124
|
+
if (!(f & F.Object) && !objectIntersection || type.getCallSignatures().length || type.getConstructSignatures().length) {
|
|
125
|
+
return refuse(`${location}: non-JSON type ${checker.typeToString(type)}`);
|
|
126
|
+
}
|
|
127
|
+
const declaration = type.symbol?.declarations?.[0];
|
|
128
|
+
if (declaration && (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) {
|
|
129
|
+
return refuse(`${location}: class instance ${checker.typeToString(type)}`);
|
|
130
|
+
}
|
|
131
|
+
const properties = checker.getPropertiesOfType(type);
|
|
132
|
+
const indexes = checker.getIndexInfosOfType(type);
|
|
133
|
+
if (indexes.length) {
|
|
134
|
+
if (indexes.length !== 1 || !(indexes[0].keyType.flags & F.String) || properties.length) {
|
|
135
|
+
return refuse(`${location}: mixed or non-string index signature`);
|
|
136
|
+
}
|
|
137
|
+
return `z.record(${schema(indexes[0].type, `${location}.*`)})`;
|
|
138
|
+
}
|
|
139
|
+
if (!properties.length) return refuse(`${location}: empty structural type`);
|
|
140
|
+
const fields = properties.map(property => {
|
|
141
|
+
if (property.escapedName.toString().startsWith('__@')) return refuse(`${location}: symbol property`);
|
|
142
|
+
const name = property.getName();
|
|
143
|
+
const value = schema(checker.getTypeOfSymbolAtLocation(property, method), `${location}.${name}`);
|
|
144
|
+
return `${JSON.stringify(name)}:${value}${property.flags & ts.SymbolFlags.Optional ? '.optional()' : ''}`;
|
|
145
|
+
});
|
|
146
|
+
return `z.object({${fields.join(',')}}).strict()`;
|
|
147
|
+
} finally { ancestors.delete(type); }
|
|
148
|
+
}
|
|
149
|
+
return schema(result, 'return');
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { createReturnInference };
|