@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.
@@ -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,11 +62,19 @@ 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. Disjoint string-literal declarations fail as definite
69
+ mismatches; a declaration with some matching labels remains unproven. Result nullability is outside
70
+ the check.
71
+
65
72
  Input and output contracts are separate. For example, an int8 input accepts exact text, bigint, or
66
73
  an explicit encoder; its default output is a string. Strings, numbers, bigint, Date, and supported
67
74
  Temporal inputs use OID 0/server inference. Booleans, bytes, and recoverable `json`/`param` calls
68
75
  supply the same OID hints as execution. Unions with different hints create variants. Input types do
69
- not prove value ranges, valid Dates, or textual formats.
76
+ not prove value ranges, valid Dates, or textual formats. These general limits are stated once in
77
+ readable output, rather than repeated as diagnostics for ordinary queries.
70
78
 
71
79
  A plain object, array, or undefined input contradicts the driver's bind API. An opaque `Parameter`
72
80
  is trusted, with its unknown OID reported as unproven. Describe uses server inference in that case;
@@ -83,8 +91,8 @@ dependent result comparisons and inference errors remain unproven. This does not
83
91
  - **Unproven:** retain the declaration when justified, with validation/tests for what metadata cannot
84
92
  establish. A note alone does not fail the command.
85
93
 
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:
94
+ Finite optional mutation shapes are supported within the variant budget. A required, narrower
95
+ contract can reduce that budget when application validation always supplies a value:
88
96
 
89
97
  ```ts
90
98
  type ActivePatch = { active: boolean };
@@ -95,14 +103,18 @@ function setActive(id: number, patch: ActivePatch) {
95
103
  }
96
104
  ```
97
105
 
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.
106
+ If omission is intended, keep optional fields. An explicit column list fixes column selection and
107
+ order; it does not make optional fields required. Empty assignments still reject at runtime, so
108
+ validate the patch if that rejection is not intended. Keep execution tests for the actual effects.
100
109
 
101
110
  Exit 0 means no mismatch or unchecked site in the selected scope. It does not establish JSON shape,
102
111
  nullability, cardinality, or every runtime branch. No query sites also fails. Independent queries
103
112
  continue after an unsupported site, and a recoverable branch can expose a mismatch even when another
104
113
  branch is unchecked. Diagnostics include locations, codes, parameter/column identifiers, expected
105
- and observed types, and variant numbers. `--json` returns the structured report.
114
+ and observed types, and variant numbers. Readable output groups equivalent findings within each
115
+ query and counts those groups, preserving distinct columns, parameters and types. `--json` retains
116
+ the individual variant findings in the structured report. Ordinary scalar bindings and unknown
117
+ result nullability do not generate per-query findings.
106
118
 
107
119
  ## Supported composition
108
120
 
@@ -132,7 +144,7 @@ runtime SQL, but need database tests. Unknown source is never replaced with inve
132
144
  For checking, `insertValues` and `assignments` need an explicit finite shape type:
133
145
 
134
146
  ```ts
135
- type NewUser = { email: string; active: boolean };
147
+ type NewUser = { email: string; active?: boolean | undefined };
136
148
 
137
149
  function createUser(input: NewUser) {
138
150
  return sql<{ id: number }>`INSERT INTO public.users
@@ -140,16 +152,37 @@ function createUser(input: NewUser) {
140
152
  }
