@kequtech/tenon-orm 0.1.4 → 0.2.0

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