@kequtech/tenon-orm 0.2.1 → 0.3.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 +52 -36
- package/README.md +27 -70
- package/dist/affected-rows.js +6 -6
- package/dist/baseline.js +4 -4
- package/dist/client.js +1 -1
- package/dist/connection.d.ts +15 -16
- package/dist/connection.js +124 -14
- package/dist/database.js +36 -17
- package/dist/driver/types.d.ts +1 -1
- package/dist/driver/types.js +11 -11
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +15 -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/schema-summary.d.ts +3 -3
- package/dist/generation/schema-summary.js +11 -11
- package/dist/generation/table-types.js +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -1
- package/dist/inspection/catalog.d.ts +4 -3
- package/dist/inspection/catalog.js +13 -10
- package/dist/inspection/cli.js +3 -3
- package/dist/inspection/connection.d.ts +2 -2
- package/dist/inspection/connection.js +22 -22
- package/dist/inspection/options.d.ts +3 -3
- package/dist/inspection/options.js +9 -1
- package/dist/inspection/protocol.d.ts +7 -0
- package/dist/inspection/protocol.js +45 -0
- package/dist/inspection/result.d.ts +1 -1
- package/dist/inspection/result.js +6 -6
- package/dist/inspection/types.d.ts +10 -10
- package/dist/migrations/connection.d.ts +3 -2
- package/dist/migrations/connection.js +18 -20
- package/dist/migrations/history.d.ts +4 -4
- package/dist/migrations/history.js +5 -5
- package/dist/migrations.js +7 -7
- package/dist/query-options.js +5 -5
- package/dist/query.js +2 -2
- package/dist/transaction.d.ts +2 -2
- package/dist/transaction.js +19 -14
- package/dist/types.d.ts +7 -3
- package/docs/adoption-guide.md +109 -122
- package/docs/agent-guide.md +124 -228
- package/docs/human-guide.md +44 -55
- package/docs/mutations.md +58 -76
- package/docs/query-inspection.md +97 -89
- package/docs/reference.md +264 -319
- package/package.json +4 -4
- package/dist/driver/array.d.ts +0 -5
- package/dist/driver/array.js +0 -70
- package/dist/driver/lifecycle.d.ts +0 -15
- package/dist/driver/lifecycle.js +0 -57
- package/dist/driver/session.d.ts +0 -8
- package/dist/driver/session.js +0 -92
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.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
11
|
+
const schema = await client.query('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
|
|
12
12
|
namespace,
|
|
13
13
|
]);
|
|
14
|
-
if (!schema.length)
|
|
14
|
+
if (!schema.rows.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.query(`SET LOCAL search_path TO pg_catalog, ${quote(namespace)}`);
|
|
19
|
+
await client.query('SET LOCAL quote_all_identifiers TO off');
|
|
20
|
+
await client.query('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.query('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 { PoolClient } from 'pg';
|
|
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: PoolClient, 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.query(`
|
|
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.rows) {
|
|
11
11
|
// Attached descendants are storage details, not separate generated models.
|
|
12
|
-
const columns = await client.
|
|
12
|
+
const columns = await client.query(`
|
|
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.rows });
|
|
36
36
|
}
|
|
37
37
|
return definitions;
|
|
38
38
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PoolClient } from 'pg';
|
|
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: PoolClient, 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: PoolClient, 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.query(`
|
|
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"`, [
|
|
19
|
+
ORDER BY pg_catalog.format_type(t.oid, NULL) COLLATE "C"`, [typeOids]);
|
|
20
20
|
const summaries = [];
|
|
21
|
-
for (const domain of domains) {
|
|
22
|
-
const constraints = await client.
|
|
21
|
+
for (const domain of domains.rows) {
|
|
22
|
+
const constraints = await client.query(`
|
|
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.map((constraint) => ` CONSTRAINT ${quote(constraint.name)} ${constraint.definition}`),
|
|
31
|
+
...constraints.rows.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.query(`
|
|
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.query(`
|
|
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.query(`
|
|
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.map((column) => ` ${quote(column.name)} ${column.definition}`),
|
|
87
|
-
...constraints.map((constraint) => ` CONSTRAINT ${quote(constraint.name)} ${constraint.definition}`),
|
|
88
|
-
...indexes.map((index) => {
|
|
86
|
+
...columns.rows.map((column) => ` ${quote(column.name)} ${column.definition}`),
|
|
87
|
+
...constraints.rows.map((constraint) => ` CONSTRAINT ${quote(constraint.name)} ${constraint.definition}`),
|
|
88
|
+
...indexes.rows.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;
|
|
@@ -37,7 +37,7 @@ function columnTypes(value, column, onWarning) {
|
|
|
37
37
|
const underlying = value.declaredOid === value.oid
|
|
38
38
|
? ''
|
|
39
39
|
: ` with underlying type ${JSON.stringify(value.typeSchema)}.${JSON.stringify(value.type)} (OID ${value.oid})`;
|
|
40
|
-
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 pg mapping; generated as unknown for reads and never for non-null writes.`);
|
|
41
41
|
types = { read: 'unknown', write: 'never' };
|
|
42
42
|
}
|
|
43
43
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
export type { JSONValue } from 'postgres';
|
|
2
1
|
export type { Connection, ConnectionOptions, QueryResult } from './connection.js';
|
|
3
2
|
export type { AffectedRowsConstraint } from './affected-rows.js';
|
|
4
3
|
export { createTenon } from './database.js';
|
|
5
4
|
export type { TenonOptions } from './database.js';
|
|
6
|
-
export { AffectedRowsError } from './errors.js';
|
|
5
|
+
export { AffectedRowsError, CardinalityError } from './errors.js';
|
|
7
6
|
export type { QueryOptions, QueryOutput, QueryReturnMode } from './query-options.js';
|
|
8
7
|
export { identifier, sql, unsafeSql } from './query.js';
|
|
9
8
|
export type { SqlFragment, SqlQuery, UnknownRow } from './query.js';
|
|
10
|
-
export type { Database, QueryClient, QueryEvent, Transaction } from './types.js';
|
|
9
|
+
export type { CloseOptions, Database, QueryClient, QueryEvent, Transaction } from './types.js';
|
|
11
10
|
export { describeQuery } from './describe.js';
|
|
12
11
|
export type { DescribeOptions, DescribedColumn, DescribedType, DescriptionIssue, InspectionContext, InspectionSession, InspectionTimeouts, QueryDescription, } from './inspection/types.js';
|
|
13
12
|
export { adoptBaseline, captureBaseline } from './baseline.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createTenon } from './database.js';
|
|
2
|
-
export { AffectedRowsError } from './errors.js';
|
|
2
|
+
export { AffectedRowsError, CardinalityError } from './errors.js';
|
|
3
3
|
export { identifier, sql, unsafeSql } from './query.js';
|
|
4
4
|
export { describeQuery } from './describe.js';
|
|
5
5
|
export { adoptBaseline, captureBaseline } from './baseline.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import type { Client } from 'pg';
|
|
2
|
+
import { type Description } from './protocol.js';
|
|
2
3
|
interface CatalogType {
|
|
3
4
|
oid: number;
|
|
4
5
|
schema: string;
|
|
@@ -16,10 +17,10 @@ interface ColumnSource {
|
|
|
16
17
|
notNull: boolean;
|
|
17
18
|
}
|
|
18
19
|
export interface StatementMetadata {
|
|
19
|
-
statement:
|
|
20
|
+
statement: Description;
|
|
20
21
|
types: readonly CatalogType[];
|
|
21
22
|
sources: readonly ColumnSource[];
|
|
22
23
|
}
|
|
23
24
|
/** Describe the statement and read its type and source-column catalog records. */
|
|
24
|
-
export declare function readStatementMetadata(client:
|
|
25
|
+
export declare function readStatementMetadata(client: Client, text: string): Promise<StatementMetadata>;
|
|
25
26
|
export {};
|
|
@@ -1,29 +1,32 @@
|
|
|
1
|
+
import { describe } from './protocol.js';
|
|
1
2
|
/** Describe the statement and read its type and source-column catalog records. */
|
|
2
3
|
export async function readStatementMetadata(client, text) {
|
|
3
|
-
const statement = await client
|
|
4
|
-
const oids = [
|
|
5
|
-
|
|
4
|
+
const statement = await describe(client, text);
|
|
5
|
+
const oids = [
|
|
6
|
+
...new Set([...statement.parameters, ...statement.fields.map((c) => c.dataTypeID)]),
|
|
7
|
+
];
|
|
8
|
+
const types = (await client.query(`
|
|
6
9
|
WITH RECURSIVE wanted(oid) AS (
|
|
7
|
-
SELECT unnest(string_to_array($
|
|
10
|
+
SELECT pg_catalog.unnest(pg_catalog.string_to_array($1, ',')::pg_catalog.oid[])
|
|
8
11
|
UNION
|
|
9
12
|
SELECT t.typbasetype FROM pg_catalog.pg_type t JOIN wanted w ON w.oid = t.oid
|
|
10
13
|
WHERE t.typbasetype <> 0
|
|
11
14
|
)
|
|
12
15
|
SELECT t.oid, n.nspname AS schema, t.typname AS name,
|
|
13
16
|
t.typtype AS kind, t.typbasetype AS base,
|
|
14
|
-
(SELECT to_json(array_agg(e.enumlabel::text ORDER BY e.enumsortorder))
|
|
17
|
+
(SELECT pg_catalog.to_json(pg_catalog.array_agg(e.enumlabel::text ORDER BY e.enumsortorder))
|
|
15
18
|
FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS choices
|
|
16
19
|
FROM wanted w JOIN pg_catalog.pg_type t ON t.oid = w.oid
|
|
17
20
|
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
|
18
|
-
|
|
19
|
-
const tables = [...new Set(statement.
|
|
20
|
-
const sources = await client
|
|
21
|
+
`, [oids.join(',')])).rows;
|
|
22
|
+
const tables = [...new Set(statement.fields.map((c) => c.tableID).filter(Boolean))];
|
|
23
|
+
const sources = (await client.query(`
|
|
21
24
|
SELECT c.oid, a.attnum AS number, n.nspname AS schema,
|
|
22
25
|
c.relname AS table, a.attname AS column, a.attnotnull AS "notNull"
|
|
23
26
|
FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
24
27
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
25
|
-
WHERE c.oid = ANY(string_to_array($
|
|
28
|
+
WHERE c.oid = ANY(pg_catalog.string_to_array($1, ',')::pg_catalog.oid[])
|
|
26
29
|
AND a.attnum > 0 AND NOT a.attisdropped
|
|
27
|
-
|
|
30
|
+
`, [tables.join(',')])).rows;
|
|
28
31
|
return { statement, types, sources };
|
|
29
32
|
}
|
package/dist/inspection/cli.js
CHANGED
|
@@ -14,9 +14,9 @@ export async function runDescribe(filename, connection, values) {
|
|
|
14
14
|
connection,
|
|
15
15
|
query: await readFile(filename, 'utf8'),
|
|
16
16
|
session: {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
role: values.role,
|
|
18
|
+
searchPath: values['search-path'],
|
|
19
|
+
timeZone: values['time-zone'],
|
|
20
20
|
},
|
|
21
21
|
timeouts: {
|
|
22
22
|
...(values['connect-timeout-ms'] !== undefined
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Client } from 'pg';
|
|
2
2
|
import type { DescribeOptions, InspectionContext } from './types.js';
|
|
3
3
|
/** Bound every database statement, including bootstrap and metadata queries. */
|
|
4
|
-
export declare function withInspectionConnection<T>(options: Pick<DescribeOptions, 'connection' | 'session' | 'timeouts'>, work: (client:
|
|
4
|
+
export declare function withInspectionConnection<T>(options: Pick<DescribeOptions, 'connection' | 'session' | 'timeouts'>, work: (client: Client, context: InspectionContext) => Promise<T>): Promise<T>;
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { prepareArrays } from '../driver/array.js';
|
|
1
|
+
import { Client } from 'pg';
|
|
3
2
|
import { inspectionOptions } from './options.js';
|
|
4
3
|
/** Bound every database statement, including bootstrap and metadata queries. */
|
|
5
4
|
export async function withInspectionConnection(options, work) {
|
|
6
5
|
const { url, limits, session } = inspectionOptions(options);
|
|
7
|
-
const client =
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
const client = new Client({
|
|
7
|
+
connectionString: url.toString(),
|
|
8
|
+
connectionTimeoutMillis: limits.connectMs,
|
|
9
|
+
statement_timeout: limits.statementMs,
|
|
10
|
+
lock_timeout: limits.lockMs,
|
|
11
|
+
pipeline: false,
|
|
13
12
|
});
|
|
13
|
+
// Disconnection errors outside an active query must still have a listener.
|
|
14
|
+
client.on('error', () => { });
|
|
14
15
|
try {
|
|
15
|
-
await client
|
|
16
|
-
|
|
17
|
-
set_config('
|
|
18
|
-
|
|
16
|
+
await client.connect();
|
|
17
|
+
await client.query(`SELECT
|
|
18
|
+
pg_catalog.set_config('statement_timeout', $1, false),
|
|
19
|
+
pg_catalog.set_config('lock_timeout', $2, false)`, [String(limits.statementMs), String(limits.lockMs)]);
|
|
19
20
|
for (const [key, name] of [
|
|
20
21
|
['role', 'role'],
|
|
21
22
|
['searchPath', 'search_path'],
|
|
@@ -23,22 +24,21 @@ export async function withInspectionConnection(options, work) {
|
|
|
23
24
|
]) {
|
|
24
25
|
const value = session[key];
|
|
25
26
|
if (value !== undefined)
|
|
26
|
-
await client
|
|
27
|
+
await client.query('SELECT pg_catalog.set_config($1, $2, false)', [name, value]);
|
|
27
28
|
}
|
|
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",
|
|
29
|
+
const { rows: [context], } = await client.query(`
|
|
30
|
+
SELECT pg_catalog.current_database() AS database, session_user AS "sessionUser",
|
|
31
|
+
current_user AS "currentUser", pg_catalog.current_setting('search_path') AS "searchPath",
|
|
32
|
+
pg_catalog.to_json(pg_catalog.current_schemas(true)) AS "resolvedSearchPath",
|
|
33
|
+
pg_catalog.current_setting('TimeZone') AS "timeZone",
|
|
33
34
|
(SELECT setting::integer FROM pg_catalog.pg_settings WHERE name='statement_timeout') AS "statementTimeoutMs",
|
|
34
35
|
(SELECT setting::integer FROM pg_catalog.pg_settings WHERE name='lock_timeout') AS "lockTimeoutMs"
|
|
35
|
-
|
|
36
|
+
`);
|
|
36
37
|
if (!context)
|
|
37
38
|
throw new Error('Inspection context was not returned.');
|
|
38
|
-
return await work(client, { mode: 'separate-session', readMappings: '
|
|
39
|
+
return await work(client, { mode: 'separate-session', readMappings: 'pg', ...context });
|
|
39
40
|
}
|
|
40
41
|
finally {
|
|
41
|
-
|
|
42
|
-
await client.end({ timeout: 1 });
|
|
42
|
+
await client.end();
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -8,8 +8,8 @@ export declare function inspectionOptions(options: Pick<DescribeOptions, 'connec
|
|
|
8
8
|
lockMs: number;
|
|
9
9
|
};
|
|
10
10
|
session: {
|
|
11
|
-
role?: string;
|
|
12
|
-
searchPath?: string;
|
|
13
|
-
timeZone?: string;
|
|
11
|
+
role?: string | undefined;
|
|
12
|
+
searchPath?: string | undefined;
|
|
13
|
+
timeZone?: string | undefined;
|
|
14
14
|
};
|
|
15
15
|
};
|
|
@@ -10,6 +10,8 @@ export function inspectionOptions(options) {
|
|
|
10
10
|
if (!['role', 'searchPath', 'timeZone'].includes(key)) {
|
|
11
11
|
throw new Error(`Unknown inspection session option: ${key}`);
|
|
12
12
|
}
|
|
13
|
+
if (value === undefined)
|
|
14
|
+
continue;
|
|
13
15
|
if (typeof value !== 'string' || !value.trim() || value.includes('\0')) {
|
|
14
16
|
throw new Error(`Inspection session ${key} requires a nonempty string without NUL.`);
|
|
15
17
|
}
|
|
@@ -20,11 +22,17 @@ export function inspectionOptions(options) {
|
|
|
20
22
|
for (const [key, value] of Object.entries(timeouts)) {
|
|
21
23
|
if (!Object.hasOwn(defaults, key))
|
|
22
24
|
throw new Error(`Unknown inspection timeout: ${key}`);
|
|
25
|
+
if (value === undefined)
|
|
26
|
+
continue;
|
|
23
27
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 2147483647) {
|
|
24
28
|
throw new Error(`Inspection timeout ${key} must be a positive integer in milliseconds.`);
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
|
-
const limits = {
|
|
31
|
+
const limits = {
|
|
32
|
+
connectMs: timeouts.connectMs ?? defaults.connectMs,
|
|
33
|
+
statementMs: timeouts.statementMs ?? defaults.statementMs,
|
|
34
|
+
lockMs: timeouts.lockMs ?? defaults.lockMs,
|
|
35
|
+
};
|
|
28
36
|
// URL startup options must not disable the inspection's server-side bounds.
|
|
29
37
|
let url;
|
|
30
38
|
try {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Client, FieldDef } from 'pg';
|
|
2
|
+
export interface Description {
|
|
3
|
+
parameters: number[];
|
|
4
|
+
fields: FieldDef[];
|
|
5
|
+
}
|
|
6
|
+
/** Describe through pg custom query dispatch; send no Bind or Execute messages. */
|
|
7
|
+
export declare function describe(client: Client, text: string): Promise<Description>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Describe through pg custom query dispatch; send no Bind or Execute messages. */
|
|
2
|
+
export function describe(client, text) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
let connection;
|
|
5
|
+
const description = { parameters: [], fields: [] };
|
|
6
|
+
const parameters = (message) => {
|
|
7
|
+
description.parameters = message.dataTypeIDs;
|
|
8
|
+
};
|
|
9
|
+
const cleanup = () => connection?.removeListener('parameterDescription', parameters);
|
|
10
|
+
const query = {
|
|
11
|
+
// pg wraps callback to manage query_timeout, including clearing its timer.
|
|
12
|
+
callback(error) {
|
|
13
|
+
cleanup();
|
|
14
|
+
if (error)
|
|
15
|
+
reject(error);
|
|
16
|
+
else
|
|
17
|
+
resolve(description);
|
|
18
|
+
},
|
|
19
|
+
submit(wire) {
|
|
20
|
+
connection = wire;
|
|
21
|
+
wire.once('parameterDescription', parameters);
|
|
22
|
+
try {
|
|
23
|
+
wire.parse({ name: '', text, types: [] }, false);
|
|
24
|
+
wire.describe({ type: 'S', name: '' }, false);
|
|
25
|
+
wire.sync();
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return error instanceof Error
|
|
29
|
+
? error
|
|
30
|
+
: new Error('Describe dispatch failed', { cause: error });
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
handleRowDescription(message) {
|
|
34
|
+
description.fields = message.fields;
|
|
35
|
+
},
|
|
36
|
+
handleReadyForQuery() {
|
|
37
|
+
query.callback(null);
|
|
38
|
+
},
|
|
39
|
+
handleError(error) {
|
|
40
|
+
query.callback(error);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
client.query(query);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { StatementMetadata } from './catalog.js';
|
|
2
2
|
import type { InspectionContext, QueryDescription } from './types.js';
|
|
3
|
-
/** Convert database metadata to conservative
|
|
3
|
+
/** Convert database metadata to conservative pg types and diagnostics. */
|
|
4
4
|
export declare function queryDescription({ statement, types, sources }: StatementMetadata, context: InspectionContext): QueryDescription;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { arrayTypes, scalarTypes, valueTypes } from '../driver/types.js';
|
|
2
|
-
/** Convert database metadata to conservative
|
|
2
|
+
/** Convert database metadata to conservative pg types and diagnostics. */
|
|
3
3
|
export function queryDescription({ statement, types, sources }, context) {
|
|
4
4
|
const catalog = new Map(types.map((t) => [t.oid, t]));
|
|
5
5
|
const issues = new Map();
|
|
@@ -26,14 +26,14 @@ export function queryDescription({ statement, types, sources }, context) {
|
|
|
26
26
|
code: 'unmapped-type',
|
|
27
27
|
oid,
|
|
28
28
|
postgres: `${type.schema}.${type.name}`,
|
|
29
|
-
message: `No verified
|
|
29
|
+
message: `No verified pg mapping for ${type.schema}.${type.name} (OID ${oid}).`,
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
return { oid, postgres: `${type.schema}.${type.name}`, readType };
|
|
33
33
|
}
|
|
34
34
|
const sourceMap = new Map(sources.map((s) => [`${s.oid}:${s.number}`, s]));
|
|
35
35
|
const names = new Set();
|
|
36
|
-
const columns = statement.
|
|
36
|
+
const columns = statement.fields.map((column) => {
|
|
37
37
|
if (names.has(column.name)) {
|
|
38
38
|
issues.set(`column:${column.name}`, {
|
|
39
39
|
code: 'duplicate-column',
|
|
@@ -42,10 +42,10 @@ export function queryDescription({ statement, types, sources }, context) {
|
|
|
42
42
|
});
|
|
43
43
|
}
|
|
44
44
|
names.add(column.name);
|
|
45
|
-
const source = sourceMap.get(`${column.
|
|
45
|
+
const source = sourceMap.get(`${column.tableID}:${column.columnID}`);
|
|
46
46
|
return {
|
|
47
47
|
name: column.name,
|
|
48
|
-
...describeType(column.
|
|
48
|
+
...describeType(column.dataTypeID),
|
|
49
49
|
nullability: 'unknown',
|
|
50
50
|
source: source
|
|
51
51
|
? {
|
|
@@ -57,7 +57,7 @@ export function queryDescription({ statement, types, sources }, context) {
|
|
|
57
57
|
: null,
|
|
58
58
|
};
|
|
59
59
|
});
|
|
60
|
-
const parameters = statement.
|
|
60
|
+
const parameters = statement.parameters.map(describeType);
|
|
61
61
|
if (columns.length) {
|
|
62
62
|
issues.set('nullability', {
|
|
63
63
|
code: 'nullability-unknown',
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { SqlQuery } from '../query.js';
|
|
2
2
|
export interface InspectionSession {
|
|
3
|
-
readonly role?: string;
|
|
3
|
+
readonly role?: string | undefined;
|
|
4
4
|
/** PostgreSQL's search_path setting, as used in the application's connection options. */
|
|
5
|
-
readonly searchPath?: string;
|
|
6
|
-
readonly timeZone?: string;
|
|
5
|
+
readonly searchPath?: string | undefined;
|
|
6
|
+
readonly timeZone?: string | undefined;
|
|
7
7
|
}
|
|
8
8
|
export interface InspectionTimeouts {
|
|
9
|
-
readonly connectMs?: number;
|
|
10
|
-
readonly statementMs?: number;
|
|
11
|
-
readonly lockMs?: number;
|
|
9
|
+
readonly connectMs?: number | undefined;
|
|
10
|
+
readonly statementMs?: number | undefined;
|
|
11
|
+
readonly lockMs?: number | undefined;
|
|
12
12
|
}
|
|
13
13
|
export interface InspectionContext {
|
|
14
14
|
readonly mode: 'separate-session';
|
|
@@ -20,15 +20,15 @@ export interface InspectionContext {
|
|
|
20
20
|
readonly timeZone: string;
|
|
21
21
|
readonly statementTimeoutMs: number;
|
|
22
22
|
readonly lockTimeoutMs: number;
|
|
23
|
-
readonly readMappings: '
|
|
23
|
+
readonly readMappings: 'pg';
|
|
24
24
|
}
|
|
25
25
|
export interface DescribeOptions {
|
|
26
26
|
/** PostgreSQL connection URI. Inspection owns a separate, short-lived connection. */
|
|
27
27
|
readonly connection: string;
|
|
28
28
|
/** Finished SQL; query values are neither bound nor executed. */
|
|
29
29
|
readonly query: string | Pick<SqlQuery, 'text'>;
|
|
30
|
-
readonly session?: InspectionSession;
|
|
31
|
-
readonly timeouts?: InspectionTimeouts;
|
|
30
|
+
readonly session?: InspectionSession | undefined;
|
|
31
|
+
readonly timeouts?: InspectionTimeouts | undefined;
|
|
32
32
|
}
|
|
33
33
|
export type DescriptionIssue = {
|
|
34
34
|
readonly code: 'nullability-unknown';
|
|
@@ -50,7 +50,7 @@ export type DescriptionIssue = {
|
|
|
50
50
|
export interface DescribedType {
|
|
51
51
|
readonly oid: number;
|
|
52
52
|
readonly postgres: string;
|
|
53
|
-
/**
|
|
53
|
+
/** pg read representation, before considering result nullability. */
|
|
54
54
|
readonly readType: string;
|
|
55
55
|
}
|
|
56
56
|
export interface DescribedColumn extends DescribedType {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PoolClient } from 'pg';
|
|
2
|
+
import type { Connection } from '../connection.js';
|
|
2
3
|
export interface DatabaseToolOptions {
|
|
3
4
|
readonly connection: Connection;
|
|
4
5
|
readonly namespace?: string;
|
|
5
6
|
readonly directory?: string;
|
|
6
7
|
}
|
|
7
8
|
/** Coordinate tools independently of application sessions; PostgreSQL owns the wait. */
|
|
8
|
-
export declare function withMigrationLock<T>(options: DatabaseToolOptions, mode: 'shared' | 'exclusive', work: (client:
|
|
9
|
+
export declare function withMigrationLock<T>(options: DatabaseToolOptions, mode: 'shared' | 'exclusive', work: (client: PoolClient, namespace: string) => Promise<T>): Promise<T>;
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createConnection } from '../connection.js';
|
|
1
|
+
import { createConnection, watchClient } from '../connection.js';
|
|
3
2
|
import { validateName } from '../sql.js';
|
|
4
3
|
/** Coordinate tools independently of application sessions; PostgreSQL owns the wait. */
|
|
5
4
|
export async function withMigrationLock(options, mode, work) {
|
|
6
5
|
const namespace = options.namespace ?? 'public';
|
|
7
6
|
validateName(namespace);
|
|
8
|
-
const {
|
|
7
|
+
const { pool, close } = createConnection(options.connection);
|
|
9
8
|
const suffix = mode === 'shared' ? '_shared' : '';
|
|
10
9
|
const lockKey = `tenon:migrations:${namespace}`;
|
|
11
10
|
const errors = [];
|
|
11
|
+
let client;
|
|
12
12
|
let connection;
|
|
13
13
|
let locked = false;
|
|
14
14
|
let result;
|
|
15
15
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
await client.
|
|
16
|
+
client = await pool.connect();
|
|
17
|
+
connection = watchClient(client);
|
|
18
|
+
await client.query(`SELECT pg_advisory_lock${suffix}(hashtextextended($1, 0))`, [lockKey]);
|
|
19
19
|
locked = true;
|
|
20
20
|
result = await work(client, namespace);
|
|
21
21
|
connection.check();
|
|
@@ -24,37 +24,35 @@ export async function withMigrationLock(options, mode, work) {
|
|
|
24
24
|
errors.push(error);
|
|
25
25
|
}
|
|
26
26
|
finally {
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
let discard = false;
|
|
27
|
+
if (client) {
|
|
28
|
+
let releaseError;
|
|
30
29
|
try {
|
|
31
30
|
connection.check();
|
|
32
31
|
if (errors.length)
|
|
33
|
-
await client.
|
|
32
|
+
await client.query('ROLLBACK');
|
|
34
33
|
if (locked) {
|
|
35
|
-
await client.
|
|
34
|
+
await client.query(`SELECT pg_advisory_unlock${suffix}(hashtextextended($1, 0))`, [
|
|
36
35
|
lockKey,
|
|
37
36
|
]);
|
|
38
37
|
}
|
|
39
38
|
}
|
|
40
39
|
catch (error) {
|
|
41
40
|
errors.push(error);
|
|
42
|
-
|
|
41
|
+
releaseError =
|
|
42
|
+
error instanceof Error ? error : new Error('Tool connection cleanup failed.');
|
|
43
43
|
}
|
|
44
44
|
try {
|
|
45
|
-
|
|
45
|
+
connection.release(releaseError);
|
|
46
46
|
}
|
|
47
47
|
catch (error) {
|
|
48
48
|
errors.push(error);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
errors.push(error);
|
|
57
|
-
}
|
|
51
|
+
try {
|
|
52
|
+
await close(1000);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
errors.push(error);
|
|
58
56
|
}
|
|
59
57
|
}
|
|
60
58
|
if (errors.length === 1) {
|