@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.
Files changed (70) hide show
  1. package/dist/bin/palbase-backend.cjs +556 -487
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +6 -6
  4. package/dist/{chunk-L4R7WXLH.js → chunk-7WAGQ3VR.js} +4 -171
  5. package/dist/chunk-7WAGQ3VR.js.map +1 -0
  6. package/dist/{chunk-UMESJJRS.js → chunk-CS6NQ6PO.js} +2 -1
  7. package/dist/chunk-CS6NQ6PO.js.map +1 -0
  8. package/dist/{chunk-BOD3DT6D.js → chunk-EHKEMHB4.js} +560 -430
  9. package/dist/chunk-EHKEMHB4.js.map +1 -0
  10. package/dist/{chunk-PRH45NZU.js → chunk-FFO36MQQ.js} +2 -45
  11. package/dist/chunk-FFO36MQQ.js.map +1 -0
  12. package/dist/{chunk-P7MGSAFT.js → chunk-LFUNRWUE.js} +1 -40
  13. package/dist/chunk-LFUNRWUE.js.map +1 -0
  14. package/dist/{chunk-RZLY4BOY.js → chunk-PDD55QWN.js} +2 -2
  15. package/dist/{chunk-YO5GYM73.js → chunk-X7UR3VXA.js} +5 -6
  16. package/dist/chunk-X7UR3VXA.js.map +1 -0
  17. package/dist/{chunk-6FEHCEB7.js → chunk-YANGWAIM.js} +3 -3
  18. package/dist/db/index.cjs +1 -232
  19. package/dist/db/index.cjs.map +1 -1
  20. package/dist/db/index.d.cts +3 -46
  21. package/dist/db/index.d.ts +3 -46
  22. package/dist/db/index.js +4 -12
  23. package/dist/engine/index.cjs +556 -487
  24. package/dist/engine/index.cjs.map +1 -1
  25. package/dist/engine/index.d.cts +4 -4
  26. package/dist/engine/index.d.ts +4 -4
  27. package/dist/engine/index.js +6 -6
  28. package/dist/{index-DllNjpTl.d.cts → index-CI7S0Wqv.d.cts} +61 -8
  29. package/dist/{endpoint-BI0yGuEL.d.ts → index-CUy50OLU.d.ts} +6 -82
  30. package/dist/{endpoint-DaGnjt42.d.cts → index-DRx880KY.d.cts} +6 -82
  31. package/dist/{index-E8Sd6NFy.d.ts → index-Ogi30dbt.d.ts} +61 -8
  32. package/dist/index.cjs +5 -237
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +6 -7
  35. package/dist/index.d.ts +6 -7
  36. package/dist/index.js +7 -15
  37. package/dist/index.js.map +1 -1
  38. package/dist/openapi/index.cjs +1 -0
  39. package/dist/openapi/index.cjs.map +1 -1
  40. package/dist/openapi/index.d.cts +3 -3
  41. package/dist/openapi/index.d.ts +3 -3
  42. package/dist/openapi/index.js +2 -2
  43. package/dist/{registry-BnKvIyve.d.cts → registry-C7UCkQf0.d.cts} +1 -1
  44. package/dist/{registry-zDCj0eOU.d.ts → registry-DJcvbomD.d.ts} +1 -1
  45. package/dist/test/index.cjs +3 -11
  46. package/dist/test/index.cjs.map +1 -1
  47. package/dist/test/index.d.cts +2 -2
  48. package/dist/test/index.d.ts +2 -2
  49. package/dist/test/index.js +2 -9
  50. package/dist/test/index.js.map +1 -1
  51. package/docs/README.md +1 -1
  52. package/docs/database.md +84 -67
  53. package/docs/examples/database-dx.ts +12 -17
  54. package/docs/llms-full.txt +82 -65
  55. package/package.json +1 -1
  56. package/stager/inferred_returns.js +153 -0
  57. package/stager/return_types.js +9 -4
  58. package/stager/stage.js +3 -1
  59. package/template/AGENTS.md +5 -5
  60. package/template/package.json +1 -1
  61. package/dist/chunk-BOD3DT6D.js.map +0 -1
  62. package/dist/chunk-L4R7WXLH.js.map +0 -1
  63. package/dist/chunk-P7MGSAFT.js.map +0 -1
  64. package/dist/chunk-PRH45NZU.js.map +0 -1
  65. package/dist/chunk-UMESJJRS.js.map +0 -1
  66. package/dist/chunk-YO5GYM73.js.map +0 -1
  67. package/docs/examples/check-query-contracts.ts +0 -5
  68. package/docs/examples/database-dx.contract.json +0 -65
  69. /package/dist/{chunk-RZLY4BOY.js.map → chunk-PDD55QWN.js.map} +0 -0
  70. /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` 34.
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
- ## Reusable queries and response contracts
828
+ ## Query results and response contracts
829
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.
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
- 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:
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
- ```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
844
+ // Controller
845
+ @Get("/")
846
+ list(@QueryParams(ListInput) input: ListInput) {
847
+ return this.todos.list(input.prefix);
848
+ }
865
849
  ```
866
850
 
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).
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
- For contention on the same accounts/resources, declare a workload once:
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
- 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.
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` | Workload admission wait, including refused waits |
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, defineQuery, defineWorkload,
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 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
- });
20
+ export const ListInput = z.object({ prefix: z.string().default("") });
28
21
 
