@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +52 -36
  2. package/README.md +27 -70
  3. package/dist/affected-rows.js +6 -6
  4. package/dist/baseline.js +4 -4
  5. package/dist/client.js +1 -1
  6. package/dist/connection.d.ts +15 -16
  7. package/dist/connection.js +124 -14
  8. package/dist/database.js +36 -17
  9. package/dist/driver/types.d.ts +1 -1
  10. package/dist/driver/types.js +11 -11
  11. package/dist/errors.d.ts +8 -0
  12. package/dist/errors.js +15 -0
  13. package/dist/generate.js +7 -7
  14. package/dist/generation/catalog.d.ts +2 -2
  15. package/dist/generation/catalog.js +4 -4
  16. package/dist/generation/schema-summary.d.ts +3 -3
  17. package/dist/generation/schema-summary.js +11 -11
  18. package/dist/generation/table-types.js +1 -1
  19. package/dist/index.d.ts +2 -3
  20. package/dist/index.js +1 -1
  21. package/dist/inspection/catalog.d.ts +4 -3
  22. package/dist/inspection/catalog.js +13 -10
  23. package/dist/inspection/cli.js +3 -3
  24. package/dist/inspection/connection.d.ts +2 -2
  25. package/dist/inspection/connection.js +22 -22
  26. package/dist/inspection/options.d.ts +3 -3
  27. package/dist/inspection/options.js +9 -1
  28. package/dist/inspection/protocol.d.ts +7 -0
  29. package/dist/inspection/protocol.js +45 -0
  30. package/dist/inspection/result.d.ts +1 -1
  31. package/dist/inspection/result.js +6 -6
  32. package/dist/inspection/types.d.ts +10 -10
  33. package/dist/migrations/connection.d.ts +3 -2
  34. package/dist/migrations/connection.js +18 -20
  35. package/dist/migrations/history.d.ts +4 -4
  36. package/dist/migrations/history.js +5 -5
  37. package/dist/migrations.js +7 -7
  38. package/dist/query-options.js +5 -5
  39. package/dist/query.js +2 -2
  40. package/dist/transaction.d.ts +2 -2
  41. package/dist/transaction.js +19 -14
  42. package/dist/types.d.ts +7 -3
  43. package/docs/adoption-guide.md +109 -122
  44. package/docs/agent-guide.md +124 -228
  45. package/docs/human-guide.md +44 -55
  46. package/docs/mutations.md +58 -76
  47. package/docs/query-inspection.md +97 -89
  48. package/docs/reference.md +264 -319
  49. package/package.json +4 -4
  50. package/dist/driver/array.d.ts +0 -5
  51. package/dist/driver/array.js +0 -70
  52. package/dist/driver/lifecycle.d.ts +0 -15
  53. package/dist/driver/lifecycle.js +0 -57
  54. package/dist/driver/session.d.ts +0 -8
  55. 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.unsafe('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
11
- const schema = await client.unsafe('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
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.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');
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.unsafe('COMMIT');
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 { SqlConnection } from '../connection.js';
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: SqlConnection, namespace: string): Promise<TableRow[]>;
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.unsafe(`
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.unsafe(`
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 { SqlConnection } from '../connection.js';
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: SqlConnection, typeOids: readonly string[]): Promise<string[]>;
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: SqlConnection, oid: string, name: string): Promise<string>;
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.unsafe(`
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"`, [client.array([...typeOids])]);
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.unsafe(`
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.unsafe(`
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.unsafe(`
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.unsafe(`
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 Postgres.js mapping; generated as unknown for reads and never for non-null writes.`);
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 postgres from 'postgres';
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: postgres.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: postgres.Sql, text: string): Promise<StatementMetadata>;
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.unsafe(text, [], { prepare: true }).describe();
4
- const oids = [...new Set([...statement.types, ...statement.columns.map((c) => c.type)])];
5
- const types = await client `
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(${oids.join(',')}, ',')::oid[])
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.columns.map((c) => c.table).filter(Boolean))];
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(${tables.join(',')}, ',')::oid[])
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
  }
@@ -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
- ...(values.role !== undefined ? { role: values.role } : {}),
18
- ...(values['search-path'] !== undefined ? { searchPath: values['search-path'] } : {}),
19
- ...(values['time-zone'] !== undefined ? { timeZone: values['time-zone'] } : {}),
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 postgres from 'postgres';
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: postgres.Sql, context: InspectionContext) => Promise<T>): Promise<T>;
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 postgres from 'postgres';
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 = 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() { },
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 `SELECT
16
- set_config('statement_timeout', ${String(limits.statementMs)}, false),
17
- set_config('lock_timeout', ${String(limits.lockMs)}, false)`;
18
- await prepareArrays(client);
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 `SELECT set_config(${name}, ${value}, false)`;
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: 'postgres', ...context });
39
+ return await work(client, { mode: 'separate-session', readMappings: 'pg', ...context });
39
40
  }
40
41
  finally {
41
- // Bound cleanup as well; never leave an unbounded end() after a server/network failure.
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 = { ...defaults, ...timeouts };
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 Postgres.js types and diagnostics. */
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 Postgres.js types and diagnostics. */
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 Postgres.js mapping for ${type.schema}.${type.name} (OID ${oid}).`,
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.columns.map((column) => {
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.table}:${column.number}`);
45
+ const source = sourceMap.get(`${column.tableID}:${column.columnID}`);
46
46
  return {
47
47
  name: column.name,
48
- ...describeType(column.type),
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.types.map(describeType);
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: 'postgres';
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
- /** Postgres.js read representation, before considering result nullability. */
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 { Connection, SqlConnection } from '../connection.js';
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: SqlConnection, namespace: string) => Promise<T>): Promise<T>;
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 { reserveConnection } from '../driver/session.js';
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 { sql: pool, owned } = createConnection(options.connection);
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
- connection = await reserveConnection(pool);
17
- const client = connection.sql;
18
- await client.unsafe(`SELECT pg_advisory_lock${suffix}(hashtextextended($1, 0))`, [lockKey]);
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 (connection) {
28
- const client = connection.sql;
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.unsafe('ROLLBACK');
32
+ await client.query('ROLLBACK');
34
33
  if (locked) {
35
- await client.unsafe(`SELECT pg_advisory_unlock${suffix}(hashtextextended($1, 0))`, [
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
- discard = true;
41
+ releaseError =
42
+ error instanceof Error ? error : new Error('Tool connection cleanup failed.');
43
43
  }
44
44
  try {
45
- await connection.release(discard);
45
+ connection.release(releaseError);
46
46
  }
47
47
  catch (error) {
48
48
  errors.push(error);
49
49
  }
50
50
  }
51
- if (owned) {
52
- try {
53
- await pool.end();
54
- }
55
- catch (error) {
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) {