@palbase/backend 35.0.1 → 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 +2 -60
  53. package/docs/examples/database-dx.ts +12 -17
  54. package/docs/llms-full.txt +3 -61
  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 +2 -2
  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` 35.
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
@@ -871,62 +871,6 @@ An explicit nullable response returns HTTP 200 with JSON `null`; a response with
871
871
  no body remains 204. This fixes nullable client contracts previously receiving an
872
872
  empty body.
873
873
 
874
- ### Existing query definitions
875
-
876
- `defineQuery` is deprecated for normal application work. Its existing `run`,
877
- SQL inspection, manifests and validation remain supported for compatibility.
878
-
879
- `defineQuery` combines an explicit selection with a reusable input/filter. The
880
- result is also a Zod array schema: a controller can return
881
- `Promise<z.infer<typeof ListTodos>>`, and the existing build binder uses that
882
- same value for response validation, OpenAPI and generated clients.
883
-
884
- ```ts
885
- export const ListTodos = defineQuery("ListTodos", {
886
- table: todos, // a defineTable value already registered by defineSchema
887
- input: z.object({ prefix: z.string().default("") }),
888
- select: ["id", "title"],
889
- where: ({ prefix }) => ({ title: { startsWith: prefix } }),
890
- orderBy: { column: "id" },
891
- limit: 50,
892
- });
893
-
894
- const rows = await ListTodos.run(Database, { prefix: "Buy" });
895
- const compiled = ListTodos.sql(Database, { prefix: "Buy" }); // no connection
896
- // compiled.sql and compiled.params describe the same statement run() executes.
897
- ```
898
-
899
- `run` validates its input before SQL and its response before returning. SQL uses
900
- the same SELECT compiler as `findMany`, under the caller's transaction and RLS
901
- identity. Table names retain their declared schema. Selection is mandatory;
902
- unselected database columns cannot silently expand the API. Numeric/bigint and
903
- timestamp outputs retain their exact string representations, and named codecs
904
- determine the surfaced type. A selected `jsonb<T>()` needs a matching Zod schema
905
- in `fields: { payload: PayloadSchema }`, because its TypeScript payload is erased
906
- at runtime. Free-form column transforms require migration to a named codec.
907
-
908
- Keep a contract manifest in the application repository:
909
-
910
- ```ts
911
- const generated = queryManifest([ListTodos]);
912
- // Write generated JSON for the initial, reviewed baseline.
913
- assertQueryManifest([ListTodos], committedManifest); // run in the CI/build check
914
- ```
915
-
916
- The manifest contains input/response OpenAPI components, selected storage types,
917
- nullability and codecs. A changed contract fails the check until its diff is
918
- reviewed and the committed baseline is updated. The runtime also refuses a query
919
- whose selected columns disagree with the installed declaration, before SQL.
920
- This check compares declarations; migration validation still owns drift in the
921
- physical PostgreSQL schema. Queries currently project one table; use `findMany`
922
- or `page` for nested relation reads.
923
-
924
- `await ListTodos.explain(Database, input)` executes plain `EXPLAIN (FORMAT JSON)`
925
- with the same role and parameters. It never enables `ANALYZE`. Plans can contain
926
- filter literals, so inspect them in development/operator tooling. No EXPLAIN is
927
- run automatically on production requests. The complete controller/schema example
928
- is [examples/database-dx.ts](./examples/database-dx.ts).
929
-
930
874
  ## Nested relations and unique lookups
931
875
 
932
876
  `with` accepts three relation edges, with independent selection, filtering,
@@ -1031,10 +975,8 @@ can use the entire available pool. A custom tenant claim must be signed and
1031
975
  issuer-controlled; do not use editable user metadata. Missing metadata does not
1032
976
  reduce an installation to a fraction of its pool.
1033
977
 
1034
- Existing `defineWorkload` options retain their old behavior, including conflict
1035
- ordering and any explicit tenant cap. They are deprecated, not silently ignored.
1036
- Removing them removes that application-local scheduling policy; sorted database
1037
- row locks and constraints remain the authority across all runtime instances.
978
+ Sorted PostgreSQL row locks and constraints coordinate conflicting writes across
979
+ all runtime instances. No application workload declaration is needed.
1038
980
  Do not place external service calls in a retryable callback. Use an idempotency
1039
981
  claim and transactional outbox for effects that must survive retries.
1040
982
 
@@ -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` 35.
93
+ > and will not compile against `@palbase/backend` 36.
94
94
 
