@kequtech/tenon-orm 0.4.5 → 0.4.6

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.6
4
+
5
+ - Add `inspectSchema` and `tenon schema` for versioned, codec-independent catalog snapshots and
6
+ SHA-256 fingerprints. Version 1 includes scoped relation/type/sequence definitions and PostgreSQL
7
+ major version; functions, security policy, data and other excluded objects still need independent
8
+ checks. Existing adoption guards are unchanged. See [schema snapshots](docs/schema-inspection.md).
9
+ - Check bounded optional insert/update mutation shapes, including omission, DEFAULT and nullable
10
+ values. Empty runtime-rejected combinations remain explicit notes; unresolved SQL and queries
11
+ beyond the 16-variant budget still fail. Reports include recognized-site coverage counts.
12
+ Constrained generic fields retain their undefined/DEFAULT branches; zero-variant sites remain
13
+ unchecked and fail the report.
14
+ - Inspect default-decoded enum outputs as catalog label unions, including with unrelated codec
15
+ overrides. Source checking proves matching enum output unions while retaining narrower/custom
16
+ refinements and unknown nullability as unproven.
17
+ - Require pgclear `^0.3.4`, adding `builtin.uuidArray`, safe decode-error column/type context, and
18
+ a complete shared-listener ownership example. See [connections](docs/connections.md).
19
+
3
20
  ## 0.4.5
4
21
 
5
22
  - 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
@@ -144,13 +144,17 @@ export function compareContracts(project, query, variant, description, readTypes
144
144
  continue; // Structural result types may omit additional selected columns.
145
145
  const representation = readTypes[column.oid] ?? column.readType;
146
146
  const actual = project.types.get(representation);
147
+ const actualTypes = column.enumLabels?.map((label) => checker.getStringLiteralType(label)) ??
148
+ (actual && !(actual.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any))
149
+ ? [actual]
150
+ : undefined);
147
151
  const common = {
148
152
  ...at,
149
153
  resultColumn: column.name,
150
154
  expected: checker.typeToString(expected),
151
155
  observed: representation,
152
156
  };
153
- if (!actual || actual.flags & (ts.TypeFlags.Unknown | ts.TypeFlags.Any)) {
157
+ if (!actualTypes) {
154
158
  findings.push({
155
159
  ...common,
156
160
  kind: 'unproven',
@@ -168,8 +172,8 @@ export function compareContracts(project, query, variant, description, readTypes
168
172
  message: `Column ${JSON.stringify(column.name)} is declared as any.`,
169
173
  });
170
174
  }
171
- else if (!checker.isTypeAssignableTo(actual, nonnull)) {
172
- const mismatch = disjoint(project, actual, nonnull);
175
+ else if (!actualTypes.every((type) => checker.isTypeAssignableTo(type, nonnull))) {
176
+ const mismatch = actualTypes.every((type) => disjoint(project, type, nonnull));
173
177
  findings.push({
174
178
  ...common,
175
179
  kind: mismatch ? 'mismatch' : 'unproven',
@@ -40,7 +40,6 @@ export declare class SourceAnalysis {
40
40
  private conditional;
41
41
  private template;
42
42
  private mutation;
43
- private containsUndefined;
44
43
  private expression;
45
44
  private codecOid;
46
45
  private encoderOid;
@@ -300,50 +300,94 @@ export class SourceAnalysis {
300
300
  if (!property)
301
301
  return this.fail(node, 'expansion-columns', `Column ${name} is absent from the expansion shape.`);
302
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 } };
303
+ // Presence follows the same constrained value type used to derive bind hints.
304
+ const valueType = this.project.checker.getBaseConstraintOfType(type) ?? type;
305
+ const members = valueType.isUnion() ? valueType.types : [valueType];
306
+ return {
307
+ name,
308
+ type,
309
+ optional: !!(property.flags & ts.SymbolFlags.Optional) ||
310
+ members.some((member) => !!(member.flags & ts.TypeFlags.Undefined)),
311
+ defined: members.some((member) => !(member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never))),
312
+ };
307
313
  });
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);
314
+ const inputType = this.project.checker.getTypeAtLocation(input);
315
+ const rows = helper === 'sql.insertValues' &&
316
+ (this.project.checker.isArrayType(inputType) || this.project.checker.isTupleType(inputType))
317
+ ? this.array(input)
318
+ : [input];
319
+ if (!rows.length)
320
+ return this.fail(node, 'empty-expansion', 'Expansion requires a nonempty batch.');
321
+ let variants = [[]];
322
+ for (const row of rows) {
323
+ let choices = [[]];
324
+ for (const field of fields) {
325
+ const values = [];
326
+ if (field.defined)
327
+ values.push({ node: row, type: field.type, field: field.name });
328
+ if (field.optional || !field.defined)
329
+ values.push(undefined);
330
+ choices = choices.flatMap((choice) => values.map((value) => [...choice, value]));
331
+ if (choices.length * variants.length > variantLimit)
332
+ return this.fail(node, 'variant-limit', `Mutation expansion exceeds the ${variantLimit}-variant budget.`);
316
333
  }
334
+ variants = variants.flatMap((variant) => choices.map((choice) => [...variant, choice]));
317
335
  }
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)
336
+ const result = [];
337
+ let rejected = false;
338
+ for (const variant of variants) {
339
+ const selected = node.arguments[1]
340
+ ? columns.map((_, index) => index)
341
+ : [
342
+ ...new Set(variant.flatMap((row) => row.flatMap((cell, index) => (cell ? [index] : [])))),
343
+ ];
344
+ const parts = [];
345
+ if (helper === 'sql.assignments') {
346
+ for (const [index, cell] of variant[0].entries()) {
347
+ if (!cell)
348
+ continue;
349
+ if (parts.length)
350
+ parts.push(', ');
351
+ parts.push(`${quote(columns[index])} = `, cell);
352
+ }
353
+ if (!parts.length) {
354
+ rejected = true;
355
+ continue;
356
+ }
357
+ }
358
+ else if (!selected.length) {
359
+ if (rows.length > 1) {
360
+ rejected = true;
361
+ continue;
362
+ }
326
363
  parts.push('DEFAULT VALUES');
364
+ }
327
365
  else {
328
- parts.push(`(${columns.map(quote).join(', ')}) VALUES `);
329
- for (const [index, row] of rows.entries()) {
366
+ parts.push(`(${selected.map((index) => quote(columns[index])).join(', ')}) VALUES `);
367
+ for (const [index, row] of variant.entries()) {
330
368
  if (index)
331
369
  parts.push(', ');
332
370
  parts.push('(');
333
- for (const [position, field] of fields.entries()) {
371
+ for (const [position, column] of selected.entries()) {
334
372
  if (position)
335
373
  parts.push(', ');
336
- parts.push({ ...field.binding, node: row });
374
+ parts.push(row[column] ?? 'DEFAULT');
337
375
  }
338
376
  parts.push(')');
339
377
  }
340
378
  }
379
+ result.push({ parts, choices: new Map() });
341
380
  }
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))));
381
+ if (!result.length)
382
+ 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.');
383
+ if (rejected)
384
+ this.issues.push({
385
+ ...location(node),
386
+ kind: 'unproven',
387
+ code: 'empty-expansion-rejected',
388
+ 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.',
389
+ });
390
+ return result;
347
391
  }
