@kequtech/tenon-orm 0.1.3 → 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 +16 -20
  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 +82 -111
  65. package/docs/human-guide.md +42 -56
  66. package/docs/mutations.md +13 -13
  67. package/docs/query-inspection.md +128 -0
  68. package/docs/reference.md +81 -294
  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
package/CHANGELOG.md ADDED
@@ -0,0 +1,56 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ Changes since 0.1.0, including improvements shipped in 0.1.1–0.1.4.
6
+
7
+ ### Breaking changes
8
+
9
+ - Tenon includes `postgres@3.4.9` as its only runtime dependency. Removed `pg`, `@types/pg`,
10
+ and driver peer requirements. Connections accept a PostgreSQL URI, Postgres.js options, or
11
+ an existing Postgres.js client.
12
+ - Native results are row arrays with `count`, `command`, and `columns` metadata. Replace
13
+ `result.rows` with the result itself, `rowCount` with `count`, and `fields` with `columns`.
14
+ The `many`, `oneOrNone`, and `one` result modes remain available. Native error fields use
15
+ Postgres.js names such as `table_name` and `constraint_name`.
16
+ - Value conversions follow Postgres.js defaults: undefined parameters reject, JSON inputs use
17
+ native JavaScript values, numeric arrays return strings, and timestamp infinities return
18
+ invalid `Date` objects. Pre-serialized JSON text becomes a JSON string.
19
+ - Removed the `/safeql` entry point, `safeqlPlugin`, `eslintPlugin`, and their dependencies and
20
+ peer requirements. Tenon no longer checks or fixes SQL result annotations automatically.
21
+
22
+ ### Added
23
+
24
+ - `describeQuery({ connection, query, session?, timeouts? })` and `tenon describe <query.sql>`
25
+ report PostgreSQL parameter/result types, source columns, a conservative row declaration,
26
+ and unresolved findings. Inspection accepts completed dynamic SQL, uses a separate session,
27
+ and supports explicit role, search path, timezone, and timeouts. It does not execute the query
28
+ or verify its TypeScript annotation. See [query inspection](docs/query-inspection.md).
29
+ - `sql.insertValues(input, columns?)` builds inserts with database defaults for missing or
30
+ undefined cells. `sql.assignments(input, columns?)` builds updates that omit undefined fields.
31
+ Both preserve explicit null. See the [mutation cookbook](docs/mutations.md).
32
+ - Generated `Rows.Insert['table']` and `Rows.Update['table']` types account for defaults,
33
+ nullability, domains, generated columns, and driver input representations.
34
+
35
+ ### Fixes and improvements
36
+
37
+ - SQL NULL array elements remain JavaScript null; quoted `"NULL"`, escapes, nesting, and custom
38
+ parsers retain their meanings.
39
+ - Transactions and migration locks reject work after connection loss. Failed recovery discards
40
+ the affected session, and accepted pending work finishes before transaction cleanup.
41
+ - Transactions and standalone guarded writes issue one fewer bootstrap query by registering
42
+ their connection during BEGIN. Cold-start regressions cover concurrent reservations and tooling locks.
43
+ - Large fragment composition avoids argument-spread failures. Queries reject more than 65,533
44
+ bound parameters before execution; batching remains application-controlled.
45
+ - PostgreSQL `name` columns generate string types. `tenon generate --check` reports missing
46
+ files and a short diff for stale definitions without modifying the saved file.
47
+
48
+ ### Upgrading
49
+
50
+ 1. Remove Tenon's SafeQL imports and plugin configuration, and unused checking dependencies.
51
+ 2. Install Tenon and remove `pg`/`@types/pg` when no other code needs them. Replace pg-specific
52
+ connection configuration and pools with Postgres.js equivalents.
53
+ 3. Update native result/error access, pass JSON values directly, and regenerate table definitions.
54
+ Review numeric arrays and date validity. Use generated insert/update types for write inputs.
55
+ 4. Inspect meaningful query variants and keep database tests for complete rows, permissions,
56
+ ordering, write effects, and rollback. Match inspection settings to the execution session.
package/README.md CHANGED
@@ -3,20 +3,20 @@
3
3
  A PostgreSQL ORM designed for coding agents. Built for Node.js and TypeScript.
