@kequtech/tenon-orm 0.3.0 → 0.4.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 (63) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +27 -5
  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 +38 -9
  16. package/dist/connection.d.ts +5 -12
  17. package/dist/connection.js +12 -121
  18. package/dist/database.js +5 -8
  19. package/dist/describe.js +10 -3
  20. package/dist/generate.d.ts +2 -3
  21. package/dist/generate.js +26 -24
  22. package/dist/generation/catalog.d.ts +5 -6
  23. package/dist/generation/catalog.js +7 -9
  24. package/dist/generation/render.d.ts +3 -0
  25. package/dist/generation/render.js +60 -0
  26. package/dist/generation/schema-summary.d.ts +3 -3
  27. package/dist/generation/schema-summary.js +2 -2
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.js +2 -0
  30. package/dist/inspection/catalog.d.ts +3 -6
  31. package/dist/inspection/catalog.js +9 -18
  32. package/dist/inspection/cli.d.ts +12 -0
  33. package/dist/inspection/cli.js +9 -4
  34. package/dist/inspection/connection.d.ts +2 -3
  35. package/dist/inspection/connection.js +20 -22
  36. package/dist/inspection/options.js +0 -6
  37. package/dist/inspection/result.d.ts +3 -2
  38. package/dist/inspection/result.js +13 -26
  39. package/dist/inspection/types.d.ts +6 -4
  40. package/dist/migrations/connection.d.ts +3 -3
  41. package/dist/migrations/connection.js +14 -21
  42. package/dist/migrations/history.d.ts +4 -4
  43. package/dist/migrations/history.js +5 -5
  44. package/dist/migrations.js +7 -9
  45. package/dist/query-options.d.ts +1 -1
  46. package/dist/query.js +1 -1
  47. package/dist/transaction.d.ts +2 -2
  48. package/dist/transaction.js +21 -77
  49. package/dist/types.d.ts +1 -1
  50. package/docs/adoption-guide.md +64 -12
  51. package/docs/agent-guide.md +57 -27
  52. package/docs/human-guide.md +53 -18
  53. package/docs/mutations.md +22 -9
  54. package/docs/query-checking.md +200 -0
  55. package/docs/query-inspection.md +40 -13
  56. package/docs/reference.md +330 -108
  57. package/package.json +11 -7
  58. package/dist/driver/types.d.ts +0 -10
  59. package/dist/driver/types.js +0 -49
  60. package/dist/generation/table-types.d.ts +0 -2
  61. package/dist/generation/table-types.js +0 -96
  62. package/dist/inspection/protocol.d.ts +0 -7
  63. package/dist/inspection/protocol.js +0 -45
@@ -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';
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 {};
@@ -1,11 +1,11 @@
1
1
  import { qualified } from '../sql.js';
2
2
  export const migrationTable = '__tenon_migrations';
