@kequtech/tenon-orm 0.4.5 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +2 -1
- package/dist/check/contracts.d.ts +2 -2
- package/dist/check/contracts.js +12 -28
- package/dist/check/output.d.ts +3 -0
- package/dist/check/output.js +31 -0
- package/dist/check/source.d.ts +2 -2
- package/dist/check/source.js +105 -63
- package/dist/check/types.d.ts +7 -0
- package/dist/check.d.ts +1 -1
- package/dist/check.js +21 -3
- package/dist/cli.js +24 -10
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/inspection/catalog.d.ts +1 -0
- package/dist/inspection/catalog.js +5 -1
- package/dist/inspection/result.js +12 -2
- package/dist/inspection/types.d.ts +2 -0
- package/dist/migrations/connection.js +2 -2
- package/dist/schema/catalog.d.ts +4 -0
- package/dist/schema/catalog.js +69 -0
- package/dist/schema/types.d.ts +70 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema.d.ts +4 -0
- package/dist/schema.js +47 -0
- package/docs/adoption-guide.md +4 -1
- package/docs/configuration.md +2 -1
- package/docs/connections.md +45 -1
- package/docs/query-checking.md +64 -19
- package/docs/query-inspection.md +9 -0
- package/docs/reference.md +6 -2
- package/docs/schema-inspection.md +94 -0
- package/docs/schema-types.md +24 -0
- package/package.json +2 -2
|
@@ -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 {};
|
package/dist/schema.d.ts
ADDED
|
@@ -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
|
+
}
|
package/docs/adoption-guide.md
CHANGED
|
@@ -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.
|
|
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
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
package/docs/connections.md
CHANGED
|
@@ -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.
|
|
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.
|
package/docs/query-checking.md
CHANGED
|
@@ -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
|
-
|
|
87
|
-
validation always supplies a
|
|
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
|
|
99
|
-
not make optional fields required.
|
|
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.
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
170
|
-
|
|
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
|
|
package/docs/query-inspection.md
CHANGED
|
@@ -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.
|
|
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).
|