@kequtech/tenon-orm 0.3.1 → 0.4.2

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 (68) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +81 -26
  3. package/dist/affected-rows.js +0 -1
  4. package/dist/baseline.js +3 -5
  5. package/dist/check/contracts.d.ts +6 -0
  6. package/dist/check/contracts.js +206 -0
  7. package/dist/check/project.d.ts +18 -0
  8. package/dist/check/project.js +224 -0
  9. package/dist/check/source.d.ts +51 -0
  10. package/dist/check/source.js +629 -0
  11. package/dist/check/types.d.ts +36 -0
  12. package/dist/check/types.js +1 -0
  13. package/dist/check.d.ts +4 -0
  14. package/dist/check.js +147 -0
  15. package/dist/cli.js +40 -27
  16. package/dist/connection.d.ts +5 -12
  17. package/dist/connection.js +12 -121
  18. package/dist/database.js +10 -8
  19. package/dist/describe.js +10 -3
  20. package/dist/generate.d.ts +13 -3
  21. package/dist/generate.js +52 -25
  22. package/dist/generation/catalog.d.ts +5 -6
  23. package/dist/generation/catalog.js +7 -9
  24. package/dist/generation/output.js +2 -4
  25. package/dist/generation/render.d.ts +3 -0
  26. package/dist/generation/render.js +60 -0
  27. package/dist/generation/schema-summary.d.ts +3 -3
  28. package/dist/generation/schema-summary.js +2 -2
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +2 -0
  31. package/dist/inspection/catalog.d.ts +3 -6
  32. package/dist/inspection/catalog.js +9 -18
  33. package/dist/inspection/cli.d.ts +12 -0
  34. package/dist/inspection/cli.js +9 -4
  35. package/dist/inspection/connection.d.ts +2 -3
  36. package/dist/inspection/connection.js +20 -22
  37. package/dist/inspection/options.js +0 -6
  38. package/dist/inspection/result.d.ts +3 -2
  39. package/dist/inspection/result.js +13 -26
  40. package/dist/inspection/types.d.ts +6 -4
  41. package/dist/migrations/connection.d.ts +3 -3
  42. package/dist/migrations/connection.js +14 -21
  43. package/dist/migrations/history.d.ts +4 -4
  44. package/dist/migrations/history.js +5 -5
  45. package/dist/migrations.js +7 -9
  46. package/dist/query-options.d.ts +1 -1
  47. package/dist/query.js +1 -1
  48. package/dist/transaction.d.ts +2 -2
  49. package/dist/transaction.js +21 -77
  50. package/dist/types.d.ts +4 -1
  51. package/docs/adoption-guide.md +88 -111
  52. package/docs/agent-guide.md +94 -147
  53. package/docs/connections.md +185 -0
  54. package/docs/human-guide.md +133 -146
  55. package/docs/migrations.md +133 -0
  56. package/docs/mutations.md +110 -60
  57. package/docs/query-checking.md +199 -0
  58. package/docs/query-inspection.md +89 -58
  59. package/docs/reference.md +232 -437
  60. package/docs/schema-types.md +263 -0
  61. package/docs/sql.md +212 -0
  62. package/package.json +11 -7
  63. package/dist/driver/types.d.ts +0 -10
  64. package/dist/driver/types.js +0 -49
  65. package/dist/generation/table-types.d.ts +0 -2
  66. package/dist/generation/table-types.js +0 -96
  67. package/dist/inspection/protocol.d.ts +0 -7
  68. package/dist/inspection/protocol.js +0 -45
