@kequtech/tenon-orm 0.4.5 → 0.4.7

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 CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.7
4
+
5
+ - Remove routine parameter-value and result-nullability checker notes. General limits are stated
6
+ once; remaining readable findings are grouped per query with their variant numbers. JSON retains
7
+ individual variant findings, without the removed routine notes.
8
+ - Reject enum result declarations whose string literals are disjoint from every catalog label.
9
+ Overlapping refinements and custom decoders retain conservative handling.
10
+ - Recover actual fields and value types from direct mutation object literals and fixed literal
11
+ batches, reducing needless expansion under broad generated shapes. Source checking and runtime
12
+ mutations share SQL assembly; dynamic inputs retain bounded shape analysis.
13
+
14
+ ## 0.4.6
15
+
16
+ - Add `inspectSchema` and `tenon schema` for versioned, codec-independent catalog snapshots and
17
+ SHA-256 fingerprints. Version 1 includes scoped relation/type/sequence definitions and PostgreSQL
18
+ major version; functions, security policy, data and other excluded objects still need independent
19
+ checks. Existing adoption guards are unchanged. See [schema snapshots](docs/schema-inspection.md).
20
+ - Check bounded optional insert/update mutation shapes, including omission, DEFAULT and nullable
21
+ values. Empty runtime-rejected combinations remain explicit notes; unresolved SQL and queries
22
+ beyond the 16-variant budget still fail. Reports include recognized-site coverage counts.
23
+ Constrained generic fields retain their undefined/DEFAULT branches; zero-variant sites remain
24
+ unchecked and fail the report.
25
+ - Inspect default-decoded enum outputs as catalog label unions, including with unrelated codec
26
+ overrides. Source checking proves matching enum output unions while retaining narrower/custom
27
+ refinements and unknown nullability as unproven.
28
+ - Require pgclear `^0.3.4`, adding `builtin.uuidArray`, safe decode-error column/type context, and
29
+ a complete shared-listener ownership example. See [connections](docs/connections.md).
30
+
3
31
  ## 0.4.5
4
32
 
5
33
  - Preserve generated enum and enum-domain label unions when unrelated codecs change, including
