@kequtech/tenon-orm 0.3.0 → 0.4.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/CHANGELOG.md +54 -0
- package/README.md +27 -5
- package/dist/affected-rows.js +0 -1
- package/dist/baseline.js +3 -5
- package/dist/check/contracts.d.ts +6 -0
- package/dist/check/contracts.js +206 -0
- package/dist/check/project.d.ts +18 -0
- package/dist/check/project.js +224 -0
- package/dist/check/source.d.ts +51 -0
- package/dist/check/source.js +629 -0
- package/dist/check/types.d.ts +36 -0
- package/dist/check/types.js +1 -0
- package/dist/check.d.ts +4 -0
- package/dist/check.js +147 -0
- package/dist/cli.js +38 -9
- package/dist/connection.d.ts +5 -12
- package/dist/connection.js +12 -121
- package/dist/database.js +5 -8
- package/dist/describe.js +10 -3
- package/dist/generate.d.ts +2 -3
- package/dist/generate.js +26 -24
- package/dist/generation/catalog.d.ts +5 -6
- package/dist/generation/catalog.js +7 -9
- package/dist/generation/render.d.ts +3 -0
- package/dist/generation/render.js +60 -0
- package/dist/generation/schema-summary.d.ts +3 -3
- package/dist/generation/schema-summary.js +2 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/inspection/catalog.d.ts +3 -6
- package/dist/inspection/catalog.js +9 -18
- package/dist/inspection/cli.d.ts +12 -0
- package/dist/inspection/cli.js +9 -4
- package/dist/inspection/connection.d.ts +2 -3
- package/dist/inspection/connection.js +20 -22
- package/dist/inspection/options.js +0 -6
- package/dist/inspection/result.d.ts +3 -2
- package/dist/inspection/result.js +13 -26
- package/dist/inspection/types.d.ts +6 -4
- package/dist/migrations/connection.d.ts +3 -3
- package/dist/migrations/connection.js +14 -21
- package/dist/migrations/history.d.ts +4 -4
- package/dist/migrations/history.js +5 -5
- package/dist/migrations.js +7 -9
- package/dist/query-options.d.ts +1 -1
- package/dist/query.js +1 -1
- package/dist/transaction.d.ts +2 -2
- package/dist/transaction.js +21 -77
- package/dist/types.d.ts +1 -1
- package/docs/adoption-guide.md +64 -12
- package/docs/agent-guide.md +57 -27
- package/docs/human-guide.md +53 -18
- package/docs/mutations.md +22 -9
- package/docs/query-checking.md +200 -0
- package/docs/query-inspection.md +40 -13
- package/docs/reference.md +330 -108
- package/package.json +11 -7
- package/dist/driver/types.d.ts +0 -10
- package/dist/driver/types.js +0 -49
- package/dist/generation/table-types.d.ts +0 -2
- package/dist/generation/table-types.js +0 -96
- package/dist/inspection/protocol.d.ts +0 -7
- package/dist/inspection/protocol.js +0 -45
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Breaking changes
|
|
6
|
+
|
|
7
|
+
- Replace bundled `pg` and `@types/pg` with the `@kequtech/pgclear` runtime dependency (`^0.3.0`).
|
|
8
|
+
Installing Tenon includes the driver; compatible driver patches can update independently.
|
|
9
|
+
Connections accept a PostgreSQL URI, pgclear `PoolOptions`, or a pgclear `Pool`; translate former
|
|
10
|
+
pg configuration and supply deployment settings explicitly.
|
|
11
|
+
PG environment defaults, pgpass/service files, and pg-specific URI options are not read. See
|
|
12
|
+
[upgrading from 0.3.x](docs/adoption-guide.md#upgrade-from-tenon-03x).
|
|
13
|
+
- `db.native` is now a pgclear `Pool`. Use `queryClassic(text, values?)` for finished SQL and
|
|
14
|
+
acquire/withSession for held sessions; native `query` accepts pgclear's own sql tag only. Prepared
|
|
15
|
+
statements, streams, and COPY use the public session APIs. Use pgclear `listen` for dedicated
|
|
16
|
+
notifications. Pool shutdown, transaction outcomes, and savepoint recovery delegate to the driver.
|
|
17
|
+
- Command counts remain safe integer numbers; int8/bigserial, SQL count(*) and int8 array elements
|
|
18
|
+
use exact strings. Temporal values and exact numeric arrays are strings, bytes are Uint8Array,
|
|
19
|
+
and result arrays are readonly. JSON/array binds need pgclear json/param or encoded strings.
|
|
20
|
+
Plain bigint, Date, and supported native/polyfilled Temporal inputs use text and server inference;
|
|
21
|
+
output conversions are opt-in. Undefined, plain objects, and arrays reject; mutation-helper
|
|
22
|
+
undefined still omits. JSON encoding still rejects bigint values.
|
|
23
|
+
- Tenon owns schema generation and exports `Database[table]['row' | 'insert' | 'update']`.
|
|
24
|
+
Regenerate committed definitions, replace former Rows imports, and review write representations.
|
|
25
|
+
Catalog queries, schema summaries, defaults/domains/identities, and TypeScript rendering live in
|
|
26
|
+
Tenon; decoder representations come from pgclear. Import generation from Tenon instead of pgclear.
|
|
27
|
+
Tenon retains migration coordination, atomic file output, and `generate --check` freshness checks.
|
|
28
|
+
|
|
29
|
+
### Added and improved
|
|
30
|
+
|
|
31
|
+
- Re-export Pgclear parameter/codec helpers and their types through Tenon, so application queries
|
|
32
|
+
and generated declarations use one import path. Use Tenon's `sql` for `db.query`.
|
|
33
|
+
- Add optional `tenon check` for a project, file, or file:line, with readable/JSON diagnostics and a
|
|
34
|
+
programmatic `@kequtech/tenon-orm/check` API. Fixed fragments, joins, explicit object shapes, and
|
|
35
|
+
bounded ternaries are checked through pgclear Describe without importing application code or
|
|
36
|
+
executing application SQL. Definite mismatches and unchecked queries fail; inconclusive
|
|
37
|
+
nullability, JSON shapes, decoder types, and refinements remain unproven notes.
|
|
38
|
+
- Bundle TypeScript 6 for source checking and load it only on the checking path. Ordinary execution
|
|
39
|
+
continues to trust result annotations; database tests remain necessary for rows and write effects.
|
|
40
|
+
- Inspection uses public pgclear Describe with parameter OID hints and a selected codec registry.
|
|
41
|
+
Custom text codecs can declare `text.typescript`; generation and source checking use that metadata,
|
|
42
|
+
including derived arrays, without duplicate readTypes declarations. Unidentified custom decoders
|
|
43
|
+
remain unresolved. Migrations, generation, and inspection use public pgclear sessions without a
|
|
44
|
+
private protocol adapter. Catalog reads keep default decoding on the application's borrowed session.
|
|
45
|
+
- Clarify the documentation reading path, common failures, optional patch checking, and the roles of
|
|
46
|
+
source checking, SQL inspection, and generated-file freshness. Keep historical driver reviews out
|
|
47
|
+
of the published consumer docs.
|
|
48
|
+
|
|
49
|
+
## 0.3.1
|
|
50
|
+
|
|
51
|
+
- Expand documentation for driver configuration, transaction result adapters, value mappings, and
|
|
52
|
+
validation between persistence representations and application domain values.
|
|
53
|
+
|
|
3
54
|
## 0.3.0
|
|
4
55
|
|
|
5
56
|
- **Breaking:** `pg@8.23.1` replaces Postgres.js. Use pg `PoolConfig` (`connectionString` for a URI)
|
|
6
57
|
or `pg.Pool`; removed `{ url, options }` and `ConnectionUri`. `db.native` is the actual pool;
|
|
7
58
|
results use `rows`, `rowCount`, and `fields`, and error fields use pg names (`table`,
|
|
8
59
|
`constraint`). Native LISTEN uses checked-out clients with application-owned resubscription.
|
|
60
|
+
Default results are no longer iterable: use `result.rows`, replace metadata `count` with
|
|
61
|
+
`rowCount`, and update recording helpers. Selected SQL columns named `count` are unchanged. See
|
|
62
|
+
[transaction adapters](docs/reference.md#adapting-transactional-results) for `{ rows }` consumers.
|
|
9
63
|
- Driver defaults apply, including no acquisition timeout and SQL null for bound `undefined`. The
|
|
10
64
|
parameter ceiling now matches PostgreSQL's 65,535 limit.
|
|
11
65
|
- Regenerate types: temporal reads include numeric infinities, date-only values use Node's local
|
package/README.md
CHANGED
|
@@ -4,7 +4,10 @@ A PostgreSQL ORM designed for coding agents. Built for Node.js and TypeScript.
|
|
|
4
4
|
|
|
5
5
|
Write SQL directly, bind values with a template tag, and reuse queries through ordinary functions.
|
|
6
6
|
Tenon provides transactions, affected-row guards, SQL migrations, generated database types, and
|
|
7
|
-
query inspection.
|
|
7
|
+
query inspection and optional source-aware checking. Tenon owns database declarations and application
|
|
8
|
+
TypeScript tooling. Execution, Describe, and value conversion come from the
|
|
9
|
+
`@kequtech/pgclear` runtime dependency. Import `sql`, `json`, `param`, and codec helpers from
|
|
10
|
+
Tenon for application queries; no separate driver installation is needed.
|
|
8
11
|
|
|
9
12
|
```sh
|
|
10
13
|
npm install @kequtech/tenon-orm
|
|
@@ -38,26 +41,45 @@ The example assumes `public.users` exists. In a server, reuse the instance and c
|
|
|
38
41
|
`oneOrNone` rejects multiple rows; use `one` for exactly one row, `many` for an array, or omit
|
|
39
42
|
`returns` for the driver's result object and metadata.
|
|
40
43
|
|
|
44
|
+
Command `rowCount` values are numbers. SQL `count(*)`, bigint/bigserial columns, and bigint array
|
|
45
|
+
elements read as exact strings by default. Plain bigint, Date, and supported native/polyfilled
|
|
46
|
+
Temporal inputs use server type inference; output conversions to bigint, Date, or Temporal are
|
|
47
|
+
explicit codec choices. JSON and PostgreSQL arrays use explicit helpers. See the
|
|
48
|
+
[value mappings](docs/reference.md#generated-types-and-checking).
|
|
49
|
+
|
|
41
50
|
Joins, relationships, aggregates, and upserts use PostgreSQL SQL. Object-based writes can use
|
|
42
51
|
`sql.insertValues` and `sql.assignments` while keeping the surrounding statement explicit.
|
|
43
|
-
`db.native` exposes the underlying `
|
|
44
|
-
those calls bypass Tenon's query and transaction guards.
|
|
52
|
+
`db.native` exposes the underlying pgclear `Pool` for prepared statements, streams, COPY, and
|
|
53
|
+
other driver features; those calls bypass Tenon's query and transaction guards. Use pgclear's
|
|
54
|
+
`listen` for a dedicated notification connection.
|
|
45
55
|
|
|
46
|
-
`sql<Row>` declares an expected result type. `
|
|
47
|
-
|
|
56
|
+
`sql<Row>` declares an expected result type. `tenon check src/users.ts:23` checks a recoverable query
|
|
57
|
+
against that type and PostgreSQL Describe without executing the statement. Inconclusive types remain
|
|
58
|
+
unproven; unsupported SQL composition is reported as unchecked. `describeQuery` inspects finished SQL
|
|
59
|
+
without comparing a source annotation. Keep database tests for actual
|
|
48
60
|
rows and write effects. Validation, authorization, and retry policy belong to the application.
|
|
49
61
|
Existing migration runners can be retained; Tenon's migrations and type generation are optional.
|
|
50
62
|
|
|
51
63
|
## Documentation
|
|
52
64
|
|
|
65
|
+
Start with this README, then follow the [agent guide](docs/agent-guide.md) when working in an
|
|
66
|
+
existing project or the [human guide](docs/human-guide.md) for a first setup. Use the reference for
|
|
67
|
+
specific options and the focused guides when a task needs them. Read the docs shipped with the
|
|
68
|
+
installed Tenon version; unreleased repository changes may differ.
|
|
69
|
+
|
|
53
70
|
- **[Human guide](docs/human-guide.md)** — a table, queries, transactions, and generated types.
|
|
54
71
|
- **[Agent guide](docs/agent-guide.md)** — implementation contracts and upgrade workflow.
|
|
55
72
|
- **[Reference](docs/reference.md)** — API options, value mappings, CLI, and limitations.
|
|
56
73
|
- **[Adoption guide](docs/adoption-guide.md)** — replace an ORM and transfer migration ownership.
|
|
57
74
|
- **[Mutation cookbook](docs/mutations.md)** — defaults, patches, upserts, arrays, and batching.
|
|
58
75
|
- **[Query inspection](docs/query-inspection.md)** — Describe reports and database tests.
|
|
76
|
+
- **[Query checking](docs/query-checking.md)** — project/file/line checking, composition, and limitations.
|
|
59
77
|
- **[Changelog](CHANGELOG.md)** — versioned changes and upgrade instructions.
|
|
60
78
|
|
|
79
|
+
For common failures, see [troubleshooting](docs/reference.md#troubleshooting). To choose between
|
|
80
|
+
source checking, SQL inspection, and generated-file freshness, see
|
|
81
|
+
[validation tools](docs/reference.md#choosing-a-validation-tool).
|
|
82
|
+
|
|
61
83
|
These documents all ship with the npm package, including the changelog so an agent upgrading a
|
|
62
84
|
project can read the relevant release entries locally. Repository maintenance instructions are in
|
|
63
85
|
[CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md).
|
package/dist/affected-rows.js
CHANGED
|
@@ -18,7 +18,6 @@ export function validateAffectedRows(value) {
|
|
|
18
18
|
export function checkAffectedRows(constraint, result) {
|
|
19
19
|
if (!['INSERT', 'UPDATE', 'DELETE', 'MERGE'].includes(result.command) ||
|
|
20
20
|
result.rowCount === null ||
|
|
21
|
-
!Number.isSafeInteger(result.rowCount) ||
|
|
22
21
|
result.rowCount < 0) {
|
|
23
22
|
throw new AffectedRowsError(constraint, result.rowCount, result.command, false);
|
|
24
23
|
}
|
package/dist/baseline.js
CHANGED
|
@@ -82,16 +82,14 @@ export async function adoptBaseline(options) {
|
|
|
82
82
|
if (applied.length) {
|
|
83
83
|
return [];
|
|
84
84
|
}
|
|
85
|
-
const schema = await client.
|
|
86
|
-
namespace,
|
|
87
|
-
]);
|
|
85
|
+
const schema = await client.queryClassic('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [namespace]);
|
|
88
86
|
if (!schema.rows.length) {
|
|
89
87
|
throw new Error('The baseline namespace does not exist. Use migrate to initialize a fresh database.');
|
|
90
88
|
}
|
|
91
|
-
await client.
|
|
89
|
+
await client.queryClassic('BEGIN', []);
|
|
92
90
|
await createHistoryTable(client, namespace);
|
|
93
91
|
await recordMigration(client, namespace, baseline);
|
|
94
|
-
const result = await client.
|
|
92
|
+
const result = await client.queryClassic('COMMIT', []);
|
|
95
93
|
if (result.command !== 'COMMIT') {
|
|
96
94
|
throw new Error('Baseline adoption was rolled back.');
|
|
97
95
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { QueryDescription } from '../inspection/types.js';
|
|
2
|
+
import { type Project } from './project.js';
|
|
3
|
+
import type { QueryNode, QueryVariant } from './source.js';
|
|
4
|
+
import type { CheckDiagnostic } from './types.js';
|
|
5
|
+
/** Compare non-NULL representations; nullability and value refinements remain explicit limitations. */
|
|
6
|
+
export declare function compareContracts(project: Project, query: QueryNode, variant: QueryVariant, description: QueryDescription, readTypes: Readonly<Record<number, string>>): CheckDiagnostic[];
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { location } from './project.js';
|
|
3
|
+
function kinds(project, type, seen = new Set()) {
|
|
4
|
+
if (seen.has(type))
|
|
5
|
+
return new Set();
|
|
6
|
+
seen.add(type);
|
|
7
|
+
if (type.isUnion())
|
|
8
|
+
return new Set(type.types.flatMap((member) => [...kinds(project, member, seen)]));
|
|
9
|
+
if (type.isIntersection())
|
|
10
|
+
return new Set(type.types.flatMap((member) => [...kinds(project, member, seen)]));
|
|
11
|
+
if (type.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Never))
|
|
12
|
+
return new Set();
|
|
13
|
+
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.TypeParameter))
|
|
14
|
+
return new Set(['unknown']);
|
|
15
|
+
for (const [flag, name] of [
|
|
16
|
+
[ts.TypeFlags.StringLike, 'string'],
|
|
17
|
+
[ts.TypeFlags.NumberLike, 'number'],
|
|
18
|
+
[ts.TypeFlags.BigIntLike, 'bigint'],
|
|
19
|
+
[ts.TypeFlags.BooleanLike, 'boolean'],
|
|
20
|
+
])
|
|
21
|
+
if (type.flags & flag)
|
|
22
|
+
return new Set([name]);
|
|
23
|
+
if (project.checker.isArrayType(type) || project.checker.isTupleType(type))
|
|
24
|
+
return new Set(['array']);
|
|
25
|
+
return new Set(['object']);
|
|
26
|
+
}
|
|
27
|
+
function disjoint(project, actual, expected, depth = 0) {
|
|
28
|
+
if (depth > 8)
|
|
29
|
+
return false;
|
|
30
|
+
const a = kinds(project, actual), b = kinds(project, expected);
|
|
31
|
+
if (!b.size)
|
|
32
|
+
return true;
|
|
33
|
+
if (a.has('unknown') || b.has('unknown'))
|
|
34
|
+
return false;
|
|
35
|
+
if (![...a].some((kind) => b.has(kind)))
|
|
36
|
+
return true;
|
|
37
|
+
if (a.size === 1 && a.has('array') && b.size === 1 && b.has('array')) {
|
|
38
|
+
const elements = (type) => (type.isUnion() ? type.types : [type]).flatMap((part) => {
|
|
39
|
+
const element = project.checker.getIndexTypeOfType(part, ts.IndexKind.Number);
|
|
40
|
+
return element ? [element] : [];
|
|
41
|
+
});
|
|
42
|
+
const actualElements = elements(actual), expectedElements = elements(expected);
|
|
43
|
+
return (actualElements.length > 0 &&
|
|
44
|
+
expectedElements.length > 0 &&
|
|
45
|
+
actualElements.every((a) => expectedElements.every((b) => disjoint(project, a, b, depth + 1))));
|
|
46
|
+
}
|
|
47
|
+
if (a.size === 1 && a.has('object') && b.size === 1 && b.has('object')) {
|
|
48
|
+
for (const property of actual.getProperties()) {
|
|
49
|
+
const other = expected.getProperty(property.name);
|
|
50
|
+
if (!other ||
|
|
51
|
+
(property.flags & ts.SymbolFlags.Optional && other.flags & ts.SymbolFlags.Optional))
|
|
52
|
+
continue;
|
|
53
|
+
const context = project.selected[0];
|
|
54
|
+
if (disjoint(project, project.checker.getTypeOfSymbolAtLocation(property, context), project.checker.getTypeOfSymbolAtLocation(other, context), depth + 1))
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
function rowType(project, query) {
|
|
61
|
+
const explicit = query.typeArguments?.[0];
|
|
62
|
+
if (explicit)
|
|
63
|
+
return project.checker.getTypeFromTypeNode(explicit);
|
|
64
|
+
const contextual = project.checker.getContextualType(query);
|
|
65
|
+
const type = contextual ?? project.checker.getTypeAtLocation(query);
|
|
66
|
+
const property = type.getProperty('__rowType');
|
|
67
|
+
return property
|
|
68
|
+
? project.checker.getNonNullableType(project.checker.getTypeOfSymbolAtLocation(property, query))
|
|
69
|
+
: undefined;
|
|
70
|
+
}
|
|
71
|
+
/** Compare non-NULL representations; nullability and value refinements remain explicit limitations. */
|
|
72
|
+
export function compareContracts(project, query, variant, description, readTypes) {
|
|
73
|
+
const checker = project.checker;
|
|
74
|
+
const at = location(query);
|
|
75
|
+
const diagnostics = [];
|
|
76
|
+
for (const [index, parameter] of variant.parameters.entries()) {
|
|
77
|
+
const expected = description.parameters[index];
|
|
78
|
+
if (!expected)
|
|
79
|
+
continue;
|
|
80
|
+
const inputKinds = kinds(project, parameter.type);
|
|
81
|
+
if (inputKinds.has('string') || inputKinds.has('number')) {
|
|
82
|
+
diagnostics.push({
|
|
83
|
+
...location(parameter.node),
|
|
84
|
+
kind: 'unproven',
|
|
85
|
+
code: 'parameter-value-unproven',
|
|
86
|
+
parameter: index + 1,
|
|
87
|
+
expected: expected.postgres,
|
|
88
|
+
observed: checker.typeToString(parameter.type),
|
|
89
|
+
message: `Parameter ${index + 1}${parameter.field ? ` (${parameter.field})` : ''}: ${checker.typeToString(parameter.type)} does not prove PostgreSQL ranges or textual formats for ${expected.postgres}.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (description.columns.length)
|
|
94
|
+
diagnostics.push({
|
|
95
|
+
...at,
|
|
96
|
+
kind: 'unproven',
|
|
97
|
+
code: 'nullability-unproven',
|
|
98
|
+
message: 'Describe does not establish result nullability; column representation checks exclude SQL NULL.',
|
|
99
|
+
});
|
|
100
|
+
for (const issue of description.issues) {
|
|
101
|
+
if (issue.code === 'duplicate-column')
|
|
102
|
+
diagnostics.push({
|
|
103
|
+
...at,
|
|
104
|
+
kind: 'mismatch',
|
|
105
|
+
code: issue.code,
|
|
106
|
+
resultColumn: issue.column,
|
|
107
|
+
message: issue.message,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
const declared = rowType(project, query);
|
|
111
|
+
if (!declared ||
|
|
112
|
+
declared.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.TypeParameter)) {
|
|
113
|
+
diagnostics.push({
|
|
114
|
+
...at,
|
|
115
|
+
kind: 'unproven',
|
|
116
|
+
code: 'result-type-unproven',
|
|
117
|
+
message: 'No concrete declared result shape was resolved.',
|
|
118
|
+
});
|
|
119
|
+
return diagnostics;
|
|
120
|
+
}
|
|
121
|
+
const alternatives = declared.isUnion() ? declared.types : [declared];
|
|
122
|
+
const results = alternatives.map((row) => {
|
|
123
|
+
const findings = [];
|
|
124
|
+
for (const property of row.getProperties()) {
|
|
125
|
+
if (!(property.flags & ts.SymbolFlags.Optional) &&
|
|
126
|
+
!description.columns.some((column) => column.name === property.name)) {
|
|
127
|
+
findings.push({
|
|
128
|
+
...at,
|
|
129
|
+
kind: 'mismatch',
|
|
130
|
+
code: 'missing-column',
|
|
131
|
+
resultColumn: property.name,
|
|
132
|
+
expected: checker.typeToString(checker.getTypeOfSymbolAtLocation(property, query)),
|
|
133
|
+
observed: 'absent',
|
|
134
|
+
message: `Required result property ${JSON.stringify(property.name)} is absent from the SQL result.`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const column of description.columns) {
|
|
139
|
+
const property = row.getProperty(column.name);
|
|
140
|
+
const expected = property
|
|
141
|
+
? checker.getTypeOfSymbolAtLocation(property, query)
|
|
142
|
+
: checker.getIndexTypeOfType(row, ts.IndexKind.String);
|
|
143
|
+
if (!expected)
|
|
144
|
+
continue; // Structural result types may omit additional selected columns.
|
|
145
|
+
const representation = readTypes[column.oid] ?? column.readType;
|
|
146
|
+
const actual = project.types.get(representation);
|
|
147
|
+
const common = {
|
|
148
|
+
...at,
|
|
149
|
+
resultColumn: column.name,
|
|
150
|
+
expected: checker.typeToString(expected),
|
|
151
|
+
observed: representation,
|
|
152
|
+
};
|
|
153
|
+
if (!actual || actual.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any)) {
|
|
154
|
+
findings.push({
|
|
155
|
+
...common,
|
|
156
|
+
kind: 'unproven',
|
|
157
|
+
code: 'result-representation-unproven',
|
|
158
|
+
message: `Column ${JSON.stringify(column.name)} has an unresolved decoder representation or JSON shape; ${checker.typeToString(expected)} is unproven.`,
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const nonnull = checker.getNonNullableType(expected);
|
|
163
|
+
if (nonnull.flags & ts.TypeFlags.Any) {
|
|
164
|
+
findings.push({
|
|
165
|
+
...common,
|
|
166
|
+
kind: 'unproven',
|
|
167
|
+
code: 'result-type-unproven',
|
|
168
|
+
message: `Column ${JSON.stringify(column.name)} is declared as any.`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
else if (!checker.isTypeAssignableTo(actual, nonnull)) {
|
|
172
|
+
const mismatch = disjoint(project, actual, nonnull);
|
|
173
|
+
findings.push({
|
|
174
|
+
...common,
|
|
175
|
+
kind: mismatch ? 'mismatch' : 'unproven',
|
|
176
|
+
code: mismatch ? 'result-type-mismatch' : 'result-refinement-unproven',
|
|
177
|
+
message: `Column ${JSON.stringify(column.name)} decodes as ${representation}; its declared type ${checker.typeToString(expected)} ${mismatch ? 'contradicts that representation' : 'requires a refinement Describe cannot prove'}.`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return findings;
|
|
182
|
+
});
|
|
183
|
+
const compatible = results.filter((findings) => !findings.some((finding) => finding.kind === 'mismatch'));
|
|
184
|
+
if (compatible.length) {
|
|
185
|
+
compatible.sort((a, b) => a.length - b.length);
|
|
186
|
+
diagnostics.push(...compatible[0]);
|
|
187
|
+
}
|
|
188
|
+
else if (results.length === 1)
|
|
189
|
+
diagnostics.push(...results[0]);
|
|
190
|
+
else {
|
|
191
|
+
diagnostics.push({
|
|
192
|
+
...at,
|
|
193
|
+
kind: 'mismatch',
|
|
194
|
+
code: 'result-union-mismatch',
|
|
195
|
+
expected: checker.typeToString(declared),
|
|
196
|
+
observed: description.rowType ?? 'no row declaration',
|
|
197
|
+
message: `No declared result union member matches this SQL variant: ${results
|
|
198
|
+
.map((findings) => findings
|
|
199
|
+
.filter((finding) => finding.kind === 'mismatch')
|
|
200
|
+
.map((finding) => finding.message)
|
|
201
|
+
.join(' '))
|
|
202
|
+
.join(' / ')}`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return diagnostics;
|
|
206
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import type { CheckOptions, CheckDiagnostic, SourceLocation } from './types.js';
|
|
3
|
+
export declare function location(node: ts.Node): SourceLocation;
|
|
4
|
+
export declare function symbol(checker: ts.TypeChecker, node: ts.Node): ts.Symbol | undefined;
|
|
5
|
+
export declare function loadProject(options: CheckOptions): {
|
|
6
|
+
program: ts.Program;
|
|
7
|
+
checker: ts.TypeChecker;
|
|
8
|
+
types: Map<string, ts.Type>;
|
|
9
|
+
parameterType: ts.Type;
|
|
10
|
+
files: ts.SourceFile[];
|
|
11
|
+
selected: ts.SourceFile[];
|
|
12
|
+
line: number | undefined;
|
|
13
|
+
project: string;
|
|
14
|
+
diagnostics: CheckDiagnostic[];
|
|
15
|
+
};
|
|
16
|
+
export type Project = ReturnType<typeof loadProject>;
|
|
17
|
+
/** Resolve actual exported symbols, including aliases and barrel re-exports. */
|
|
18
|
+
export declare function librarySymbols(project: Project): ReadonlyMap<string, ts.Symbol>;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
import { defaultCodecs } from '@kequtech/pgclear';
|
|
5
|
+
export function location(node) {
|
|
6
|
+
const source = node.getSourceFile();
|
|
7
|
+
const position = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
8
|
+
return {
|
|
9
|
+
file: resolve(source.fileName),
|
|
10
|
+
line: position.line + 1,
|
|
11
|
+
column: position.character + 1,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function symbol(checker, node) {
|
|
15
|
+
const found = node.parent && ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node
|
|
16
|
+
? checker.getShorthandAssignmentValueSymbol(node.parent)
|
|
17
|
+
: checker.getSymbolAtLocation(node);
|
|
18
|
+
return found && found.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(found) : found;
|
|
19
|
+
}
|
|
20
|
+
/** Validate modules supplying representation types without selecting unrelated project files. */
|
|
21
|
+
function representationSources(checker, root) {
|
|
22
|
+
const sources = new Set();
|
|
23
|
+
const pending = [root];
|
|
24
|
+
while (pending.length) {
|
|
25
|
+
const source = pending.pop();
|
|
26
|
+
if (sources.has(source))
|
|
27
|
+
continue;
|
|
28
|
+
sources.add(source);
|
|
29
|
+
const visit = (node) => {
|
|
30
|
+
let reference;
|
|
31
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
32
|
+
reference = node.moduleSpecifier;
|
|
33
|
+
}
|
|
34
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
35
|
+
reference = node.argument.literal;
|
|
36
|
+
}
|
|
37
|
+
else if (ts.isImportEqualsDeclaration(node) &&
|
|
38
|
+
ts.isExternalModuleReference(node.moduleReference)) {
|
|
39
|
+
reference = node.moduleReference.expression;
|
|
40
|
+
}
|
|
41
|
+
else if (ts.isCallExpression(node) &&
|
|
42
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
43
|
+
reference = node.arguments[0];
|
|
44
|
+
}
|
|
45
|
+
if (reference && ts.isStringLiteralLike(reference)) {
|
|
46
|
+
const module = checker.getSymbolAtLocation(reference);
|
|
47
|
+
for (const declaration of module?.declarations ?? []) {
|
|
48
|
+
pending.push(declaration.getSourceFile());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
ts.forEachChild(node, visit);
|
|
52
|
+
};
|
|
53
|
+
visit(source);
|
|
54
|
+
}
|
|
55
|
+
return [...sources];
|
|
56
|
+
}
|
|
57
|
+
const representations = [
|
|
58
|
+
'unknown',
|
|
59
|
+
'string',
|
|
60
|
+
'number',
|
|
61
|
+
'bigint',
|
|
62
|
+
'boolean',
|
|
63
|
+
'globalThis.Uint8Array',
|
|
64
|
+
'globalThis.Date',
|
|
65
|
+
'import("@kequtech/tenon-orm").TemporalInput',
|
|
66
|
+
...['unknown', 'string', 'number', 'bigint', 'boolean', 'globalThis.Uint8Array'].map((type) => `PgArray<${type}>`),
|
|
67
|
+
];
|
|
68
|
+
export function loadProject(options) {
|
|
69
|
+
const match = options.target?.match(/^(.*):(\d+)$/);
|
|
70
|
+
const filename = options.target === undefined ? undefined : resolve(match?.[1] ?? options.target);
|
|
71
|
+
const line = match === null || match === undefined ? undefined : Number(match[2]);
|
|
72
|
+
if (line !== undefined && (!Number.isSafeInteger(line) || line < 1)) {
|
|
73
|
+
throw new Error('The query line must be a positive, one-based integer.');
|
|
74
|
+
}
|
|
75
|
+
const configPath = options.project === undefined
|
|
76
|
+
? ts.findConfigFile(filename ? dirname(filename) : process.cwd(), ts.sys.fileExists)
|
|
77
|
+
: ts.sys.directoryExists(resolve(options.project))
|
|
78
|
+
? resolve(options.project, 'tsconfig.json')
|
|
79
|
+
: resolve(options.project);
|
|
80
|
+
if (!configPath)
|
|
81
|
+
throw new Error('No tsconfig.json found. Pass --project with the project configuration.');
|
|
82
|
+
const config = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
83
|
+
if (config.error)
|
|
84
|
+
throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n'));
|
|
85
|
+
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(configPath), undefined, configPath);
|
|
86
|
+
if (parsed.errors.length) {
|
|
87
|
+
throw new Error(parsed.errors
|
|
88
|
+
.map((error) => ts.flattenDiagnosticMessageText(error.messageText, '\n'))
|
|
89
|
+
.join('\n'));
|
|
90
|
+
}
|
|
91
|
+
if (!(parsed.options.strictNullChecks ?? parsed.options.strict)) {
|
|
92
|
+
throw new Error('Query checking requires strictNullChecks in the TypeScript project.');
|
|
93
|
+
}
|
|
94
|
+
const custom = Object.entries(options.readTypes ?? {});
|
|
95
|
+
for (const [oid, type] of custom) {
|
|
96
|
+
if (!Number.isInteger(Number(oid)) ||
|
|
97
|
+
Number(oid) < 1 ||
|
|
98
|
+
Number(oid) > 0xffffffff ||
|
|
99
|
+
typeof type !== 'string' ||
|
|
100
|
+
!type.trim()) {
|
|
101
|
+
throw new Error('readTypes requires PostgreSQL OIDs and nonempty TypeScript type expressions.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const expressions = [
|
|
105
|
+
...new Set([
|
|
106
|
+
...representations,
|
|
107
|
+
...(options.codecs ?? defaultCodecs).typescriptTypes(),
|
|
108
|
+
...custom.map(([, type]) => type),
|
|
109
|
+
]),
|
|
110
|
+
];
|
|
111
|
+
const virtualPath = resolve(dirname(configPath), `.tenon-check-${randomUUID()}.ts`);
|
|
112
|
+
const virtualSource = [
|
|
113
|
+
'type PgArray<T> = readonly (T | null | PgArray<T>)[];',
|
|
114
|
+
...expressions.map((type, index) => `export type Read${index} = ${type};`),
|
|
115
|
+
'export type Parameter = import("@kequtech/tenon-orm").Parameter;',
|
|
116
|
+
].join('\n');
|
|
117
|
+
// Emission layout must not constrain the in-memory representation module.
|
|
118
|
+
const compilerOptions = { ...parsed.options, noEmit: true };
|
|
119
|
+
delete compilerOptions.rootDir;
|
|
120
|
+
const host = ts.createCompilerHost(compilerOptions);
|
|
121
|
+
const readFile = host.readFile.bind(host);
|
|
122
|
+
const fileExists = host.fileExists.bind(host);
|
|
123
|
+
host.readFile = (path) => (resolve(path) === virtualPath ? virtualSource : readFile(path));
|
|
124
|
+
host.fileExists = (path) => resolve(path) === virtualPath || fileExists(path);
|
|
125
|
+
const program = ts.createProgram({
|
|
126
|
+
rootNames: [...parsed.fileNames, virtualPath],
|
|
127
|
+
options: compilerOptions,
|
|
128
|
+
host,
|
|
129
|
+
...(parsed.projectReferences ? { projectReferences: parsed.projectReferences } : {}),
|
|
130
|
+
});
|
|
131
|
+
const checker = program.getTypeChecker();
|
|
132
|
+
const helper = program.getSourceFile(virtualPath);
|
|
133
|
+
const types = new Map();
|
|
134
|
+
for (const statement of helper.statements) {
|
|
135
|
+
if (ts.isTypeAliasDeclaration(statement) && statement.name.text.startsWith('Read')) {
|
|
136
|
+
types.set(expressions[Number(statement.name.text.slice(4))], checker.getTypeFromTypeNode(statement.type));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const parameterNode = helper.statements.find((node) => ts.isTypeAliasDeclaration(node) && node.name.text === 'Parameter');
|
|
140
|
+
const parameterType = checker.getTypeFromTypeNode(parameterNode.type);
|
|
141
|
+
const helperErrors = representationSources(checker, helper).flatMap((source) => [
|
|
142
|
+
...program.getSyntacticDiagnostics(source),
|
|
143
|
+
...program.getSemanticDiagnostics(source),
|
|
144
|
+
]);
|
|
145
|
+
if (helperErrors.length) {
|
|
146
|
+
throw new Error(`Cannot resolve checker representation types: ${helperErrors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')}`);
|
|
147
|
+
}
|
|
148
|
+
const files = program
|
|
149
|
+
.getSourceFiles()
|
|
150
|
+
.filter((source) => !source.isDeclarationFile &&
|
|
151
|
+
resolve(source.fileName) !== virtualPath &&
|
|
152
|
+
!program.isSourceFileFromExternalLibrary(source));
|
|
153
|
+
const selected = filename === undefined
|
|
154
|
+
? files
|
|
155
|
+
: files.filter((source) => resolve(source.fileName) === filename);
|
|
156
|
+
if (filename !== undefined && !selected.length)
|
|
157
|
+
throw new Error('The target file is not a source file in this TypeScript project. Check --project and tsconfig inclusion.');
|
|
158
|
+
const diagnostics = [];
|
|
159
|
+
for (const error of [...program.getOptionsDiagnostics(), ...program.getGlobalDiagnostics()]) {
|
|
160
|
+
if (error.category === ts.DiagnosticCategory.Error)
|
|
161
|
+
diagnostics.push({
|
|
162
|
+
file: resolve(configPath),
|
|
163
|
+
line: 1,
|
|
164
|
+
column: 1,
|
|
165
|
+
kind: 'mismatch',
|
|
166
|
+
code: 'typescript-configuration',
|
|
167
|
+
message: ts.flattenDiagnosticMessageText(error.messageText, '\n'),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
for (const source of selected) {
|
|
171
|
+
for (const error of [
|
|
172
|
+
...program.getSyntacticDiagnostics(source),
|
|
173
|
+
...program.getSemanticDiagnostics(source),
|
|
174
|
+
]) {
|
|
175
|
+
if (error.category !== ts.DiagnosticCategory.Error)
|
|
176
|
+
continue;
|
|
177
|
+
const position = source.getLineAndCharacterOfPosition(error.start ?? 0);
|
|
178
|
+
diagnostics.push({
|
|
179
|
+
file: resolve(source.fileName),
|
|
180
|
+
line: position.line + 1,
|
|
181
|
+
column: position.character + 1,
|
|
182
|
+
kind: 'mismatch',
|
|
183
|
+
code: 'typescript-error',
|
|
184
|
+
message: ts.flattenDiagnosticMessageText(error.messageText, '\n'),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
program,
|
|
190
|
+
checker,
|
|
191
|
+
types,
|
|
192
|
+
parameterType,
|
|
193
|
+
files,
|
|
194
|
+
selected,
|
|
195
|
+
line,
|
|
196
|
+
project: resolve(configPath),
|
|
197
|
+
diagnostics,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** Resolve actual exported symbols, including aliases and barrel re-exports. */
|
|
201
|
+
export function librarySymbols(project) {
|
|
202
|
+
const found = new Map();
|
|
203
|
+
for (const source of project.program.getSourceFiles()) {
|
|
204
|
+
for (const statement of source.statements) {
|
|
205
|
+
if (!(ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) ||
|
|
206
|
+
!statement.moduleSpecifier ||
|
|
207
|
+
!ts.isStringLiteral(statement.moduleSpecifier))
|
|
208
|
+
continue;
|
|
209
|
+
const moduleName = statement.moduleSpecifier.text;
|
|
210
|
+
if (!['@kequtech/tenon-orm', '@kequtech/pgclear'].includes(moduleName))
|
|
211
|
+
continue;
|
|
212
|
+
const module = project.checker.getSymbolAtLocation(statement.moduleSpecifier);
|
|
213
|
+
if (!module)
|
|
214
|
+
continue;
|
|
215
|
+
for (const exported of project.checker.getExportsOfModule(module)) {
|
|
216
|
+
const target = exported.flags & ts.SymbolFlags.Alias
|
|
217
|
+
? project.checker.getAliasedSymbol(exported)
|
|
218
|
+
: exported;
|
|
219
|
+
found.set(`${moduleName}:${exported.name}`, target);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return found;
|
|
224
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { type Project } from './project.js';
|
|
3
|
+
import type { CheckDiagnostic } from './types.js';
|
|
4
|
+
export declare const variantLimit = 16;
|
|
5
|
+
type QueryNode = ts.TaggedTemplateExpression | ts.CallExpression;
|
|
6
|
+
export interface Binding {
|
|
7
|
+
readonly node: ts.Expression;
|
|
8
|
+
readonly type: ts.Type;
|
|
9
|
+
readonly field?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface QueryVariant {
|
|
12
|
+
readonly text: string;
|
|
13
|
+
readonly parameters: readonly Binding[];
|
|
14
|
+
readonly parameterTypes: readonly number[];
|
|
15
|
+
readonly uncertainParameters: readonly number[];
|
|
16
|
+
}
|
|
17
|
+
export declare class Unsupported extends Error {
|
|
18
|
+
readonly node: ts.Node;
|
|
19
|
+
readonly code: string;
|
|
20
|
+
constructor(node: ts.Node, code: string, message: string);
|
|
21
|
+
}
|
|
22
|
+
/** A bounded interpreter for SQL structure, never for application values or function bodies. */
|
|
23
|
+
export declare class SourceAnalysis {
|
|
24
|
+
readonly project: Project;
|
|
25
|
+
private readonly libraries;
|
|
26
|
+
readonly issues: CheckDiagnostic[];
|
|
27
|
+
private readonly immutableArrays;
|
|
28
|
+
private readonly stableParameters;
|
|
29
|
+
private readonly unstableConditions;
|
|
30
|
+
constructor(project: Project, libraries: ReadonlyMap<string, ts.Symbol>);
|
|
31
|
+
role(input: ts.Expression, depth?: number): string | undefined;
|
|
32
|
+
query(node: ts.Node): node is QueryNode;
|
|
33
|
+
discover(source: ts.SourceFile): QueryNode[];
|
|
34
|
+
private fail;
|
|
35
|
+
private reference;
|
|
36
|
+
private text;
|
|
37
|
+
private array;
|
|
38
|
+
private closedReference;
|
|
39
|
+
private combine;
|
|
40
|
+
private conditional;
|
|
41
|
+
private template;
|
|
42
|
+
private mutation;
|
|
43
|
+
private containsUndefined;
|
|
44
|
+
private expression;
|
|
45
|
+
private codecOid;
|
|
46
|
+
private encoderOid;
|
|
47
|
+
private hints;
|
|
48
|
+
recover(node: QueryNode): QueryVariant[];
|
|
49
|
+
private withHints;
|
|
50
|
+
}
|
|
51
|
+
export type { QueryNode };
|