348
392
  expression(input, depth) {
349
393
  if (depth > 32)
@@ -461,6 +505,8 @@ export class SourceAnalysis {
461
505
  const hints = new Set();
462
506
  let uncertain = false;
463
507
  const visit = (type) => {
508
+ if (binding.field !== undefined && type.flags & ts.TypeFlags.Undefined)
509
+ return;
464
510
  if (type.isUnion()) {
465
511
  type.types.forEach(visit);
466
512
  return;
@@ -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)) {
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { loadConfig } from './config/load.js';
9
9
  import { initConfig } from './config/init.js';
10
10
  import { configConnection } from './config/connection.js';
11
11
  import { generateDefinitions } from './generate.js';
12
+ import { inspectSchema } from './schema.js';
12
13
  import { writeDefinitions } from './generation/output.js';
13
14
  import { validatePath } from './validation.js';
14
15
  import { describeQuery } from './describe.js';
@@ -20,9 +21,10 @@ tenon baseline capture|adopt [--directory migrations] [--namespace public]
20
21
  baseline capture: [--schema name ...] [--connection native-uri] (default: whole database schema)
21
22
  tenon new <name> [--directory migrations] (no database required)
22
23
  tenon generate [--out path] [--namespace public] [--check] [--config path]
24
+ tenon schema [--namespace public] (versioned catalog snapshot and fingerprint as JSON)
23
25
  tenon describe <query.sql> [--role name] [--search-path schemas] [--time-zone zone]
24
26
  tenon check [file.ts[:line]] [--project tsconfig.json] [--json]
25
- generate/check/describe require tenon.config.ts, .mts, .js or .mjs, or --config path.
27
+ generate/schema/check/describe require tenon.config.ts, .mts, .js or .mjs, or --config path.
26
28
  describe: [--connect-timeout-ms 3000] [--statement-timeout-ms 5000] [--lock-timeout-ms 1000]
27
29
  describe emits JSON metadata; it does not execute the query or verify result annotations.
28
30
  check compares source contracts; mismatches, unchecked sites and no sites fail. Unproven types are notes.
@@ -84,7 +86,7 @@ async function main() {
84
86
  // Validate explicitly supplied flags, not defaults. Silently ignoring --check
85
87
  // on migrate, for example, would turn an intended dry run into a schema change.
86
88
  const allowed = new Set(['help']);
87
- if (!['describe', 'check', 'generate', 'init'].includes(command ?? ''))
89
+ if (!['describe', 'check', 'generate', 'schema', 'init'].includes(command ?? ''))
88
90
  allowed.add('directory');
89
91
  if (!['new', 'describe', 'check', 'init'].includes(command ?? '')) {
90
92
  allowed.add('namespace');
@@ -141,12 +143,26 @@ async function main() {
141
143
  : baseline || command === 'describe'
142
144
  ? positionals.length === 2
143
145
  : positionals.length === 1 &&
144
- ['migrate', 'generate', 'status', 'rollback'].includes(command ?? '');
146
+ ['migrate', 'generate', 'schema', 'status', 'rollback'].includes(command ?? '');
145
147
  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.');
148
+ 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
149
  }
148
- if (command === 'generate' || command === 'check' || command === 'describe') {
150
+ if (command === 'generate' ||
151
+ command === 'schema' ||
152
+ command === 'check' ||
153
+ command === 'describe') {
149
154
  const config = await loadConfig(values.config, values.database);
155
+ if (command === 'schema') {
156
+ const snapshot = await inspectSchema({
157
+ connection: configConnection(config.connection),
158
+ namespace: values.namespace ??
159
+ config.generate?.namespace ??
160
+ config.migrations?.namespace ??
161
+ 'public',
162
+ });
163
+ process.stdout.write(`${JSON.stringify(snapshot, null, 2)}\n`);
164
+ return;
165
+ }
150
166
  if (command === 'generate') {
151
167
  const out = values.out === undefined ? config.generate?.out : resolve(values.out);
152
168
  if (out === undefined)
@@ -184,7 +200,7 @@ async function main() {
184
200
  else {
185
201
  for (const diagnostic of report.diagnostics)
186
202
  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`);
203
+ process.stdout.write(`${report.coverage.total} queries, ${report.coverage.analysed} analysed, ${report.coverage.unchecked} 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
204
  }
189
205
  if (!report.ok)
190
206
  process.exitCode = 1;
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();
@@ -55,6 +55,8 @@ export interface DescribedType {
55
55
  readonly postgres: string;
56
56
  /** pgclear read representation, before considering result nullability. */
57
57
  readonly readType: string;
58
+ /** Catalog labels when this enum's effective decoder preserves its default output. */
59
+ readonly enumLabels?: readonly string[];
58
60
  }
59
61
  export interface DescribedColumn extends DescribedType {
60
62
  readonly name: string;
@@ -15,7 +15,7 @@ export async function withMigrationLock(options, mode, work) {
15
15
  try {
16
16
  lease = await pool.acquire();
17
17
  const client = lease;
18
- await client.queryClassic(`SELECT pg_advisory_lock${suffix}(hashtextextended($1, 0))`, [lockKey], { codecs: defaultCodecs });
18
+ await client.queryClassic(`SELECT pg_catalog.pg_advisory_lock${suffix}(pg_catalog.hashtextextended($1, 0))`, [lockKey], { codecs: defaultCodecs });
19
19
  locked = true;
20
20
  result = await work(client, namespace);
21
21
  }
@@ -29,7 +29,7 @@ export async function withMigrationLock(options, mode, work) {
29
29
  if (lease.transactionStatus !== 'idle')
30
30
  await client.queryClassic('ROLLBACK', []);
31
31
  if (locked) {
32
- await client.queryClassic(`SELECT pg_advisory_unlock${suffix}(hashtextextended($1, 0))`, [lockKey], { codecs: defaultCodecs });
32
+ await client.queryClassic(`SELECT pg_catalog.pg_advisory_unlock${suffix}(pg_catalog.hashtextextended($1, 0))`, [lockKey], { codecs: defaultCodecs });
33
33
  }
34
34
  }
35
35
  catch (error) {
@@ -0,0 +1,4 @@
1
+ import type { CatalogClient } from '../generation/catalog.js';
2
+ import type { SchemaMetadata } from './types.js';
3
+ /** Versioned catalog contract: change formatVersion when changing fingerprint inputs. */
4
+ export declare function readSchema(client: CatalogClient, namespace: string): Promise<SchemaMetadata>;
@@ -0,0 +1,69 @@
1
+ import { migrationTable } from '../migrations/history.js';
2
+ /** Versioned catalog contract: change formatVersion when changing fingerprint inputs. */
3
+ export async function readSchema(client, namespace) {
4
+ const result = await client.query(`SELECT (pg_catalog.current_setting('server_version_num')::integer / 10000) AS "postgresMajor",
5
+ COALESCE((
6
+ SELECT jsonb_agg(jsonb_build_object(
7
+ 'name', c.relname, 'kind', c.relkind, 'persistence', c.relpersistence,
8
+ 'parents', COALESCE((SELECT jsonb_agg(i.inhparent::regclass::text ORDER BY i.inhseqno)
9
+ FROM pg_inherits i WHERE i.inhrelid = c.oid), '[]'::jsonb),
10
+ 'partitionKey', pg_get_partkeydef(c.oid),
11
+ 'partitionBound', pg_get_expr(c.relpartbound, c.oid, false),
12
+ 'columns', COALESCE((SELECT jsonb_agg(jsonb_build_object(
13
+ 'name', a.attname, 'type', format_type(a.atttypid, a.atttypmod),
14
+ 'nullable', NOT a.attnotnull, 'expression', pg_get_expr(d.adbin, d.adrelid, false),
15
+ 'identity', a.attidentity, 'generated', a.attgenerated,
16
+ 'collation', CASE WHEN a.attcollation <> t.typcollation THEN a.attcollation::regcollation::text END
17
+ ) ORDER BY a.attnum)
18
+ FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid
19
+ LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
20
+ WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped), '[]'::jsonb),
21
+ 'constraints', COALESCE((SELECT jsonb_agg(jsonb_build_object(
22
+ 'name', x.conname, 'definition', pg_get_constraintdef(x.oid, false)
23
+ ) ORDER BY x.conname COLLATE "C") FROM pg_constraint x
24
+ WHERE x.conrelid = c.oid AND x.contype IN ('p','u','f','c','x')), '[]'::jsonb),
25
+ 'indexes', COALESCE((SELECT jsonb_agg(jsonb_build_object(
26
+ 'name', ic.relname, 'definition', pg_get_indexdef(i.indexrelid, 0, false),
27
+ 'valid', i.indisvalid, 'ready', i.indisready
28
+ ) ORDER BY ic.relname COLLATE "C") FROM pg_index i
29
+ JOIN pg_class ic ON ic.oid = i.indexrelid WHERE i.indrelid = c.oid), '[]'::jsonb)
30
+ ) ORDER BY c.relname COLLATE "C")
31
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
32
+ WHERE n.nspname = $1 AND c.relkind IN ('r','p') AND c.relname <> $2
33
+ ), '[]'::jsonb) AS tables,
34
+ COALESCE((
35
+ SELECT jsonb_agg(jsonb_build_object('name', t.typname, 'labels',
36
+ COALESCE((SELECT jsonb_agg(e.enumlabel ORDER BY e.enumsortorder)
37
+ FROM pg_enum e WHERE e.enumtypid = t.oid), '[]'::jsonb)
38
+ ) ORDER BY t.typname COLLATE "C") FROM pg_type t
39
+ JOIN pg_namespace n ON n.oid = t.typnamespace WHERE n.nspname = $1 AND t.typtype = 'e'
40
+ ), '[]'::jsonb) AS enums,
41
+ COALESCE((
42
+ SELECT jsonb_agg(jsonb_build_object(
43
+ 'name', t.typname, 'type', format_type(t.typbasetype, t.typtypmod),
44
+ 'nullable', NOT t.typnotnull, 'default', pg_get_expr(t.typdefaultbin, 0, false),
45
+ 'collation', CASE WHEN t.typcollation <> b.typcollation THEN t.typcollation::regcollation::text END,
46
+ 'constraints', COALESCE((SELECT jsonb_agg(jsonb_build_object(
47
+ 'name', x.conname, 'definition', pg_get_constraintdef(x.oid, false)
48
+ ) ORDER BY x.conname COLLATE "C") FROM pg_constraint x WHERE x.contypid = t.oid), '[]'::jsonb)
49
+ ) ORDER BY t.typname COLLATE "C") FROM pg_type t
50
+ JOIN pg_type b ON b.oid = t.typbasetype JOIN pg_namespace n ON n.oid = t.typnamespace
51
+ WHERE n.nspname = $1 AND t.typtype = 'd'
52
+ ), '[]'::jsonb) AS domains,
53
+ COALESCE((
54
+ SELECT jsonb_agg(jsonb_build_object(
55
+ 'name', c.relname, 'type', s.seqtypid::regtype::text,
56
+ 'start', s.seqstart::text, 'increment', s.seqincrement::text,
57
+ 'min', s.seqmin::text, 'max', s.seqmax::text, 'cache', s.seqcache::text, 'cycle', s.seqcycle,
58
+ 'ownedBy', (SELECT jsonb_build_object('schema', rn.nspname, 'table', r.relname, 'column', a.attname)
59
+ FROM pg_depend d JOIN pg_class r ON r.oid = d.refobjid
60
+ JOIN pg_namespace rn ON rn.oid = r.relnamespace
61
+ JOIN pg_attribute a ON a.attrelid = r.oid AND a.attnum = d.refobjsubid
62
+ WHERE d.classid = 'pg_catalog.pg_class'::regclass AND d.objid = c.oid
63
+ AND d.refclassid = 'pg_catalog.pg_class'::regclass AND d.deptype IN ('a','i'))
64
+ ) ORDER BY c.relname COLLATE "C") FROM pg_sequence s
65
+ JOIN pg_class c ON c.oid = s.seqrelid JOIN pg_namespace n ON n.oid = c.relnamespace
66
+ WHERE n.nspname = $1
67
+ ), '[]'::jsonb) AS sequences`, [namespace, migrationTable]);
68
+ return { formatVersion: 1, namespace, ...result.rows[0] };
69
+ }
@@ -0,0 +1,70 @@
1
+ import type { DatabaseToolOptions } from '../migrations/connection.js';
2
+ export type SchemaOptions = Pick<DatabaseToolOptions, 'connection' | 'namespace'>;
3
+ export interface SchemaDefinition {
4
+ readonly name: string;
5
+ readonly definition: string;
6
+ }
7
+ export interface SchemaColumn {
8
+ readonly name: string;
9
+ readonly type: string;
10
+ readonly nullable: boolean;
11
+ /** Default or generated expression, distinguished by generated. */
12
+ readonly expression: string | null;
13
+ readonly identity: '' | 'a' | 'd';
14
+ readonly generated: '' | 's' | 'v';
15
+ readonly collation: string | null;
16
+ }
17
+ export interface SchemaTable {
18
+ readonly name: string;
19
+ readonly kind: 'r' | 'p';
20
+ readonly persistence: 'p' | 'u' | 't';
21
+ readonly parents: readonly string[];
22
+ readonly partitionKey: string | null;
23
+ readonly partitionBound: string | null;
24
+ readonly columns: readonly SchemaColumn[];
25
+ readonly constraints: readonly SchemaDefinition[];
26
+ readonly indexes: readonly (SchemaDefinition & {
27
+ readonly valid: boolean;
28
+ readonly ready: boolean;
29
+ })[];
30
+ }
31
+ export interface SchemaEnum {
32
+ readonly name: string;
33
+ readonly labels: readonly string[];
34
+ }
35
+ export interface SchemaDomain {
36
+ readonly name: string;
37
+ readonly type: string;
38
+ readonly nullable: boolean;
39
+ readonly default: string | null;
40
+ readonly collation: string | null;
41
+ readonly constraints: readonly SchemaDefinition[];
42
+ }
43
+ export interface SchemaSequence {
44
+ readonly name: string;
45
+ readonly type: string;
46
+ readonly start: string;
47
+ readonly increment: string;
48
+ readonly min: string;
49
+ readonly max: string;
50
+ readonly cache: string;
51
+ readonly cycle: boolean;
52
+ readonly ownedBy: {
53
+ readonly schema: string;
54
+ readonly table: string;
55
+ readonly column: string;
56
+ } | null;
57
+ }
58
+ /** Version 1 covers declared relation/type metadata, not data, functions or security policy. */
59
+ export interface SchemaMetadata {
60
+ readonly formatVersion: 1;
61
+ readonly postgresMajor: number;
62
+ readonly namespace: string;
63
+ readonly tables: readonly SchemaTable[];
64
+ readonly enums: readonly SchemaEnum[];
65
+ readonly domains: readonly SchemaDomain[];
66
+ readonly sequences: readonly SchemaSequence[];
67
+ }
68
+ export interface SchemaSnapshot extends SchemaMetadata {
69
+ readonly fingerprint: string;
70
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import type { SchemaOptions, SchemaSnapshot } from './schema/types.js';
2
+ export type { SchemaOptions, SchemaMetadata, SchemaSnapshot, SchemaTable, SchemaColumn, SchemaEnum, SchemaDomain, SchemaSequence, SchemaDefinition, } from './schema/types.js';
3
+ /** Read a versioned, codec-independent catalog snapshot without executing application SQL. */
4
+ export declare function inspectSchema(options: SchemaOptions): Promise<SchemaSnapshot>;
package/dist/schema.js ADDED
@@ -0,0 +1,47 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { defaultCodecs } from '@kequtech/pgclear';
3
+ import { withMigrationLock } from './migrations/connection.js';
4
+ import { quote } from './sql.js';
5
+ import { plainObject } from './validation.js';
6
+ import { readSchema } from './schema/catalog.js';
7
+ /** Canonical JSON: object keys are sorted; ordered catalog arrays retain their semantic order. */
8
+ function canonical(value) {
9
+ if (Array.isArray(value))
10
+ return '[' + value.map(canonical).join(',') + ']';
11
+ if (value !== null && typeof value === 'object') {
12
+ const object = value;
13
+ return ('{' +
14
+ Object.keys(object)
15
+ .sort()
16
+ .map((key) => JSON.stringify(key) + ':' + canonical(object[key]))
17
+ .join(',') +
18
+ '}');
19
+ }
20
+ const json = JSON.stringify(value);
21
+ if (json === undefined)
22
+ throw new Error('Schema metadata is not JSON serializable.');
23
+ return json;
24
+ }
25
+ /** Read a versioned, codec-independent catalog snapshot without executing application SQL. */
26
+ export async function inspectSchema(options) {
27
+ for (const name of Object.keys(plainObject(options, 'Schema options')))
28
+ if (!['connection', 'namespace'].includes(name))
29
+ throw new Error(`Unknown schema option: ${name}`);
30
+ return withMigrationLock(options, 'shared', (client, namespace) => client.transaction(async (transaction) => {
31
+ const catalog = {
32
+ query: (text, values = []) => transaction.queryClassic(text, values, { codecs: defaultCodecs }),
33
+ };
34
+ const exists = await catalog.query('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [namespace]);
35
+ if (!exists.rows.length)
36
+ throw new Error(`Schema ${quote(namespace)} does not exist.`);
37
+ await catalog.query(`SELECT pg_catalog.set_config(name, value, true) FROM (VALUES
38
+ ('search_path', 'pg_catalog'), ('quote_all_identifiers', 'on'),
39
+ ('standard_conforming_strings', 'on'), ('TimeZone', 'UTC'),
40
+ ('DateStyle', 'ISO, YMD'), ('IntervalStyle', 'postgres'),
41
+ ('bytea_output', 'hex'), ('extra_float_digits', '3'), ('lc_monetary', 'C')
42
+ ) AS settings(name, value)`);
43
+ const metadata = await readSchema(catalog, namespace);
44
+ const hash = createHash('sha256').update(canonical(metadata)).digest('hex');
45
+ return { ...metadata, fingerprint: `tenon-schema-v1:sha256:${hash}` };
46
+ }, { isolation: 'repeatable read', readOnly: true }));
47
+ }
@@ -91,7 +91,10 @@ The namespace must already exist. Repeating adoption with matching history is ha
91
91
  after later migrations; conflicting history fails. Once adopted, leave the baseline unchanged and
92
92
  add forward migrations.
93
93
 
94
- For an application-owned schema guard, keep a frozen checkpoint reference. Generated relation types
94
+ For an application-owned schema guard, keep a frozen checkpoint reference. The versioned
95
+ [schema snapshot API](schema-inspection.md) provides codec- and generator-independent relation
96
+ metadata for new guards; verify any transition against an immutable disposable baseline replay.
97
+ Keep function, security, and migration-history checks independent. Generated relation types
95
98
  do not cover functions or privileges; check required objects separately. Neither generated text nor
96
99
  schema-summary formatting is a version-stable fingerprint. If a guard hashes that text, preserve
97
100
  its original reference and freeze a separate reviewed reference for each generator version, taken
@@ -120,12 +120,13 @@ npx tenon new create_users
120
120
  npx tenon migrate
121
121
  npx tenon generate
122
122
  npx tenon generate --check
123
+ npx tenon schema
123
124
  npx tenon check
124
125
  npx tenon check src/users.ts:23 --json
125
126
  npx tenon describe query.sql
126
127
  ```
127
128
 
128
- Generation, checking, and inspection require configuration and use its connection and codecs. A
129
+ Generation, schema snapshots, checking, and inspection require configuration and use its connection and codecs. A
129
130
  missing or invalid config fails instead of falling back to `DATABASE_URL`. The config chooses which
130
131
  environment variables to read; Tenon does not load `.env` files.
131
132
 
@@ -203,7 +203,7 @@ for deadlines, cancellation, and changes queued during reconnect.
203
203
 
204
204
  ## Update the driver
205
205
 
206
- Tenon depends on `@kequtech/pgclear: ^0.3.3`, which permits compatible 0.3.x patch updates. With npm:
206
+ Tenon depends on `@kequtech/pgclear: ^0.3.4`, which permits compatible 0.3.x patch updates. With npm:
207
207
 
208
208
  ```sh
209
209
  npm update @kequtech/pgclear
@@ -216,3 +216,47 @@ this range and requires an explicit Tenon compatibility update.
216
216
 
217
217
  If the application imports pgclear directly, declare it as a dependency with a compatible version
218
218
  so the package manager can share a copy. See the [changelog](../CHANGELOG.md) when updating Tenon.
219
+
220
+ ### Reuse configuration for a dedicated listener
221
+
222
+ Keep session settings separate from pool limits when both a query pool and a listener use them:
223
+
224
+ ```ts
225
+ import { builtin, defaultCodecs, type TenonConfig } from '@kequtech/tenon-orm';
226
+ import { listen } from '@kequtech/pgclear';
227
+
228
+ const session = {
229
+ codecs: defaultCodecs.with(builtin.timestamptzDate),
230
+ startupParameters: { search_path: 'app,public' },
231
+ connectTimeoutMs: 5000,
232
+ };
233
+ export const main = {
234
+ connection: { ...session, connectionString: process.env.DATABASE_URL, maxConnections: 10 },
235
+ } satisfies TenonConfig;
236
+
237
+ // Call during startup with the application's validated database URI.
238
+ async function startNotifications(connectionString: string) {
239
+ return listen(
240
+ { ...session, connectionString },
241
+ {
242
+ channels: [],
243
+ timeoutMs: 5000,
244
+ reconnect: false,
245
+ onNotification: ({ channel, payload }) => console.log(channel, payload),
246
+ },
247
+ );
248
+ }
249
+ ```
250
+
251
+ The config remains safe to import without credentials. Pool-only settings (`maxConnections`,
252
+ `maxWaiting`, `acquireTimeoutMs`, `idleTimeoutMs`, `maxLifetimeMs`) belong on the query pool; `listen`
253
+ takes native connection settings and rejects pool limits. A supplied pool does not expose its
254
+ credentials; retain the application-owned connection settings when using both resources.
255
+
256
+ For multiple SSE/WebSocket consumers, use the complete
257
+ [pgclear shared-listener example](https://github.com/kequtech/pgclear/blob/main/docs/notifications.md#shared-consumer-ownership).
258
+ It demonstrates reference counts, shared readiness, ordered removal/replacement, bounded shutdown,
259
+ and stale-generation guards. Those remain application responsibilities. If a table's actual schema
260
+ must be discovered first, reuse the existing application pool before opening subscriptions; a
261
+ separate listener query hook is unnecessary for that startup workflow. Keep reconciliation after a
262
+ notification gap tied to authoritative application reads.
@@ -62,6 +62,11 @@ Each variant must fit at least one member of a declared result union. Nullabilit
62
62
  literal/branded refinements, and unfamiliar decoder types may remain unproven. A broader metadata
63
63
  type alone does not reject a narrower application declaration.
64
64
 
65
+ For enum results with unchanged default decoding, catalog labels establish exact or superset string
66
+ unions, including when unrelated Date/bigint codecs change. Narrower label subsets and branded
67
+ strings remain unproven. Custom enum decoders keep their registry representations; catalog labels
68
+ never narrow a decoder that transforms them. Result nullability remains unproven.
69
+
65
70
  Input and output contracts are separate. For example, an int8 input accepts exact text, bigint, or
66
71
  an explicit encoder; its default output is a string. Strings, numbers, bigint, Date, and supported
67
72
  Temporal inputs use OID 0/server inference. Booleans, bytes, and recoverable `json`/`param` calls
@@ -83,8 +88,8 @@ dependent result comparisons and inference errors remain unproven. This does not
83
88
  - **Unproven:** retain the declaration when justified, with validation/tests for what metadata cannot
84
89
  establish. A note alone does not fail the command.
85
90
 
86
- For example, generated update fields are optional and can disappear from SQL. If application
87
- validation always supplies a boolean, declare that required contract:
91
+ Finite optional mutation shapes are supported within the variant budget. A required, narrower
92
+ contract can reduce that budget when application validation always supplies a value:
88
93
 
89
94
  ```ts
90
95
  type ActivePatch = { active: boolean };
@@ -95,8 +100,9 @@ function setActive(id: number, patch: ActivePatch) {
95
100
  }
96
101
  ```
97
102
 
98
- If omission is intended, keep optional fields and test the SQL/effects. An explicit column list does
99
- not make optional fields required. Avoid assertions that merely hide this distinction.
103
+ If omission is intended, keep optional fields. An explicit column list fixes column selection and
104
+ order; it does not make optional fields required. Empty assignments still reject at runtime, so
105
+ validate the patch if that rejection is not intended. Keep execution tests for the actual effects.
100
106
 
101
107
  Exit 0 means no mismatch or unchecked site in the selected scope. It does not establish JSON shape,
102
108
  nullability, cardinality, or every runtime branch. No query sites also fails. Independent queries
@@ -132,7 +138,7 @@ runtime SQL, but need database tests. Unknown source is never replaced with inve
132
138
  For checking, `insertValues` and `assignments` need an explicit finite shape type:
133
139
 
134
140
  ```ts
135
- type NewUser = { email: string; active: boolean };
141
+ type NewUser = { email: string; active?: boolean | undefined };
136
142
 
137
143
  function createUser(input: NewUser) {
138
144
  return sql<{ id: number }>`INSERT INTO public.users
@@ -145,11 +151,25 @@ column list so runtime and checking select the same keys/order. Without one, the
145
151
  assumed to describe exactly the expanded fields. Structural typing cannot prove the absence of
146
152
  extra own keys or their order, so that assumption is unproven.
147
153
 
148
- Selected fields must be required and exclude undefined. Optional presence changes the SQL through
149
- omission or DEFAULT and is unchecked. Unselected optional properties are fine. Index signatures,
150
- unresolved generic key sets, shape unions, and unknown shapes are unsupported. Fixed literal batches
151
- are supported; unknown batch lengths are not. Runtime helpers still accept inferred shapes and
152
- optional fields.
154
+ Optional or undefined-valued fields create bounded SQL variants. With explicit insert columns, an
155
+ absent/undefined cell becomes `DEFAULT`; with inferred columns, a field absent from every row is
156
+ omitted. Fixed batches use each row's defined fields and per-cell defaults. Assignments omit
157
+ absent/undefined fields. SQL NULL stays a bound value and is separate from omission.
158
+
159
+ The checker describes only variants that can produce SQL. Empty assignments and inferred multi-row batches
160
+ with no defined fields are rejected by the runtime helper before dispatch; the report retains an
161
+ `empty-expansion-rejected` unproven note when such an input is possible. A mutation whose every
162
+ combination rejects is unchecked. These checks do not establish that application input validation
163
+ avoids runtime rejection.
164
+
165
+ The same 16-variant limit covers optional combinations, surrounding fragments, and parameter OID
166
+ alternatives. Select fewer columns or a narrower validated shape when that limit is exceeded.
167
+ Index signatures, unresolved generic key sets, shape unions, and unknown shapes remain unsupported.
168
+ Fixed literal batches are supported; unknown batch lengths are not. Runtime helpers still accept
169
+ inferred shapes and optional fields without source checking.
170
+
171
+ For an otherwise finite shape, a constrained generic field uses its base constraint to determine
172
+ defined and omitted variants. Open generic shapes and unconstrained field values remain unchecked.
153
173
 
154
174
  ## Programmatic use and custom codecs
155
175
 
@@ -166,8 +186,20 @@ const report = await checkQueries({
166
186
  if (!report.ok) process.exitCode = 1;
167
187
  ```
168
188
 
169
- The report has `ok`, `project`, `queries`, `diagnostics`, and available inspection `context`. Options
170
- also accept `session`, `timeouts`, `codecs`, and `readTypes`. `connection` is a URI or pool-option
189
+ The report has `ok`, `project`, `queries`, `diagnostics`, `coverage`, and available inspection
190
+ `context`. Coverage counts recognized Tenon query sites in the selected scope:
191
+
192
+ - `total`: recognized complete query sites, including unsupported ones.
193
+ - `analysed`: sites whose complete SQL variant set was recovered and inspected.
194
+ - `unchecked`: sites with unsupported structure or incomplete inspection, including a site where only
195
+ some branches were recovered.
196
+
197
+ An analysed site can still have mismatches or unproven findings; use `ok` and diagnostics for the
198
+ result. Unrelated SQL tags are outside these counts. No recognized sites still fails with zero
199
+ coverage. JSON reports expose the same counts, so focused CI checks can retain visibility of their
200
+ scope without accepting unchecked sites as successes.
201
+
202
+ Options also accept `session`, `timeouts`, `codecs`, and `readTypes`. `connection` is a URI or pool-option
171
203
  values; its `codecs` are inherited unless the explicit inspection `codecs` argument is supplied.
172
204
  The programmatic API does not discover shared configuration.
173
205
 
@@ -75,6 +75,12 @@ Known read types in `rowType` include null; unknown already includes it. A sourc
75
75
  constraint cannot prove result nullability through outer joins, expressions, or grouping. JSON
76
76
  members remain unknown.
77
77
 
78
+ An enum with its default decoder reports its catalog label union and `enumLabels`, including when
79
+ unrelated Date or bigint codecs are configured. Source checking can verify an exact enum union or
80
+ broader string result annotation against those labels. Narrower application refinements and
81
+ nullability remain unproven. Enum arrays retain their actual wire representation rather than
82
+ becoming arrays of label unions.
83
+
78
84
  | Finding code | Meaning |
79
85
  | --------------------- | ----------------------------------------------------- |
80
86
  | `nullability-unknown` | Result nullability is unresolved |
@@ -100,6 +106,9 @@ declaration and are shared with generation/source checking. Without known metada
100
106
  decoder remains unknown even for a familiar scalar/enum/domain OID. Catalog base types do not
101
107
  replace the wire OID's decoder contract.
102
108
 
109
+ A custom enum decoder uses its declared output representation and does not receive catalog-label
110
+ narrowing or `enumLabels`, even if it declares `string`: the decoder may transform those labels.
111
+
103
112
  Inspection's own catalog/context queries use default decoding. Reported parameter read types are
104
113
  not encoding rules: exact text can be a valid int8 input while its output also uses text by default.
105
114
  See [schema mappings and codecs](schema-types.md#custom-codecs).
package/docs/reference.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  For a complete first setup, follow [Getting started](human-guide.md). Task guides explain
4
4
  [queries](sql.md), [writes](mutations.md), [schema types](schema-types.md),
5
- [migrations](migrations.md), and [connections](connections.md).
5
+ [migrations](migrations.md), [schema snapshots](schema-inspection.md), and [connections](connections.md).
6
6
 
7
7
  ## Installation and connection
8
8
 
@@ -44,7 +44,7 @@ handling, timeout bounds, and lifecycle examples. Runtime `search_path` is indep
44
44
 
45
45
  ### Driver updates
46
46
 
47
- The dependency range is `@kequtech/pgclear: ^0.3.3`. Compatible patches may be updated independently;
47
+ The dependency range is `@kequtech/pgclear: ^0.3.4`. Compatible patches may be updated independently;
48
48
  see [driver updates](connections.md#update-the-driver). Tenon re-exports `builtin`, `json`, `param`,
49
49
  `defaultCodecs`, and `createTemporalCodecs`, with the driver's `Codec`, `CodecRegistry`, `Parameter`,
50
50
  `PgArray`, `TemporalConstructors`, and `TemporalInput` types.
@@ -325,6 +325,7 @@ Unsupported command flags reject.
325
325
  ```text
326
326
  tenon init [--config path]
327
327
  tenon new <name> [--directory migrations]
328
+ tenon schema [--namespace schema]
328
329
  tenon generate [--out path] [--namespace schema] [--check]
329
330
  tenon migrate [--directory migrations] [--namespace public]
330
331
  tenon status [--directory migrations] [--namespace public]
@@ -344,6 +345,7 @@ tenon describe <query.sql> [--role name] [--search-path value] [--time-zone valu
344
345
  | `new` | Paths of the new empty up/down pair; fill before applying |
345
346
  | `migrate` / `rollback` | Completed IDs or “nothing to do”; each file commits independently |
346
347
  | `status` | JSON `{ applied, pending }`; validates history |
348
+ | `schema` | Emits a versioned catalog snapshot and fingerprint; requires shared configuration |
347
349
  | `baseline capture` | Writes a reviewed schema-only baseline using native `pg_dump` |
348
350
  | `baseline adopt` | Records baseline history without executing its SQL |
349
351
  | `generate` | Writes output atomically; `--check` rejects missing/stale output without writing |
@@ -0,0 +1,94 @@
1
+ # Schema snapshots and fingerprints
2
+
3
+ `inspectSchema` reads a versioned catalog snapshot independently of generated TypeScript, codec
4
+ choices, and generator comments. Use it as one part of an application-owned schema checkpoint guard.
5
+ It does not adopt a baseline, execute migrations, or validate a whole database.
6
+
7
+ With [shared configuration](configuration.md):
8
+
9
+ ```sh
10
+ npx tenon schema
11
+ npx tenon schema --namespace app --database main
12
+ ```
13
+
14
+ The command requires configuration and emits JSON. Namespace selection matches generation:
15
+ `--namespace`, then `generate.namespace`, then `migrations.namespace`, then `public`.
16
+ It writes nothing to the database or filesystem. Save and review the JSON using your existing
17
+ application tooling.
18
+
19
+ The programmatic API takes an explicit connection and optional namespace, without discovering config:
20
+
21
+ ```ts
22
+ import { inspectSchema } from '@kequtech/tenon-orm';
23
+
24
+ const snapshot = await inspectSchema({ connection, namespace: 'app' });
25
+ console.log(snapshot.formatVersion, snapshot.postgresMajor, snapshot.fingerprint);
26
+ ```
27
+
28
+ A supplied pool remains application-owned. Catalog reads use default codecs on one borrowed session,
29
+ a read-only repeatable-read transaction, and Tenon's shared migration advisory lock. Temporary
30
+ search-path and deparser settings are restored before release. Snapshot reads pin UTC, ISO dates,
31
+ PostgreSQL interval output, hex bytes, round-trip floats and the C monetary locale, so caller session
32
+ formatting cannot change a fingerprint. Uncoordinated external DDL is outside
33
+ Tenon's migration locking contract.
34
+
35
+ ## Version 1 scope
36
+
37
+ `SchemaSnapshot` contains `formatVersion: 1`, `postgresMajor`, `namespace`, the collections below,
38
+ and `fingerprint`. Names are sorted using PostgreSQL's C collation. Column, enum-label, and parent
39
+ orders retain their catalog meaning. Public types are readonly.
40
+
41
+ | Collection | Included metadata |
42
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | `tables` | Ordinary and partitioned tables, including attached partitions; persistence, inheritance parents, partition keys and bounds |
44
+ | Table columns | Ordered names, formatted PostgreSQL types, column-level nullability, `expression` for defaults/generated expressions, identity/generated kinds, explicit non-default collations |
45
+ | Table constraints | Named primary, unique, foreign-key, check, and exclusion definitions |
46
+ | Table indexes | Named definitions, including expressions/predicates, validity and readiness |
47
+ | `enums` | All enums in the namespace, with ordered labels, including unused or empty enums |
48
+ | `domains` | All domains in the namespace, with base type, nullability, default, collation and named constraints |
49
+ | `sequences` | All sequence declarations in the namespace: type, start/increment/min/max/cache/cycle and owned-column association |
50
+
51
+ The catalog kind codes are `r` (ordinary table) and `p` (partitioned table); persistence is `p`
52
+ (permanent), `u` (unlogged), or `t` (temporary). Column `identity` is empty, `a` (always), or `d`
53
+ (by default); `generated` is empty, `s` (stored), or `v` (virtual).
54
+
55
+ Sequence integers are exact strings. Sequence positions and table rows are excluded. The selected
56
+ namespace's `__tenon_migrations` table is excluded; migration-file checksums and adoption history
57
+ remain separate checks.
58
+
59
+ Version 1 excludes functions and their bodies, privileges/owners, row-security settings and
60
+ policies, triggers, rules, views/materialized views, foreign tables, extension/composite/range type
61
+ definitions, collation definitions, table storage settings and external object definitions.
62
+ Index storage clauses emitted by PostgreSQL's index definition (such as `WITH (fillfactor=...)`)
63
+ are included in that definition and therefore affect the fingerprint.
64
+ References to external objects remain qualified in PostgreSQL definitions. Verify excluded
65
+ application contracts independently; this fingerprint is not proof of complete schema equivalence.
66
+
67
+ ## Fingerprint contract
68
+
69
+ The fingerprint is `tenon-schema-v1:sha256:<hex>`. It hashes canonical JSON of the metadata, excluding
70
+ the fingerprint itself: object keys are sorted, arrays preserve their order, and strings retain their
71
+ exact content. Database object OIDs and connection values are absent. Schema names and the PostgreSQL
72
+ major version are included. A fresh replay into the same namespace on the same PostgreSQL major
73
+ reproduces the fingerprint despite different OIDs or sequence positions.
74
+
75
+ Generator syntax, prose and codec changes do not affect this format. Changes to fingerprint inputs
76
+ require a new format version. PostgreSQL owns SQL deparsing; inspect snapshot differences when
77
+ upgrading PostgreSQL, including patch releases. Different major versions deliberately produce
78
+ incomparable checkpoints. This avoids silently assuming that their catalog/deparser contracts match.
79
+
80
+ ## Adopt a reviewed checkpoint
81
+
82
+ Keep any existing guard until the replacement is verified:
83
+
84
+ 1. Replay the immutable baseline into a disposable database using the intended namespace and
85
+ PostgreSQL major. Verify it against the existing frozen references and independent checks.
86
+ 2. Capture and review the versioned snapshot. Commit its expected fingerprint alongside the
87
+ application's fixed adoption policy, retaining the original references.
88
+ 3. Before adoption, inspect the target and compare it with that reviewed reference. Refuse on a
89
+ mismatch. Keep function/security/history checks and test matching adoption, preserved rows,
90
+ repeat adoption, and refusal after relevant drift.
91
+
92
+ Do not establish the expected fingerprint from whichever live database happens to be configured.
93
+ Tenon's baseline adoption does not automatically perform this comparison. See
94
+ [adoption safety](adoption-guide.md#check-and-adopt-an-existing-database).
@@ -272,3 +272,27 @@ It does not check query annotations, pending migrations, functions, privileges,
272
272
  object. A driver or generator update can change output without schema drift. Review generated diffs
273
273
  after upgrades. Generated text and schema-summary formatting are not version-stable schema
274
274
  fingerprints; see [baseline verification](adoption-guide.md#check-and-adopt-an-existing-database).
275
+
276
+ ### UUID array parameters
277
+
278
+ `builtin.uuidArray` encodes a UUID array parameter with PostgreSQL's UUID-array OID. For a UUID
279
+ `id` column:
280
+
281
+ ```ts
282
+ import { builtin, param, sql } from '@kequtech/tenon-orm';
283
+
284
+ const ids = ['123e4567-e89b-42d3-a456-426614174000'];
285
+ const query = sql`SELECT id FROM public.users WHERE id = ANY(${param(ids, builtin.uuidArray)})`;
286
+ ```
287
+
288
+ PostgreSQL validates UUID syntax; the codec uses the existing recursive string-array encoding rules,
289
+ including empty arrays and SQL NULL elements. This additive helper does not change default decoding.
290
+ The existing `param(ids, builtin.textArray)::uuid[]` form remains valid.
291
+
292
+ ### Decode error context
293
+
294
+ `PGCLEAR_DECODE` errors expose `column` and `dataTypeOid` when the failing output field is known.
295
+ These identify the field without adding row values, SQL, or parameters to the error context. The
296
+ original decoder error remains available through `cause`; application-defined causes retain their
297
+ own content. Date/infinity failures still propagate and should retain the application's existing
298
+ fail-closed expiry checks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kequtech/tenon-orm",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "A PostgreSQL ORM designed for coding agents, with explicit SQL, typed queries, and migrations",
@@ -71,7 +71,7 @@
71
71
  "printWidth": 100
72
72
  },
73
73
  "dependencies": {
74
- "@kequtech/pgclear": "^0.3.3",
74
+ "@kequtech/pgclear": "^0.3.4",
75
75
  "typescript": "~6.0.2"
76
76
  }
77
77
  }