@kequtech/tenon-orm 0.1.4 → 0.2.0
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 +56 -0
- package/README.md +15 -19
- package/dist/affected-rows.d.ts +1 -1
- package/dist/affected-rows.js +6 -6
- package/dist/baseline.d.ts +1 -1
- package/dist/baseline.js +5 -5
- package/dist/cli.js +18 -39
- package/dist/client.js +1 -1
- package/dist/connection.d.ts +18 -13
- package/dist/connection.js +14 -43
- package/dist/database.js +4 -2
- package/dist/describe.d.ts +3 -0
- package/dist/describe.js +21 -0
- package/dist/driver/array.d.ts +5 -0
- package/dist/driver/array.js +70 -0
- package/dist/driver/lifecycle.d.ts +15 -0
- package/dist/driver/lifecycle.js +57 -0
- package/dist/driver/session.d.ts +8 -0
- package/dist/driver/session.js +92 -0
- package/dist/driver/types.d.ts +10 -0
- package/dist/driver/types.js +49 -0
- package/dist/generate.js +7 -7
- package/dist/generation/catalog.d.ts +2 -2
- package/dist/generation/catalog.js +4 -4
- package/dist/generation/output.d.ts +2 -0
- package/dist/generation/output.js +35 -0
- package/dist/generation/schema-summary.d.ts +3 -3
- package/dist/generation/schema-summary.js +11 -11
- package/dist/generation/table-types.js +2 -52
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/inspection/catalog.d.ts +25 -0
- package/dist/inspection/catalog.js +29 -0
- package/dist/inspection/cli.d.ts +22 -0
- package/dist/inspection/cli.js +34 -0
- package/dist/inspection/connection.d.ts +4 -0
- package/dist/inspection/connection.js +44 -0
- package/dist/inspection/options.d.ts +15 -0
- package/dist/inspection/options.js +49 -0
- package/dist/inspection/result.d.ts +4 -0
- package/dist/inspection/result.js +90 -0
- package/dist/inspection/types.d.ts +74 -0
- package/dist/inspection/types.js +1 -0
- package/dist/migrations/baseline-sql.d.ts +1 -1
- package/dist/migrations/baseline-sql.js +1 -1
- package/dist/migrations/connection.d.ts +2 -3
- package/dist/migrations/connection.js +15 -13
- package/dist/migrations/history.d.ts +4 -4
- package/dist/migrations/history.js +5 -5
- package/dist/migrations.js +7 -7
- package/dist/mutations.d.ts +0 -5
- package/dist/mutations.js +33 -41
- package/dist/query-options.d.ts +2 -2
- package/dist/query-options.js +4 -4
- package/dist/query.d.ts +3 -4
- package/dist/query.js +2 -2
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +1 -1
- package/dist/transaction-sql.js +1 -1
- package/dist/transaction.d.ts +2 -2
- package/dist/transaction.js +14 -13
- package/dist/types.d.ts +4 -4
- package/docs/adoption-guide.md +39 -76
- package/docs/agent-guide.md +76 -107
- package/docs/human-guide.md +20 -34
- package/docs/mutations.md +13 -13
- package/docs/query-inspection.md +128 -0
- package/docs/reference.md +66 -288
- package/package.json +10 -31
- package/dist/safeql/composition.d.ts +0 -9
- package/dist/safeql/composition.js +0 -168
- package/dist/safeql/eslint.d.ts +0 -2
- package/dist/safeql/eslint.js +0 -50
- package/dist/safeql/mutations.d.ts +0 -3
- package/dist/safeql/mutations.js +0 -74
- package/dist/safeql/plugin.d.ts +0 -3
- package/dist/safeql/plugin.js +0 -43
- package/dist/safeql/static.d.ts +0 -10
- package/dist/safeql/static.js +0 -59
- package/dist/safeql.d.ts +0 -6
- package/dist/safeql.js +0 -7
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type postgres from 'postgres';
|
|
2
|
+
import type { SqlConnection } from '../connection.js';
|
|
3
|
+
/** Pin a healthy session for transactions and advisory locks, with explicit failure cleanup. */
|
|
4
|
+
export declare function reserveConnection(sql: postgres.Sql, initial?: 'SELECT 1' | 'BEGIN'): Promise<{
|
|
5
|
+
sql: SqlConnection;
|
|
6
|
+
check: () => void;
|
|
7
|
+
release(discard?: boolean): Promise<void>;
|
|
8
|
+
}>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { prepareArrays } from './array.js';
|
|
2
|
+
import { observeConnections } from './lifecycle.js';
|
|
3
|
+
let nextLease = 0;
|
|
4
|
+
/** Pin a healthy session for transactions and advisory locks, with explicit failure cleanup. */
|
|
5
|
+
export async function reserveConnection(sql, initial = 'SELECT 1') {
|
|
6
|
+
await prepareArrays(sql);
|
|
7
|
+
const state = observeConnections(sql);
|
|
8
|
+
// Warm a connection before reserve(): 3.4.9 can strand a cold reserve after reconnect.
|
|
9
|
+
await sql `SELECT 1`;
|
|
10
|
+
let abandoned = false;
|
|
11
|
+
let rejectAcquisition;
|
|
12
|
+
const closed = new Promise((_, reject) => {
|
|
13
|
+
rejectAcquisition = reject;
|
|
14
|
+
});
|
|
15
|
+
state.acquiring.add(rejectAcquisition);
|
|
16
|
+
const pending = sql.reserve();
|
|
17
|
+
void pending.then((client) => {
|
|
18
|
+
if (abandoned)
|
|
19
|
+
client.release();
|
|
20
|
+
}, () => { });
|
|
21
|
+
let client;
|
|
22
|
+
try {
|
|
23
|
+
client = await Promise.race([pending, closed]);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
abandoned = true;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
state.acquiring.delete(rejectAcquisition);
|
|
31
|
+
}
|
|
32
|
+
let rejectClosed;
|
|
33
|
+
const closedSession = new Promise((_, reject) => {
|
|
34
|
+
rejectClosed = reject;
|
|
35
|
+
});
|
|
36
|
+
void closedSession.catch(() => { });
|
|
37
|
+
const watch = {
|
|
38
|
+
closed: closedSession,
|
|
39
|
+
reject: rejectClosed,
|
|
40
|
+
check() {
|
|
41
|
+
if (watch.failure)
|
|
42
|
+
throw watch.failure;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const safe = {
|
|
46
|
+
async unsafe(text, input = [], options) {
|
|
47
|
+
watch.check();
|
|
48
|
+
// Driver dispatch runs in the next microtask, before another socket event.
|
|
49
|
+
const query = client.unsafe(text, input, options).execute();
|
|
50
|
+
return Promise.race([query, watch.closed]);
|
|
51
|
+
},
|
|
52
|
+
array: client.array,
|
|
53
|
+
typed: client.typed,
|
|
54
|
+
};
|
|
55
|
+
const connection = {
|
|
56
|
+
sql: safe,
|
|
57
|
+
check: watch.check,
|
|
58
|
+
async release(discard = false) {
|
|
59
|
+
for (const [id, active] of state.active)
|
|
60
|
+
if (active === watch)
|
|
61
|
+
state.active.delete(id);
|
|
62
|
+
if (watch.failure)
|
|
63
|
+
return; // The driver already removed the closed session.
|
|
64
|
+
if (discard) {
|
|
65
|
+
// Failed recovery must not return an unknown transaction/lock state to the pool.
|
|
66
|
+
try {
|
|
67
|
+
await client.unsafe('SELECT pg_terminate_backend(pg_backend_pid())').execute();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* The backend closes itself. */
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
client.release();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
// Capture the connection on BEGIN itself when opening a transaction.
|
|
78
|
+
const marker = `${initial} /* tenon_session_${++nextLease} */`;
|
|
79
|
+
const stopCapture = state.capture(marker, watch);
|
|
80
|
+
try {
|
|
81
|
+
await safe.unsafe(marker, [], { simple: false, prepare: false });
|
|
82
|
+
watch.check();
|
|
83
|
+
return connection;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
await connection.release(true);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
stopCapture();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Postgres.js default representations (with SQL NULL array elements preserved), shared by generation and query inspection. */
|
|
2
|
+
export type ValueTypes = Readonly<{
|
|
3
|
+
read: string;
|
|
4
|
+
write: string;
|
|
5
|
+
}>;
|
|
6
|
+
type TypeMapping = string | ValueTypes;
|
|
7
|
+
export declare const arrayTypes: Readonly<Record<string, TypeMapping>>;
|
|
8
|
+
export declare const scalarTypes: Readonly<Record<string, TypeMapping>>;
|
|
9
|
+
export declare function valueTypes(mapping: TypeMapping): ValueTypes;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Arrays use verified built-in OIDs: their parsers can differ from scalar parsers.
|
|
2
|
+
export const arrayTypes = {
|
|
3
|
+
'1000': 'boolean',
|
|
4
|
+
'1001': 'import("node:buffer").Buffer',
|
|
5
|
+
'1005': 'number',
|
|
6
|
+
'1007': 'number',
|
|
7
|
+
'1021': 'number',
|
|
8
|
+
'1022': 'number',
|
|
9
|
+
'1028': 'number',
|
|
10
|
+
'1016': { read: 'string', write: 'string | bigint' },
|
|
11
|
+
'1231': { read: 'string', write: 'string | number' },
|
|
12
|
+
'1009': 'string',
|
|
13
|
+
'1014': 'string',
|
|
14
|
+
'1015': 'string',
|
|
15
|
+
'1183': 'string',
|
|
16
|
+
'1270': 'string',
|
|
17
|
+
'2951': 'string',
|
|
18
|
+
'1182': { read: 'globalThis.Date', write: 'globalThis.Date | string' },
|
|
19
|
+
'1115': { read: 'globalThis.Date', write: 'globalThis.Date | string' },
|
|
20
|
+
'1185': { read: 'globalThis.Date', write: 'globalThis.Date | string' },
|
|
21
|
+
'199': { read: 'unknown', write: 'Exclude<import("@kequtech/tenon-orm").JSONValue, null>' },
|
|
22
|
+
'3807': { read: 'unknown', write: 'Exclude<import("@kequtech/tenon-orm").JSONValue, null>' },
|
|
23
|
+
};
|
|
24
|
+
export const scalarTypes = {
|
|
25
|
+
int2: 'number',
|
|
26
|
+
int4: 'number',
|
|
27
|
+
oid: 'number',
|
|
28
|
+
float4: 'number',
|
|
29
|
+
float8: 'number',
|
|
30
|
+
bool: 'boolean',
|
|
31
|
+
text: 'string',
|
|
32
|
+
name: 'string',
|
|
33
|
+
varchar: 'string',
|
|
34
|
+
bpchar: 'string',
|
|
35
|
+
time: 'string',
|
|
36
|
+
timetz: 'string',
|
|
37
|
+
uuid: 'string',
|
|
38
|
+
date: { read: 'globalThis.Date', write: 'globalThis.Date | string' },
|
|
39
|
+
timestamp: { read: 'globalThis.Date', write: 'globalThis.Date | string' },
|
|
40
|
+
timestamptz: { read: 'globalThis.Date', write: 'globalThis.Date | string' },
|
|
41
|
+
int8: { read: 'string', write: 'string | bigint' },
|
|
42
|
+
numeric: { read: 'string', write: 'string | number' },
|
|
43
|
+
json: { read: 'unknown', write: 'Exclude<import("@kequtech/tenon-orm").JSONValue, null>' },
|
|
44
|
+
jsonb: { read: 'unknown', write: 'Exclude<import("@kequtech/tenon-orm").JSONValue, null>' },
|
|
45
|
+
bytea: 'import("node:buffer").Buffer',
|
|
46
|
+
};
|
|
47
|
+
export function valueTypes(mapping) {
|
|
48
|
+
return typeof mapping === 'string' ? { read: mapping, write: mapping } : mapping;
|
|
49
|
+
}
|
package/dist/generate.js
CHANGED
|
@@ -7,17 +7,17 @@ import { quote } from './sql.js';
|
|
|
7
7
|
export async function generateDefinitions(options) {
|
|
8
8
|
const onWarning = options.onWarning ?? ((message) => console.warn(`Warning: ${message}`));
|
|
9
9
|
return withMigrationLock(options, 'shared', async (client, namespace) => {
|
|
10
|
-
await client.
|
|
11
|
-
const schema = await client.
|
|
10
|
+
await client.unsafe('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
11
|
+
const schema = await client.unsafe('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
|
|
12
12
|
namespace,
|
|
13
13
|
]);
|
|
14
|
-
if (!schema.
|
|
14
|
+
if (!schema.length)
|
|
15
15
|
throw new Error(`Schema ${quote(namespace)} does not exist.`);
|
|
16
16
|
// Keep catalog deparsing stable across connection settings and replay schemas.
|
|
17
17
|
// Transaction-local settings are restored before returning a borrowed client.
|
|
18
|
-
await client.
|
|
19
|
-
await client.
|
|
20
|
-
await client.
|
|
18
|
+
await client.unsafe(`SET LOCAL search_path TO pg_catalog, ${quote(namespace)}`);
|
|
19
|
+
await client.unsafe('SET LOCAL quote_all_identifiers TO off');
|
|
20
|
+
await client.unsafe('SET LOCAL standard_conforming_strings TO on');
|
|
21
21
|
const definitions = await readTables(client, namespace);
|
|
22
22
|
const summaries = [];
|
|
23
23
|
for (const table of definitions) {
|
|
@@ -25,7 +25,7 @@ export async function generateDefinitions(options) {
|
|
|
25
25
|
}
|
|
26
26
|
const domains = await domainSummaries(client, definitions.flatMap((table) => table.columns.map((column) => column.declaredOid)));
|
|
27
27
|
const types = renderTableTypes(definitions, namespace, onWarning);
|
|
28
|
-
await client.
|
|
28
|
+
await client.unsafe('COMMIT');
|
|
29
29
|
return `// Generated by Tenon from the PostgreSQL schema. Do not edit.
|
|
30
30
|
// Commit this file and regenerate it after changing the schema.
|
|
31
31
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SqlConnection } from '../connection.js';
|
|
2
2
|
export interface ColumnRow {
|
|
3
3
|
name: string;
|
|
4
4
|
oid: string;
|
|
@@ -19,4 +19,4 @@ export interface TableRow {
|
|
|
19
19
|
name: string;
|
|
20
20
|
columns: ColumnRow[];
|
|
21
21
|
}
|
|
22
|
-
export declare function readTables(client:
|
|
22
|
+
export declare function readTables(client: SqlConnection, namespace: string): Promise<TableRow[]>;
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { migrationTable } from '../migrations/history.js';
|
|
2
2
|
export async function readTables(client, namespace) {
|
|
3
|
-
const tables = await client.
|
|
3
|
+
const tables = await client.unsafe(`
|
|
4
4
|
SELECT c.oid::text AS oid, c.relname AS name
|
|
5
5
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
6
6
|
WHERE n.nspname = $1 AND c.relname <> $2 AND c.relkind IN ('r', 'p')
|
|
7
7
|
AND NOT c.relispartition
|
|
8
8
|
ORDER BY c.relname COLLATE "C"`, [namespace, migrationTable]);
|
|
9
9
|
const definitions = [];
|
|
10
|
-
for (const table of tables
|
|
10
|
+
for (const table of tables) {
|
|
11
11
|
// Attached descendants are storage details, not separate generated models.
|
|
12
|
-
const columns = await client.
|
|
12
|
+
const columns = await client.unsafe(`
|
|
13
13
|
WITH RECURSIVE resolved AS (
|
|
14
14
|
SELECT a.attnum, t.oid, t.typbasetype, t.typnotnull AS domain_not_null
|
|
15
15
|
FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
|
@@ -32,7 +32,7 @@ export async function readTables(client, namespace) {
|
|
|
32
32
|
JOIN pg_catalog.pg_type declared ON declared.oid = a.atttypid
|
|
33
33
|
JOIN pg_catalog.pg_namespace dn ON dn.oid = declared.typnamespace
|
|
34
34
|
WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, [table.oid]);
|
|
35
|
-
definitions.push({ ...table, columns: columns
|
|
35
|
+
definitions.push({ ...table, columns: columns });
|
|
36
36
|
}
|
|
37
37
|
return definitions;
|
|
38
38
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { definitionDiff } from './diff.js';
|
|
5
|
+
/** Check without writing, or replace the complete generated file atomically. */
|
|
6
|
+
export async function writeDefinitions(source, filename, check) {
|
|
7
|
+
const output = resolve(filename);
|
|
8
|
+
if (check) {
|
|
9
|
+
let existing;
|
|
10
|
+
try {
|
|
11
|
+
existing = await readFile(output, 'utf8');
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
throw new Error(`Generated definitions file is missing: ${output}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
|
|
18
|
+
}
|
|
19
|
+
if (existing !== source) {
|
|
20
|
+
throw new Error(`Generated definitions differ: ${output}\n${definitionDiff(existing, source)}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
|
|
21
|
+
}
|
|
22
|
+
process.stdout.write('Generated definitions are current.\n');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
await mkdir(dirname(output), { recursive: true });
|
|
26
|
+
const temporary = `${output}.${randomUUID()}.tmp`;
|
|
27
|
+
try {
|
|
28
|
+
await writeFile(temporary, source, 'utf8');
|
|
29
|
+
await rename(temporary, output);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await rm(temporary, { force: true });
|
|
33
|
+
}
|
|
34
|
+
process.stdout.write(`Generated ${output}\n`);
|
|
35
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SqlConnection } from '../connection.js';
|
|
2
2
|
/** Include only domains reached through selected columns, base domains or arrays. */
|
|
3
|
-
export declare function domainSummaries(client:
|
|
3
|
+
export declare function domainSummaries(client: SqlConnection, typeOids: readonly string[]): Promise<string[]>;
|
|
4
4
|
/** Called inside generation's read-only snapshot, with a fixed local search_path. */
|
|
5
|
-
export declare function tableSummary(client:
|
|
5
|
+
export declare function tableSummary(client: SqlConnection, oid: string, name: string): Promise<string>;
|
|
6
6
|
export declare function schemaSummary(summaries: readonly string[]): string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { quote } from '../sql.js';
|
|
2
2
|
/** Include only domains reached through selected columns, base domains or arrays. */
|
|
3
3
|
export async function domainSummaries(client, typeOids) {
|
|
4
|
-
const domains = await client.
|
|
4
|
+
const domains = await client.unsafe(`
|
|
5
5
|
WITH RECURSIVE referenced(oid) AS (
|
|
6
6
|
SELECT unnest($1::oid[])
|
|
7
7
|
UNION
|
|
@@ -16,10 +16,10 @@ export async function domainSummaries(client, typeOids) {
|
|
|
16
16
|
FROM referenced r JOIN pg_catalog.pg_type t ON t.oid = r.oid
|
|
17
17
|
JOIN pg_catalog.pg_type base ON base.oid = t.typbasetype
|
|
18
18
|
WHERE t.typtype = 'd'
|
|
19
|
-
ORDER BY pg_catalog.format_type(t.oid, NULL) COLLATE "C"`, [typeOids]);
|
|
19
|
+
ORDER BY pg_catalog.format_type(t.oid, NULL) COLLATE "C"`, [client.array([...typeOids])]);
|
|
20
20
|
const summaries = [];
|
|
21
|
-
for (const domain of domains
|
|
22
|
-
const constraints = await client.
|
|
21
|
+
for (const domain of domains) {
|
|
22
|
+
const constraints = await client.unsafe(`
|
|
23
23
|
SELECT conname AS name, pg_catalog.pg_get_constraintdef(oid, false) AS definition
|
|
24
24
|
FROM pg_catalog.pg_constraint WHERE contypid = $1::oid
|
|
25
25
|
ORDER BY conname COLLATE "C"`, [domain.oid]);
|
|
@@ -28,14 +28,14 @@ export async function domainSummaries(client, typeOids) {
|
|
|
28
28
|
(domain.collation === null ? '' : ` COLLATE ${domain.collation}`) +
|
|
29
29
|
(domain.expression === null ? '' : ` DEFAULT ${domain.expression}`) +
|
|
30
30
|
(domain.nullable ? '' : ' NOT NULL'),
|
|
31
|
-
...constraints.
|
|
31
|
+
...constraints.map((constraint) => ` CONSTRAINT ${quote(constraint.name)} ${constraint.definition}`),
|
|
32
32
|
].join('\n'));
|
|
33
33
|
}
|
|
34
34
|
return summaries;
|
|
35
35
|
}
|
|
36
36
|
/** Called inside generation's read-only snapshot, with a fixed local search_path. */
|
|
37
37
|
export async function tableSummary(client, oid, name) {
|
|
38
|
-
const columns = await client.
|
|
38
|
+
const columns = await client.unsafe(`
|
|
39
39
|
SELECT a.attname AS name,
|
|
40
40
|
pg_catalog.format_type(a.atttypid, a.atttypmod)
|
|
41
41
|
|| CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END
|
|
@@ -63,12 +63,12 @@ export async function tableSummary(client, oid, name) {
|
|
|
63
63
|
ORDER BY a.attnum`, [oid]);
|
|
64
64
|
// Column definitions already describe NOT NULL; PostgreSQL 18 also stores it
|
|
65
65
|
// as a table constraint, which would duplicate every required column here.
|
|
66
|
-
const constraints = await client.
|
|
66
|
+
const constraints = await client.unsafe(`
|
|
67
67
|
SELECT conname AS name, pg_catalog.pg_get_constraintdef(oid, false) AS definition
|
|
68
68
|
FROM pg_catalog.pg_constraint
|
|
69
69
|
WHERE conrelid = $1::oid AND conparentid = 0 AND contype IN ('p', 'u', 'f', 'c', 'x')
|
|
70
70
|
ORDER BY conname COLLATE "C"`, [oid]);
|
|
71
|
-
const indexes = await client.
|
|
71
|
+
const indexes = await client.unsafe(`
|
|
72
72
|
SELECT pg_catalog.pg_get_indexdef(i.indexrelid, 0, false) AS definition,
|
|
73
73
|
pg_catalog.format('CREATE %sINDEX %I ON %s',
|
|
74
74
|
CASE WHEN i.indisunique THEN 'UNIQUE ' ELSE '' END, ic.relname,
|
|
@@ -83,9 +83,9 @@ export async function tableSummary(client, oid, name) {
|
|
|
83
83
|
ORDER BY ic.relname COLLATE "C"`, [oid]);
|
|
84
84
|
const lines = [
|
|
85
85
|
`TABLE ${quote(name)}`,
|
|
86
|
-
...columns.
|
|
87
|
-
...constraints.
|
|
88
|
-
...indexes.
|
|
86
|
+
...columns.map((column) => ` ${quote(column.name)} ${column.definition}`),
|
|
87
|
+
...constraints.map((constraint) => ` CONSTRAINT ${quote(constraint.name)} ${constraint.definition}`),
|
|
88
|
+
...indexes.map((index) => {
|
|
89
89
|
// pg_get_indexdef always qualifies its target table. Remove only that
|
|
90
90
|
// known prefix, never matching/replacing namespace text inside SQL literals.
|
|
91
91
|
const qualifiedPrefix = index.prefix + index.qualification;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { arrayTypes, scalarTypes, valueTypes } from '../driver/types.js';
|
|
1
2
|
// Every generated table remains accessible through Rows["exact SQL name"].
|
|
2
3
|
const reservedTypeNames = new Set(`
|
|
3
4
|
as await break case catch class const continue debugger default delete do else enum
|
|
@@ -12,57 +13,6 @@ const reservedTypeNames = new Set(`
|
|
|
12
13
|
function canExportRowName(name) {
|
|
13
14
|
return (/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(name) && !reservedTypeNames.has(name));
|
|
14
15
|
}
|
|
15
|
-
// A string means the read and write representations match. Arrays use verified
|
|
16
|
-
// built-in OIDs because their parsers can differ from scalar parsers (numeric[]).
|
|
17
|
-
const arrayTypes = {
|
|
18
|
-
'1000': 'boolean',
|
|
19
|
-
'1001': 'import("node:buffer").Buffer',
|
|
20
|
-
'1005': 'number',
|
|
21
|
-
'1007': 'number',
|
|
22
|
-
'1021': 'number',
|
|
23
|
-
'1022': 'number',
|
|
24
|
-
'1028': 'number',
|
|
25
|
-
'1016': { read: 'string', write: 'string | bigint' },
|
|
26
|
-
'1231': { read: 'number', write: 'string | number' },
|
|
27
|
-
'1009': 'string',
|
|
28
|
-
'1014': 'string',
|
|
29
|
-
'1015': 'string',
|
|
30
|
-
'1183': 'string',
|
|
31
|
-
'1270': 'string',
|
|
32
|
-
'2951': 'string',
|
|
33
|
-
'1182': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
34
|
-
'1115': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
35
|
-
'1185': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
36
|
-
'199': { read: 'unknown', write: 'string' },
|
|
37
|
-
'3807': { read: 'unknown', write: 'string' },
|
|
38
|
-
};
|
|
39
|
-
const scalarTypes = {
|
|
40
|
-
int2: 'number',
|
|
41
|
-
int4: 'number',
|
|
42
|
-
oid: 'number',
|
|
43
|
-
float4: 'number',
|
|
44
|
-
float8: 'number',
|
|
45
|
-
bool: 'boolean',
|
|
46
|
-
text: 'string',
|
|
47
|
-
name: 'string',
|
|
48
|
-
varchar: 'string',
|
|
49
|
-
bpchar: 'string',
|
|
50
|
-
time: 'string',
|
|
51
|
-
timetz: 'string',
|
|
52
|
-
uuid: 'string',
|
|
53
|
-
// pg returns +/-Infinity as numbers. TypeScript has no infinity literal type.
|
|
54
|
-
date: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
55
|
-
timestamp: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
56
|
-
timestamptz: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
|
|
57
|
-
int8: { read: 'string', write: 'string | bigint' },
|
|
58
|
-
numeric: { read: 'string', write: 'string | number' },
|
|
59
|
-
json: { read: 'unknown', write: 'string' },
|
|
60
|
-
jsonb: { read: 'unknown', write: 'string' },
|
|
61
|
-
bytea: 'import("node:buffer").Buffer',
|
|
62
|
-
};
|
|
63
|
-
function valueTypes(mapping) {
|
|
64
|
-
return typeof mapping === 'string' ? { read: mapping, write: mapping } : mapping;
|
|
65
|
-
}
|
|
66
16
|
function isSupportedArray(value) {
|
|
67
17
|
return Object.hasOwn(arrayTypes, value.oid);
|
|
68
18
|
}
|
|
@@ -87,7 +37,7 @@ function columnTypes(value, column, onWarning) {
|
|
|
87
37
|
const underlying = value.declaredOid === value.oid
|
|
88
38
|
? ''
|
|
89
39
|
: ` with underlying type ${JSON.stringify(value.typeSchema)}.${JSON.stringify(value.type)} (OID ${value.oid})`;
|
|
90
|
-
onWarning(`Column ${column} uses PostgreSQL type ${JSON.stringify(value.declaredSchema)}.${JSON.stringify(value.declaredType)} (OID ${value.declaredOid})${underlying} without a verified
|
|
40
|
+
onWarning(`Column ${column} uses PostgreSQL type ${JSON.stringify(value.declaredSchema)}.${JSON.stringify(value.declaredType)} (OID ${value.declaredOid})${underlying} without a verified Postgres.js mapping; generated as unknown for reads and never for non-null writes.`);
|
|
91
41
|
types = { read: 'unknown', write: 'never' };
|
|
92
42
|
}
|
|
93
43
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export type {
|
|
1
|
+
export type { JSONValue } from 'postgres';
|
|
2
|
+
export type { Connection, ConnectionOptions, QueryResult } from './connection.js';
|
|
2
3
|
export type { AffectedRowsConstraint } from './affected-rows.js';
|
|
3
4
|
export { createTenon } from './database.js';
|
|
4
5
|
export type { TenonOptions } from './database.js';
|
|
@@ -7,6 +8,8 @@ export type { QueryOptions, QueryOutput, QueryReturnMode } from './query-options
|
|
|
7
8
|
export { identifier, sql, unsafeSql } from './query.js';
|
|
8
9
|
export type { SqlFragment, SqlQuery, UnknownRow } from './query.js';
|
|
9
10
|
export type { Database, QueryClient, QueryEvent, Transaction } from './types.js';
|
|
11
|
+
export { describeQuery } from './describe.js';
|
|
12
|
+
export type { DescribeOptions, DescribedColumn, DescribedType, DescriptionIssue, InspectionContext, InspectionSession, InspectionTimeouts, QueryDescription, } from './inspection/types.js';
|
|
10
13
|
export { adoptBaseline, captureBaseline } from './baseline.js';
|
|
11
14
|
export type { CaptureBaselineOptions, CapturedBaseline } from './baseline.js';
|
|
12
15
|
export { generateDefinitions } from './generate.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { createTenon } from './database.js';
|
|
2
2
|
export { AffectedRowsError } from './errors.js';
|
|
3
3
|
export { identifier, sql, unsafeSql } from './query.js';
|
|
4
|
+
export { describeQuery } from './describe.js';
|
|
4
5
|
export { adoptBaseline, captureBaseline } from './baseline.js';
|
|
5
6
|
export { generateDefinitions } from './generate.js';
|
|
6
7
|
export { migrate, migrationStatus, rollback } from './migrations.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type postgres from 'postgres';
|
|
2
|
+
interface CatalogType {
|
|
3
|
+
oid: number;
|
|
4
|
+
schema: string;
|
|
5
|
+
name: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
base: number;
|
|
8
|
+
choices: string[] | null;
|
|
9
|
+
}
|
|
10
|
+
interface ColumnSource {
|
|
11
|
+
oid: number;
|
|
12
|
+
number: number;
|
|
13
|
+
schema: string;
|
|
14
|
+
table: string;
|
|
15
|
+
column: string;
|
|
16
|
+
notNull: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface StatementMetadata {
|
|
19
|
+
statement: postgres.Statement;
|
|
20
|
+
types: readonly CatalogType[];
|
|
21
|
+
sources: readonly ColumnSource[];
|
|
22
|
+
}
|
|
23
|
+
/** Describe the statement and read its type and source-column catalog records. */
|
|
24
|
+
export declare function readStatementMetadata(client: postgres.Sql, text: string): Promise<StatementMetadata>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Describe the statement and read its type and source-column catalog records. */
|
|
2
|
+
export async function readStatementMetadata(client, text) {
|
|
3
|
+
const statement = await client.unsafe(text, [], { prepare: true }).describe();
|
|
4
|
+
const oids = [...new Set([...statement.types, ...statement.columns.map((c) => c.type)])];
|
|
5
|
+
const types = await client `
|
|
6
|
+
WITH RECURSIVE wanted(oid) AS (
|
|
7
|
+
SELECT unnest(string_to_array(${oids.join(',')}, ',')::oid[])
|
|
8
|
+
UNION
|
|
9
|
+
SELECT t.typbasetype FROM pg_catalog.pg_type t JOIN wanted w ON w.oid = t.oid
|
|
10
|
+
WHERE t.typbasetype <> 0
|
|
11
|
+
)
|
|
12
|
+
SELECT t.oid, n.nspname AS schema, t.typname AS name,
|
|
13
|
+
t.typtype AS kind, t.typbasetype AS base,
|
|
14
|
+
(SELECT to_json(array_agg(e.enumlabel::text ORDER BY e.enumsortorder))
|
|
15
|
+
FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS choices
|
|
16
|
+
FROM wanted w JOIN pg_catalog.pg_type t ON t.oid = w.oid
|
|
17
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
18
|
+
`;
|
|
19
|
+
const tables = [...new Set(statement.columns.map((c) => c.table).filter(Boolean))];
|
|
20
|
+
const sources = await client `
|
|
21
|
+
SELECT c.oid, a.attnum AS number, n.nspname AS schema,
|
|
22
|
+
c.relname AS table, a.attname AS column, a.attnotnull AS "notNull"
|
|
23
|
+
FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
24
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
25
|
+
WHERE c.oid = ANY(string_to_array(${tables.join(',')}, ',')::oid[])
|
|
26
|
+
AND a.attnum > 0 AND NOT a.attisdropped
|
|
27
|
+
`;
|
|
28
|
+
return { statement, types, sources };
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const describeFlags: {
|
|
2
|
+
readonly role: {
|
|
3
|
+
readonly type: "string";
|
|
4
|
+
};
|
|
5
|
+
readonly 'search-path': {
|
|
6
|
+
readonly type: "string";
|
|
7
|
+
};
|
|
8
|
+
readonly 'time-zone': {
|
|
9
|
+
readonly type: "string";
|
|
10
|
+
};
|
|
11
|
+
readonly 'connect-timeout-ms': {
|
|
12
|
+
readonly type: "string";
|
|
13
|
+
};
|
|
14
|
+
readonly 'statement-timeout-ms': {
|
|
15
|
+
readonly type: "string";
|
|
16
|
+
};
|
|
17
|
+
readonly 'lock-timeout-ms': {
|
|
18
|
+
readonly type: "string";
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/** Translate CLI flags; validation and defaults belong to the public API. */
|
|
22
|
+
export declare function runDescribe(filename: string, connection: string, values: Partial<Record<keyof typeof describeFlags, string | undefined>>): Promise<void>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { describeQuery } from '../describe.js';
|
|
3
|
+
export const describeFlags = {
|
|
4
|
+
role: { type: 'string' },
|
|
5
|
+
'search-path': { type: 'string' },
|
|
6
|
+
'time-zone': { type: 'string' },
|
|
7
|
+
'connect-timeout-ms': { type: 'string' },
|
|
8
|
+
'statement-timeout-ms': { type: 'string' },
|
|
9
|
+
'lock-timeout-ms': { type: 'string' },
|
|
10
|
+
};
|
|
11
|
+
/** Translate CLI flags; validation and defaults belong to the public API. */
|
|
12
|
+
export async function runDescribe(filename, connection, values) {
|
|
13
|
+
const result = await describeQuery({
|
|
14
|
+
connection,
|
|
15
|
+
query: await readFile(filename, 'utf8'),
|
|
16
|
+
session: {
|
|
17
|
+
...(values.role !== undefined ? { role: values.role } : {}),
|
|
18
|
+
...(values['search-path'] !== undefined ? { searchPath: values['search-path'] } : {}),
|
|
19
|
+
...(values['time-zone'] !== undefined ? { timeZone: values['time-zone'] } : {}),
|
|
20
|
+
},
|
|
21
|
+
timeouts: {
|
|
22
|
+
...(values['connect-timeout-ms'] !== undefined
|
|
23
|
+
? { connectMs: Number(values['connect-timeout-ms']) }
|
|
24
|
+
: {}),
|
|
25
|
+
...(values['statement-timeout-ms'] !== undefined
|
|
26
|
+
? { statementMs: Number(values['statement-timeout-ms']) }
|
|
27
|
+
: {}),
|
|
28
|
+
...(values['lock-timeout-ms'] !== undefined
|
|
29
|
+
? { lockMs: Number(values['lock-timeout-ms']) }
|
|
30
|
+
: {}),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
34
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import postgres from 'postgres';
|
|
2
|
+
import type { DescribeOptions, InspectionContext } from './types.js';
|
|
3
|
+
/** Bound every database statement, including bootstrap and metadata queries. */
|
|
4
|
+
export declare function withInspectionConnection<T>(options: Pick<DescribeOptions, 'connection' | 'session' | 'timeouts'>, work: (client: postgres.Sql, context: InspectionContext) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import postgres from 'postgres';
|
|
2
|
+
import { prepareArrays } from '../driver/array.js';
|
|
3
|
+
import { inspectionOptions } from './options.js';
|
|
4
|
+
/** Bound every database statement, including bootstrap and metadata queries. */
|
|
5
|
+
export async function withInspectionConnection(options, work) {
|
|
6
|
+
const { url, limits, session } = inspectionOptions(options);
|
|
7
|
+
const client = postgres(url.toString(), {
|
|
8
|
+
max: 1,
|
|
9
|
+
connect_timeout: limits.connectMs / 1000,
|
|
10
|
+
// The inspector reads its own metadata; avoid an implicit array-catalog fetch.
|
|
11
|
+
fetch_types: false,
|
|
12
|
+
onnotice() { },
|
|
13
|
+
});
|
|
14
|
+
try {
|
|
15
|
+
await client `SELECT
|
|
16
|
+
set_config('statement_timeout', ${String(limits.statementMs)}, false),
|
|
17
|
+
set_config('lock_timeout', ${String(limits.lockMs)}, false)`;
|
|
18
|
+
await prepareArrays(client);
|
|
19
|
+
for (const [key, name] of [
|
|
20
|
+
['role', 'role'],
|
|
21
|
+
['searchPath', 'search_path'],
|
|
22
|
+
['timeZone', 'TimeZone'],
|
|
23
|
+
]) {
|
|
24
|
+
const value = session[key];
|
|
25
|
+
if (value !== undefined)
|
|
26
|
+
await client `SELECT set_config(${name}, ${value}, false)`;
|
|
27
|
+
}
|
|
28
|
+
const [context] = await client `
|
|
29
|
+
SELECT current_database() AS database, session_user AS "sessionUser",
|
|
30
|
+
current_user AS "currentUser", current_setting('search_path') AS "searchPath",
|
|
31
|
+
to_json(current_schemas(true)) AS "resolvedSearchPath",
|
|
32
|
+
current_setting('TimeZone') AS "timeZone",
|
|
33
|
+
(SELECT setting::integer FROM pg_catalog.pg_settings WHERE name='statement_timeout') AS "statementTimeoutMs",
|
|
34
|
+
(SELECT setting::integer FROM pg_catalog.pg_settings WHERE name='lock_timeout') AS "lockTimeoutMs"
|
|
35
|
+
`;
|
|
36
|
+
if (!context)
|
|
37
|
+
throw new Error('Inspection context was not returned.');
|
|
38
|
+
return await work(client, { mode: 'separate-session', readMappings: 'postgres', ...context });
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
// Bound cleanup as well; never leave an unbounded end() after a server/network failure.
|
|
42
|
+
await client.end({ timeout: 1 });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DescribeOptions } from './types.js';
|
|
2
|
+
/** Validate and capture caller-owned settings before opening a connection. */
|
|
3
|
+
export declare function inspectionOptions(options: Pick<DescribeOptions, 'connection' | 'session' | 'timeouts'>): {
|
|
4
|
+
url: URL;
|
|
5
|
+
limits: {
|
|
6
|
+
connectMs: number;
|
|
7
|
+
statementMs: number;
|
|
8
|
+
lockMs: number;
|
|
9
|
+
};
|
|
10
|
+
session: {
|
|
11
|
+
role?: string;
|
|
12
|
+
searchPath?: string;
|
|
13
|
+
timeZone?: string;
|
|
14
|
+
};
|
|
15
|
+
};
|