@kequtech/tenon-orm 0.4.0 → 0.4.3
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 +22 -2
- package/README.md +93 -51
- package/dist/check.js +4 -2
- package/dist/cli.js +97 -40
- package/dist/config/init.d.ts +2 -0
- package/dist/config/init.js +34 -0
- package/dist/config/load.d.ts +7 -0
- package/dist/config/load.js +159 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +1 -0
- package/dist/connection.d.ts +5 -0
- package/dist/describe.js +3 -1
- package/dist/generation/output.js +0 -2
- package/dist/index.d.ts +2 -1
- package/dist/inspection/cli.d.ts +3 -4
- package/dist/inspection/cli.js +3 -13
- package/dist/inspection/connection.js +23 -19
- package/dist/inspection/options.d.ts +5 -7
- package/dist/inspection/options.js +49 -22
- package/dist/inspection/types.d.ts +3 -2
- package/dist/migrations/history.d.ts +1 -1
- package/dist/migrations/history.js +3 -2
- package/dist/validation.d.ts +2 -0
- package/dist/validation.js +6 -0
- package/docs/adoption-guide.md +94 -153
- package/docs/agent-guide.md +93 -176
- package/docs/configuration.md +146 -0
- package/docs/connections.md +187 -0
- package/docs/human-guide.md +150 -179
- package/docs/migrations.md +140 -0
- package/docs/mutations.md +104 -67
- package/docs/query-checking.md +122 -120
- package/docs/query-inspection.md +83 -76
- package/docs/reference.md +234 -474
- package/docs/schema-types.md +266 -0
- package/docs/sql.md +212 -0
- package/package.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { sessionOptions, timeoutOptions } from '../inspection/options.js';
|
|
5
|
+
import { validateName } from '../sql.js';
|
|
6
|
+
import { plainObject, validatePath } from '../validation.js';
|
|
7
|
+
export const configNames = [
|
|
8
|
+
'tenon.config.ts',
|
|
9
|
+
'tenon.config.mts',
|
|
10
|
+
'tenon.config.js',
|
|
11
|
+
'tenon.config.mjs',
|
|
12
|
+
];
|
|
13
|
+
async function exists(path) {
|
|
14
|
+
try {
|
|
15
|
+
return (await stat(path)).isFile();
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
19
|
+
return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function keys(value, label, allowed) {
|
|
24
|
+
const config = plainObject(value, label);
|
|
25
|
+
for (const key of Object.keys(config)) {
|
|
26
|
+
if (!allowed.includes(key))
|
|
27
|
+
throw new Error(`Unknown ${label} option: ${key}`);
|
|
28
|
+
}
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
31
|
+
/** Find configuration without importing it, including for offline initialization. */
|
|
32
|
+
export async function findConfig(directory = process.cwd()) {
|
|
33
|
+
while (true) {
|
|
34
|
+
const candidates = await Promise.all(configNames.map(async (name) => {
|
|
35
|
+
const candidate = join(directory, name);
|
|
36
|
+
return (await exists(candidate)) ? candidate : undefined;
|
|
37
|
+
}));
|
|
38
|
+
const found = candidates.filter((candidate) => candidate !== undefined);
|
|
39
|
+
if (found.length > 1)
|
|
40
|
+
throw new Error(`Multiple Tenon config files in ${directory}; select one with --config.`);
|
|
41
|
+
if (found.length) {
|
|
42
|
+
return found[0];
|
|
43
|
+
}
|
|
44
|
+
const parent = dirname(directory);
|
|
45
|
+
if (parent === directory)
|
|
46
|
+
return undefined;
|
|
47
|
+
directory = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function loadConfig(filename, required = true) {
|
|
51
|
+
if (filename !== undefined)
|
|
52
|
+
validatePath(filename, 'Config filename');
|
|
53
|
+
const selected = filename === undefined ? await findConfig() : resolve(filename);
|
|
54
|
+
if (!selected && !required)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (!selected)
|
|
57
|
+
throw new Error('Tenon configuration is required. Run tenon init or pass --config.');
|
|
58
|
+
let value;
|
|
59
|
+
try {
|
|
60
|
+
value = (await import(pathToFileURL(selected).href)).default;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Import errors may contain credentials from environment validation or dependency messages.
|
|
64
|
+
throw new Error(`Could not load Tenon configuration: ${selected}. Check the module and its imports.`);
|
|
65
|
+
}
|
|
66
|
+
const config = keys(value, 'Tenon config', [
|
|
67
|
+
'connection',
|
|
68
|
+
'generate',
|
|
69
|
+
'migrations',
|
|
70
|
+
'baseline',
|
|
71
|
+
'check',
|
|
72
|
+
'session',
|
|
73
|
+
'timeouts',
|
|
74
|
+
]);
|
|
75
|
+
if (typeof config.connection === 'string') {
|
|
76
|
+
if (!config.connection.trim())
|
|
77
|
+
throw new Error('Tenon config connection must be nonempty.');
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
const connection = plainObject(config.connection, 'Tenon config connection');
|
|
81
|
+
if ('acquire' in connection || 'close' in connection) {
|
|
82
|
+
throw new Error('Tenon config requires connection options, not an open pool.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const root = dirname(selected);
|
|
86
|
+
const migrations = keys(config.migrations === undefined ? {} : config.migrations, 'migration config', ['directory', 'namespace']);
|
|
87
|
+
if (migrations.directory !== undefined)
|
|
88
|
+
validatePath(migrations.directory, 'Migration directory');
|
|
89
|
+
if (migrations.namespace !== undefined)
|
|
90
|
+
validateName(migrations.namespace);
|
|
91
|
+
let generate;
|
|
92
|
+
if (config.generate !== undefined) {
|
|
93
|
+
const options = keys(config.generate, 'generation config', ['out', 'namespace']);
|
|
94
|
+
validatePath(options.out, 'Generation out');
|
|
95
|
+
if (options.namespace !== undefined)
|
|
96
|
+
validateName(options.namespace);
|
|
97
|
+
generate = {
|
|
98
|
+
out: resolve(root, options.out),
|
|
99
|
+
...(options.namespace !== undefined && { namespace: options.namespace }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const check = keys(config.check === undefined ? {} : config.check, 'checking config', [
|
|
103
|
+
'project',
|
|
104
|
+
'readTypes',
|
|
105
|
+
]);
|
|
106
|
+
if (check.project !== undefined)
|
|
107
|
+
validatePath(check.project, 'Checking project');
|
|
108
|
+
if (check.readTypes !== undefined) {
|
|
109
|
+
const mappings = plainObject(check.readTypes, 'Checking readTypes');
|
|
110
|
+
for (const [oid, expression] of Object.entries(mappings)) {
|
|
111
|
+
if (!/^[1-9]\d*$/.test(oid) ||
|
|
112
|
+
Number(oid) > 0xffffffff ||
|
|
113
|
+
typeof expression !== 'string' ||
|
|
114
|
+
!expression.trim()) {
|
|
115
|
+
throw new Error('Checking readTypes requires PostgreSQL OIDs and nonempty type expressions.');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const baseline = keys(config.baseline === undefined ? {} : config.baseline, 'baseline config', [
|
|
120
|
+
'connection',
|
|
121
|
+
'schemas',
|
|
122
|
+
]);
|
|
123
|
+
if (baseline.connection !== undefined &&
|
|
124
|
+
(typeof baseline.connection !== 'string' ||
|
|
125
|
+
!baseline.connection.trim() ||
|
|
126
|
+
baseline.connection.includes('\0'))) {
|
|
127
|
+
throw new Error('Baseline connection must be a nonempty native libpq connection string.');
|
|
128
|
+
}
|
|
129
|
+
if (baseline.schemas !== undefined) {
|
|
130
|
+
if (!Array.isArray(baseline.schemas) || !baseline.schemas.length)
|
|
131
|
+
throw new Error('Baseline schemas must be a nonempty array.');
|
|
132
|
+
for (const schema of baseline.schemas)
|
|
133
|
+
validateName(schema);
|
|
134
|
+
}
|
|
135
|
+
const session = sessionOptions(config.session === undefined ? {} : config.session);
|
|
136
|
+
const timeouts = timeoutOptions(config.timeouts === undefined ? {} : config.timeouts);
|
|
137
|
+
return {
|
|
138
|
+
connection: config.connection,
|
|
139
|
+
migrations: {
|
|
140
|
+
directory: resolve(root, migrations.directory ?? 'migrations'),
|
|
141
|
+
...(migrations.namespace !== undefined && { namespace: migrations.namespace }),
|
|
142
|
+
},
|
|
143
|
+
...(generate !== undefined && { generate }),
|
|
144
|
+
check: {
|
|
145
|
+
project: resolve(root, check.project ?? 'tsconfig.json'),
|
|
146
|
+
...(check.readTypes !== undefined && {
|
|
147
|
+
readTypes: check.readTypes,
|
|
148
|
+
}),
|
|
149
|
+
},
|
|
150
|
+
...(config.baseline !== undefined && {
|
|
151
|
+
baseline: {
|
|
152
|
+
...(baseline.connection !== undefined && { connection: baseline.connection }),
|
|
153
|
+
...(baseline.schemas !== undefined && { schemas: [...baseline.schemas] }),
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
session,
|
|
157
|
+
timeouts,
|
|
158
|
+
};
|
|
159
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ConnectionConfig } from './connection.js';
|
|
2
|
+
import type { InspectionSession, InspectionTimeouts } from './inspection/types.js';
|
|
3
|
+
/** Shared runtime connection values and CLI defaults. Export without opening a database. */
|
|
4
|
+
export interface TenonConfig {
|
|
5
|
+
readonly connection: ConnectionConfig;
|
|
6
|
+
readonly generate?: {
|
|
7
|
+
readonly out: string;
|
|
8
|
+
readonly namespace?: string;
|
|
9
|
+
};
|
|
10
|
+
readonly migrations?: {
|
|
11
|
+
readonly directory?: string;
|
|
12
|
+
readonly namespace?: string;
|
|
13
|
+
};
|
|
14
|
+
/** Native pg_dump connection/scope; defaults to the main connection only when it is a string. */
|
|
15
|
+
readonly baseline?: {
|
|
16
|
+
readonly connection?: string;
|
|
17
|
+
readonly schemas?: readonly string[];
|
|
18
|
+
};
|
|
19
|
+
readonly check?: {
|
|
20
|
+
readonly project?: string;
|
|
21
|
+
readonly readTypes?: Readonly<Record<number, string>>;
|
|
22
|
+
};
|
|
23
|
+
readonly session?: InspectionSession;
|
|
24
|
+
readonly timeouts?: InspectionTimeouts;
|
|
25
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/connection.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { type Pool, type PoolOptions, type QueryResult as DriverResult, type Executor } from '@kequtech/pgclear';
|
|
2
2
|
import type { UnknownRow } from './query.js';
|
|
3
3
|
export type ConnectionOptions = PoolOptions;
|
|
4
|
+
/** Connection settings and executable credentials/codecs, without a live pool. */
|
|
5
|
+
export type ConnectionConfig = string | (ConnectionOptions & {
|
|
6
|
+
readonly acquire?: never;
|
|
7
|
+
readonly close?: never;
|
|
8
|
+
});
|
|
4
9
|
export type Connection = string | ConnectionOptions | Pool;
|
|
5
10
|
export type QueryResult<Row extends object = UnknownRow> = DriverResult<Row>;
|
|
6
11
|
export type QueryExecutor = <T>(text: string, input: readonly unknown[], readResult: (result: QueryResult) => T, guarded: boolean) => Promise<T>;
|
package/dist/describe.js
CHANGED
|
@@ -23,6 +23,8 @@ export async function describeQuery(options) {
|
|
|
23
23
|
? { parameterTypes: [...options.query.parameterTypes] }
|
|
24
24
|
: {}),
|
|
25
25
|
};
|
|
26
|
-
const codecs = options.codecs ??
|
|
26
|
+
const codecs = options.codecs ??
|
|
27
|
+
(typeof options.connection === 'object' ? options.connection?.codecs : undefined) ??
|
|
28
|
+
defaultCodecs;
|
|
27
29
|
return withInspectionConnection(options, async (client, context) => queryDescription(await readStatementMetadata(client, query), context, codecs));
|
|
28
30
|
}
|
|
@@ -19,7 +19,6 @@ export async function writeDefinitions(source, filename, check) {
|
|
|
19
19
|
if (existing !== source) {
|
|
20
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
21
|
}
|
|
22
|
-
process.stdout.write('Generated definitions are current.\n');
|
|
23
22
|
return;
|
|
24
23
|
}
|
|
25
24
|
await mkdir(dirname(output), { recursive: true });
|
|
@@ -31,5 +30,4 @@ export async function writeDefinitions(source, filename, check) {
|
|
|
31
30
|
finally {
|
|
32
31
|
await rm(temporary, { force: true });
|
|
33
32
|
}
|
|
34
|
-
process.stdout.write(`Generated ${output}\n`);
|
|
35
33
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export { builtin, createTemporalCodecs, defaultCodecs, json, param } from '@kequtech/pgclear';
|
|
2
2
|
export type { Codec, CodecRegistry, Parameter, PgArray, TemporalConstructors, TemporalInput, } from '@kequtech/pgclear';
|
|
3
|
-
export type { Connection, ConnectionOptions, QueryResult } from './connection.js';
|
|
3
|
+
export type { Connection, ConnectionConfig, ConnectionOptions, QueryResult } from './connection.js';
|
|
4
4
|
export type { AffectedRowsConstraint } from './affected-rows.js';
|
|
5
5
|
export { createTenon } from './database.js';
|
|
6
6
|
export type { TenonOptions } from './database.js';
|
|
7
|
+
export type { TenonConfig } from './config.js';
|
|
7
8
|
export { AffectedRowsError, CardinalityError } from './errors.js';
|
|
8
9
|
export type { QueryOptions, QueryOutput, QueryReturnMode } from './query-options.js';
|
|
9
10
|
export { identifier, sql, unsafeSql } from './query.js';
|
package/dist/inspection/cli.d.ts
CHANGED
|
@@ -21,9 +21,9 @@ export declare const describeFlags: {
|
|
|
21
21
|
/** Translate CLI flags; validation and defaults belong to the public API. */
|
|
22
22
|
export declare function inspectionSettings(values: Partial<Record<keyof typeof describeFlags, string | undefined>>): {
|
|
23
23
|
session: {
|
|
24
|
-
|
|
25
|
-
searchPath
|
|
26
|
-
|
|
24
|
+
timeZone?: string;
|
|
25
|
+
searchPath?: string;
|
|
26
|
+
role?: string;
|
|
27
27
|
};
|
|
28
28
|
timeouts: {
|
|
29
29
|
lockMs?: number;
|
|
@@ -31,4 +31,3 @@ export declare function inspectionSettings(values: Partial<Record<keyof typeof d
|
|
|
31
31
|
connectMs?: number;
|
|
32
32
|
};
|
|
33
33
|
};
|
|
34
|
-
export declare function runDescribe(filename: string, connection: string, values: Partial<Record<keyof typeof describeFlags, string | undefined>>): Promise<void>;
|
package/dist/inspection/cli.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { describeQuery } from '../describe.js';
|
|
3
1
|
export const describeFlags = {
|
|
4
2
|
role: { type: 'string' },
|
|
5
3
|
'search-path': { type: 'string' },
|
|
@@ -12,9 +10,9 @@ export const describeFlags = {
|
|
|
12
10
|
export function inspectionSettings(values) {
|
|
13
11
|
return {
|
|
14
12
|
session: {
|
|
15
|
-
role: values.role,
|
|
16
|
-
searchPath: values['search-path'],
|
|
17
|
-
timeZone: values['time-zone'],
|
|
13
|
+
...(values.role !== undefined && { role: values.role }),
|
|
14
|
+
...(values['search-path'] !== undefined && { searchPath: values['search-path'] }),
|
|
15
|
+
...(values['time-zone'] !== undefined && { timeZone: values['time-zone'] }),
|
|
18
16
|
},
|
|
19
17
|
timeouts: {
|
|
20
18
|
...(values['connect-timeout-ms'] !== undefined
|
|
@@ -29,11 +27,3 @@ export function inspectionSettings(values) {
|
|
|
29
27
|
},
|
|
30
28
|
};
|
|
31
29
|
}
|
|
32
|
-
export async function runDescribe(filename, connection, values) {
|
|
33
|
-
const result = await describeQuery({
|
|
34
|
-
connection,
|
|
35
|
-
query: await readFile(filename, 'utf8'),
|
|
36
|
-
...inspectionSettings(values),
|
|
37
|
-
});
|
|
38
|
-
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
39
|
-
}
|
|
@@ -1,27 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createPool, defaultCodecs } from '@kequtech/pgclear';
|
|
2
2
|
import { inspectionOptions } from './options.js';
|
|
3
3
|
export async function withInspectionConnection(options, work) {
|
|
4
|
-
const {
|
|
5
|
-
const
|
|
6
|
-
|
|
4
|
+
const { connection, limits, session } = inspectionOptions(options);
|
|
5
|
+
const pool = createPool({
|
|
6
|
+
...connection,
|
|
7
|
+
codecs: defaultCodecs,
|
|
8
|
+
maxConnections: 1,
|
|
7
9
|
connectTimeoutMs: limits.connectMs,
|
|
8
10
|
startupParameters: {
|
|
11
|
+
...Object.fromEntries(Object.entries(connection.startupParameters ?? {}).filter(([name]) => !['statement_timeout', 'lock_timeout'].includes(name.toLowerCase()))),
|
|
9
12
|
statement_timeout: String(limits.statementMs),
|
|
10
13
|
lock_timeout: String(limits.lockMs),
|
|
11
14
|
},
|
|
12
15
|
});
|
|
13
|
-
const client = connection;
|
|
14
16
|
try {
|
|
15
|
-
|
|
16
|
-
[
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
return await pool.withSession(async (client) => {
|
|
18
|
+
for (const [key, name] of [
|
|
19
|
+
['role', 'role'],
|
|
20
|
+
['searchPath', 'search_path'],
|
|
21
|
+
['timeZone', 'TimeZone'],
|
|
22
|
+
]) {
|
|
23
|
+
const value = session[key];
|
|
24
|
+
if (value !== undefined)
|
|
25
|
+
await client.queryClassic('SELECT pg_catalog.set_config($1, $2, false)', [name, value]);
|
|
26
|
+
}
|
|
27
|
+
const { rows: [context], } = await client.queryClassic(`
|
|
25
28
|
SELECT pg_catalog.current_database() AS database,
|
|
26
29
|
session_user AS "sessionUser", current_user AS "currentUser",
|
|
27
30
|
pg_catalog.current_setting('search_path') AS "searchPath",
|
|
@@ -32,11 +35,12 @@ export async function withInspectionConnection(options, work) {
|
|
|
32
35
|
(SELECT setting::integer FROM pg_catalog.pg_settings
|
|
33
36
|
WHERE name = 'lock_timeout') AS "lockTimeoutMs"
|
|
34
37
|
`, []);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
if (!context)
|
|
39
|
+
throw new Error('Inspection context was not returned.');
|
|
40
|
+
return await work(client, { mode: 'separate-session', readMappings: 'pgclear', ...context });
|
|
41
|
+
});
|
|
38
42
|
}
|
|
39
43
|
finally {
|
|
40
|
-
await
|
|
44
|
+
await pool.close({ timeoutMs: limits.connectMs });
|
|
41
45
|
}
|
|
42
46
|
}
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import type { DescribeOptions } from './types.js';
|
|
1
|
+
import type { DescribeOptions, InspectionSession, InspectionTimeouts } from './types.js';
|
|
2
|
+
export declare function sessionOptions(value: unknown): InspectionSession;
|
|
3
|
+
export declare function timeoutOptions(value: unknown): InspectionTimeouts;
|
|
2
4
|
/** Validate and capture caller-owned settings before opening a connection. */
|
|
3
5
|
export declare function inspectionOptions(options: Pick<DescribeOptions, 'connection' | 'session' | 'timeouts'>): {
|
|
4
|
-
|
|
6
|
+
connection: import("@kequtech/pgclear").PoolOptions;
|
|
5
7
|
limits: {
|
|
6
8
|
connectMs: number;
|
|
7
9
|
statementMs: number;
|
|
8
10
|
lockMs: number;
|
|
9
11
|
};
|
|
10
|
-
session:
|
|
11
|
-
role?: string | undefined;
|
|
12
|
-
searchPath?: string | undefined;
|
|
13
|
-
timeZone?: string | undefined;
|
|
14
|
-
};
|
|
12
|
+
session: InspectionSession;
|
|
15
13
|
};
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { plainObject } from '../validation.js';
|
|
2
|
-
|
|
3
|
-
export function
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
plainObject(options.session ?? {}, 'Inspection session');
|
|
8
|
-
const session = { ...options.session };
|
|
2
|
+
const defaults = { connectMs: 3000, statementMs: 5000, lockMs: 1000 };
|
|
3
|
+
export function sessionOptions(value) {
|
|
4
|
+
const input = plainObject(value, 'Inspection session');
|
|
5
|
+
const session = { ...input };
|
|
9
6
|
for (const [key, value] of Object.entries(session)) {
|
|
10
7
|
if (!['role', 'searchPath', 'timeZone'].includes(key)) {
|
|
11
8
|
throw new Error(`Unknown inspection session option: ${key}`);
|
|
@@ -16,9 +13,11 @@ export function inspectionOptions(options) {
|
|
|
16
13
|
throw new Error(`Inspection session ${key} requires a nonempty string without NUL.`);
|
|
17
14
|
}
|
|
18
15
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
return session;
|
|
17
|
+
}
|
|
18
|
+
export function timeoutOptions(value) {
|
|
19
|
+
const input = plainObject(value, 'Inspection timeouts');
|
|
20
|
+
const timeouts = { ...input };
|
|
22
21
|
for (const [key, value] of Object.entries(timeouts)) {
|
|
23
22
|
if (!Object.hasOwn(defaults, key))
|
|
24
23
|
throw new Error(`Unknown inspection timeout: ${key}`);
|
|
@@ -28,24 +27,52 @@ export function inspectionOptions(options) {
|
|
|
28
27
|
throw new Error(`Inspection timeout ${key} must be a positive integer in milliseconds.`);
|
|
29
28
|
}
|
|
30
29
|
}
|
|
30
|
+
return timeouts;
|
|
31
|
+
}
|
|
32
|
+
/** Validate and capture caller-owned settings before opening a connection. */
|
|
33
|
+
export function inspectionOptions(options) {
|
|
34
|
+
let connection;
|
|
35
|
+
if (typeof options.connection === 'string') {
|
|
36
|
+
if (!options.connection.trim())
|
|
37
|
+
throw new Error('Describe requires an explicit nonempty connection string.');
|
|
38
|
+
connection = { connectionString: options.connection };
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
plainObject(options.connection, 'Inspection connection');
|
|
42
|
+
if ('acquire' in options.connection || 'close' in options.connection) {
|
|
43
|
+
throw new Error('Inspection requires connection options, not an open pool.');
|
|
44
|
+
}
|
|
45
|
+
connection = { ...options.connection };
|
|
46
|
+
}
|
|
47
|
+
if (connection.startupParameters !== undefined) {
|
|
48
|
+
plainObject(connection.startupParameters, 'Startup parameters');
|
|
49
|
+
}
|
|
50
|
+
const session = sessionOptions(options.session ?? {});
|
|
51
|
+
const timeouts = timeoutOptions(options.timeouts ?? {});
|
|
31
52
|
const limits = {
|
|
32
53
|
connectMs: timeouts.connectMs ?? defaults.connectMs,
|
|
33
54
|
statementMs: timeouts.statementMs ?? defaults.statementMs,
|
|
34
55
|
lockMs: timeouts.lockMs ?? defaults.lockMs,
|
|
35
56
|
};
|
|
36
57
|
// URL startup options must not disable the inspection's server-side bounds.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
if (connection.connectionString !== undefined) {
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(connection.connectionString);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new Error('Describe requires a PostgreSQL connection URI.');
|
|
65
|
+
}
|
|
66
|
+
if (!['postgres:', 'postgresql:'].includes(url.protocol)) {
|
|
67
|
+
throw new Error('Describe requires a PostgreSQL connection URI.');
|
|
68
|
+
}
|
|
69
|
+
if (!url.searchParams.has('application_name') && connection.applicationName === undefined) {
|
|
70
|
+
url.searchParams.set('application_name', 'tenon-describe');
|
|
71
|
+
}
|
|
72
|
+
connection = { ...connection, connectionString: url.toString() };
|
|
46
73
|
}
|
|
47
|
-
if (
|
|
48
|
-
|
|
74
|
+
else if (connection.applicationName === undefined) {
|
|
75
|
+
connection = { ...connection, applicationName: 'tenon-describe' };
|
|
49
76
|
}
|
|
50
|
-
return {
|
|
77
|
+
return { connection, limits, session };
|
|
51
78
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CodecRegistry, DescribeStatement } from '@kequtech/pgclear';
|
|
2
|
+
import type { ConnectionConfig } from '../connection.js';
|
|
2
3
|
export interface InspectionSession {
|
|
3
4
|
readonly role?: string | undefined;
|
|
4
5
|
/** PostgreSQL's search_path setting, as used in the application's connection options. */
|
|
@@ -23,8 +24,8 @@ export interface InspectionContext {
|
|
|
23
24
|
readonly readMappings: 'pgclear';
|
|
24
25
|
}
|
|
25
26
|
export interface DescribeOptions {
|
|
26
|
-
/**
|
|
27
|
-
readonly connection:
|
|
27
|
+
/** URI or pool configuration values. Inspection owns a separate, short-lived connection. */
|
|
28
|
+
readonly connection: ConnectionConfig;
|
|
28
29
|
/** Finished SQL; query values are neither bound nor executed. */
|
|
29
30
|
readonly query: string | DescribeStatement;
|
|
30
31
|
/** Use the same registry as execution; custom decoder types can remain unresolved. */
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { defaultCodecs } from '@kequtech/pgclear';
|
|
1
2
|
import { qualified } from '../sql.js';
|
|
2
3
|
export const migrationTable = '__tenon_migrations';
|
|
3
4
|
export async function migrationState(client, namespace, migrations) {
|
|
4
|
-
const exists = await client.queryClassic('SELECT to_regclass($1)::text AS name', [qualified(namespace, migrationTable)]);
|
|
5
|
+
const exists = await client.queryClassic('SELECT pg_catalog.to_regclass($1)::text AS name', [qualified(namespace, migrationTable)], { codecs: defaultCodecs });
|
|
5
6
|
if (exists.rows[0].name === null) {
|
|
6
7
|
return [];
|
|
7
8
|
}
|
|
8
|
-
const { rows: applied } = await client.queryClassic(`SELECT id, name, checksum FROM ${qualified(namespace, migrationTable)} ORDER BY id::numeric`, []);
|
|
9
|
+
const { rows: applied } = await client.queryClassic(`SELECT id, name, checksum FROM ${qualified(namespace, migrationTable)} ORDER BY id::numeric`, [], { codecs: defaultCodecs });
|
|
9
10
|
for (const [i, record] of applied.entries()) {
|
|
10
11
|
const file = migrations[i];
|
|
11
12
|
if (!file ||
|
package/dist/validation.d.ts
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
1
|
export declare function plainObject(value: unknown, name: string): Record<string, unknown>;
|
|
2
|
+
/** Validate before resolving paths so an empty override cannot become the working directory. */
|
|
3
|
+
export declare function validatePath(value: unknown, label: string): asserts value is string;
|
package/dist/validation.js
CHANGED
|
@@ -8,3 +8,9 @@ export function plainObject(value, name) {
|
|
|
8
8
|
}
|
|
9
9
|
return value;
|
|
10
10
|
}
|
|
11
|
+
/** Validate before resolving paths so an empty override cannot become the working directory. */
|
|
12
|
+
export function validatePath(value, label) {
|
|
13
|
+
if (typeof value !== 'string' || !value.trim() || value.includes('\0')) {
|
|
14
|
+
throw new Error(`${label} must be a nonempty path without NUL.`);
|
|
15
|
+
}
|
|
16
|
+
}
|