@@ -1,13 +1,12 @@
1
- import type { PoolClient } from 'pg';
1
+ import { type InputValue, type QueryResult, type Row } from '@kequtech/pgclear';
2
+ export interface CatalogClient {
3
+ query<T extends object = Row>(text: string, values?: readonly InputValue[]): Promise<QueryResult<T>>;
4
+ }
2
5
  export interface ColumnRow {
3
6
  name: string;
4
7
  oid: string;
5
- type: string;
6
- typeSchema: string;
7
8
  typeKind: string;
8
9
  declaredOid: string;
9
- declaredType: string;
10
- declaredSchema: string;
11
10
  nullable: boolean;
12
11
  writeNullable: boolean;
13
12
  hasDefault: boolean;
@@ -19,4 +18,4 @@ export interface TableRow {
19
18
  name: string;
20
19
  columns: ColumnRow[];
21
20
  }
22
- export declare function readTables(client: PoolClient, namespace: string): Promise<TableRow[]>;
21
+ export declare function readTables(client: CatalogClient, namespace: string, exclude: readonly string[]): Promise<TableRow[]>;
@@ -1,11 +1,11 @@
1
- import { migrationTable } from '../migrations/history.js';
2
- export async function readTables(client, namespace) {
1
+ import { builtin, param } from '@kequtech/pgclear';
2
+ export async function readTables(client, namespace, exclude) {
3
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
- WHERE n.nspname = $1 AND c.relname <> $2 AND c.relkind IN ('r', 'p')
6
+ WHERE n.nspname = $1 AND NOT (c.relname = ANY($2::text[])) AND c.relkind IN ('r', 'p')
7
7
  AND NOT c.relispartition
8
- ORDER BY c.relname COLLATE "C"`, [namespace, migrationTable]);
8
+ ORDER BY c.relname COLLATE "C"`, [namespace, param(exclude, builtin.textArray)]);
9
9
  const definitions = [];
10
10
  for (const table of tables.rows) {
11
11
  // Attached descendants are storage details, not separate generated models.
@@ -18,8 +18,8 @@ export async function readTables(client, namespace) {
18
18
  SELECT r.attnum, t.oid, t.typbasetype, r.domain_not_null OR t.typnotnull
19
19
  FROM resolved r JOIN pg_catalog.pg_type t ON t.oid = r.typbasetype
20
20
  )
21
- SELECT a.attname AS name, t.oid::text AS oid, t.typname AS type, tn.nspname AS "typeSchema", t.typtype AS "typeKind",
22
- declared.oid::text AS "declaredOid", declared.typname AS "declaredType", dn.nspname AS "declaredSchema",
21
+ SELECT a.attname AS name, t.oid::text AS oid, t.typtype AS "typeKind",
22
+ declared.oid::text AS "declaredOid",
23
23
  NOT a.attnotnull AS nullable,
24
24
  NOT (a.attnotnull OR r.domain_not_null) AS "writeNullable",
25
25
  (a.atthasdef OR declared.typdefaultbin IS NOT NULL OR a.attidentity <> '') AS "hasDefault",
@@ -28,11 +28,9 @@ export async function readTables(client, namespace) {
28
28
  FROM pg_catalog.pg_attribute a
29
29
  JOIN resolved r ON r.attnum = a.attnum AND r.typbasetype = 0
30
30
  JOIN pg_catalog.pg_type t ON t.oid = r.oid
31
- JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace
32
31
  JOIN pg_catalog.pg_type declared ON declared.oid = a.atttypid
33
- JOIN pg_catalog.pg_namespace dn ON dn.oid = declared.typnamespace
34
32
  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.rows });
33
+ definitions.push({ ...table, columns: [...columns.rows] });
36
34
  }
37
35
  return definitions;
38
36
  }
@@ -14,12 +14,11 @@ export async function writeDefinitions(source, filename, check) {
14
14
  if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
15
15
  throw error;
16
16
  }
