@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/dist/check.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { defaultCodecs } from '@kequtech/pgclear';
|
|
2
|
+
import { compareContracts } from './check/contracts.js';
|
|
3
|
+
import { librarySymbols, loadProject, location } from './check/project.js';
|
|
4
|
+
import { SourceAnalysis } from './check/source.js';
|
|
5
|
+
import { readStatementMetadata } from './inspection/catalog.js';
|
|
6
|
+
import { withInspectionConnection } from './inspection/connection.js';
|
|
7
|
+
import { queryDescription } from './inspection/result.js';
|
|
8
|
+
import { plainObject } from './validation.js';
|
|
9
|
+
function distinct(diagnostics) {
|
|
10
|
+
return [
|
|
11
|
+
...new Map(diagnostics.map((diagnostic) => [JSON.stringify(diagnostic), diagnostic])).values(),
|
|
12
|
+
];
|
|
13
|
+
}
|
|
14
|
+
/** Analyse authored source without importing the application, binding values or executing its SQL. */
|
|
15
|
+
export async function checkQueries(options) {
|
|
16
|
+
for (const name of Object.keys(plainObject(options, 'Check options'))) {
|
|
17
|
+
if (!['connection', 'target', 'project', 'session', 'timeouts', 'codecs', 'readTypes'].includes(name))
|
|
18
|
+
throw new Error(`Unknown check option: ${name}`);
|
|
19
|
+
}
|
|
20
|
+
const project = loadProject(options);
|
|
21
|
+
const libraries = librarySymbols(project);
|
|
22
|
+
const diagnostics = [...project.diagnostics];
|
|
23
|
+
const queries = project.selected.flatMap((source) => new SourceAnalysis(project, libraries).discover(source));
|
|
24
|
+
const selected = project.line === undefined
|
|
25
|
+
? queries
|
|
26
|
+
: queries.filter((query) => {
|
|
27
|
+
const source = query.getSourceFile();
|
|
28
|
+
const start = source.getLineAndCharacterOfPosition(query.getStart(source)).line + 1;
|
|
29
|
+
const end = source.getLineAndCharacterOfPosition(query.end).line + 1;
|
|
30
|
+
// Include a variable declaration's first line when the template starts on its next line.
|
|
31
|
+
const declarationStart = query.parent.getSourceFile() === source
|
|
32
|
+
? source.getLineAndCharacterOfPosition(query.parent.getStart(source)).line + 1
|
|
33
|
+
: start;
|
|
34
|
+
return project.line >= Math.min(start, declarationStart) && project.line <= end;
|
|
35
|
+
});
|
|
36
|
+
if (!selected.length || (project.line !== undefined && selected.length > 1)) {
|
|
37
|
+
diagnostics.push({
|
|
38
|
+
file: project.selected[0]?.fileName ?? project.project,
|
|
39
|
+
line: project.line ?? 1,
|
|
40
|
+
column: 1,
|
|
41
|
+
kind: 'unchecked',
|
|
42
|
+
code: selected.length ? 'ambiguous-query-target' : 'no-query-sites',
|
|
43
|
+
message: selected.length
|
|
44
|
+
? 'Several queries occupy this line. Put their SQL tags on distinct lines.'
|
|
45
|
+
: 'No recognized complete Tenon query was found at the selected target.',
|
|
46
|
+
});
|
|
47
|
+
return { project: project.project, ok: false, queries: [], diagnostics };
|
|
48
|
+
}
|
|
49
|
+
const recovered = selected.map((query) => {
|
|
50
|
+
const analysis = new SourceAnalysis(project, libraries);
|
|
51
|
+
const variants = analysis.recover(query);
|
|
52
|
+
return { query, variants, diagnostics: analysis.issues };
|
|
53
|
+
});
|
|
54
|
+
const reports = [];
|
|
55
|
+
const codecs = options.codecs ?? defaultCodecs;
|
|
56
|
+
const readTypes = { ...options.readTypes };
|
|
57
|
+
for (const oid of Object.keys(readTypes)) {
|
|
58
|
+
if (codecs.typescript(Number(oid)) !== undefined)
|
|
59
|
+
throw new Error(`readTypes may only supply an unresolved custom decoder representation (OID ${oid} already has a known representation).`);
|
|
60
|
+
}
|
|
61
|
+
const finish = () => {
|
|
62
|
+
const all = distinct([...diagnostics, ...reports.flatMap((report) => report.diagnostics)]);
|
|
63
|
+
return {
|
|
64
|
+
project: project.project,
|
|
65
|
+
ok: reports.length > 0 && !all.some((diagnostic) => diagnostic.kind !== 'unproven'),
|
|
66
|
+
queries: reports,
|
|
67
|
+
diagnostics: all,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
if (!recovered.some(({ variants }) => variants.length)) {
|
|
71
|
+
for (const entry of recovered)
|
|
72
|
+
reports.push({
|
|
73
|
+
...location(entry.query),
|
|
74
|
+
status: 'unchecked',
|
|
75
|
+
variants: 0,
|
|
76
|
+
diagnostics: distinct(entry.diagnostics),
|
|
77
|
+
});
|
|
78
|
+
return finish();
|
|
79
|
+
}
|
|
80
|
+
let context;
|
|
81
|
+
try {
|
|
82
|
+
context = await withInspectionConnection(options, async (session, context) => {
|
|
83
|
+
for (const entry of recovered) {
|
|
84
|
+
const findings = [...entry.diagnostics];
|
|
85
|
+
let described = 0;
|
|
86
|
+
for (const [index, variant] of entry.variants.entries()) {
|
|
87
|
+
try {
|
|
88
|
+
const metadata = await readStatementMetadata(session, {
|
|
89
|
+
text: variant.text,
|
|
90
|
+
parameterTypes: variant.parameterTypes,
|
|
91
|
+
});
|
|
92
|
+
const description = queryDescription(metadata, context, codecs);
|
|
93
|
+
described++;
|
|
94
|
+
findings.push(...compareContracts(project, entry.query, variant, description, readTypes).map((diagnostic) => variant.uncertainParameters.length && diagnostic.kind === 'mismatch'
|
|
95
|
+
? {
|
|
96
|
+
...diagnostic,
|
|
97
|
+
kind: 'unproven',
|
|
98
|
+
code: 'encoder-dependent-result-unproven',
|
|
99
|
+
variant: index + 1,
|
|
100
|
+
message: `${diagnostic.message} The actual encoder OID is unknown, so this inferred result cannot contradict the declared type.`,
|
|
101
|
+
}
|
|
102
|
+
: { ...diagnostic, variant: index + 1 }));
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
|
|
106
|
+
const sqlError = typeof code === 'string' && code.startsWith('42') && code !== '42501';
|
|
107
|
+
const uncertain = sqlError &&
|
|
108
|
+
variant.uncertainParameters.length > 0 &&
|
|
109
|
+
!['42601', '42P01'].includes(String(code));
|
|
110
|
+
findings.push({
|
|
111
|
+
...location(entry.query),
|
|
112
|
+
kind: uncertain ? 'unproven' : sqlError ? 'mismatch' : 'unchecked',
|
|
113
|
+
code: typeof code === 'string' ? code : 'describe-failed',
|
|
114
|
+
variant: index + 1,
|
|
115
|
+
message: `${error instanceof Error ? error.message : 'Describe failed.'}${uncertain ? ' The actual encoder OID is unknown; this inference failure does not disprove the declared contract.' : ''}`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
reports.push({
|
|
120
|
+
...location(entry.query),
|
|
121
|
+
status: findings.some((finding) => finding.kind === 'unchecked')
|
|
122
|
+
? 'unchecked'
|
|
123
|
+
: 'analysed',
|
|
124
|
+
variants: described,
|
|
125
|
+
diagnostics: distinct(findings),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return context;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
for (const entry of recovered.slice(reports.length))
|
|
133
|
+
reports.push({
|
|
134
|
+
...location(entry.query),
|
|
135
|
+
status: 'unchecked',
|
|
136
|
+
variants: 0,
|
|
137
|
+
diagnostics: entry.diagnostics,
|
|
138
|
+
});
|
|
139
|
+
diagnostics.push({
|
|
140
|
+
...location(selected[0]),
|
|
141
|
+
kind: 'unchecked',
|
|
142
|
+
code: 'inspection-failed',
|
|
143
|
+
message: error instanceof Error ? error.message : 'Inspection connection failed.',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { ...finish(), ...(context ? { context } : {}) };
|
|
147
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { parseArgs } from 'node:util';
|
|
|
3
3
|
import { adoptBaseline, captureBaseline } from './baseline.js';
|
|
4
4
|
import { generateDefinitions } from './generate.js';
|
|
5
5
|
import { writeDefinitions } from './generation/output.js';
|
|
6
|
-
import { describeFlags, runDescribe } from './inspection/cli.js';
|
|
6
|
+
import { describeFlags, inspectionSettings, runDescribe } from './inspection/cli.js';
|
|
7
7
|
import { migrate, migrationStatus, rollback } from './migrations.js';
|
|
8
8
|
import { createMigration } from './migrations/files.js';
|
|
9
9
|
const help = `tenon migrate|status|rollback [--directory migrations] [--namespace public]
|
|
@@ -12,8 +12,10 @@ baseline capture: [--schema name ...] (default: whole database schema)
|
|
|
12
12
|
tenon new <name> [--directory migrations] (no database required)
|
|
13
13
|
tenon generate [--namespace public] [--out src/generated-schema.ts] [--check]
|
|
14
14
|
tenon describe <query.sql> [--role name] [--search-path schemas] [--time-zone zone]
|
|
15
|
+
tenon check [file.ts[:line]] [--project tsconfig.json] [--json]
|
|
15
16
|
describe: [--connect-timeout-ms 3000] [--statement-timeout-ms 5000] [--lock-timeout-ms 1000]
|
|
16
17
|
describe emits JSON metadata; it does not execute the query or verify result annotations.
|
|
18
|
+
check compares source contracts; mismatches, unchecked sites and no sites fail. Unproven types are notes.
|
|
17
19
|
rollback: --steps 1
|
|
18
20
|
Connection: DATABASE_URL
|
|
19
21
|
`;
|
|
@@ -44,6 +46,8 @@ async function main() {
|
|
|
44
46
|
check: { type: 'boolean', default: false },
|
|
45
47
|
steps: { type: 'string', default: '1' },
|
|
46
48
|
help: { type: 'boolean', default: false },
|
|
49
|
+
project: { type: 'string' },
|
|
50
|
+
json: { type: 'boolean', default: false },
|
|
47
51
|
...describeFlags,
|
|
48
52
|
},
|
|
49
53
|
});
|
|
@@ -55,9 +59,9 @@ async function main() {
|
|
|
55
59
|
// Validate explicitly supplied flags, not defaults. Silently ignoring --check
|
|
56
60
|
// on migrate, for example, would turn an intended dry run into a schema change.
|
|
57
61
|
const allowed = new Set(['help']);
|
|
58
|
-
if (command !== 'generate' && command !== 'describe')
|
|
62
|
+
if (command !== 'generate' && command !== 'describe' && command !== 'check')
|
|
59
63
|
allowed.add('directory');
|
|
60
|
-
if (command !== 'new' && command !== 'describe') {
|
|
64
|
+
if (command !== 'new' && command !== 'describe' && command !== 'check') {
|
|
61
65
|
allowed.add('namespace');
|
|
62
66
|
}
|
|
63
67
|
if (command === 'generate') {
|
|
@@ -70,7 +74,11 @@ async function main() {
|
|
|
70
74
|
if (command === 'baseline' && argument === 'capture') {
|
|
71
75
|
allowed.add('schema');
|
|
72
76
|
}
|
|
73
|
-
if (command === '
|
|
77
|
+
if (command === 'check') {
|
|
78
|
+
allowed.add('project');
|
|
79
|
+
allowed.add('json');
|
|
80
|
+
}
|
|
81
|
+
if (command === 'describe' || command === 'check') {
|
|
74
82
|
for (const name of Object.keys(describeFlags))
|
|
75
83
|
allowed.add(name);
|
|
76
84
|
}
|
|
@@ -88,12 +96,14 @@ async function main() {
|
|
|
88
96
|
return;
|
|
89
97
|
}
|
|
90
98
|
const baseline = command === 'baseline' && (argument === 'capture' || argument === 'adopt');
|
|
91
|
-
const valid =
|
|
92
|
-
? positionals.length
|
|
93
|
-
:
|
|
94
|
-
|
|
99
|
+
const valid = command === 'check'
|
|
100
|
+
? positionals.length <= 2
|
|
101
|
+
: baseline || command === 'describe'
|
|
102
|
+
? positionals.length === 2
|
|
103
|
+
: positionals.length === 1 &&
|
|
104
|
+
['migrate', 'generate', 'status', 'rollback'].includes(command ?? '');
|
|
95
105
|
if (!valid) {
|
|
96
|
-
throw new Error('Choose new, baseline capture|adopt, migrate, generate, describe <query.sql>, status, or rollback. Use --help for options.');
|
|
106
|
+
throw new Error('Choose new, baseline capture|adopt, migrate, generate, describe <query.sql>, check [file.ts[:line]], status, or rollback. Use --help for options.');
|
|
97
107
|
}
|
|
98
108
|
const connection = process.env.DATABASE_URL;
|
|
99
109
|
if (!connection) {
|
|
@@ -103,6 +113,25 @@ async function main() {
|
|
|
103
113
|
await runDescribe(argument, connection, values);
|
|
104
114
|
return;
|
|
105
115
|
}
|
|
116
|
+
if (command === 'check') {
|
|
117
|
+
const { checkQueries } = await import('./check.js');
|
|
118
|
+
const report = await checkQueries({
|
|
119
|
+
connection,
|
|
120
|
+
...(argument === undefined ? {} : { target: argument }),
|
|
121
|
+
...(values.project === undefined ? {} : { project: values.project }),
|
|
122
|
+
...inspectionSettings(values),
|
|
123
|
+
});
|
|
124
|
+
if (values.json)
|
|
125
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
126
|
+
else {
|
|
127
|
+
for (const diagnostic of report.diagnostics)
|
|
128
|
+
process.stdout.write(`${diagnostic.file}:${diagnostic.line}:${diagnostic.column} [${diagnostic.kind}] ${diagnostic.code}: ${diagnostic.message}\n`);
|
|
129
|
+
process.stdout.write(`${report.queries.length} queries, ${report.queries.filter((query) => query.status === 'analysed').length} analysed, ${report.queries.filter((query) => query.status === 'unchecked').length} unchecked, ${report.queries.reduce((count, query) => count + query.variants, 0)} variants described; ${report.diagnostics.filter((diagnostic) => diagnostic.kind === 'mismatch').length} mismatches, ${report.diagnostics.filter((diagnostic) => diagnostic.kind === 'unproven').length} unproven findings.\n`);
|
|
130
|
+
}
|
|
131
|
+
if (!report.ok)
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
106
135
|
const options = { connection, directory: values.directory, namespace: values.namespace };
|
|
107
136
|
if (baseline) {
|
|
108
137
|
await runBaseline(argument, {
|
package/dist/connection.d.ts
CHANGED
|
@@ -1,19 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type Pool, type PoolOptions, type QueryResult as DriverResult, type Executor } from '@kequtech/pgclear';
|
|
2
2
|
import type { UnknownRow } from './query.js';
|
|
3
|
-
export type ConnectionOptions =
|
|
3
|
+
export type ConnectionOptions = PoolOptions;
|
|
4
4
|
export type Connection = string | ConnectionOptions | Pool;
|
|
5
|
-
export type QueryResult<Row extends object = UnknownRow> =
|
|
5
|
+
export type QueryResult<Row extends object = UnknownRow> = DriverResult<Row>;
|
|
6
6
|
export type QueryExecutor = <T>(text: string, input: readonly unknown[], readResult: (result: QueryResult) => T, guarded: boolean) => Promise<T>;
|
|
7
|
-
/**
|
|
7
|
+
/** Constructed pools are owned; supplied public pgclear pools remain application-owned. */
|
|
8
8
|
export declare function createConnection(connection: Connection): {
|
|
9
9
|
pool: Pool;
|
|
10
|
-
run<T>(work: () => Promise<T>): Promise<T>;
|
|
11
10
|
close(timeoutMs: number): Promise<void>;
|
|
12
11
|
};
|
|
13
|
-
|
|
14
|
-
export declare function watchClient(client: PoolClient): {
|
|
15
|
-
check(): void;
|
|
16
|
-
release(error?: Error): void;
|
|
17
|
-
};
|
|
18
|
-
/** Unnamed extended protocol enforces a single statement, even without parameters. */
|
|
19
|
-
export declare function executeSql(connection: Pool | PoolClient, text: string, input?: readonly unknown[]): Promise<PgResult<UnknownRow>>;
|
|
12
|
+
export declare function executeSql(connection: Executor, text: string, input?: readonly unknown[]): Promise<QueryResult>;
|
package/dist/connection.js
CHANGED
|
@@ -1,132 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createPool, } from '@kequtech/pgclear';
|
|
2
2
|
import { plainObject } from './validation.js';
|
|
3
|
-
/** A supplied pool may come from another installed copy of pg. */
|
|
4
3
|
function isPool(connection) {
|
|
5
4
|
return (connection !== null &&
|
|
6
5
|
typeof connection === 'object' &&
|
|
7
|
-
'
|
|
8
|
-
typeof connection.
|
|
9
|
-
'
|
|
10
|
-
typeof connection.
|
|
11
|
-
'
|
|
12
|
-
typeof connection.totalCount === 'number' &&
|
|
13
|
-
'end' in connection &&
|
|
14
|
-
typeof connection.end === 'function');
|
|
6
|
+
'acquire' in connection &&
|
|
7
|
+
typeof connection.acquire === 'function' &&
|
|
8
|
+
'close' in connection &&
|
|
9
|
+
typeof connection.close === 'function' &&
|
|
10
|
+
'stats' in connection);
|
|
15
11
|
}
|
|
16
|
-
/**
|
|
12
|
+
/** Constructed pools are owned; supplied public pgclear pools remain application-owned. */
|
|
17
13
|
export function createConnection(connection) {
|
|
18
14
|
if (isPool(connection))
|
|
19
|
-
return { pool: connection,
|
|
20
|
-
|
|
21
|
-
if (typeof connection === 'string') {
|
|
22
|
-
if (!connection.trim())
|
|
23
|
-
throw new Error('Connection string must not be empty.');
|
|
24
|
-
config = { connectionString: connection };
|
|
25
|
-
}
|
|
26
|
-
else {
|
|
15
|
+
return { pool: connection, close: async () => { } };
|
|
16
|
+
if (typeof connection !== 'string')
|
|
27
17
|
plainObject(connection, 'Connection configuration');
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
config = connection;
|
|
32
|
-
}
|
|
33
|
-
const pool = new Pool(config);
|
|
34
|
-
const clients = new Set();
|
|
35
|
-
const pending = new Map();
|
|
36
|
-
let terminating = false;
|
|
37
|
-
const terminate = (client) => {
|
|
38
|
-
void client.end().catch(() => { });
|
|
39
|
-
};
|
|
40
|
-
// pool.end handles idle clients; the deadline also ends native checkouts.
|
|
41
|
-
pool.on('acquire', (client) => {
|
|
42
|
-
clients.add(client);
|
|
43
|
-
if (terminating)
|
|
44
|
-
terminate(client);
|
|
45
|
-
});
|
|
46
|
-
pool.on('release', (_error, client) => clients.delete(client));
|
|
47
|
-
pool.on('error', (error) => process.emitWarning(error));
|
|
48
|
-
return {
|
|
49
|
-
pool,
|
|
50
|
-
run(work) {
|
|
51
|
-
let expire;
|
|
52
|
-
const task = new Promise((resolve, reject) => {
|
|
53
|
-
expire = reject;
|
|
54
|
-
void Promise.resolve().then(work).then(resolve, reject);
|
|
55
|
-
});
|
|
56
|
-
pending.set(task, expire);
|
|
57
|
-
const forget = () => {
|
|
58
|
-
pending.delete(task);
|
|
59
|
-
};
|
|
60
|
-
void task.then(forget, forget);
|
|
61
|
-
return task;
|
|
62
|
-
},
|
|
63
|
-
async close(timeoutMs) {
|
|
64
|
-
let timer;
|
|
65
|
-
let ending;
|
|
66
|
-
const end = () => (ending ??= pool.end());
|
|
67
|
-
try {
|
|
68
|
-
await Promise.race([
|
|
69
|
-
(async () => {
|
|
70
|
-
await Promise.allSettled([...pending.keys()]);
|
|
71
|
-
await end();
|
|
72
|
-
})(),
|
|
73
|
-
new Promise((resolve) => {
|
|
74
|
-
timer = setTimeout(resolve, timeoutMs);
|
|
75
|
-
}).then(async () => {
|
|
76
|
-
// Native callbacks must reject close on failure, not escape a timer callback.
|
|
77
|
-
terminating = true;
|
|
78
|
-
const error = new Error('The database shutdown deadline expired.');
|
|
79
|
-
for (const reject of pending.values())
|
|
80
|
-
reject(error);
|
|
81
|
-
try {
|
|
82
|
-
void end().catch(() => { });
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
// Attempt every checkout even when one native end call throws.
|
|
86
|
-
await Promise.all([...clients].map(async (client) => terminate(client)));
|
|
87
|
-
}
|
|
88
|
-
}),
|
|
89
|
-
]);
|
|
90
|
-
}
|
|
91
|
-
finally {
|
|
92
|
-
clearTimeout(timer);
|
|
93
|
-
}
|
|
94
|
-
},
|
|
95
|
-
};
|
|
18
|
+
const pool = createPool(connection);
|
|
19
|
+
return { pool, close: (timeoutMs) => pool.close({ timeoutMs }) };
|
|
96
20
|
}
|
|
97
|
-
/** A checked-out pg client remains tied to its session and is discarded after a failure. */
|
|
98
|
-
export function watchClient(client) {
|
|
99
|
-
let failure;
|
|
100
|
-
const onError = (error) => {
|
|
101
|
-
failure ??= error;
|
|
102
|
-
};
|
|
103
|
-
const onEnd = () => {
|
|
104
|
-
failure ??= new Error('The PostgreSQL connection closed.');
|
|
105
|
-
};
|
|
106
|
-
client.on('error', onError);
|
|
107
|
-
client.on('end', onEnd);
|
|
108
|
-
return {
|
|
109
|
-
check() {
|
|
110
|
-
if (failure)
|
|
111
|
-
throw failure;
|
|
112
|
-
},
|
|
113
|
-
release(error) {
|
|
114
|
-
try {
|
|
115
|
-
client.release(error ?? failure);
|
|
116
|
-
}
|
|
117
|
-
finally {
|
|
118
|
-
client.removeListener('error', onError);
|
|
119
|
-
client.removeListener('end', onEnd);
|
|
120
|
-
}
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
/** Unnamed extended protocol enforces a single statement, even without parameters. */
|
|
125
21
|
export function executeSql(connection, text, input = []) {
|
|
126
|
-
|
|
127
|
-
text,
|
|
128
|
-
values: [...input],
|
|
129
|
-
queryMode: 'extended',
|
|
130
|
-
};
|
|
131
|
-
return connection.query(config);
|
|
22
|
+
return connection.queryClassic(text, input);
|
|
132
23
|
}
|
package/dist/database.js
CHANGED
|
@@ -26,7 +26,7 @@ export function createTenon(options) {
|
|
|
26
26
|
if (options.onQuery !== undefined && typeof options.onQuery !== 'function') {
|
|
27
27
|
throw new Error('onQuery must be a function.');
|
|
28
28
|
}
|
|
29
|
-
const { pool,
|
|
29
|
+
const { pool, close } = createConnection(options.connection);
|
|
30
30
|
let closing;
|
|
31
31
|
function checkOpen() {
|
|
32
32
|
if (closing) {
|
|
@@ -35,19 +35,16 @@ export function createTenon(options) {
|
|
|
35
35
|
}
|
|
36
36
|
const query = (text, input, readResult, guarded) => {
|
|
37
37
|
checkOpen();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
return readResult(await executeSql(pool, text, input));
|
|
43
|
-
});
|
|
38
|
+
if (guarded)
|
|
39
|
+
return withTransaction(pool, (query) => query(text, input, readResult, false));
|
|
40
|
+
return executeSql(pool, text, input).then(readResult);
|
|
44
41
|
};
|
|
45
42
|
return {
|
|
46
43
|
...createClient(query, options.onQuery),
|
|
47
44
|
native: pool,
|
|
48
45
|
async transaction(callback) {
|
|
49
46
|
checkOpen();
|
|
50
|
-
return
|
|
47
|
+
return withTransaction(pool, (query) => callback(createClient(query, options.onQuery)));
|
|
51
48
|
},
|
|
52
49
|
close(options) {
|
|
53
50
|
if (closing)
|
package/dist/describe.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defaultCodecs } from '@kequtech/pgclear';
|
|
1
2
|
import { readStatementMetadata } from './inspection/catalog.js';
|
|
2
3
|
import { withInspectionConnection } from './inspection/connection.js';
|
|
3
4
|
import { queryDescription } from './inspection/result.js';
|
|
@@ -6,7 +7,7 @@ import { plainObject } from './validation.js';
|
|
|
6
7
|
export async function describeQuery(options) {
|
|
7
8
|
const input = plainObject(options, 'Describe options');
|
|
8
9
|
for (const key of Object.keys(input)) {
|
|
9
|
-
if (!['connection', 'query', 'session', 'timeouts'].includes(key)) {
|
|
10
|
+
if (!['connection', 'query', 'session', 'timeouts', 'codecs'].includes(key)) {
|
|
10
11
|
throw new Error(`Unknown Describe option: ${key}`);
|
|
11
12
|
}
|
|
12
13
|
}
|
|
@@ -16,6 +17,12 @@ export async function describeQuery(options) {
|
|
|
16
17
|
if (typeof text !== 'string' || !text.trim()) {
|
|
17
18
|
throw new Error('Describe query requires nonempty SQL text or a query object with text.');
|
|
18
19
|
}
|
|
19
|
-
const query =
|
|
20
|
-
|
|
20
|
+
const query = {
|
|
21
|
+
text,
|
|
22
|
+
...(typeof options.query === 'object' && options.query.parameterTypes !== undefined
|
|
23
|
+
? { parameterTypes: [...options.query.parameterTypes] }
|
|
24
|
+
: {}),
|
|
25
|
+
};
|
|
26
|
+
const codecs = options.codecs ?? defaultCodecs;
|
|
27
|
+
return withInspectionConnection(options, async (client, context) => queryDescription(await readStatementMetadata(client, query), context, codecs));
|
|
21
28
|
}
|
package/dist/generate.d.ts
CHANGED
|
@@ -2,8 +2,7 @@ import type { Connection } from './connection.js';
|
|
|
2
2
|
export interface GenerateOptions {
|
|
3
3
|
readonly connection: Connection;
|
|
4
4
|
readonly namespace?: string;
|
|
5
|
-
|
|
6
|
-
readonly onWarning?: (message: string) => void;
|
|
5
|
+
readonly onWarning?: (message: string) => void | Promise<void>;
|
|
7
6
|
}
|
|
8
|
-
/** Read
|
|
7
|
+
/** Read one schema snapshot with default catalog codecs and the selected application read mappings. */
|
|
9
8
|
export declare function generateDefinitions(options: GenerateOptions): Promise<string>;
|
package/dist/generate.js
CHANGED
|
@@ -1,34 +1,36 @@
|
|
|
1
|
+
import { defaultCodecs } from '@kequtech/pgclear';
|
|
1
2
|
import { readTables } from './generation/catalog.js';
|
|
2
|
-
import {
|
|
3
|
+
import { renderDatabase } from './generation/render.js';
|
|
3
4
|
import { domainSummaries, schemaSummary, tableSummary } from './generation/schema-summary.js';
|
|
4
5
|
import { withMigrationLock } from './migrations/connection.js';
|
|
6
|
+
import { migrationTable } from './migrations/history.js';
|
|
5
7
|
import { quote } from './sql.js';
|
|
6
|
-
/** Read
|
|
8
|
+
/** Read one schema snapshot with default catalog codecs and the selected application read mappings. */
|
|
7
9
|
export async function generateDefinitions(options) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
if (options.onWarning !== undefined && typeof options.onWarning !== 'function') {
|
|
11
|
+
throw new Error('onWarning must be a function.');
|
|
12
|
+
}
|
|
13
|
+
const warn = options.onWarning ?? ((message) => console.warn('Warning: ' + message));
|
|
14
|
+
return withMigrationLock(options, 'shared', (client, namespace) => client.transaction(async (transaction) => {
|
|
15
|
+
const catalog = {
|
|
16
|
+
query: (text, values = []) => transaction.queryClassic(text, values, { codecs: defaultCodecs }),
|
|
17
|
+
};
|
|
18
|
+
const schema = await catalog.query('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [namespace]);
|
|
14
19
|
if (!schema.rows.length)
|
|
15
20
|
throw new Error(`Schema ${quote(namespace)} does not exist.`);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
await
|
|
19
|
-
await
|
|
20
|
-
await client.query('SET LOCAL standard_conforming_strings TO on');
|
|
21
|
-
const definitions = await readTables(client, namespace);
|
|
21
|
+
await catalog.query(`SET LOCAL search_path TO pg_catalog, ${quote(namespace)}`);
|
|
22
|
+
await catalog.query('SET LOCAL quote_all_identifiers TO off');
|
|
23
|
+
await catalog.query('SET LOCAL standard_conforming_strings TO on');
|
|
24
|
+
const tables = await readTables(catalog, namespace, [migrationTable]);
|
|
22
25
|
const summaries = [];
|
|
23
|
-
for (const table of
|
|
24
|
-
summaries.push(await tableSummary(
|
|
26
|
+
for (const table of tables) {
|
|
27
|
+
summaries.push(await tableSummary(catalog, table.oid, table.name));
|
|
25
28
|
}
|
|
26
|
-
const domains = await domainSummaries(
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
});
|
|
29
|
+
const domains = await domainSummaries(catalog, tables.flatMap((table) => table.columns.map((column) => column.declaredOid)));
|
|
30
|
+
const warnings = [];
|
|
31
|
+
const types = renderDatabase(tables, namespace, transaction.codecs, (message) => warnings.push(message));
|
|
32
|
+
for (const warning of warnings)
|
|
33
|
+
await warn(warning);
|
|
34
|
+
return `// Generated by Tenon from the PostgreSQL schema. Do not edit.\n\n${schemaSummary([...domains, ...summaries])}${types}`;
|
|
35
|
+
}, { isolation: 'repeatable read', readOnly: true }));
|
|
34
36
|
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type InputValue, type QueryResult, type Row } from '@kequtech/pgclear';
|
|
2
|
+
export interface CatalogClient {
|
|
3
|
+
query<T extends object = Row>(text: string, values?: readonly InputValue[]): Promise<QueryResult<T>>;
|
|
4
|
+
}
|
|
2
5
|
export interface ColumnRow {
|
|
3
6
|
name: string;
|
|
4
7
|
oid: string;
|
|
5
|
-
type: string;
|
|
6
|
-
typeSchema: string;
|
|
7
8
|
typeKind: string;
|
|
8
9
|
declaredOid: string;
|
|
9
|
-
declaredType: string;
|
|
10
|
-
declaredSchema: string;
|
|
11
10
|
nullable: boolean;
|
|
12
11
|
writeNullable: boolean;
|
|
13
12
|
hasDefault: boolean;
|
|
@@ -19,4 +18,4 @@ export interface TableRow {
|
|
|
19
18
|
name: string;
|
|
20
19
|
columns: ColumnRow[];
|
|
21
20
|
}
|
|
22
|
-
export declare function readTables(client:
|
|
21
|
+
export declare function readTables(client: CatalogClient, namespace: string, exclude: readonly string[]): Promise<TableRow[]>;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export async function readTables(client, namespace) {
|
|
1
|
+
import { builtin, param } from '@kequtech/pgclear';
|
|
2
|
+
export async function readTables(client, namespace, exclude) {
|
|
3
3
|
const tables = await client.query(`
|
|
4
4
|
SELECT c.oid::text AS oid, c.relname AS name
|
|
5
5
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
6
|
-
WHERE n.nspname = $1 AND c.relname
|
|
6
|
+
WHERE n.nspname = $1 AND NOT (c.relname = ANY($2::text[])) AND c.relkind IN ('r', 'p')
|
|
7
7
|
AND NOT c.relispartition
|
|
8
|
-
ORDER BY c.relname COLLATE "C"`, [namespace,
|
|
8
|
+
ORDER BY c.relname COLLATE "C"`, [namespace, param(exclude, builtin.textArray)]);
|
|
9
9
|
const definitions = [];
|
|
10
10
|
for (const table of tables.rows) {
|
|
11
11
|
// Attached descendants are storage details, not separate generated models.
|
|
@@ -18,8 +18,8 @@ export async function readTables(client, namespace) {
|
|
|
18
18
|
SELECT r.attnum, t.oid, t.typbasetype, r.domain_not_null OR t.typnotnull
|
|
19
19
|
FROM resolved r JOIN pg_catalog.pg_type t ON t.oid = r.typbasetype
|
|
20
20
|
)
|
|
21
|
-
SELECT a.attname AS name, t.oid::text AS oid, t.
|
|
22
|
-
declared.oid::text AS "declaredOid",
|
|
21
|
+
SELECT a.attname AS name, t.oid::text AS oid, t.typtype AS "typeKind",
|
|
22
|
+
declared.oid::text AS "declaredOid",
|
|
23
23
|
NOT a.attnotnull AS nullable,
|
|
24
24
|
NOT (a.attnotnull OR r.domain_not_null) AS "writeNullable",
|
|
25
25
|
(a.atthasdef OR declared.typdefaultbin IS NOT NULL OR a.attidentity <> '') AS "hasDefault",
|
|
@@ -28,11 +28,9 @@ export async function readTables(client, namespace) {
|
|
|
28
28
|
FROM pg_catalog.pg_attribute a
|
|
29
29
|
JOIN resolved r ON r.attnum = a.attnum AND r.typbasetype = 0
|
|
30
30
|
JOIN pg_catalog.pg_type t ON t.oid = r.oid
|
|
31
|
-
JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace
|
|
32
31
|
JOIN pg_catalog.pg_type declared ON declared.oid = a.atttypid
|
|
33
|
-
JOIN pg_catalog.pg_namespace dn ON dn.oid = declared.typnamespace
|
|
34
32
|
WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, [table.oid]);
|
|
35
|
-
definitions.push({ ...table, columns: columns.rows });
|
|
33
|
+
definitions.push({ ...table, columns: [...columns.rows] });
|
|
36
34
|
}
|
|
37
35
|
return definitions;
|
|
38
36
|
}
|