141
153
  ```
142
154
 
143
- The shape is a trusted expansion contract; the checker does not read the object. Prefer an explicit
144
- column list so runtime and checking select the same keys/order. Without one, the declared shape is
145
- assumed to describe exactly the expanded fields. Structural typing cannot prove the absence of
146
- extra own keys or their order, so that assumption is unproven.
147
-
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.
155
+ For a direct object literal, the checker recovers its actual own fields and value expressions without
156
+ evaluating them. This includes shorthand properties and literals within fixed batches. A broad
157
+ generated shape therefore does not multiply variants for fields absent from the literal. An
158
+ undefined-valued expression still creates defined/omitted branches when its type permits both.
159
+
160
+ For other inputs, the shape is a trusted expansion contract. Prefer an explicit column list so
161
+ runtime and checking select the same keys/order. Without one, the declared shape is assumed to
162
+ describe exactly the expanded fields, unless the input is a recovered literal. Structural typing
163
+ cannot prove the absence of extra own keys or their order, so that assumption is unproven. Object
164
+ aliases, spreads, computed keys and accessors retain this conservative shape analysis; the checker
165
+ does not reconstruct object mutation or evaluate getters.
166
+
167
+ Optional or undefined-valued fields create bounded SQL variants. With explicit insert columns, an
168
+ absent/undefined cell becomes `DEFAULT`; with inferred columns, a field absent from every row is
169
+ omitted. Fixed batches use each row's defined fields and per-cell defaults. Assignments omit
170
+ absent/undefined fields. SQL NULL stays a bound value and is separate from omission.
171
+
172
+ The checker describes only variants that can produce SQL. Empty assignments and inferred multi-row batches
173
+ with no defined fields are rejected by the runtime helper before dispatch; the report retains an
174
+ `empty-expansion-rejected` unproven note when such an input is possible. A mutation whose every
175
+ combination rejects is unchecked. These checks do not establish that application input validation
176
+ avoids runtime rejection.
177
+
178
+ The same 16-variant limit covers optional combinations, surrounding fragments, and parameter OID
179
+ alternatives. Select fewer columns or a narrower validated shape when that limit is exceeded.
180
+ Index signatures, unresolved generic key sets, shape unions, and unknown shapes remain unsupported.
181
+ Fixed literal batches are supported; unknown batch lengths are not. Runtime helpers still accept
182
+ inferred shapes and optional fields without source checking.
183
+
184
+ For an otherwise finite shape, a constrained generic field uses its base constraint to determine
185
+ defined and omitted variants. Open generic shapes and unconstrained field values remain unchecked.
153
186
 
154
187
  ## Programmatic use and custom codecs
155
188
 
@@ -166,8 +199,20 @@ const report = await checkQueries({
166
199
  if (!report.ok) process.exitCode = 1;
167
200
  ```
168
201
 
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
202
+ The report has `ok`, `project`, `queries`, `diagnostics`, `coverage`, and available inspection
203
+ `context`. Coverage counts recognized Tenon query sites in the selected scope:
204
+
205
+ - `total`: recognized complete query sites, including unsupported ones.
206
+ - `analysed`: sites whose complete SQL variant set was recovered and inspected.
207
+ - `unchecked`: sites with unsupported structure or incomplete inspection, including a site where only
208
+ some branches were recovered.
209
+
210
+ An analysed site can still have mismatches or unproven findings; use `ok` and diagnostics for the
211
+ result. Unrelated SQL tags are outside these counts. No recognized sites still fails with zero
212
+ coverage. JSON reports expose the same counts, so focused CI checks can retain visibility of their
213
+ scope without accepting unchecked sites as successes.
214
+
215
+ Options also accept `session`, `timeouts`, `codecs`, and `readTypes`. `connection` is a URI or pool-option
171
216
  values; its `codecs` are inherited unless the explicit inspection `codecs` argument is supplied.
172
217
  The programmatic API does not discover shared configuration.
173
218
 
@@ -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.
@@ -265,6 +265,8 @@ The bundled TypeScript compiler loads only through that path or the check comman
265
265
  Options are `{ connection, target?, project?, session?, timeouts?, codecs?, readTypes? }`.
266
266
  Connection is a URI or pool-option values; connection codecs are inherited unless `codecs` is explicit. The report contains `ok`, `project`, `queries`, `diagnostics`, and available
267
267
  inspection `context`. Mismatches, unchecked sites, and no sites fail; unproven findings alone do not.
268
+ Readable output groups equivalent findings per query; JSON retains their variant records. General
269
+ limits on runtime values and result nullability do not generate individual diagnostics.
268
270
  See [query checking](query-checking.md) for selection, supported composition, and custom decoders.
269
271
 
270
272
  ## Query inspection
@@ -325,6 +327,7 @@ Unsupported command flags reject.
325
327
  ```text
326
328
  tenon init [--config path]
327
329
  tenon new <name> [--directory migrations]
330
+ tenon schema [--namespace schema]
328
331
  tenon generate [--out path] [--namespace schema] [--check]
329
332
  tenon migrate [--directory migrations] [--namespace public]
330
333
  tenon status [--directory migrations] [--namespace public]
@@ -344,6 +347,7 @@ tenon describe <query.sql> [--role name] [--search-path value] [--time-zone valu
344
347
  | `new` | Paths of the new empty up/down pair; fill before applying |
345
348
  | `migrate` / `rollback` | Completed IDs or “nothing to do”; each file commits independently |
346
349
  | `status` | JSON `{ applied, pending }`; validates history |
350
+ | `schema` | Emits a versioned catalog snapshot and fingerprint; requires shared configuration |
347
351
  | `baseline capture` | Writes a reviewed schema-only baseline using native `pg_dump` |
348
352
  | `baseline adopt` | Records baseline history without executing its SQL |
349
353
  | `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).