4
4
 
5
5
  Your coding agent already knows SQL. Tenon gives it a small API for parameterized queries,
6
- transactions, and migrations, with optional tooling to check SQL and generate database types. Queries
6
+ transactions, migrations, query inspection, and generated database types. Queries
7
7
  stay readable, reusable operations are ordinary functions, and humans can review exactly what will
8
8
  run against the database.
9
9
 
10
10
  Tenon takes the place of an ORM without hiding PostgreSQL behind a query builder. You or your agent
11
11
  write the SQL; Tenon handles binding values and returning results. Your application controls the
12
- `pg` driver, connection configuration, and validation.
12
+ connection configuration and validation. Tenon includes its pinned Postgres.js driver.
13
13
 
14
14
  ```sh
15
- npm install @kequtech/tenon-orm pg
15
+ npm install @kequtech/tenon-orm
16
16
  ```
17
17
 
18
18
  Requires Node.js 24+, PostgreSQL 16+, and ESM. TypeScript consumers need TypeScript 6+ and
19
- development typings for Node and `pg`.
19
+ Node development typings.
20
20
 
21
21
  ```ts
22
22
  import { createTenon, sql } from '@kequtech/tenon-orm';
@@ -29,7 +29,7 @@ try {
29
29
  const email = 'reader@example.test';
30
30
  const user = await db.query(
31
31
  sql<{ id: number; email: string }>`