package/README.md CHANGED
@@ -74,7 +74,8 @@ version. For work in an existing project, start with the [agent guide](docs/agen
74
74
  - **SQL defines the operation.** Use PostgreSQL joins, aggregates, constraints, and upserts directly.
75
75
  Reuse queries with ordinary functions; use fragments when parts of the SQL need to vary.
76
76
  - **TypeScript describes the values.** Declare query projections with `sql<Row>`. Optionally generate
77
- table read/insert/update types with `tenon generate`.
77
+ table read/insert/update types with `tenon generate`. Capture a versioned
78
+ [schema fingerprint](docs/schema-inspection.md) with `tenon schema` for application-owned guards.
78
79
  - **Transactions define what commits together.** Use `db.transaction(async (tx) => { ... })` and run
79
80
  every participating query through `tx`.
80
81
  - **Write guards check affected rows.** `{ affectedRows: { exact: 1 } }` rolls back a write unless
@@ -1,6 +1,6 @@
1
1
  import type { QueryDescription } from '../inspection/types.js';
2
2
  import { type Project } from './project.js';
3
- import type { QueryNode, QueryVariant } from './source.js';
3
+ import type { QueryNode } from './source.js';
4
4
  import type { CheckDiagnostic } from './types.js';
5
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[];
6
+ export declare function compareContracts(project: Project, query: QueryNode, description: QueryDescription, readTypes: Readonly<Record<number, string>>): CheckDiagnostic[];
@@ -27,6 +27,10 @@ function kinds(project, type, seen = new Set()) {
27
27
  function disjoint(project, actual, expected, depth = 0) {
28
28
  if (depth > 8)
29
29
  return false;
30
+ if (expected.isUnion())
31
+ return expected.types.every((member) => disjoint(project, actual, member, depth + 1));
32
+ if (actual.isStringLiteral() && expected.isStringLiteral())
33
+ return actual.value !== expected.value;
30
34
  const a = kinds(project, actual), b = kinds(project, expected);
31
35
  if (!b.size)
32
36
  return true;
@@ -69,34 +73,10 @@ function rowType(project, query) {
69
73
  : undefined;
70
74
  }
71
75
  /** Compare non-NULL representations; nullability and value refinements remain explicit limitations. */
72
- export function compareContracts(project, query, variant, description, readTypes) {
76
+ export function compareContracts(project, query, description, readTypes) {
73
77
  const checker = project.checker;
74
78
  const at = location(query);
75
79
  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
80
  for (const issue of description.issues) {
101
81
  if (issue.code === 'duplicate-column')
102
82
  diagnostics.push({
@@ -144,13 +124,17 @@ export function compareContracts(project, query, variant, description, readTypes
144
124
  continue; // Structural result types may omit additional selected columns.
145
125
  const representation = readTypes[column.oid] ?? column.readType;
146
126
  const actual = project.types.get(representation);
127
+ const actualTypes = column.enumLabels?.map((label) => checker.getStringLiteralType(label)) ??
128
+ (actual && !(actual.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any))
129
+ ? [actual]
130
+ : undefined);
147
131
  const common = {
148
132
  ...at,
149
133
  resultColumn: column.name,
150
134
  expected: checker.typeToString(expected),
151
135
  observed: representation,
152
136
  };
153
- if (!actual || actual.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any)) {
137
+ if (!actualTypes) {
154
138
  findings.push({
155
139
  ...common,
156
140
  kind: 'unproven',
@@ -168,8 +152,8 @@ export function compareContracts(project, query, variant, description, readTypes
168
152
  message: `Column ${JSON.stringify(column.name)} is declared as any.`,
169
153
  });
170
154
  }
171
- else if (!checker.isTypeAssignableTo(actual, nonnull)) {
172
- const mismatch = disjoint(project, actual, nonnull);
155
+ else if (!actualTypes.every((type) => checker.isTypeAssignableTo(type, nonnull))) {
156
+ const mismatch = actualTypes.every((type) => disjoint(project, type, nonnull));
173
157
  findings.push({
174
158
  ...common,
175
159
  kind: mismatch ? 'mismatch' : 'unproven',
@@ -0,0 +1,3 @@
1
+ import type { CheckReport } from './types.js';
2
+ /** Group presentation only; JSON retains each finding's SQL variant. */
3
+ export declare function formatCheckReport(report: CheckReport): string;
@@ -0,0 +1,31 @@
1
+ function group(diagnostics) {
2
+ const groups = new Map();
3
+ for (const diagnostic of diagnostics) {
4
+ const { variant, ...finding } = diagnostic;
5
+ const key = JSON.stringify(finding);
6
+ let entry = groups.get(key);
7
+ if (!entry) {
8
+ entry = { diagnostic, variants: new Set() };
9
+ groups.set(key, entry);
10
+ }
11
+ if (variant !== undefined)
12
+ entry.variants.add(variant);
13
+ }
14
+ return [...groups.values()];
15
+ }
16
+ /** Group presentation only; JSON retains each finding's SQL variant. */
17
+ export function formatCheckReport(report) {
18
+ const queryFindings = new Set(report.queries.flatMap((query) => query.diagnostics));
19
+ const findings = [
20
+ ...group(report.diagnostics.filter((finding) => !queryFindings.has(finding))),
21
+ ...report.queries.flatMap((query) => group(query.diagnostics)),
22
+ ];
23
+ const lines = findings.map(({ diagnostic: d, variants }) => {
24
+ const scope = variants.size
25
+ ? ` [variant${variants.size === 1 ? '' : 's'} ${[...variants].join(', ')}]`
26
+ : '';
27
+ return `${d.file}:${d.line}:${d.column} [${d.kind}] ${d.code}${scope}: ${d.message}`;
28
+ });
29
+ lines.push(`${report.coverage.total} queries, ${report.coverage.analysed} analysed, ${report.coverage.unchecked} unchecked; ${findings.filter(({ diagnostic }) => diagnostic.kind === 'mismatch').length} definite mismatches, ${findings.filter(({ diagnostic }) => diagnostic.kind === 'unproven').length} unproven findings.`, 'Checks exclude result nullability and runtime value validation.');
30
+ return lines.join('\n') + '\n';
31
+ }
@@ -10,7 +10,6 @@ export interface Binding {
10
10
  }
11
11
  export interface QueryVariant {
12
12
  readonly text: string;
13
- readonly parameters: readonly Binding[];
14
13
  readonly parameterTypes: readonly number[];
15
14
  readonly uncertainParameters: readonly number[];
16
15
  }
@@ -39,8 +38,9 @@ export declare class SourceAnalysis {
39
38
  private combine;
40
39
  private conditional;
41
40
  private template;
41
+ /** Only direct data-property literals establish actual own keys and value expressions. */
42
+ private literalFields;
42
43
  private mutation;
43
- private containsUndefined;
44
44
  private expression;
45
45
  private codecOid;
46
46
  private encoderOid;
@@ -1,5 +1,6 @@
1
1
  import { builtin } from '@kequtech/pgclear';
2
2
  import ts from 'typescript';
3
+ import { assignmentParts, insertParts } from '../mutations.js';
3
4
  import { joinSeparator, quote } from '../sql.js';
4
5
  import { location, symbol } from './project.js';
5
6
  export const variantLimit = 16;
@@ -247,6 +248,30 @@ export class SourceAnalysis {
247
248
  }
248
249
  return variants;
249
250
  }
251
+ /** Only direct data-property literals establish actual own keys and value expressions. */
252
+ literalFields(input) {
253
+ const node = unwrap(input);
254
+ if (!ts.isObjectLiteralExpression(node))
255
+ return undefined;
256
+ const fields = new Map();
257
+ for (const property of node.properties) {
258
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property))
259
+ return undefined;
260
+ const name = property.name;
261
+ if (!ts.isIdentifier(name) && !ts.isStringLiteral(name) && !ts.isNumericLiteral(name))
262
+ return undefined;
263
+ // A colon-form __proto__ property changes the prototype rather than defining an own key.
264
+ if (name.text === '__proto__' && ts.isPropertyAssignment(property))
265
+ return undefined;
266
+ const value = ts.isPropertyAssignment(property) ? property.initializer : property.name;
267
+ fields.set(name.text, {
268
+ node: value,
269
+ type: this.project.checker.getTypeAtLocation(value),
270
+ field: name.text,
271
+ });
272
+ }
273
+ return fields;
274
+ }
250
275
  mutation(node, helper) {
251
276
  const shapeNode = node.typeArguments?.[0];
252
277
  if (!shapeNode)
@@ -273,77 +298,93 @@ export class SourceAnalysis {
273
298
  const input = node.arguments[0];
274
299
  if (!input)
275
300
  return this.fail(node, 'expansion-input-required', 'Object expansion requires an input.');
276
- if (!node.arguments[1])
301
+ const explicit = node.arguments[1]
302
+ ? this.array(node.arguments[1]).map((key) => this.text(key))
303
+ : undefined;
304
+ if (explicit && (!explicit.length || new Set(explicit).size !== explicit.length))
305
+ return this.fail(node, 'expansion-columns', 'Expansion columns must be nonempty and unique.');
306
+ for (const name of explicit ?? [])
307
+ if (!shape.getProperty(name))
308
+ return this.fail(node, 'expansion-columns', `Column ${name} is absent from the expansion shape.`);
309
+ const inputType = this.project.checker.getTypeAtLocation(input);
310
+ const rows = helper === 'sql.insertValues' &&
311
+ (this.project.checker.isArrayType(inputType) || this.project.checker.isTupleType(inputType))
312
+ ? this.array(input)
313
+ : [input];
314
+ if (!rows.length)
315
+ return this.fail(node, 'empty-expansion', 'Expansion requires a nonempty batch.');
316
+ const literals = rows.map((row) => this.literalFields(row));
317
+ if (!explicit && literals.some((literal) => !literal))
277
318
  this.issues.push({
278
319
  ...location(node),
279
320
  kind: 'unproven',
280
321
  code: 'expansion-shape-assumed',
281
322
  message: 'The explicit shape is trusted to describe exactly the expanded own fields and their order. An explicit column list removes that assumption.',
282
323
  });
283
- const columns = node.arguments[1]
284
- ? this.array(node.arguments[1]).map((key) => this.text(key))
285
- : shape.getProperties().map((property) => property.name);
286
- if (node.arguments[1] && !columns.length)
287
- return this.fail(node, 'expansion-columns', 'An explicit column list must be nonempty.');
288
- if (new Set(columns).size !== columns.length)
289
- return this.fail(node, 'expansion-columns', 'Expansion column names must be unique.');
290
- for (const name of columns) {
291
- try {
292
- quote(name);
293
- }
294
- catch {
295
- return this.fail(node, 'expansion-columns', 'Expansion columns must be valid PostgreSQL identifiers.');
296
- }
297
- }
298
- const fields = columns.map((name) => {
299
- const property = shape.getProperty(name);
300
- if (!property)
301
- return this.fail(node, 'expansion-columns', `Column ${name} is absent from the expansion shape.`);
302
- const type = this.project.checker.getTypeOfSymbolAtLocation(property, shapeNode);
303
- if (property.flags & ts.SymbolFlags.Optional || this.containsUndefined(type)) {
304
- return this.fail(shapeNode, 'optional-expansion', `Expansion field ${name} may be absent or undefined, changing the SQL. Select required, defined fields for checking.`);
305
- }
306
- return { name, binding: { node: input, type, field: name } };
307
- });
308
- const parts = [];
309
- if (helper === 'sql.assignments') {
310
- if (!fields.length)
311
- return this.fail(node, 'empty-expansion', 'An assignment needs at least one field.');
312
- for (const [index, field] of fields.entries()) {
313
- if (index)
314
- parts.push(', ');
315
- parts.push(`${quote(field.name)} = `, field.binding);
324
+ let variants = [[]];
325
+ for (const [index, row] of rows.entries()) {
326
+ const literal = literals[index];
327
+ const columns = explicit ?? (literal ? [...literal.keys()] : shape.getProperties().map((p) => p.name));
328
+ let choices = [{}];
329
+ for (const name of columns) {
330
+ try {
331
+ quote(name);
332
+ }
333
+ catch {
334
+ return this.fail(node, 'expansion-columns', 'Expansion columns must be valid PostgreSQL identifiers.');
335
+ }
336
+ const property = shape.getProperty(name);
337
+ const binding = literal
338
+ ? literal.get(name)
339
+ : property && {
340
+ node: row,
341
+ type: this.project.checker.getTypeOfSymbolAtLocation(property, shapeNode),
342
+ field: name,
343
+ };
344
+ const valueType = binding && (this.project.checker.getBaseConstraintOfType(binding.type) ?? binding.type);
345
+ const members = valueType ? (valueType.isUnion() ? valueType.types : [valueType]) : [];
346
+ const defined = members.some((member) => !(member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never)));
347
+ const optional = !binding ||
348
+ (!literal && !!(property && property.flags & ts.SymbolFlags.Optional)) ||
349
+ members.some((member) => !!(member.flags & ts.TypeFlags.Undefined));
350
+ const values = [];
351
+ if (binding && defined)
352
+ values.push(binding);
353
+ if (optional || !defined)
354
+ values.push(undefined);
355
+ choices = choices.flatMap((choice) => values.map((value) => ({ ...choice, [name]: value })));
356
+ if (choices.length * variants.length > variantLimit)
357
+ return this.fail(node, 'variant-limit', `Mutation expansion exceeds the ${variantLimit}-variant budget.`);
316
358
  }
359
+ variants = variants.flatMap((variant) => choices.map((choice) => [...variant, choice]));
317
360
  }
318
- else {
319
- const inputType = this.project.checker.getTypeAtLocation(input);
320
- const rows = this.project.checker.isArrayType(inputType) || this.project.checker.isTupleType(inputType)
321
- ? this.array(input)
322
- : [input];
323
- if (!rows.length || (!fields.length && rows.length > 1))
324
- return this.fail(node, 'empty-expansion', 'Expansion requires a nonempty batch; a default-only insert requires one row.');
325
- if (!fields.length)
326
- parts.push('DEFAULT VALUES');
327
- else {
328
- parts.push(`(${columns.map(quote).join(', ')}) VALUES `);
329
- for (const [index, row] of rows.entries()) {
330
- if (index)
331
- parts.push(', ');
332
- parts.push('(');
333
- for (const [position, field] of fields.entries()) {
334
- if (position)
335
- parts.push(', ');
336
- parts.push({ ...field.binding, node: row });
337
- }
338
- parts.push(')');
339
- }
361
+ const result = [];
362
+ let rejected = false;
363
+ for (const variant of variants) {
364
+ const hasValues = variant.some((row) => Object.values(row).some((value) => value !== undefined));
365
+ if (!hasValues && (helper === 'sql.assignments' || (!explicit && rows.length > 1))) {
366
+ rejected = true;
367
+ continue;
340
368
  }
369
+ // These builders see only compiler-created bindings, never application objects or values.
370
+ const parts = helper === 'sql.assignments'
371
+ ? assignmentParts(variant[0], explicit)
372
+ : insertParts(variant, explicit);
373
+ result.push({
374
+ parts: parts.map((part) => (typeof part === 'string' ? part : part.value)),
375
+ choices: new Map(),
376
+ });
341
377
  }
342
- return [{ parts, choices: new Map() }];
343
- }
344
- containsUndefined(type) {
345
- return (!!(type.flags & ts.TypeFlags.Undefined) ||
346
- (!!type.isUnion() && type.types.some((part) => this.containsUndefined(part))));
378
+ if (!result.length)
379
+ return this.fail(node, 'empty-expansion', 'Every expansion is rejected by the runtime helper: assignments need a defined field and default-only inserts need one row.');
380
+ if (rejected)
381
+ this.issues.push({
382
+ ...location(node),
383
+ kind: 'unproven',
384
+ code: 'empty-expansion-rejected',
385
+ message: 'The runtime helper rejects empty assignments or default-only batches before SQL is sent. Only SQL-producing variants are analysed; validate inputs if rejection is not intended.',
386
+ });
387
+ return result;
347
388
  }
348
389
  expression(input, depth) {
349
390
  if (depth > 32)
@@ -461,6 +502,8 @@ export class SourceAnalysis {
461
502
  const hints = new Set();
462
503
  let uncertain = false;
463
504
  const visit = (type) => {
505
+ if (binding.field !== undefined && type.flags & ts.TypeFlags.Undefined)
506
+ return;
464
507
  if (type.isUnion()) {
465
508
  type.types.forEach(visit);
466
509
  return;
@@ -509,7 +552,7 @@ export class SourceAnalysis {
509
552
  if (checker.isTypeAssignableTo(type, this.project.parameterType)) {
510
553
  let oid;
511
554
  try {
512
- oid = binding.field === undefined ? this.encoderOid(binding.node) : undefined;
555
+ oid = this.encoderOid(binding.node);
513
556
  }
514
557
  catch (error) {
515
558
  if (!(error instanceof Unsupported))
@@ -621,7 +664,6 @@ export class SourceAnalysis {
621
664
  }
622
665
  return lists.map((parameterTypes) => ({
623
666
  text,
624
- parameters: bindings,
625
667
  parameterTypes,
626
668
  uncertainParameters,
627
669
  }));
@@ -32,5 +32,12 @@ export interface CheckReport {
32
32
  readonly ok: boolean;
33
33
  readonly queries: readonly CheckedQuery[];
34
34
  readonly diagnostics: readonly CheckDiagnostic[];
35
+ /** Recognized query sites in the selected scope; analysed does not mean mismatch-free. */
36
+ readonly coverage: CheckCoverage;
35
37
  readonly context?: InspectionContext;
36
38
  }
39
+ export interface CheckCoverage {
40
+ readonly total: number;
41
+ readonly analysed: number;
42
+ readonly unchecked: number;
43
+ }
package/dist/check.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  import type { CheckOptions, CheckReport } from './check/types.js';
2
- export type { CheckOptions, CheckReport, CheckDiagnostic, CheckedQuery, SourceLocation, } from './check/types.js';
2
+ export type { CheckOptions, CheckReport, CheckCoverage, CheckDiagnostic, CheckedQuery, SourceLocation, } from './check/types.js';
3
3
  /** Analyse authored source without importing the application, binding values or executing its SQL. */
4
4
  export declare function checkQueries(options: CheckOptions): Promise<CheckReport>;
package/dist/check.js CHANGED
@@ -47,11 +47,25 @@ export async function checkQueries(options) {
47
47
  ? 'Several queries occupy this line. Put their SQL tags on distinct lines.'
48
48
  : 'No recognized complete Tenon query was found at the selected target.',
49
49
  });
50
- return { project: project.project, ok: false, queries: [], diagnostics };
50
+ return {
51
+ project: project.project,
52
+ ok: false,
53
+ queries: [],
54
+ diagnostics,
55
+ coverage: { total: selected.length, analysed: 0, unchecked: selected.length },
56
+ };
51
57
  }
52
58
  const recovered = selected.map((query) => {
53
59
  const analysis = new SourceAnalysis(project, libraries);
54
60
  const variants = analysis.recover(query);
61
+ if (!variants.length && !analysis.issues.some((issue) => issue.kind === 'unchecked')) {
62
+ analysis.issues.push({
63
+ ...location(query),
64
+ kind: 'unchecked',
65
+ code: 'no-query-variants',
66
+ message: 'No complete SQL variant could be recovered for this query.',
67
+ });
68
+ }
55
69
  return { query, variants, diagnostics: analysis.issues };
56
70
  });
57
71
  const reports = [];
@@ -62,11 +76,15 @@ export async function checkQueries(options) {
62
76
  }
63
77
  const finish = () => {
64
78
  const all = distinct([...diagnostics, ...reports.flatMap((report) => report.diagnostics)]);
79
+ const analysed = reports.filter((report) => report.status === 'analysed').length;
65
80
  return {
66
81
  project: project.project,
67
- ok: reports.length > 0 && !all.some((diagnostic) => diagnostic.kind !== 'unproven'),
82
+ ok: analysed > 0 &&
83
+ analysed === selected.length &&
84
+ !all.some((diagnostic) => diagnostic.kind !== 'unproven'),
68
85
  queries: reports,
69
86
  diagnostics: all,
87
+ coverage: { total: selected.length, analysed, unchecked: selected.length - analysed },
70
88
  };
71
89
  };
72
90
  if (!recovered.some(({ variants }) => variants.length)) {
@@ -93,7 +111,7 @@ export async function checkQueries(options) {
93
111
  });
94
112
  const description = queryDescription(metadata, context, codecs);
95
113
  described++;
96
- findings.push(...compareContracts(project, entry.query, variant, description, readTypes).map((diagnostic) => variant.uncertainParameters.length && diagnostic.kind === 'mismatch'
114
+ findings.push(...compareContracts(project, entry.query, description, readTypes).map((diagnostic) => variant.uncertainParameters.length && diagnostic.kind === 'mismatch'
97
115
  ? {
98
116
  ...diagnostic,
99
117
  kind: 'unproven',
package/dist/cli.js CHANGED
@@ -3,12 +3,14 @@ import { parseArgs } from 'node:util';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { resolve } from 'node:path';
5
5
  import { defaultCodecs } from '@kequtech/pgclear';
6
+ import { formatCheckReport } from './check/output.js';
6
7
  import { adoptBaseline, captureBaseline } from './baseline.js';
7
8
  import { describeFlags, inspectionSettings } from './inspection/cli.js';
8
9
  import { loadConfig } from './config/load.js';
9
10
  import { initConfig } from './config/init.js';
10
11
  import { configConnection } from './config/connection.js';
11
12
  import { generateDefinitions } from './generate.js';
13
+ import { inspectSchema } from './schema.js';
12
14
  import { writeDefinitions } from './generation/output.js';
13
15
  import { validatePath } from './validation.js';
14
16
  import { describeQuery } from './describe.js';
@@ -20,9 +22,10 @@ tenon baseline capture|adopt [--directory migrations] [--namespace public]
20
22
  baseline capture: [--schema name ...] [--connection native-uri] (default: whole database schema)
21
23
  tenon new <name> [--directory migrations] (no database required)
22
24
  tenon generate [--out path] [--namespace public] [--check] [--config path]
25
+ tenon schema [--namespace public] (versioned catalog snapshot and fingerprint as JSON)
23
26
  tenon describe <query.sql> [--role name] [--search-path schemas] [--time-zone zone]
24
27
  tenon check [file.ts[:line]] [--project tsconfig.json] [--json]
25
- generate/check/describe require tenon.config.ts, .mts, .js or .mjs, or --config path.
28
+ generate/schema/check/describe require tenon.config.ts, .mts, .js or .mjs, or --config path.
26
29
  describe: [--connect-timeout-ms 3000] [--statement-timeout-ms 5000] [--lock-timeout-ms 1000]
27
30
  describe emits JSON metadata; it does not execute the query or verify result annotations.
28
31
  check compares source contracts; mismatches, unchecked sites and no sites fail. Unproven types are notes.
@@ -84,7 +87,7 @@ async function main() {
84
87
  // Validate explicitly supplied flags, not defaults. Silently ignoring --check
85
88
  // on migrate, for example, would turn an intended dry run into a schema change.
86
89
  const allowed = new Set(['help']);
87
- if (!['describe', 'check', 'generate', 'init'].includes(command ?? ''))
90
+ if (!['describe', 'check', 'generate', 'schema', 'init'].includes(command ?? ''))
88
91
  allowed.add('directory');
89
92
  if (!['new', 'describe', 'check', 'init'].includes(command ?? '')) {
90
93
  allowed.add('namespace');
@@ -141,12 +144,26 @@ async function main() {
141
144
  : baseline || command === 'describe'
142
145
  ? positionals.length === 2
143
146
  : positionals.length === 1 &&
144
- ['migrate', 'generate', 'status', 'rollback'].includes(command ?? '');
147
+ ['migrate', 'generate', 'schema', 'status', 'rollback'].includes(command ?? '');
145
148
  if (!valid) {
146
- throw new Error('Choose init, new, baseline capture|adopt, migrate, generate, describe <query.sql>, check [file.ts[:line]], status, or rollback. Use --help for options.');
149
+ throw new Error('Choose init, new, baseline capture|adopt, migrate, generate, schema, describe <query.sql>, check [file.ts[:line]], status, or rollback. Use --help for options.');
147
150
  }
148
- if (command === 'generate' || command === 'check' || command === 'describe') {
151
+ if (command === 'generate' ||
152
+ command === 'schema' ||
153
+ command === 'check' ||
154
+ command === 'describe') {
149
155
  const config = await loadConfig(values.config, values.database);
156
+ if (command === 'schema') {
157
+ const snapshot = await inspectSchema({
158
+ connection: configConnection(config.connection),
159
+ namespace: values.namespace ??
160
+ config.generate?.namespace ??
161
+ config.migrations?.namespace ??
162
+ 'public',
163
+ });
164
+ process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
165
+ return;
166
+ }
150
167
  if (command === 'generate') {
151
168
  const out = values.out === undefined ? config.generate?.out : resolve(values.out);
152
169
  if (out === undefined)
@@ -181,11 +198,8 @@ async function main() {
181
198
  });
182
199
  if (values.json)
183
200
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
184
- else {
185
- for (const diagnostic of report.diagnostics)
186
- process.stdout.write(`${diagnostic.file}:${diagnostic.line}:${diagnostic.column} [${diagnostic.kind}] ${diagnostic.code}: ${diagnostic.message}\n`);
187
- 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`);
188
- }
201
+ else
202
+ process.stdout.write(formatCheckReport(report));
189
203
  if (!report.ok)
190
204
  process.exitCode = 1;
191
205
  return;
package/dist/index.d.ts CHANGED
@@ -15,6 +15,8 @@ export { adoptBaseline, captureBaseline } from './baseline.js';
15
15
  export type { CaptureBaselineOptions, CapturedBaseline } from './baseline.js';
16
16
  export { generateDefinitions } from './generate.js';
17
17
  export type { GenerateOptions } from './generate.js';
18
+ export { inspectSchema } from './schema.js';
19
+ export type { SchemaOptions, SchemaMetadata, SchemaSnapshot, SchemaTable, SchemaColumn, SchemaEnum, SchemaDomain, SchemaSequence, SchemaDefinition, } from './schema.js';
18
20
  export { migrate, migrationStatus, rollback } from './migrations.js';
19
21
  export type { MigrationStatus } from './migrations.js';
20
22
  export type { DatabaseToolOptions } from './migrations/connection.js';
package/dist/index.js CHANGED
@@ -6,5 +6,6 @@ export { identifier, sql, unsafeSql } from './query.js';
6
6
  export { describeQuery } from './describe.js';
7
7
  export { adoptBaseline, captureBaseline } from './baseline.js';
8
8
  export { generateDefinitions } from './generate.js';
9
+ export { inspectSchema } from './schema.js';
9
10
  export { migrate, migrationStatus, rollback } from './migrations.js';
10
11
  export { createMigration, readMigrations } from './migrations/files.js';
@@ -4,6 +4,7 @@ interface CatalogType {
4
4
  oid: number;
5
5
  schema: string;
6
6
  name: string;
7
+ enumLabels: readonly string[] | null;
7
8
  }
8
9
  interface ColumnSource {
9
10
  oid: number;
@@ -5,7 +5,11 @@ export async function readStatementMetadata(client, query) {
5
5
  ...new Set([...statement.parameterTypes, ...statement.fields.map((c) => c.dataTypeOid)]),
6
6
  ];
7
7
  const types = (await client.queryClassic(`
8
- SELECT t.oid, n.nspname AS schema, t.typname AS name
8
+ SELECT t.oid, n.nspname AS schema, t.typname AS name,
9
+ CASE WHEN t.typtype = 'e' THEN ARRAY(
10
+ SELECT e.enumlabel::text FROM pg_catalog.pg_enum e
11
+ WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder
12
+ ) END AS "enumLabels"
9
13
  FROM pg_catalog.pg_type t
10
14
  JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
11
15
  WHERE t.oid = ANY(pg_catalog.string_to_array($1, ',')::pg_catalog.oid[])
@@ -7,7 +7,12 @@ export function queryDescription({ statement, types, sources }, context, codecs)
7
7
  if (!type)
8
8
  throw new Error(`PostgreSQL type OID ${oid} was not found.`);
9
9
  const representation = codecs.typescript(oid);
10
- const readType = representation ?? 'unknown';
10
+ const enumLabels = type.enumLabels !== null && representation === 'string' && codecs.isDefaultDecoder(oid)
11
+ ? type.enumLabels
12
+ : undefined;
13
+ const readType = enumLabels === undefined
14
+ ? (representation ?? 'unknown')
15
+ : enumLabels.map((label) => JSON.stringify(label)).join(' | ') || 'never';
11
16
  if (representation === undefined) {
12
17
  issues.set(`type:${oid}`, {
13
18
  code: 'unmapped-type',
@@ -16,7 +21,12 @@ export function queryDescription({ statement, types, sources }, context, codecs)
16
21
  message: `The selected pgclear registry cannot identify the decoder representation for ${type.schema}.${type.name} (OID ${oid}). Supply an application type for this custom decoder.`,
17
22
  });
18
23
  }
19
- return { oid, postgres: `${type.schema}.${type.name}`, readType };
24
+ return {
25
+ oid,
26
+ postgres: `${type.schema}.${type.name}`,
27
+ readType,
28
+ ...(enumLabels === undefined ? {} : { enumLabels }),
29
+ };
20
30
  }
21
31
  const sourceMap = new Map(sources.map((s) => [`${s.oid}:${s.number}`, s]));
22
32
  const names = new Set();