95
95
  ### Complete CRUD example (copy-pasteable, compiles)
96
96
 
@@ -1975,62 +1975,6 @@ An explicit nullable response returns HTTP 200 with JSON `null`; a response with
1975
1975
  no body remains 204. This fixes nullable client contracts previously receiving an
1976
1976
  empty body.
1977
1977
 
1978
- ### Existing query definitions
1979
-
1980
- `defineQuery` is deprecated for normal application work. Its existing `run`,
1981
- SQL inspection, manifests and validation remain supported for compatibility.
1982
-
1983
- `defineQuery` combines an explicit selection with a reusable input/filter. The
1984
- result is also a Zod array schema: a controller can return
1985
- `Promise<z.infer<typeof ListTodos>>`, and the existing build binder uses that
1986
- same value for response validation, OpenAPI and generated clients.
1987
-
1988
- ```ts
1989
- export const ListTodos = defineQuery("ListTodos", {
1990
- table: todos, // a defineTable value already registered by defineSchema
1991
- input: z.object({ prefix: z.string().default("") }),
1992
- select: ["id", "title"],
1993
- where: ({ prefix }) => ({ title: { startsWith: prefix } }),
1994
- orderBy: { column: "id" },
1995
- limit: 50,
1996
- });
1997
-
1998
- const rows = await ListTodos.run(Database, { prefix: "Buy" });
1999
- const compiled = ListTodos.sql(Database, { prefix: "Buy" }); // no connection
2000
- // compiled.sql and compiled.params describe the same statement run() executes.
2001
- ```
2002
-
2003
- `run` validates its input before SQL and its response before returning. SQL uses
2004
- the same SELECT compiler as `findMany`, under the caller's transaction and RLS
2005
- identity. Table names retain their declared schema. Selection is mandatory;
2006
- unselected database columns cannot silently expand the API. Numeric/bigint and
2007
- timestamp outputs retain their exact string representations, and named codecs
2008
- determine the surfaced type. A selected `jsonb<T>()` needs a matching Zod schema
2009
- in `fields: { payload: PayloadSchema }`, because its TypeScript payload is erased
2010
- at runtime. Free-form column transforms require migration to a named codec.
2011
-
2012
- Keep a contract manifest in the application repository:
2013
-
2014
- ```ts
2015
- const generated = queryManifest([ListTodos]);
2016
- // Write generated JSON for the initial, reviewed baseline.
2017
- assertQueryManifest([ListTodos], committedManifest); // run in the CI/build check
2018
- ```
2019
-
2020
- The manifest contains input/response OpenAPI components, selected storage types,
2021
- nullability and codecs. A changed contract fails the check until its diff is
2022
- reviewed and the committed baseline is updated. The runtime also refuses a query
2023
- whose selected columns disagree with the installed declaration, before SQL.
2024
- This check compares declarations; migration validation still owns drift in the
2025
- physical PostgreSQL schema. Queries currently project one table; use `findMany`
2026
- or `page` for nested relation reads.
2027
-
2028
- `await ListTodos.explain(Database, input)` executes plain `EXPLAIN (FORMAT JSON)`
2029
- with the same role and parameters. It never enables `ANALYZE`. Plans can contain
2030
- filter literals, so inspect them in development/operator tooling. No EXPLAIN is
2031
- run automatically on production requests. The complete controller/schema example
2032
- is [examples/database-dx.ts](./examples/database-dx.ts).
2033
-
2034
1978
  ## Nested relations and unique lookups
2035
1979
 
2036
1980
  `with` accepts three relation edges, with independent selection, filtering,
@@ -2135,10 +2079,8 @@ can use the entire available pool. A custom tenant claim must be signed and
2135
2079
  issuer-controlled; do not use editable user metadata. Missing metadata does not
