@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +27 -5
  3. package/dist/affected-rows.js +0 -1
  4. package/dist/baseline.js +3 -5
  5. package/dist/check/contracts.d.ts +6 -0
  6. package/dist/check/contracts.js +206 -0
  7. package/dist/check/project.d.ts +18 -0
  8. package/dist/check/project.js +224 -0
  9. package/dist/check/source.d.ts +51 -0
  10. package/dist/check/source.js +629 -0
  11. package/dist/check/types.d.ts +36 -0
  12. package/dist/check/types.js +1 -0
  13. package/dist/check.d.ts +4 -0
  14. package/dist/check.js +147 -0
  15. package/dist/cli.js +38 -9
  16. package/dist/connection.d.ts +5 -12
  17. package/dist/connection.js +12 -121
  18. package/dist/database.js +5 -8
  19. package/dist/describe.js +10 -3
  20. package/dist/generate.d.ts +2 -3
  21. package/dist/generate.js +26 -24
  22. package/dist/generation/catalog.d.ts +5 -6
  23. package/dist/generation/catalog.js +7 -9
  24. package/dist/generation/render.d.ts +3 -0
  25. package/dist/generation/render.js +60 -0
  26. package/dist/generation/schema-summary.d.ts +3 -3
  27. package/dist/generation/schema-summary.js +2 -2
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.js +2 -0
  30. package/dist/inspection/catalog.d.ts +3 -6
  31. package/dist/inspection/catalog.js +9 -18
  32. package/dist/inspection/cli.d.ts +12 -0
  33. package/dist/inspection/cli.js +9 -4
  34. package/dist/inspection/connection.d.ts +2 -3
  35. package/dist/inspection/connection.js +20 -22
  36. package/dist/inspection/options.js +0 -6
  37. package/dist/inspection/result.d.ts +3 -2
  38. package/dist/inspection/result.js +13 -26
  39. package/dist/inspection/types.d.ts +6 -4
  40. package/dist/migrations/connection.d.ts +3 -3
  41. package/dist/migrations/connection.js +14 -21
  42. package/dist/migrations/history.d.ts +4 -4
  43. package/dist/migrations/history.js +5 -5
  44. package/dist/migrations.js +7 -9
  45. package/dist/query-options.d.ts +1 -1
  46. package/dist/query.js +1 -1
  47. package/dist/transaction.d.ts +2 -2
  48. package/dist/transaction.js +21 -77
  49. package/dist/types.d.ts +1 -1
  50. package/docs/adoption-guide.md +64 -12
  51. package/docs/agent-guide.md +57 -27
  52. package/docs/human-guide.md +53 -18
  53. package/docs/mutations.md +22 -9
  54. package/docs/query-checking.md +200 -0
  55. package/docs/query-inspection.md +40 -13
  56. package/docs/reference.md +330 -108
  57. package/package.json +11 -7
  58. package/dist/driver/types.d.ts +0 -10
  59. package/dist/driver/types.js +0 -49
  60. package/dist/generation/table-types.d.ts +0 -2
  61. package/dist/generation/table-types.js +0 -96
  62. package/dist/inspection/protocol.d.ts +0 -7
  63. 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 === 'describe') {
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 = baseline || command === 'describe'
92
- ? positionals.length === 2
93
- : positionals.length === 1 &&
94
- ['migrate', 'generate', 'status', 'rollback'].includes(command ?? '');
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, {
@@ -1,19 +1,12 @@
1
- import { Pool, type PoolClient, type PoolConfig, type QueryResult as PgResult } from 'pg';
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 = PoolConfig;
3
+ export type ConnectionOptions = PoolOptions;
4
4
  export type Connection = string | ConnectionOptions | Pool;
5
- export type QueryResult<Row extends object = UnknownRow> = PgResult<Row>;
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
- /** Own constructed pools; supplied pools retain their application's ownership and error policy. */
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
- /** A checked-out pg client remains tied to its session and is discarded after a failure. */
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>;
@@ -1,132 +1,23 @@
1
- import { Pool, } from 'pg';
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
- 'connect' in connection &&
8
- typeof connection.connect === 'function' &&
9
- 'query' in connection &&
10
- typeof connection.query === 'function' &&
11
- 'totalCount' in connection &&
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
- /** Own constructed pools; supplied pools retain their application's ownership and error policy. */
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, run: (work) => work(), close: async () => { } };
20
- let config;
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
- if ('url' in connection) {
29
- throw new Error('Use pg connectionString in the connection configuration instead of url/options.');
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
- const config = {
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, run, close } = createConnection(options.connection);
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
- return run(async () => {
39
- if (guarded) {
40
- return withTransaction(pool, (query) => query(text, input, readResult, false));
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 run(() => withTransaction(pool, (query) => callback(createClient(query, options.onQuery))));
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 = text;
20
- return withInspectionConnection(options, async (client, context) => queryDescription(await readStatementMetadata(client, query), context));
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
  }
@@ -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
- /** Receives generation warnings; defaults to console.warn. */
6
- readonly onWarning?: (message: string) => void;
5
+ readonly onWarning?: (message: string) => void | Promise<void>;
7
6
  }
8
- /** Read-only introspection. Output needs no runtime imports or schema registration. */
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 { renderTableTypes } from './generation/table-types.js';
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-only introspection. Output needs no runtime imports or schema registration. */
8
+ /** Read one schema snapshot with default catalog codecs and the selected application read mappings. */
7
9
  export async function generateDefinitions(options) {
8
- const onWarning = options.onWarning ?? ((message) => console.warn(`Warning: ${message}`));
9
- return withMigrationLock(options, 'shared', async (client, namespace) => {
10
- await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
11
- const schema = await client.query('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
12
- namespace,
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
- // Keep catalog deparsing stable across connection settings and replay schemas.
17
- // Transaction-local settings are restored before returning a borrowed client.
18
- await client.query(`SET LOCAL search_path TO pg_catalog, ${quote(namespace)}`);
19
- await client.query('SET LOCAL quote_all_identifiers TO off');
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 definitions) {
24
- summaries.push(await tableSummary(client, table.oid, table.name));
26
+ for (const table of tables) {
27
+ summaries.push(await tableSummary(catalog, table.oid, table.name));
25
28
  }
26
- const domains = await domainSummaries(client, definitions.flatMap((table) => table.columns.map((column) => column.declaredOid)));
27
- const types = renderTableTypes(definitions, namespace, onWarning);
28
- await client.query('COMMIT');
29
- return `// Generated by Tenon from the PostgreSQL schema. Do not edit.
30
- // Commit this file and regenerate it after changing the schema.
31
-
32
- ${schemaSummary([...domains, ...summaries])}${types}`;
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 { PoolClient } from 'pg';
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: PoolClient, namespace: string): Promise<TableRow[]>;
21
+ export declare function readTables(client: CatalogClient, namespace: string, exclude: readonly string[]): Promise<TableRow[]>;
@@ -1,11 +1,11 @@
1
- import { migrationTable } from '../migrations/history.js';
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 <> $2 AND c.relkind IN ('r', 'p')
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, migrationTable]);
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.typname AS type, tn.nspname AS "typeSchema", t.typtype AS "typeKind",
22
- declared.oid::text AS "declaredOid", declared.typname AS "declaredType", dn.nspname AS "declaredSchema",
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
  }
@@ -0,0 +1,3 @@
1
+ import { type CodecRegistry } from '@kequtech/pgclear';
2
+ import type { TableRow } from './catalog.js';
3
+ export declare function renderDatabase(tables: readonly TableRow[], namespace: string, codecs: CodecRegistry, warn: (message: string) => void): string;