@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.
- 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 +2 -60
- package/docs/examples/database-dx.ts +12 -17
- package/docs/llms-full.txt +3 -61
- 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 +2 -2
- 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
|
@@ -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
|
-
|
|
1035
|
-
|
|
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,
|
|
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
|
|
|
@@ -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
|
-
|
|
2139
|
-
|
|
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
|
@@ -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 };
|
package/stager/return_types.js
CHANGED
|
@@ -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"),
|
|
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 {
|
package/template/AGENTS.md
CHANGED
|
@@ -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
|
|
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`.
|
|
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
|
|
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".
|
package/template/package.json
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "A Palbase backend
|
|
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": "^
|
|
12
|
+
"@palbase/backend": "^36.0.0",
|
|
13
13
|
"reflect-metadata": "^0.2.2"
|
|
14
14
|
},
|
|
15
15
|
"engines": {
|