32
- SELECT id, email FROM public."User" WHERE email = ${email}
32
+ SELECT id, email FROM public.users WHERE email = ${email}
33
33
  `,
34
34
  { returns: 'oneOrNone' },
35
35
  );
@@ -49,6 +49,7 @@ Tenon also provides:
49
49
  - SQL migration files and a CLI to apply, inspect, and roll back migrations.
50
50
  - TypeScript row, insert, and update types generated from your database.
51
51
  - SQL fragments for sharing query parts or assembling variable conditions.
52
+ - Query descriptions from PostgreSQL, including parameter/result types and session context.
52
53
 
53
54
  Joins, relationships, aggregates, and upserts use ordinary PostgreSQL SQL. Tenon does not introduce
54
55
  model classes or automatically load related records. Input validation, authorization, and database
@@ -68,7 +69,7 @@ migrations and table type generation are independent conveniences.
68
69
 
69
70
  For object-based writes, `sql.insertValues(rows)` preserves database defaults across heterogeneous
70
71
  rows and `sql.assignments(patch)` omits undefined fields. Keep the surrounding SQL explicit. Both
71
- support selected columns and [SafeQL checking](docs/reference.md#checking-mutation-fragments).
72
+ support selected columns.
72
73
  Use [generated input types](docs/reference.md#insert-and-update-inputs) to check those objects
73
74
  against your schema.
74
75
 
@@ -77,29 +78,24 @@ against your schema.
77
78
  The type in `sql<{ id: number; email: string }>` describes what you expect back. TypeScript alone
78
79
  cannot verify that your SQL actually selects those columns.
79
80
 
80
- For that, Tenon provides an optional integration with [SafeQL](https://safeql.dev/). SafeQL checks
81
- SQL against your development database and can write or repair the result annotation. ESLint runs the
82
- check during development and CI; it is not part of query execution.
83
-
84
- Configure SafeQL in your application's ESLint setup with Tenon's `safeqlPlugin` adapter and
85
- `eslintPlugin` companion rule. The adapter supports queries, constant fragments, and mutation
86
- helpers; the rule reports queries it cannot inspect. See the
87
- [SQL checking setup](docs/reference.md#sql-checking-with-safeql).
88
-
89
- Checking complements typed inputs and database tests. It does not validate request values or
90
- arbitrary JSON, and runtime-built query variants need their own tests.
81
+ Use `describeQuery({ connection, query })` to inspect the finished SQL, including dynamic fragments,
82
+ or `npx tenon describe query.sql` for a SQL file. It reports PostgreSQL types, Postgres.js values,
83
+ session context, and unresolved findings without executing the statement. No checking packages or
84
+ compiler API are required. Keep database tests for actual values, nullability, authorization, and
85
+ write effects; descriptions do not verify `sql<Row>`. See [query inspection](docs/query-inspection.md).
91
86
 
92
87
  ## Documentation
93
88
 
89
+ - **[Changelog](CHANGELOG.md)** — release changes and upgrade notes.
94
90
  - **[Human guide](docs/human-guide.md)** — learn the workflow through practical examples.
95
91
  - **[Agent guide](docs/agent-guide.md)** — self-contained setup, API contracts, and implementation
96
92
  conventions.
97
- - **[Reference](docs/reference.md)** — complete options, SQL checking setup, type mappings, and
93
+ - **[Reference](docs/reference.md)** — complete options, query inspection, type mappings, and
98
94
  limitations.
99
95
  - **[Adoption guide](docs/adoption-guide.md)** — replace an existing ORM and, if needed, transfer
100
- migration ownership; upgrade local write helpers and adopt SQL checking incrementally.
96
+ migration ownership, update local write helpers, and validate converted queries.
101
97
  - **[Mutation cookbook](docs/mutations.md)** — defaults, patches, upserts, arrays, batching, and
102
- the boundaries between input typing and SQL checking.
98
+ the boundaries between input typing and database validation.
103
99
 
104
100
  The complete `docs/` folder ships with the npm package. Maintainer instructions are in
105
101
  [CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md), which is not
@@ -1,4 +1,4 @@
1
- import type { QueryResult } from 'pg';
1
+ import type { QueryResult } from './connection.js';
2
2
  /** PostgreSQL's final command count, independent of RETURNING. */
3
3
  export type AffectedRowsConstraint = {
4
4
  readonly max: number;
@@ -17,14 +17,14 @@ export function validateAffectedRows(value) {
17
17
  }
18
18
  export function checkAffectedRows(constraint, result) {
19
19
  if (!['INSERT', 'UPDATE', 'DELETE', 'MERGE'].includes(result.command) ||
20
- result.rowCount === null ||
21
- !Number.isSafeInteger(result.rowCount) ||
22
- result.rowCount < 0) {
23
- throw new AffectedRowsError(constraint, result.rowCount, result.command, false);
20
+ result.count === null ||
21
+ !Number.isSafeInteger(result.count) ||
22
+ result.count < 0) {
23
+ throw new AffectedRowsError(constraint, result.count, result.command, false);
24
24
  }
25
25
  const expected = constraint.exact ?? constraint.max;
26
- const matches = constraint.exact === undefined ? result.rowCount <= expected : result.rowCount === expected;
26
+ const matches = constraint.exact === undefined ? result.count <= expected : result.count === expected;
27
27
  if (!matches) {
28
- throw new AffectedRowsError(constraint, result.rowCount, result.command, true);
28
+ throw new AffectedRowsError(constraint, result.count, result.command, true);
29
29
  }
30
30
  }
@@ -13,7 +13,7 @@ export interface CapturedBaseline {
13
13
  readonly id: string;
14
14
  readonly upPath: string;
15
15
  }
16
- /** Capture schema only. No source history writes, comparisons or ORM cleanup. */
16
+ /** Capture a portable schema; deployment provisioning owns role-specific security. */
17
17
  export declare function captureBaseline(options: CaptureBaselineOptions): Promise<CapturedBaseline>;
18
18
  /** Acknowledge the saved baseline on an existing database; never execute its SQL. */
19
19
  export declare function adoptBaseline(options: DatabaseToolOptions): Promise<readonly string[]>;
package/dist/baseline.js CHANGED
@@ -8,7 +8,7 @@ import { readMigrations, withMigrationCreationLock } from './migrations/files.js
8
8
  import { createHistoryTable, migrationState, migrationTable, recordMigration, } from './migrations/history.js';
9
9
  import { qualified, quote } from './sql.js';
10
10
  const run = promisify(execFile);
11
- /** Capture schema only. No source history writes, comparisons or ORM cleanup. */
11
+ /** Capture a portable schema; deployment provisioning owns role-specific security. */
12
12
  export async function captureBaseline(options) {
13
13
  if (typeof options.connection !== 'string' || !options.connection.trim()) {
14
14
  throw new Error('Baseline capture requires an explicit nonempty connection string.');
@@ -82,16 +82,16 @@ export async function adoptBaseline(options) {
82
82
  if (applied.length) {
83
83
  return [];
84
84
  }
85
- const schema = await client.query('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
85
+ const schema = await client.unsafe('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
86
86
  namespace,
87
87
  ]);
88
- if (!schema.rows.length) {
88
+ if (!schema.length) {
89
89
  throw new Error('The baseline namespace does not exist. Use migrate to initialize a fresh database.');
90
90
  }
91
- await client.query('BEGIN');
91
+ await client.unsafe('BEGIN');
92
92
  await createHistoryTable(client, namespace);
93
93
  await recordMigration(client, namespace, baseline);
94
- const result = await client.query('COMMIT');
94
+ const result = await client.unsafe('COMMIT');
95
95
  if (result.command !== 'COMMIT') {
96
96
  throw new Error('Baseline adoption was rolled back.');
97
97
  }
package/dist/cli.js CHANGED
@@ -1,11 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { randomUUID } from 'node:crypto';
3
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
- import { dirname, resolve } from 'node:path';
5
2
  import { parseArgs } from 'node:util';
6
3
  import { adoptBaseline, captureBaseline } from './baseline.js';
7
4
  import { generateDefinitions } from './generate.js';
8
- import { definitionDiff } from './generation/diff.js';
5
+ import { writeDefinitions } from './generation/output.js';
6
+ import { describeFlags, runDescribe } from './inspection/cli.js';
9
7
  import { migrate, migrationStatus, rollback } from './migrations.js';
10
8
  import { createMigration } from './migrations/files.js';
11
9
  const help = `tenon migrate|status|rollback [--directory migrations] [--namespace public]