29
22
  @Injectable()
30
23
  export class TodoQueries {
31
- list(input: z.input<typeof ListTodos.input>) { return ListTodos.run(Database, input); }
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(ListTodos.input) input: z.input<typeof ListTodos.input>): Promise<z.infer<typeof ListTodos>> {
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
- // 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
- });
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) {
@@ -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` 34.
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
- ## Reusable queries and response contracts
1932
+ ## Query results and response contracts
1933
1933
 
1934
- `defineQuery` combines an explicit selection with a reusable input/filter. The
1935
- result is also a Zod array schema: a controller can return
1936
- `Promise<z.infer<typeof ListTodos>>`, and the existing build binder uses that
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
- export const ListTodos = defineQuery("ListTodos", {
1941
- table: todos, // a defineTable value already registered by defineSchema
1942
- input: z.object({ prefix: z.string().default("") }),
1943
- select: ["id", "title"],
1944
- where: ({ prefix }) => ({ title: { startsWith: prefix } }),
1945
- orderBy: { column: "id" },
1946
- limit: 50,
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
- const rows = await ListTodos.run(Database, { prefix: "Buy" });
1950
- const compiled = ListTodos.sql(Database, { prefix: "Buy" }); // no connection
1951
- // compiled.sql and compiled.params describe the same statement run() executes.
1948
+ // Controller
1949
+ @Get("/")
1950
+ list(@QueryParams(ListInput) input: ListInput) {
1951
+ return this.todos.list(input.prefix);
1952
+ }
1952
1953
  ```
1953
1954
 
1954
- `run` validates its input before SQL and its response before returning. SQL uses
1955
- the same SELECT compiler as `findMany`, under the caller's transaction and RLS
1956
- identity. Table names retain their declared schema. Selection is mandatory;
1957
- unselected database columns cannot silently expand the API. Numeric/bigint and
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
- Keep a contract manifest in the application repository:
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
- ```ts
1966
- const generated = queryManifest([ListTodos]);
1967
- // Write generated JSON for the initial, reviewed baseline.
1968
- assertQueryManifest([ListTodos], committedManifest); // run in the CI/build check
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
- `await ListTodos.explain(Database, input)` executes plain `EXPLAIN (FORMAT JSON)`
1980
- with the same role and parameters. It never enables `ANALYZE`. Plans can contain
1981
- filter literals, so inspect them in development/operator tooling. No EXPLAIN is
1982
- run automatically on production requests. The complete controller/schema example
1983
- is [examples/database-dx.ts](./examples/database-dx.ts).
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
- For contention on the same accounts/resources, declare a workload once:
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
- Overlapping resource keys are admitted in order. Independent keys and tenants
2059
- can proceed while those requests wait. Queue size and per-tenant budgets are
2060
- bounded; full/expired queues return 429 before opening a transaction. Tenant
2061
- identity comes from the configured verified claim (`tenant_id`, then `sub`, then
2062
- one anonymous bucket). Keys are shared within a workload; include the tenant in
2063
- a key if resource IDs are only tenant-unique. Rejected/cancelled waiters leave the
2064
- queue. The lease spans every retry and transaction settlement.
2065
-
2066
- These limits apply per engine/driver instance. Divide pool/admission budgets
2067
- across replicas; PostgreSQL locks and constraints remain authoritative across
2068
- instances. Admission is optional because independent work also pays its cost.
2069
- Each driver supports at most 64 named workloads.
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` | Workload admission wait, including refused waits |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "35.0.0",
3
+ "version": "36.0.0",
4
4
  "description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 };