@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 +17 -0
- package/README.md +2 -1
- package/dist/check/contracts.js +7 -3
- package/dist/check/source.d.ts +0 -1
- package/dist/check/source.js +75 -29
- package/dist/check/types.d.ts +7 -0
- package/dist/check.d.ts +1 -1
- package/dist/check.js +20 -2
- package/dist/cli.js +22 -6
- 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 +44 -12
- package/docs/query-inspection.md +9 -0
- package/docs/reference.md +4 -2
- package/docs/schema-inspection.md +94 -0
- package/docs/schema-types.md +24 -0
- package/package.json +2 -2
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
|
package/dist/check/contracts.js
CHANGED
|
@@ -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 (!
|
|
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(
|
|
172
|
-
const mismatch = disjoint(project,
|
|
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',
|
package/dist/check/source.d.ts
CHANGED
package/dist/check/source.js
CHANGED
|
@@ -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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
return {
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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(`(${
|
|
329
|
-
for (const [index, row] of
|
|
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,
|
|
371
|
+
for (const [position, column] of selected.entries()) {
|
|
334
372
|
if (position)
|
|
335
373
|
parts.push(', ');
|
|
336
|
-
parts.push(
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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;
|
package/dist/check/types.d.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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' ||
|
|
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.
|
|
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';
|
|
@@ -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
|
|
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 {
|
|
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 {};
|
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,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
|
-
|
|
87
|
-
validation always supplies a
|
|
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
|
|
99
|
-
not make optional fields required.
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
170
|
-
|
|
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
|
|
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.
|
|
@@ -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).
|
package/docs/schema-types.md
CHANGED
|
@@ -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.
|
|
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.
|
|
74
|
+
"@kequtech/pgclear": "^0.3.4",
|
|
75
75
|
"typescript": "~6.0.2"
|
|
76
76
|
}
|
|
77
77
|
}
|