@@ -13,43 +11,15 @@ tenon baseline capture|adopt [--directory migrations] [--namespace public]
13
11
  baseline capture: [--schema name ...] (default: whole database schema)
14
12
  tenon new <name> [--directory migrations] (no database required)
15
13
  tenon generate [--namespace public] [--out src/generated-schema.ts] [--check]
14
+ tenon describe <query.sql> [--role name] [--search-path schemas] [--time-zone zone]
15
+ describe: [--connect-timeout-ms 3000] [--statement-timeout-ms 5000] [--lock-timeout-ms 1000]
16
+ describe emits JSON metadata; it does not execute the query or verify result annotations.
16
17
  rollback: --steps 1
17
18
  Connection: DATABASE_URL
18
19
  `;
19
20
  function warn(message) {
20
21
  process.stderr.write(`Warning: ${message}\n`);
21
22
  }
22
- /** Check without writing, or replace the complete generated file atomically. */
23
- async function writeDefinitions(source, filename, check) {
24
- const output = resolve(filename);
25
- if (check) {
26
- let existing;
27
- try {
28
- existing = await readFile(output, 'utf8');
29
- }
30
- catch (error) {
31
- if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
32
- throw error;
33
- }
34
- throw new Error(`Generated definitions file is missing: ${output}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
35
- }
36
- if (existing !== source) {
37
- throw new Error(`Generated definitions differ: ${output}\n${definitionDiff(existing, source)}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
38
- }
39
- process.stdout.write('Generated definitions are current.\n');
40
- return;
41
- }
42
- await mkdir(dirname(output), { recursive: true });
43
- const temporary = `${output}.${randomUUID()}.tmp`;
44
- try {
45
- await writeFile(temporary, source, 'utf8');
46
- await rename(temporary, output);
47
- }
48
- finally {
49
- await rm(temporary, { force: true });
50
- }
51
- process.stdout.write(`Generated ${output}\n`);
52
- }
53
23
  async function runBaseline(action, options) {
54
24
  warn("Capture one shared baseline at the old ORM's final schema checkpoint. Each existing database must be at that checkpoint before adoption. Tenon does not compare schemas or remove the old ORM.");
55
25
  if (action === 'capture') {
@@ -74,6 +44,7 @@ async function main() {
74
44
  check: { type: 'boolean', default: false },
75
45
  steps: { type: 'string', default: '1' },
76
46
  help: { type: 'boolean', default: false },
47
+ ...describeFlags,
77
48
  },
78
49
  });
79
50
  if (values.help) {
@@ -84,9 +55,9 @@ async function main() {
84
55
  // Validate explicitly supplied flags, not defaults. Silently ignoring --check
85
56
  // on migrate, for example, would turn an intended dry run into a schema change.
86
57
  const allowed = new Set(['help']);
87
- if (command !== 'generate')
58
+ if (command !== 'generate' && command !== 'describe')
88
59
  allowed.add('directory');
89
- if (command !== 'new') {
60
+ if (command !== 'new' && command !== 'describe') {
90
61
  allowed.add('namespace');
91
62
  }
92
63
  if (command === 'generate') {
@@ -99,6 +70,10 @@ async function main() {
99
70
  if (command === 'baseline' && argument === 'capture') {
100
71
  allowed.add('schema');
101
72
  }
73
+ if (command === 'describe') {
74
+ for (const name of Object.keys(describeFlags))
75
+ allowed.add(name);
76
+ }
102
77
  for (const token of tokens) {
103
78
  if (token.kind === 'option' && !allowed.has(token.name)) {
104
79
  throw new Error(`--${token.name} is not supported by ${command ?? 'this command'}.`);
@@ -113,17 +88,21 @@ async function main() {
113
88
  return;
114
89
  }
115
90
  const baseline = command === 'baseline' && (argument === 'capture' || argument === 'adopt');
116
- const valid = baseline
91
+ const valid = baseline || command === 'describe'
117
92
  ? positionals.length === 2
118
93
  : positionals.length === 1 &&
119
94
  ['migrate', 'generate', 'status', 'rollback'].includes(command ?? '');
120
95
  if (!valid) {
121
- throw new Error('Choose new, baseline capture|adopt, migrate, generate, status, or rollback. Use --help for options.');
96
+ throw new Error('Choose new, baseline capture|adopt, migrate, generate, describe <query.sql>, status, or rollback. Use --help for options.');
122
97
  }
123
98
  const connection = process.env.DATABASE_URL;
124
99
  if (!connection) {
125
100
  throw new Error('DATABASE_URL is required.');
126
101
  }
102
+ if (command === 'describe') {
103
+ await runDescribe(argument, connection, values);
104
+ return;
105
+ }
127
106
  const options = { connection, directory: values.directory, namespace: values.namespace };
128
107
  if (baseline) {
129
108
  await runBaseline(argument, {
package/dist/client.js CHANGED
@@ -16,7 +16,7 @@ export function createClient(query, onQuery) {
16
16
  try {
17
17
  const value = await query(statement.text, statement.values, (result) => {
18
18
  command = result.command;
19
- rowCount = result.rowCount;
19
+ rowCount = result.count;
20
20
  if (options.affectedRows)
21
21
  checkAffectedRows(options.affectedRows, result);
22
22
  return queryOutput(options, result);
@@ -1,15 +1,20 @@
1
- import type { PoolClient, PoolConfig, QueryResult } from 'pg';
2
- import { Pool } from 'pg';
3
- export type Connection = string | PoolConfig | Pool;
4
- export type QueryExecutor = <T>(text: string, input: readonly unknown[], readResult: (result: QueryResult<Record<string, unknown>>) => T, guarded: boolean) => Promise<T>;
5
- /** Borrow application pools; own pools created from a connection string or config. */
6
- export declare function createPool(connection: Connection): {
7
- pool: Pool;
1
+ import postgres from 'postgres';
2
+ import type { UnknownRow } from './query.js';
3
+ export type ConnectionOptions = postgres.Options<{}>;
4
+ export type Connection = string | ConnectionOptions | postgres.Sql;
5
+ export type QueryResult<Row extends object = UnknownRow> = postgres.RowList<Row[]>;
6
+ /** The driver operations shared by ordinary and reserved connections. */
7
+ export interface SqlConnection {
8
+ unsafe<Rows extends object[] = UnknownRow[]>(text: string, input?: postgres.ParameterOrJSON<never>[], options?: postgres.UnsafeQueryOptions & {
9
+ simple?: boolean;
10
+ }): Promise<postgres.RowList<Rows>>;
11
+ array: postgres.Sql['array'];
12
+ typed: postgres.Sql['typed'];
13
+ }
14
+ export type QueryExecutor = <T>(text: string, input: readonly unknown[], readResult: (result: QueryResult) => T, guarded: boolean) => Promise<T>;
15
+ /** Own clients created from a URI/config; borrow explicitly supplied Postgres.js clients. */
16
+ export declare function createConnection(connection: Connection): {
17
+ sql: postgres.Sql;
8
18
  owned: boolean;
9
19
  };
10
- /** Observe failures between queries until ownership returns to the pool. */
11
- export declare function watchClient(client: PoolClient): {
12
- check(): void;
13
- release(error?: Error): void;
14
- };
15
- export declare function executeSql(connection: Pool | PoolClient, text: string, input: readonly unknown[]): Promise<QueryResult<Record<string, unknown>>>;
20
+ export declare function executeSql(sql: SqlConnection, text: string, input?: readonly unknown[]): Promise<QueryResult>;
@@ -1,51 +1,22 @@
1
- import { Pool } from 'pg';
1
+ import postgres from 'postgres';
2
2
  import { plainObject } from './validation.js';
3
- /** Borrow application pools; own pools created from a connection string or config. */
4
- export function createPool(connection) {
5
- if (connection instanceof Pool) {
6
- return { pool: connection, owned: false };
3
+ /** Own clients created from a URI/config; borrow explicitly supplied Postgres.js clients. */
4
+ export function createConnection(connection) {
5
+ if (typeof connection === 'function' && typeof connection.unsafe === 'function') {
6
+ return { sql: connection, owned: false };
7
7
  }
8
- // Missing configuration must not silently select pg's environment defaults.
9
- // An explicit config object (including {}) may still use those defaults.
10
8
  if (typeof connection === 'string') {
11
9
  if (!connection.trim())
12
10
  throw new Error('Connection string must not be empty.');
11
+ return { sql: postgres(connection), owned: true };
13
12
  }
14
- else {
15
- plainObject(connection, 'Connection configuration');
16
- }
17
- const config = typeof connection === 'string' ? { connectionString: connection } : connection;
18
- const pool = new Pool(config);
19
- // pg removes broken idle clients itself, but an unhandled pool error would
20
- // still terminate Node. Borrowed pools keep their application's error policy.
21
- pool.on('error', (error) => process.emitWarning(error));
22
- return { pool, owned: true };
23
- }
24
- /** Observe failures between queries until ownership returns to the pool. */
25
- export function watchClient(client) {
26
- let failure;
27
- const onError = (error) => {
28
- failure ??= error;
29
- };
30
- client.on('error', onError);
31
- return {
32
- check() {
33
- if (failure) {
34
- throw failure;
35
- }
36
- },
37
- release(error) {
38
- try {
39
- client.release(error ?? failure);
40
- }
41
- finally {
42
- client.removeListener('error', onError);
43
- }
44
- },
45
- };
13
+ plainObject(connection, 'Connection configuration');
14
+ return { sql: postgres(connection), owned: true };
46
15
  }
47
- export function executeSql(connection, text, input) {
48
- // Extended protocol makes even a zero-parameter call one SQL statement.
49
- const config = { text, values: [...input], queryMode: 'extended' };
50
- return connection.query(config);
16
+ export function executeSql(sql, text, input = []) {
17
+ // Tenon compiles SQL separately from values; let PostgreSQL infer array/JSON types from the surrounding SQL.
18
+ const values = input.map((value) => (Array.isArray(value) ? sql.typed(value, 0) : value));
19
+ // Keep preparation off; CONSIDERATIONS.md records parser-cache and pipeline safety constraints.
20
+ const options = { simple: false, prepare: false };
21
+ return sql.unsafe(text, values, options);
51
22
  }
package/dist/database.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createClient } from './client.js';
2
- import { createPool, executeSql } from './connection.js';
2
+ import { createConnection, executeSql } from './connection.js';
3
+ import { prepareArrays } from './driver/array.js';
3
4
  import { withTransaction } from './transaction.js';
4
5
  import { plainObject } from './validation.js';
5
6
  export function createTenon(options) {
@@ -11,7 +12,7 @@ export function createTenon(options) {
11
12
  if (options.onQuery !== undefined && typeof options.onQuery !== 'function') {
12
13
  throw new Error('onQuery must be a function.');
13
14
  }
14
- const { pool, owned } = createPool(options.connection);
15
+ const { sql: pool, owned } = createConnection(options.connection);
15
16
  let closed = false;
16
17
  function checkOpen() {
17
18
  if (closed) {
@@ -23,6 +24,7 @@ export function createTenon(options) {
23
24
  if (guarded) {
24
25
  return withTransaction(pool, (query) => query(text, input, readResult, false));
25
26
  }
27
+ await prepareArrays(pool);
26
28
  return readResult(await executeSql(pool, text, input));
27
29
  };
28
30
  return {
@@ -0,0 +1,3 @@
1
+ import type { DescribeOptions, QueryDescription } from './inspection/types.js';
2
+ /** Describe finished SQL without binding values or executing the inspected statement. */
3
+ export declare function describeQuery(options: DescribeOptions): Promise<QueryDescription>;
@@ -0,0 +1,21 @@
1
+ import { readStatementMetadata } from './inspection/catalog.js';
2
+ import { withInspectionConnection } from './inspection/connection.js';
3
+ import { queryDescription } from './inspection/result.js';
4
+ import { plainObject } from './validation.js';
5
+ /** Describe finished SQL without binding values or executing the inspected statement. */
6
+ export async function describeQuery(options) {
7
+ const input = plainObject(options, 'Describe options');
8
+ for (const key of Object.keys(input)) {
9
+ if (!['connection', 'query', 'session', 'timeouts'].includes(key)) {
10
+ throw new Error(`Unknown Describe option: ${key}`);
11
+ }
12
+ }
13
+ let text = input.query;
14
+ if (text !== null && typeof text === 'object' && 'text' in text)
15
+ text = text.text;
16
+ if (typeof text !== 'string' || !text.trim()) {
17
+ throw new Error('Describe query requires nonempty SQL text or a query object with text.');
18
+ }
19
+ const query = text;
20
+ return withInspectionConnection(options, async (client, context) => queryDescription(await readStatementMetadata(client, query), context));
21
+ }
@@ -0,0 +1,5 @@
1
+ import type postgres from 'postgres';
2
+ /** Preserve driver scalar parsers and serializers; replace only lossy array parsing. */
3
+ export declare function prepareArrays(sql: postgres.Sql): Promise<void>;
4
+ /** PostgreSQL text arrays: only unquoted NULL is null; quoted values use the scalar parser. */
5
+ export declare function parseArray(text: string, parse: (value: string) => unknown, delimiter?: string): unknown[];
@@ -0,0 +1,70 @@
1
+ const initialization = new WeakMap();
2
+ /** Preserve driver scalar parsers and serializers; replace only lossy array parsing. */
3
+ export function prepareArrays(sql) {
4
+ const existing = initialization.get(sql);
5
+ if (existing)
6
+ return existing;
7
+ const ready = (async () => {
8
+ const customArrays = new Set(Object.values(sql.options.types).flatMap((type) => type.from));
9
+ const arrays = await sql `
10
+ SELECT t.typarray AS oid, t.oid AS element, t.typdelim AS delimiter
11
+ FROM pg_catalog.pg_type t WHERE t.typarray <> 0`;
12
+ for (const type of arrays) {
13
+ if (customArrays.has(type.oid))
14
+ continue;
15
+ const scalar = sql.options.parsers[type.element] ?? ((value) => value);
16
+ sql.options.parsers[type.oid] = (value) => parseArray(value, scalar, type.delimiter);
17
+ }
18
+ })();
19
+ initialization.set(sql, ready);
20
+ void ready.catch(() => initialization.delete(sql));
21
+ return ready;
22
+ }
23
+ /** PostgreSQL text arrays: only unquoted NULL is null; quoted values use the scalar parser. */
24
+ export function parseArray(text, parse, delimiter = ',') {
25
+ let position = text.startsWith('[') ? text.indexOf('=') + 1 : 0;
26
+ function array() {
27
+ if (text[position++] !== '{')
28
+ throw new Error('Invalid PostgreSQL array.');
29
+ const values = [];
30
+ while (position < text.length && text[position] !== '}') {
31
+ if (text[position] === '{')
32
+ values.push(array());
33
+ else {
34
+ let value = '';
35
+ const quoted = text[position] === '"';
36
+ if (quoted)
37
+ position++;
38
+ while (position < text.length) {
39
+ const char = text[position];
40
+ if (char === '\\') {
41
+ position++;
42
+ value += text[position++] ?? '';
43
+ }
44
+ else if (quoted && char === '"') {
45
+ position++;
46
+ break;
47
+ }
48
+ else if (!quoted && (char === delimiter || char === '}'))
49
+ break;
50
+ else {
51
+ value += char;
52
+ position++;
53
+ }
54
+ }
55
+ values.push(!quoted && value === 'NULL' ? null : parse(value));
56
+ }
57
+ if (text[position] === delimiter)
58
+ position++;
59
+ else if (text[position] !== '}')
60
+ throw new Error('Invalid PostgreSQL array separator.');
61
+ }
62
+ if (text[position++] !== '}')
63
+ throw new Error('Unterminated PostgreSQL array.');
64
+ return values;
65
+ }
66
+ const result = array();
67
+ if (position !== text.length)
68
+ throw new Error('Unexpected PostgreSQL array suffix.');
69
+ return result;
70
+ }
@@ -0,0 +1,15 @@
1
+ import type postgres from 'postgres';
2
+ export interface Watch {
3
+ failure?: Error;
4
+ check(): void;
5
+ closed: Promise<never>;
6
+ reject(error: Error): void;
7
+ }
8
+ interface State {
9
+ acquiring: Set<(error: Error) => void>;
10
+ active: Map<number, Watch>;
11
+ capture(marker: string, watch: Watch): () => void;
12
+ }
13
+ /** Observe the driver's public connection callbacks; never reuse a reconnected transaction. */
14
+ export declare function observeConnections(sql: postgres.Sql): State;
15
+ export {};
@@ -0,0 +1,57 @@
1
+ const states = new WeakMap();
2
+ function disconnected() {
3
+ return Object.assign(new Error('The reserved PostgreSQL connection closed.'), {
4
+ code: 'CONNECTION_CLOSED',
5
+ });
6
+ }
7
+ /** Observe the driver's public connection callbacks; never reuse a reconnected transaction. */
8
+ export function observeConnections(sql) {
9
+ const previous = states.get(sql);
10
+ if (previous)
11
+ return previous;
12
+ const markers = new Map();
13
+ let debug = sql.options.debug;
14
+ // Capture the bootstrap connection ID, then restore debug before application work.
15
+ // Leaving it enabled would make the driver include SQL and values in enumerable errors.
16
+ const captureDebug = (id, text, values, types) => {
17
+ const watch = markers.get(text);
18
+ if (watch) {
19
+ state.active.set(id, watch);
20
+ markers.delete(text);
21
+ }
22
+ if (!markers.size)
23
+ sql.options.debug = debug;
24
+ if (typeof debug === 'function')
25
+ debug(id, text, values, types);
26
+ };
27
+ const state = {
28
+ acquiring: new Set(),
29
+ active: new Map(),
30
+ capture(marker, watch) {
31
+ if (!markers.size) {
32
+ debug = sql.options.debug;
33
+ sql.options.debug = captureDebug;
34
+ }
35
+ markers.set(marker, watch);
36
+ return () => {
37
+ markers.delete(marker);
38
+ if (!markers.size)
39
+ sql.options.debug = debug;
40
+ };
41
+ },
42
+ };
43
+ const onclose = sql.options.onclose;
44
+ sql.options.onclose = (id) => {
45
+ const error = disconnected();
46
+ const watch = state.active.get(id);
47
+ if (watch) {
48
+ watch.failure ??= error;
49
+ watch.reject(watch.failure);
50
+ }
51
+ for (const reject of state.acquiring)
52
+ reject(error);
53
+ onclose?.(id);
54
+ };
55
+ states.set(sql, state);
56
+ return state;
57
+ }