17
- throw new Error(`Generated definitions file is missing: ${output}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
17
+ throw new Error(`Generated definitions file is missing: ${output}\nRun db.generateTypes with the same options and check: false, then commit the result.`);
18
18
  }
19
19
  if (existing !== source) {
20
- throw new Error(`Generated definitions differ: ${output}\n${definitionDiff(existing, source)}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
20
+ throw new Error(`Generated definitions differ: ${output}\n${definitionDiff(existing, source)}\nRun db.generateTypes with the same options and check: false, 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
  }
@@ -0,0 +1,3 @@
1
+ import { type CodecRegistry } from '@kequtech/pgclear';
2
+ import type { TableRow } from './catalog.js';
3
+ export declare function renderDatabase(tables: readonly TableRow[], namespace: string, codecs: CodecRegistry, warn: (message: string) => void): string;
@@ -0,0 +1,60 @@
1
+ import { defaultCodecs } from '@kequtech/pgclear';
2
+ function columnTypes(column, codecs, path, warn) {
3
+ const oid = Number(column.oid);
4
+ let read = codecs.typescript(oid);
5
+ if (read === undefined) {
6
+ warn(`Column ${path} uses a custom decoder without a known TypeScript representation; generated as unknown.`);
7
+ read = 'unknown';
8
+ }
9
+ const choices = column.choices?.map((choice) => JSON.stringify(choice)).join(' | ') || 'never';
10
+ if (column.typeKind === 'e' && codecs === defaultCodecs && read === 'string')
11
+ read = choices;
12
+ let write = defaultCodecs.typescript(oid);
13
+ if (column.typeKind === 'e')
14
+ write = choices;
15
+ if (write === 'unknown' || write.startsWith('PgArray<'))
16
+ write = 'string';
17
+ if ([20, 1700].includes(oid))
18
+ write += ' | bigint';
19
+ if ([1082, 1114, 1184].includes(oid))
20
+ write += ' | globalThis.Date | TemporalInput';
21
+ if ([1083, 1186].includes(oid))
22
+ write += ' | TemporalInput';
23
+ // Explicit parameters handle arrays, JSON and application-owned codecs without accepting
24
+ // ambiguous bare JS objects/arrays as ordinary binds.
25
+ write = write === 'never' ? 'Parameter' : `${write} | Parameter`;
26
+ return {
27
+ read: column.nullable && read !== 'unknown' ? `(${read}) | null` : read,
28
+ write: write + (column.writeNullable ? ' | null' : ''),
29
+ };
30
+ }
31
+ export function renderDatabase(tables, namespace, codecs, warn) {
32
+ let arrays = false, parameters = false, temporalInputs = false;
33
+ const definitions = tables.map((table) => {
34
+ const columns = table.columns.map((column) => {
35
+ const name = JSON.stringify(column.name);
36
+ const types = columnTypes(column, codecs, [namespace, table.name, column.name].map((value) => JSON.stringify(value)).join('.'), warn);
37
+ arrays ||= /\bPgArray\s*</.test(types.read);
38
+ parameters ||= column.writable;
39
+ temporalInputs ||= column.writable && types.write.includes('TemporalInput');
40
+ const required = column.writable && !column.hasDefault && !column.writeNullable;
41
+ const optional = column.writable ? `${types.write} | undefined` : 'never';
42
+ return {
43
+ row: ` ${name}: ${types.read};`,
44
+ insert: ` ${name}${required ? '' : '?'}: ${required ? types.write : optional};`,
45
+ update: ` ${name}?: ${optional};`,
46
+ };
47
+ });
48
+ const shape = (kind) => ` ${kind}: {\n${columns.map((column) => column[kind]).join('\n')}\n };`;
49
+ return ` ${JSON.stringify(table.name)}: {\n${shape('row')}\n${shape('insert')}\n${shape('update')}\n };`;
50
+ });
51
+ const imports = [
52
+ ...(parameters ? ['Parameter'] : []),
53
+ ...(arrays ? ['PgArray'] : []),
54
+ ...(temporalInputs ? ['TemporalInput'] : []),
55
+ ];
56
+ const header = imports.length
57
+ ? `import type { ${imports.join(', ')} } from '@kequtech/tenon-orm';\n\n`
58
+ : '';
59
+ return `${header}export type Database = {\n${definitions.join('\n')}\n};\n`;
60
+ }
@@ -1,6 +1,6 @@
1
- import type { PoolClient } from 'pg';
1
+ import type { CatalogClient } from './catalog.js';
2
2
  /** Include only domains reached through selected columns, base domains or arrays. */
3
- export declare function domainSummaries(client: PoolClient, typeOids: readonly string[]): Promise<string[]>;
3
+ export declare function domainSummaries(client: CatalogClient, 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: PoolClient, oid: string, name: string): Promise<string>;
5
+ export declare function tableSummary(client: CatalogClient, oid: string, name: string): Promise<string>;
6
6
  export declare function schemaSummary(summaries: readonly string[]): string;
@@ -3,7 +3,7 @@ import { quote } from '../sql.js';
3
3
  export async function domainSummaries(client, typeOids) {
4
4
  const domains = await client.query(`
5
5
  WITH RECURSIVE referenced(oid) AS (
6
- SELECT unnest($1::oid[])
6
+ SELECT pg_catalog.unnest(pg_catalog.string_to_array($1, ',')::pg_catalog.oid[])
7
7
  UNION
8
8
  SELECT child.oid FROM referenced r JOIN pg_catalog.pg_type t ON t.oid = r.oid
9
9
  CROSS JOIN LATERAL (VALUES (t.typbasetype), (t.typelem)) child(oid)
@@ -16,7 +16,7 @@ export async function domainSummaries(client, typeOids) {
16
16
  FROM referenced r JOIN pg_catalog.pg_type t ON t.oid = r.oid
17
17
  JOIN pg_catalog.pg_type base ON base.oid = t.typbasetype
18
18
  WHERE t.typtype = 'd'
19
- ORDER BY pg_catalog.format_type(t.oid, NULL) COLLATE "C"`, [typeOids]);
19
+ ORDER BY pg_catalog.format_type(t.oid, NULL) COLLATE "C"`, [typeOids.join(',')]);
20
20
  const summaries = [];
21
21
  for (const domain of domains.rows) {
22
22
  const constraints = await client.query(`
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export { builtin, createTemporalCodecs, defaultCodecs, json, param } from '@kequtech/pgclear';
2
+ export type { Codec, CodecRegistry, Parameter, PgArray, TemporalConstructors, TemporalInput, } from '@kequtech/pgclear';
1
3
  export type { Connection, ConnectionOptions, QueryResult } from './connection.js';
2
4
  export type { AffectedRowsConstraint } from './affected-rows.js';
3
5
  export { createTenon } from './database.js';
@@ -12,7 +14,7 @@ export type { DescribeOptions, DescribedColumn, DescribedType, DescriptionIssue,
12
14
  export { adoptBaseline, captureBaseline } from './baseline.js';
13
15
  export type { CaptureBaselineOptions, CapturedBaseline } from './baseline.js';
14
16
  export { generateDefinitions } from './generate.js';
15
- export type { GenerateOptions } from './generate.js';
17
+ export type { GenerateOptions, GenerateTypesOptions } from './generate.js';
16
18
  export { migrate, migrationStatus, rollback } from './migrations.js';
17
19
  export type { MigrationStatus } from './migrations.js';
18
20
  export type { DatabaseToolOptions } from './migrations/connection.js';
package/dist/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ // Reuse Pgclear's value conversion helpers without exposing its separate SQL tag.
2
+ export { builtin, createTemporalCodecs, defaultCodecs, json, param } from '@kequtech/pgclear';
1
3
  export { createTenon } from './database.js';
2
4
  export { AffectedRowsError, CardinalityError } from './errors.js';
3
5
  export { identifier, sql, unsafeSql } from './query.js';
@@ -1,12 +1,9 @@
1
- import type { Client } from 'pg';
2
- import { type Description } from './protocol.js';
1
+ import type { Session } from '@kequtech/pgclear';
2
+ import type { Description, DescribeStatement } from '@kequtech/pgclear';
3
3
  interface CatalogType {
4
4
  oid: number;
5
5
  schema: string;
6
6
  name: string;
7
- kind: string;
8
- base: number;
9
- choices: string[] | null;
10
7
  }
11
8
  interface ColumnSource {
12
9
  oid: number;
@@ -22,5 +19,5 @@ export interface StatementMetadata {
22
19
  sources: readonly ColumnSource[];
23
20
  }
24
21
  /** Describe the statement and read its type and source-column catalog records. */
25
- export declare function readStatementMetadata(client: Client, text: string): Promise<StatementMetadata>;
22
+ export declare function readStatementMetadata(client: Session, query: DescribeStatement): Promise<StatementMetadata>;
26
23
  export {};
@@ -1,26 +1,17 @@
1
- import { describe } from './protocol.js';
2
1
  /** Describe the statement and read its type and source-column catalog records. */
3
- export async function readStatementMetadata(client, text) {
4
- const statement = await describe(client, text);
2
+ export async function readStatementMetadata(client, query) {
3
+ const statement = await client.describe(query);
5
4
  const oids = [
6
- ...new Set([...statement.parameters, ...statement.fields.map((c) => c.dataTypeID)]),
5
+ ...new Set([...statement.parameterTypes, ...statement.fields.map((c) => c.dataTypeOid)]),
7
6
  ];
8
- const types = (await client.query(`
9
- WITH RECURSIVE wanted(oid) AS (
10
- SELECT pg_catalog.unnest(pg_catalog.string_to_array($1, ',')::pg_catalog.oid[])
11
- UNION
12
- SELECT t.typbasetype FROM pg_catalog.pg_type t JOIN wanted w ON w.oid = t.oid
13
- WHERE t.typbasetype <> 0
14
- )
15
- SELECT t.oid, n.nspname AS schema, t.typname AS name,
16
- t.typtype AS kind, t.typbasetype AS base,
17
- (SELECT pg_catalog.to_json(pg_catalog.array_agg(e.enumlabel::text ORDER BY e.enumsortorder))
18
- FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS choices
19
- FROM wanted w JOIN pg_catalog.pg_type t ON t.oid = w.oid
7
+ const types = (await client.queryClassic(`
8
+ SELECT t.oid, n.nspname AS schema, t.typname AS name
9
+ FROM pg_catalog.pg_type t
20
10
  JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
11
+ WHERE t.oid = ANY(pg_catalog.string_to_array($1, ',')::pg_catalog.oid[])
21
12
  `, [oids.join(',')])).rows;
22
- const tables = [...new Set(statement.fields.map((c) => c.tableID).filter(Boolean))];
23
- const sources = (await client.query(`
13
+ const tables = [...new Set(statement.fields.map((c) => c.tableOid).filter(Boolean))];
14
+ const sources = (await client.queryClassic(`
24
15
  SELECT c.oid, a.attnum AS number, n.nspname AS schema,
25
16
  c.relname AS table, a.attname AS column, a.attnotnull AS "notNull"
26
17
  FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
@@ -19,4 +19,16 @@ export declare const describeFlags: {
19
19
  };
20
20
  };
21
21
  /** Translate CLI flags; validation and defaults belong to the public API. */
22
+ export declare function inspectionSettings(values: Partial<Record<keyof typeof describeFlags, string | undefined>>): {
23
+ session: {
24
+ role: string | undefined;
25
+ searchPath: string | undefined;
26
+ timeZone: string | undefined;
27
+ };
28
+ timeouts: {
29
+ lockMs?: number;
30
+ statementMs?: number;
31
+ connectMs?: number;
32
+ };
33
+ };
22
34
  export declare function runDescribe(filename: string, connection: string, values: Partial<Record<keyof typeof describeFlags, string | undefined>>): Promise<void>;
@@ -9,10 +9,8 @@ export const describeFlags = {
9
9
  'lock-timeout-ms': { type: 'string' },
10
10
  };
11
11
  /** Translate CLI flags; validation and defaults belong to the public API. */
12
- export async function runDescribe(filename, connection, values) {
13
- const result = await describeQuery({
14
- connection,
15
- query: await readFile(filename, 'utf8'),
12
+ export function inspectionSettings(values) {
13
+ return {
16
14
  session: {
17
15
  role: values.role,
18
16
  searchPath: values['search-path'],
@@ -29,6 +27,13 @@ export async function runDescribe(filename, connection, values) {
29
27
  ? { lockMs: Number(values['lock-timeout-ms']) }
30
28
  : {}),
31
29
  },
30
+ };
31
+ }
32
+ export async function runDescribe(filename, connection, values) {
33
+ const result = await describeQuery({
34
+ connection,
35
+ query: await readFile(filename, 'utf8'),
36
+ ...inspectionSettings(values),
32
37
  });
33
38
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
34
39
  }
@@ -1,4 +1,3 @@
1
- import { Client } from 'pg';
1
+ import { type Session } from '@kequtech/pgclear';
2
2
  import type { DescribeOptions, InspectionContext } from './types.js';
3
- /** Bound every database statement, including bootstrap and metadata queries. */
4
- export declare function withInspectionConnection<T>(options: Pick<DescribeOptions, 'connection' | 'session' | 'timeouts'>, work: (client: Client, context: InspectionContext) => Promise<T>): Promise<T>;
3
+ export declare function withInspectionConnection<T>(options: Pick<DescribeOptions, 'connection' | 'session' | 'timeouts'>, work: (client: Session, context: InspectionContext) => Promise<T>): Promise<T>;
@@ -1,22 +1,17 @@
1
- import { Client } from 'pg';
1
+ import { connect } from '@kequtech/pgclear';
2
2
  import { inspectionOptions } from './options.js';
3
- /** Bound every database statement, including bootstrap and metadata queries. */
4
3
  export async function withInspectionConnection(options, work) {
5
4
  const { url, limits, session } = inspectionOptions(options);
6
- const client = new Client({
5
+ const connection = await connect({
7
6
  connectionString: url.toString(),
8
- connectionTimeoutMillis: limits.connectMs,
9
- statement_timeout: limits.statementMs,
10
- lock_timeout: limits.lockMs,
11
- pipeline: false,
7
+ connectTimeoutMs: limits.connectMs,
8
+ startupParameters: {
9
+ statement_timeout: String(limits.statementMs),
10
+ lock_timeout: String(limits.lockMs),
11
+ },
12
12
  });
13
- // Disconnection errors outside an active query must still have a listener.
14
- client.on('error', () => { });
13
+ const client = connection;
15
14
  try {
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)]);
20
15
  for (const [key, name] of [
21
16
  ['role', 'role'],
22
17
  ['searchPath', 'search_path'],
@@ -24,21 +19,24 @@ export async function withInspectionConnection(options, work) {
24
19
  ]) {
25
20
  const value = session[key];
26
21
  if (value !== undefined)
27
- await client.query('SELECT pg_catalog.set_config($1, $2, false)', [name, value]);
22
+ await client.queryClassic('SELECT pg_catalog.set_config($1, $2, false)', [name, value]);
28
23
  }
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",
24
+ const { rows: [context], } = await client.queryClassic(`
25
+ SELECT pg_catalog.current_database() AS database,
26
+ session_user AS "sessionUser", current_user AS "currentUser",
27
+ pg_catalog.current_setting('search_path') AS "searchPath",
32
28
  pg_catalog.to_json(pg_catalog.current_schemas(true)) AS "resolvedSearchPath",
33
29
  pg_catalog.current_setting('TimeZone') AS "timeZone",
34
- (SELECT setting::integer FROM pg_catalog.pg_settings WHERE name='statement_timeout') AS "statementTimeoutMs",
35
- (SELECT setting::integer FROM pg_catalog.pg_settings WHERE name='lock_timeout') AS "lockTimeoutMs"
36
- `);
30
+ (SELECT setting::integer FROM pg_catalog.pg_settings
31
+ WHERE name = 'statement_timeout') AS "statementTimeoutMs",
32
+ (SELECT setting::integer FROM pg_catalog.pg_settings
33
+ WHERE name = 'lock_timeout') AS "lockTimeoutMs"
34
+ `, []);
37
35
  if (!context)
38
36
  throw new Error('Inspection context was not returned.');
39
- return await work(client, { mode: 'separate-session', readMappings: 'pg', ...context });
37
+ return await work(client, { mode: 'separate-session', readMappings: 'pgclear', ...context });
40
38
  }
41
39
  finally {
42
- await client.end();
40
+ await connection.close();
43
41
  }
44
42
  }
@@ -44,12 +44,6 @@ export function inspectionOptions(options) {
44
44
  if (!['postgres:', 'postgresql:'].includes(url.protocol)) {
45
45
  throw new Error('Describe requires a PostgreSQL connection URI.');
46
46
  }
47
- for (const [name, value] of [
48
- ['statement_timeout', limits.statementMs],
49
- ['lock_timeout', limits.lockMs],
50
- ]) {
51
- url.searchParams.set(name, String(value));
52
- }
53
47
  if (!url.searchParams.has('application_name')) {
54
48
  url.searchParams.set('application_name', 'tenon-describe');
55
49
  }
@@ -1,4 +1,5 @@
1
+ import type { CodecRegistry } from '@kequtech/pgclear';
1
2
  import type { StatementMetadata } from './catalog.js';
2
3
  import type { InspectionContext, QueryDescription } from './types.js';
3
- /** Convert database metadata to conservative pg types and diagnostics. */
4
- export declare function queryDescription({ statement, types, sources }: StatementMetadata, context: InspectionContext): QueryDescription;
4
+ /** Read representations follow the selected registry's wire OIDs, not catalog base types. */
5
+ export declare function queryDescription({ statement, types, sources }: StatementMetadata, context: InspectionContext, codecs: CodecRegistry): QueryDescription;
@@ -1,32 +1,19 @@
1
- import { arrayTypes, scalarTypes, valueTypes } from '../driver/types.js';
2
- /** Convert database metadata to conservative pg types and diagnostics. */
3
- export function queryDescription({ statement, types, sources }, context) {
1
+ /** Read representations follow the selected registry's wire OIDs, not catalog base types. */
2
+ export function queryDescription({ statement, types, sources }, context, codecs) {
4
3
  const catalog = new Map(types.map((t) => [t.oid, t]));
5
4
  const issues = new Map();
6
5
  function describeType(oid) {
7
6
  const type = catalog.get(oid);
8
7
  if (!type)
9
8
  throw new Error(`PostgreSQL type OID ${oid} was not found.`);
10
- let readType;
11
- if (type.base) {
12
- readType = describeType(type.base).readType;
13
- }
14
- else if (type.kind === 'e') {
15
- readType = type.choices?.map((v) => JSON.stringify(v)).join(' | ') || 'never';
16
- }
17
- else if (Object.hasOwn(arrayTypes, String(oid))) {
18
- readType = `PgArray<${valueTypes(arrayTypes[String(oid)]).read}>`;
19
- }
20
- else if (type.schema === 'pg_catalog' && Object.hasOwn(scalarTypes, type.name)) {
21
- readType = valueTypes(scalarTypes[type.name]).read;
22
- }
23
- else {
24
- readType = 'unknown';
9
+ const representation = codecs.typescript(oid);
10
+ const readType = representation ?? 'unknown';
11
+ if (representation === undefined) {
25
12
  issues.set(`type:${oid}`, {
26
13
  code: 'unmapped-type',
27
14
  oid,
28
15
  postgres: `${type.schema}.${type.name}`,
29
- message: `No verified pg mapping for ${type.schema}.${type.name} (OID ${oid}).`,
16
+ message: `The selected pgclear registry cannot identify the decoder representation for ${type.schema}.${type.name} (OID ${oid}). Supply an application type for this custom decoder.`,
30
17
  });
31
18
  }
32
19
  return { oid, postgres: `${type.schema}.${type.name}`, readType };
@@ -38,14 +25,14 @@ export function queryDescription({ statement, types, sources }, context) {
38
25
  issues.set(`column:${column.name}`, {
39
26
  code: 'duplicate-column',
40
27
  column: column.name,
41
- message: `Duplicate result column ${JSON.stringify(column.name)}; object rows overwrite earlier values.`,
28
+ message: `Duplicate result column ${JSON.stringify(column.name)}; pgclear rejects duplicate object columns.`,
42
29
  });
43
30
  }
44
31
  names.add(column.name);
45
- const source = sourceMap.get(`${column.tableID}:${column.columnID}`);
32
+ const source = sourceMap.get(`${column.tableOid}:${column.columnAttribute}`);
46
33
  return {
47
34
  name: column.name,
48
- ...describeType(column.dataTypeID),
35
+ ...describeType(column.dataTypeOid),
49
36
  nullability: 'unknown',
50
37
  source: source
51
38
  ? {
@@ -57,7 +44,7 @@ export function queryDescription({ statement, types, sources }, context) {
57
44
  : null,
58
45
  };
59
46
  });
60
- const parameters = statement.parameters.map(describeType);
47
+ const parameters = statement.parameterTypes.map(describeType);
61
48
  if (columns.length) {
62
49
  issues.set('nullability', {
63
50
  code: 'nullability-unknown',
@@ -74,9 +61,9 @@ export function queryDescription({ statement, types, sources }, context) {
74
61
  message: 'An unknown read type does not verify JSON structure or an application-specific result type.',
75
62
  });
76
63
  }
77
- const members = columns.map((c) => ` ${JSON.stringify(c.name)}: ${c.readType}${c.readType === 'unknown' ? '' : ' | null'};`);
78
- const array = columns.some((c) => c.readType.startsWith('PgArray<'))
79
- ? 'type PgArray<T> = (T | null | PgArray<T>)[];\n'
64
+ const members = columns.map((c) => ` ${JSON.stringify(c.name)}: ${c.readType === 'unknown' ? c.readType : `(${c.readType}) | null`};`);
65
+ const array = columns.some((c) => /\bPgArray\b/.test(c.readType))
66
+ ? 'type PgArray<T> = readonly (T | null | PgArray<T>)[];\n'
80
67
  : '';
81
68
  return {
82
69
  context,
@@ -1,4 +1,4 @@
1
- import type { SqlQuery } from '../query.js';
1
+ import type { CodecRegistry, DescribeStatement } from '@kequtech/pgclear';
2
2
  export interface InspectionSession {
3
3
  readonly role?: string | undefined;
4
4
  /** PostgreSQL's search_path setting, as used in the application's connection options. */
@@ -20,13 +20,15 @@ export interface InspectionContext {
20
20
  readonly timeZone: string;
21
21
  readonly statementTimeoutMs: number;
22
22
  readonly lockTimeoutMs: number;
23
- readonly readMappings: 'pg';
23
+ readonly readMappings: 'pgclear';
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
- readonly query: string | Pick<SqlQuery, 'text'>;
29
+ readonly query: string | DescribeStatement;
30
+ /** Use the same registry as execution; custom decoder types can remain unresolved. */
31
+ readonly codecs?: CodecRegistry | undefined;
30
32
  readonly session?: InspectionSession | undefined;
31
33
  readonly timeouts?: InspectionTimeouts | undefined;
32
34
  }
@@ -50,7 +52,7 @@ export type DescriptionIssue = {
50
52
  export interface DescribedType {
51
53
  readonly oid: number;
52
54
  readonly postgres: string;
53
- /** pg read representation, before considering result nullability. */
55
+ /** pgclear read representation, before considering result nullability. */
54
56
  readonly readType: string;
55
57
  }
56
58
  export interface DescribedColumn extends DescribedType {
@@ -1,9 +1,9 @@
1
- import type { PoolClient } from 'pg';
1
+ import { type Session } from '@kequtech/pgclear';
2
2
  import type { Connection } from '../connection.js';
3
3
  export interface DatabaseToolOptions {
4
4
  readonly connection: Connection;
5
5
  readonly namespace?: string;
6
6
  readonly directory?: string;
7
7
  }
8
- /** Coordinate tools independently of application sessions; PostgreSQL owns the wait. */
9
- export declare function withMigrationLock<T>(options: DatabaseToolOptions, mode: 'shared' | 'exclusive', work: (client: PoolClient, namespace: string) => Promise<T>): Promise<T>;
8
+ /** Hold one session advisory lock while tooling runs, restoring transactions before release. */
9
+ export declare function withMigrationLock<T>(options: DatabaseToolOptions, mode: 'shared' | 'exclusive', work: (client: Session, namespace: string) => Promise<T>): Promise<T>;
@@ -1,6 +1,7 @@
1
- import { createConnection, watchClient } from '../connection.js';
1
+ import { defaultCodecs } from '@kequtech/pgclear';
2
+ import { createConnection } from '../connection.js';
2
3
  import { validateName } from '../sql.js';
3
- /** Coordinate tools independently of application sessions; PostgreSQL owns the wait. */
4
+ /** Hold one session advisory lock while tooling runs, restoring transactions before release. */
4
5
  export async function withMigrationLock(options, mode, work) {
5
6
  const namespace = options.namespace ?? 'public';
6
7
  validateName(namespace);
@@ -8,41 +9,34 @@ export async function withMigrationLock(options, mode, work) {
8
9
  const suffix = mode === 'shared' ? '_shared' : '';
9
10
  const lockKey = `tenon:migrations:${namespace}`;
10
11
  const errors = [];
11
- let client;
12
- let connection;
12
+ let lease;
13
13
  let locked = false;
14
14
  let result;
15
15
  try {
16
- client = await pool.connect();
17
- connection = watchClient(client);
18
- await client.query(`SELECT pg_advisory_lock${suffix}(hashtextextended($1, 0))`, [lockKey]);
16
+ lease = await pool.acquire();
17
+ const client = lease;
18
+ await client.queryClassic(`SELECT pg_advisory_lock${suffix}(hashtextextended($1, 0))`, [lockKey], { codecs: defaultCodecs });
19
19
  locked = true;
20
20
  result = await work(client, namespace);
21
- connection.check();
22
21
  }
23
22
  catch (error) {
24
23
  errors.push(error);
25
24
  }
26
25
  finally {
27
- if (client) {
28
- let releaseError;
26
+ if (lease) {
27
+ const client = lease;
29
28
  try {
30
- connection.check();
31
- if (errors.length)
32
- await client.query('ROLLBACK');
29
+ if (lease.transactionStatus !== 'idle')
30
+ await client.queryClassic('ROLLBACK', []);
33
31
  if (locked) {
34
- await client.query(`SELECT pg_advisory_unlock${suffix}(hashtextextended($1, 0))`, [
35
- lockKey,
36
- ]);
32
+ await client.queryClassic(`SELECT pg_advisory_unlock${suffix}(hashtextextended($1, 0))`, [lockKey], { codecs: defaultCodecs });
37
33
  }
38
34
  }
39
35
  catch (error) {
40
36
  errors.push(error);
41
- releaseError =
42
- error instanceof Error ? error : new Error('Tool connection cleanup failed.');
43
37
  }
44
38
  try {
45
- connection.release(releaseError);
39
+ await lease.release({ discard: errors.length > 0 });
46
40
  }
47
41
  catch (error) {
48
42
  errors.push(error);
@@ -55,9 +49,8 @@ export async function withMigrationLock(options, mode, work) {
55
49
  errors.push(error);
56
50
  }
57
51
  }
58
- if (errors.length === 1) {
52
+ if (errors.length === 1)
59
53
  throw errors[0];
60
- }
61
54
  if (errors.length > 1) {
62
55
  throw new AggregateError(errors, 'Database operation and cleanup failed.');
63
56
  }
@@ -1,4 +1,4 @@
1
- import type { PoolClient } from 'pg';
1
+ import type { Session } from '@kequtech/pgclear';
2
2
  import type { SqlMigration } from './files.js';
3
3
  export declare const migrationTable = "__tenon_migrations";
4
4
  interface AppliedMigration {
@@ -6,7 +6,7 @@ interface AppliedMigration {
6
6
  name: string;
7
7
  checksum: string;
8
8
  }
9
- export declare function migrationState(client: PoolClient, namespace: string, migrations: readonly SqlMigration[]): Promise<readonly AppliedMigration[]>;
10
- export declare function createHistoryTable(client: PoolClient, namespace: string): Promise<void>;
11
- export declare function recordMigration(client: PoolClient, namespace: string, migration: SqlMigration): Promise<void>;
9
+ export declare function migrationState(client: Session, namespace: string, migrations: readonly SqlMigration[]): Promise<readonly AppliedMigration[]>;
10
+ export declare function createHistoryTable(client: Session, namespace: string): Promise<void>;
11
+ export declare function recordMigration(client: Session, namespace: string, migration: SqlMigration): Promise<void>;
12
12
  export {};