2136
2080
  reduce an installation to a fraction of its pool.
2137
2081
 
2138
- Existing `defineWorkload` options retain their old behavior, including conflict
2139
- ordering and any explicit tenant cap. They are deprecated, not silently ignored.
2140
- Removing them removes that application-local scheduling policy; sorted database
2141
- row locks and constraints remain the authority across all runtime instances.
2082
+ Sorted PostgreSQL row locks and constraints coordinate conflicting writes across
2083
+ all runtime instances. No application workload declaration is needed.
2142
2084
  Do not place external service calls in a retryable callback. Use an idempotency
2143
2085
  claim and transactional outbox for effects that must survive retries.
2144
2086
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "35.0.1",
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 };
@@ -80,7 +80,7 @@ const RETURN_BUFFER_SYMBOL_KEY = 'palbase.backend.returnBuffer';
80
80
  * - imports: local-binding-name → module-specifier (for the injector's import)
81
81
  * Throws ReturnTypeError on an un-resolvable/disallowed return type.
82
82
  */
83
- function readReturnTypes(sourceText, fileLabel) {
83
+ function readReturnTypes(sourceText, fileLabel, inferReturn) {
84
84
  const tsapi = loadTS();
85
85
  const sf = tsapi.createSourceFile(
86
86
  fileLabel || 'controller.ts',
@@ -249,6 +249,11 @@ function readReturnTypes(sourceText, fileLabel) {
249
249
  // silent gap @Returns had (codegen would emit an untyped struct), so it's a
250
250
  // HARD error: the author must say what the route returns.
251
251
  if (!m.type) {
252
+ if (inferReturn) {
253
+ const expression = inferReturn(fileLabel, className, fnName);
254
+ if (expression !== null) methods.push({ fnName, expression });
255
+ continue;
256
+ }
252
257
  throw err(
253
258
  fnName,
254
259
  'route method has no return type — annotate it (e.g. `: Promise<TodoSchema>`, ' +
@@ -326,7 +331,7 @@ function buildInjection(parsed) {
326
331
  lines.push(' __ctor[__RBUF][fn] = schema;');
327
332
  lines.push(' };');
328
333
  for (const m of parsed.methods) {
329
- const expr = m.isArray ? `z.array(${m.typeName})` : m.typeName;
334
+ const expr = m.expression ?? (m.isArray ? `z.array(${m.typeName})` : m.typeName);
330
335
  lines.push(` __bind(${JSON.stringify(m.fnName)}, ${expr});`);
331
336
  }
332
337
  lines.push('})();');
@@ -340,8 +345,8 @@ function buildInjection(parsed) {
340
345
  * add the import). Pure string transform — the caller writes the result to the
341
346
  * file esbuild will bundle.
342
347
  */
343
- function injectReturnBindings(sourceText, fileLabel) {
344
- const parsed = readReturnTypes(sourceText, fileLabel);
348
+ function injectReturnBindings(sourceText, fileLabel, inferReturn) {
349
+ const parsed = readReturnTypes(sourceText, fileLabel, inferReturn);
345
350
  const snippet = buildInjection(parsed);
346
351
  if (!snippet) return sourceText;
347
352
  let out = sourceText;
package/stager/stage.js CHANGED
@@ -21,6 +21,7 @@ const path = require("node:path");
21
21
 
22
22
  const generics = require("./generics.js");
23
23
  const returnTypes = require("./return_types.js");
24
+ const { createReturnInference } = require("./inferred_returns.js");
24
25
  const throwAnalysis = require("./throw_analysis.js");
25
26
 
26
27
  /**
@@ -103,6 +104,7 @@ function stageControllers(srcDir, stageDir, projectRoot) {
103
104
  // normally lives. `projectRoot` is the third argument precisely so a check
104
105
  // can be wider than the staging copy.
105
106
  const examined = generics.assertNoGenericDepsInTree(projectRoot);
107
+ const inferReturn = createReturnInference(projectRoot);
106
108
 
107
109
  let injected = 0;
108
110
  for (const file of walk(srcDir)) {
@@ -110,7 +112,7 @@ function stageControllers(srcDir, stageDir, projectRoot) {
110
112
  const dest = path.join(stageDir, rel);
111
113
  fs.mkdirSync(path.dirname(dest), { recursive: true });
112
114
  if (/\.controller\.(c?ts|tsx)$/i.test(path.basename(file))) {
113
- let out = returnTypes.injectReturnBindings(fs.readFileSync(file, "utf8"), rel);
115
+ let out = returnTypes.injectReturnBindings(fs.readFileSync(file, "utf8"), file, inferReturn);
114
116
  out = throwAnalysis.injectThrowBindings(out, file, {
115
117
  readFile: (p) => {
116
118
  try {
@@ -6,7 +6,6 @@ router to register with — a `@Module` says what exists, who owns it and what i
6
6
  may reach, and a class no module lists does not exist.
7
7
 
8
8
  Everything comes from one package:
9
-
10
9
  ```ts
11
10
  import { Controller, Get, Post, Body, Param, User, z, Database, NotFound } from "@palbase/backend";
12
11
  ```
@@ -24,7 +23,7 @@ agents). This file does not repeat it; it says what the code should LOOK like.
24
23
 
25
24
  | Layer | DOES | NEVER |
26
25
  |---|---|---|
27
- | `modules/<domain>/<name>.controller.ts` | HTTP only: validate the body through a named schema, name the 200 shape as the return type, turn a missing row into a status | imports `Database` / `Storage` / `Cache` · holds business rules · stores per-request state on the instance |
26
+ | `modules/<domain>/<name>.controller.ts` | HTTP only: validate input, return the typed service result, turn a missing row into a status | imports `Database` / `Storage` / `Cache` · holds business rules · stores per-request state on the instance |
28
27
  | `modules/<domain>/<name>.service.ts` | the decisions: which rows, whose, in what order; throws the error classes | knows about HTTP (no `req`, no `res`) · reaches for a singleton itself — it takes its dependency through the constructor |
29
28
  | `modules/<domain>/dto/<endpoint>.ts` | zod schemas, exported twice under one name: a VALUE and a same-named `z.infer` TYPE | holds logic |
30
29
  | `db/public.ts` | IS the database: tables, columns, RLS policies | produces migration files — there are none |
@@ -84,7 +83,9 @@ wrong in this runtime:
84
83
  - **Hand-built responses** — no `JSON.stringify`, no `new Response(...)`; the 200
85
84
  body IS the schema your return type names.
86
85
  - **An inline return type** — `Promise<{ ok: boolean }>`, a union, or a bare
87
- `interface`. The deploy REFUSES it; name a zod schema.
86
+ `interface`. Omit it for typed CRUD: the build infers the validator and client contract.
87
+ For unknown JSON, recursive values or classes, name a Zod schema; never use `any`.
88
+ Use ordinary CRUD; core owns pool/queue settings.
88
89
 
89
90
  **A controller is exported by NAME, and no file is default-exported.** Its module imports
90
91
  it (`import { NotesController } from "./notes.controller"`) to list it in `controllers`;
@@ -186,8 +187,7 @@ process-wide state. For a stand-in covering the whole database rather than one
186
187
  table, use `fakeDatabase()` from `@palbase/backend/test`; it answers from memory
187
188
  and enforces no RLS, constraints or unique violations.
188
189
 
189
- Relative imports inside a test need the extension (`./note.service.ts`); this
190
- scaffold's `tsconfig.json` allows it.
190
+ Relative test imports need the extension (`./note.service.ts`); the scaffold allows it.
191
191
 
192
192
  `npm test` answers "is the logic right". It is not the deploy's own validation —
193
193
  decorators, return types, SDK major — which answers "would this ship".
@@ -3,13 +3,13 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
- "description": "A Palbase backend \u2014 class controllers, a declared database, and the secrets it needs.",
6
+ "description": "A Palbase backend class controllers, a declared database, and the secrets it needs.",
7
7
  "scripts": {
8
8
  "test": "./scripts/test.sh",
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@palbase/backend": "^35.0.0",
12
+ "@palbase/backend": "^36.0.0",
13
13
  "reflect-metadata": "^0.2.2"
14
14
  },
15
15
  "engines": {