3
3
  export async function migrationState(client, namespace, migrations) {
4
- const exists = await client.query('SELECT to_regclass($1)::text AS name', [qualified(namespace, migrationTable)]);
4
+ const exists = await client.queryClassic('SELECT to_regclass($1)::text AS name', [qualified(namespace, migrationTable)]);
5
5
  if (exists.rows[0].name === null) {
6
6
  return [];
7
7
  }
8
- const { rows: applied } = await client.query(`SELECT id, name, checksum FROM ${qualified(namespace, migrationTable)} ORDER BY id::numeric`);
8
+ const { rows: applied } = await client.queryClassic(`SELECT id, name, checksum FROM ${qualified(namespace, migrationTable)} ORDER BY id::numeric`, []);
9
9
  for (const [i, record] of applied.entries()) {
10
10
  const file = migrations[i];
11
11
  if (!file ||
@@ -18,13 +18,13 @@ export async function migrationState(client, namespace, migrations) {
18
18
  return applied;
19
19
  }
20
20
  export async function createHistoryTable(client, namespace) {
21
- await client.query(`CREATE TABLE IF NOT EXISTS ${qualified(namespace, migrationTable)} (
21
+ await client.queryClassic(`CREATE TABLE IF NOT EXISTS ${qualified(namespace, migrationTable)} (
22
22
  id TEXT PRIMARY KEY CHECK (id ~ '^[0-9]+$'),
23
23
  name TEXT NOT NULL,
24
24
  checksum TEXT NOT NULL,
25
25
  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
26
- )`);
26
+ )`, []);
27
27
  }
28
28
  export async function recordMigration(client, namespace, migration) {
29
- await client.query(`INSERT INTO ${qualified(namespace, migrationTable)} (id, name, checksum) VALUES ($1, $2, $3)`, [migration.id, migration.name, migration.checksum]);
29
+ await client.queryClassic(`INSERT INTO ${qualified(namespace, migrationTable)} (id, name, checksum) VALUES ($1, $2, $3)`, [migration.id, migration.name, migration.checksum]);
30
30
  }
@@ -28,25 +28,23 @@ async function execute(options, direction, steps) {
28
28
  if (!selected.length) {
29
29
  return [];
30
30
  }
31
- await client.query(`CREATE SCHEMA IF NOT EXISTS ${quote(namespace)}`);
31
+ await client.queryClassic(`CREATE SCHEMA IF NOT EXISTS ${quote(namespace)}`, []);
32
32
  await createHistoryTable(client, namespace);
33
33
  const completed = [];
34
34
  for (const file of selected) {
35
35
  try {
36
- await client.query('BEGIN');
36
+ await client.queryClassic('BEGIN', []);
37
37
  // Match the string-literal rules used by the authored migration scanner.
38
- await client.query('SET LOCAL standard_conforming_strings TO on');
39
- await client.query(`SET LOCAL search_path TO ${quote(namespace)}, pg_catalog`);
40
- await client.query(direction === 'up' ? file.up : file.down);
38
+ await client.queryClassic('SET LOCAL standard_conforming_strings TO on', []);
39
+ await client.queryClassic(`SET LOCAL search_path TO ${quote(namespace)}, pg_catalog`, []);
40
+ await client.script(direction === 'up' ? file.up : file.down);
41
41
  if (direction === 'up') {
42
42
  await recordMigration(client, namespace, file);
43
43
  }
44
44
  else {
45
- await client.query(`DELETE FROM ${qualified(namespace, migrationTable)} WHERE id = $1`, [
46
- file.id,
47
- ]);
45
+ await client.queryClassic(`DELETE FROM ${qualified(namespace, migrationTable)} WHERE id = $1`, [file.id]);
48
46
  }
49
- const result = await client.query('COMMIT');
47
+ const result = await client.queryClassic('COMMIT', []);
50
48
  if (result.command !== 'COMMIT') {
51
49
  throw new Error('The migration transaction was rolled back.');
52
50
  }
@@ -8,7 +8,7 @@ export interface QueryOptions {
8
8
  /** Optional diagnostic label. No SQL or input values are sent to observers. */
9
9
  readonly label?: string;
10
10
  }
11
- export type QueryOutput<Row extends object, Mode extends QueryReturnMode> = Mode extends 'native' ? QueryResult<Row> : Mode extends 'many' ? Row[] : Mode extends 'oneOrNone' ? Row | undefined : Row;
11
+ export type QueryOutput<Row extends object, Mode extends QueryReturnMode> = Mode extends 'native' ? QueryResult<Row> : Mode extends 'many' ? readonly Row[] : Mode extends 'oneOrNone' ? Row | undefined : Row;
12
12
  export declare function validateOptions(options?: QueryOptions): Readonly<QueryOptions & {
13
13
  returns: QueryReturnMode;
14
14
  }>;
package/dist/query.js CHANGED
@@ -124,6 +124,6 @@ export function assertQuery(value) {
124
124
  !value.text.trim() ||
125
125
  !('values' in value) ||
126
126
  !Array.isArray(value.values)) {
127
- throw new Error('Expected a query created with sql or unsafeSql.');
127
+ throw new Error('Expected a query created with sql or unsafeSql from @kequtech/tenon-orm.');
128
128
  }
129
129
  }
@@ -1,4 +1,4 @@
1
- import type { Pool } from 'pg';
1
+ import type { Pool } from '@kequtech/pgclear';
2
2
  import type { QueryExecutor } from './connection.js';
3
- /** The callback owns one connection and expires before commit or rollback begins. */
3
+ /** Serialize whole Tenon policies; pgclear owns transaction, savepoint and transport recovery. */
4
4
  export declare function withTransaction<T>(pool: Pool, work: (query: QueryExecutor) => Promise<T>): Promise<T>;