@kequtech/tenon-orm 0.1.4 → 0.2.1

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 +64 -0
  2. package/README.md +19 -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 +5 -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 +7 -4
  63. package/docs/adoption-guide.md +43 -76
  64. package/docs/agent-guide.md +82 -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 +98 -289
  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/dist/sql.js CHANGED
@@ -13,7 +13,7 @@ export function quote(name) {
13
13
  export function qualified(namespace, table) {
14
14
  return `${quote(namespace)}.${quote(table)}`;
15
15
  }
16
- /** Shared by query assembly and static checking so separators produce identical SQL. */
16
+ /** Assemble query parts with a validated SQL separator. */
17
17
  export function joinSeparator(value) {
18
18
  switch (value) {
19
19
  case 'AND':
@@ -1,4 +1,4 @@
1
- /** Read only the command prefix, leaving strings, bodies and SQL syntax to pg. */
1
+ /** Read only the command prefix, leaving strings, bodies and SQL syntax to PostgreSQL. */
2
2
  function commandPrefix(sql) {
3
3
  const words = [];
4
4
  let i = 0;
@@ -1,4 +1,4 @@
1
- import type { Pool } from 'pg';
1
+ import type postgres from 'postgres';
2
2
  import type { QueryExecutor } from './connection.js';
3
3
  /** The callback owns one connection and expires before commit or rollback begins. */
4
- export declare function withTransaction<T>(pool: Pool, work: (query: QueryExecutor) => Promise<T>): Promise<T>;
4
+ export declare function withTransaction<T>(pool: postgres.Sql, work: (query: QueryExecutor) => Promise<T>): Promise<T>;
@@ -1,10 +1,11 @@
1
- import { executeSql, watchClient } from './connection.js';
1
+ import { reserveConnection } from './driver/session.js';
2
+ import { executeSql } from './connection.js';
2
3
  /** The callback owns one connection and expires before commit or rollback begins. */
3
4
  export async function withTransaction(pool, work) {
4
- const client = await pool.connect();
5
- const connection = watchClient(client);
5
+ const connection = await reserveConnection(pool, 'BEGIN');
6
+ const client = connection.sql;
6
7
  let active = true;
7
- let releaseError;
8
+ let discard = false;
8
9
  let savepointId = 0;
9
10
  let pending = Promise.resolve();
10
11
  let recoveryFailure;
@@ -20,16 +21,16 @@ export async function withTransaction(pool, work) {
20
21
  if (!guarded)
21
22
  return readResult(await executeSql(client, text, input));
22
23
  const savepoint = `tenon_affected_rows_${++savepointId}`;
23
- await client.query(`SAVEPOINT ${savepoint}`);
24
+ await client.unsafe(`SAVEPOINT ${savepoint}`);
24
25
  try {
25
26
  const result = readResult(await executeSql(client, text, input));
26
- await client.query(`RELEASE SAVEPOINT ${savepoint}`);
27
+ await client.unsafe(`RELEASE SAVEPOINT ${savepoint}`);
27
28
  return result;
28
29
  }
29
30
  catch (error) {
30
31
  try {
31
- await client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
32
- await client.query(`RELEASE SAVEPOINT ${savepoint}`);
32
+ await client.unsafe(`ROLLBACK TO SAVEPOINT ${savepoint}`);
33
+ await client.unsafe(`RELEASE SAVEPOINT ${savepoint}`);
33
34
  }
34
35
  catch (rollbackError) {
35
36
  // Even if application code catches this error, the outer transaction
@@ -44,14 +45,13 @@ export async function withTransaction(pool, work) {
44
45
  return operation;
45
46
  };
46
47
  try {
47
- await client.query('BEGIN');
48
48
  const result = await work(query);
49
49
  active = false;
50
50
  await pending;
51
51
  connection.check();
52
52
  if (recoveryFailure)
53
53
  throw recoveryFailure;
54
- const committed = await client.query('COMMIT');
54
+ const committed = await client.unsafe('COMMIT');
55
55
  if (committed.command !== 'COMMIT') {
56
56
  throw new Error('PostgreSQL rolled back the aborted transaction.');
57
57
  }
@@ -61,15 +61,16 @@ export async function withTransaction(pool, work) {
61
61
  active = false;
62
62
  await pending;
63
63
  try {
64
- await client.query('ROLLBACK');
64
+ connection.check();
65
+ await client.unsafe('ROLLBACK');
65
66
  }
66
67
  catch (rollbackError) {
67
- releaseError = rollbackError instanceof Error ? rollbackError : new Error('Rollback failed.');
68
+ discard = true;
68
69
  throw new AggregateError([error, rollbackError], 'Transaction and rollback failed.');
69
70
  }
70
71
  throw error;
71
72
  }
72
73
  finally {
73
- connection.release(releaseError);
74
+ await connection.release(discard);
74
75
  }
75
76
  }
package/dist/types.d.ts CHANGED
@@ -1,17 +1,20 @@
1
- import type { QueryResult, QueryResultRow } from 'pg';
1
+ import type postgres from 'postgres';
2
+ import type { QueryResult } from './connection.js';
2
3
  import type { QueryOptions, QueryOutput, QueryReturnMode } from './query-options.js';
3
4
  import type { SqlQuery } from './query.js';
4
5
  export interface QueryClient {
5
- query<Row extends QueryResultRow>(query: SqlQuery<Row>, options?: QueryOptions & {
6
+ query<Row extends object>(query: SqlQuery<Row>, options?: QueryOptions & {
6
7
  readonly returns?: 'native';
7
8
  }): NoInfer<Promise<QueryResult<Row>>>;
8
- query<Row extends QueryResultRow, Mode extends QueryReturnMode>(query: SqlQuery<Row>, options: QueryOptions & {
9
+ query<Row extends object, Mode extends QueryReturnMode>(query: SqlQuery<Row>, options: QueryOptions & {
9
10
  readonly returns: Mode;
10
11
  }): NoInfer<Promise<QueryOutput<Row, Mode>>>;
11
- query<Row extends QueryResultRow>(query: SqlQuery<Row>, options: QueryOptions | undefined): NoInfer<Promise<QueryOutput<Row, QueryReturnMode>>>;
12
+ query<Row extends object>(query: SqlQuery<Row>, options: QueryOptions | undefined): NoInfer<Promise<QueryOutput<Row, QueryReturnMode>>>;
12
13
  }
13
14
  export type Transaction = QueryClient;
14
15
  export type Database = Transaction & {
16
+ /** The underlying pool-level driver client; native calls bypass Tenon's query and transaction guards. */
17
+ readonly native: postgres.Sql;
15
18
  /** Await all work inside the callback. Commit on success; rollback on an escaping error. */
16
19
  transaction<T>(callback: (tx: Transaction) => Promise<T>): Promise<T>;
17
20
  close(): Promise<void>;
@@ -22,18 +22,21 @@ queries or generate types; keep the existing migration runner if that is the int
22
22
 
23
23
  3. Replace queries with parameterized SQL in ordinary functions with named object inputs where
24
24
  useful. Preserve each caller's projection, null handling, ordering, and transaction behavior.
25
- Generated types describe pg values: check bigint/numeric strings, dates, JSON, and arrays when
25
+ Generated types describe Postgres.js values: check bigint/numeric strings, dates, JSON, and arrays when
26
26
  the old ORM transformed them.
27
- 4. If using optional SQL checking, configure the
28
- [SafeQL integration](reference.md#sql-checking-with-safeql) and follow the
29
- [incremental audit workflow](#adopt-sql-checking-incrementally) below before accepting generated
30
- annotations. Run tests against a dedicated test database. Verify
27
+ 4. Inspect completed queries and test their behavior using the
28
+ [inspection workflow](#inspect-queries-during-adoption) below. Declare result types explicitly
29
+ and run tests against a dedicated test database. Verify
31
30
  writes and rollback, relationships, validation, and any hooks you moved into application code or
32
31
  SQL. A schema-preserving first release makes application rollback easier.
33
32
  5. Remove the old runtime dependency and unused generated code after its callers are gone. Keep the
34
33
  migration tooling until the migration transition below is complete, if that transition is part of
35
34
  the work.
36
35
 
36
+ For driver features such as LISTEN or COPY, use [`db.native`](reference.md#native-driver-access).
37
+ Preserve the application's notification recovery and resource ownership. Native pool queries do
38
+ not join Tenon transactions; use `tx.query` for operations that must commit together.
39
+
37
40
  ## Replace local write helpers
38
41
 
39
42
  Use `sql.insertValues(rows)` and `sql.assignments(patch)` in place of local object-to-SQL utilities:
@@ -50,81 +53,25 @@ in SQL and remove the local utilities after their callers move. Generated `Rows.
50
53
  `Rows.Update` types can replace handwritten table inputs; retain application-specific refinements
51
54
  and validation. Run the existing write and rollback workflows. See the [mutation cookbook](mutations.md).
52
55
 
53
- ## Adopt SQL checking incrementally
54
-
55
- For an existing application, including Next.js, SQL checking can run as a separate audit while the
56
- normal lint/build commands keep their existing database requirements. Extend the existing flat
57
- ESLint configuration rather than replacing it. After installing the
58
- [checking dependencies](reference.md#install-and-configure), a single-database setup can use:
59
-
60
- ```js
61
- // eslint.sql.config.mjs
62
- import { defineConfig } from 'eslint/config';
63
- import baseConfig from './eslint.config.mjs';
64
- import safeql from '@ts-safeql/eslint-plugin/config';
65
- import tseslint from 'typescript-eslint';
66
- import { safeqlPlugin, eslintPlugin } from '@kequtech/tenon-orm/safeql';
67
-
68
- const databaseUrl = process.env.DATABASE_URL;
69
- if (!databaseUrl) throw new Error('DATABASE_URL is required for SQL checking');
70
-
71
- export default defineConfig(baseConfig, {
72
- files: ['src/**/*.ts', 'src/**/*.tsx'],
73
- languageOptions: {
74
- parser: tseslint.parser,
75
- parserOptions: { projectService: true },
76
- },
77
- plugins: { tenon: eslintPlugin },
78
- rules: { 'tenon/check-composition': 'error' },
79
- extends: [
80
- safeql.configs.connections({
81
- databaseUrl,
82
- plugins: [safeqlPlugin],
83
- targets: [{ tag: { regex: '.*' } }],
84
- }),
85
- ],
86
- });
87
- ```
56
+ ## Inspect queries during adoption
88
57
 
89
- Preserve existing type-aware parser settings where configured. The broad target above lets Tenon
90
- recognize aliases; use distinct targets for [multiple databases](reference.md#configuration-choices-and-multiple-databases).
91
- The example does not load environment files. Use the application's existing environment loader
92
- and URL selection to supply its migrated development/test database through `DATABASE_URL`.
93
-
94
- ```json
95
- {
96
- "scripts": {
97
- "lint:sql": "eslint --config eslint.sql.config.mjs \"src/**/*.{ts,tsx}\""
98
- }
99
- }
100
- ```
58
+ Use `describeQuery` from `@kequtech/tenon-orm` on the completed query object, or
59
+ `npx tenon describe query.sql` on a SQL file. Inspection is available with the normal Tenon
60
+ installation and uses no TypeScript compiler API. It works with helpers and runtime-built
61
+ fragments because it receives the final SQL. See [query inspection](query-inspection.md).
101
62
 
102
- Start with `npm run lint:sql`. Keep both checking rules enabled and distinguish the findings:
63
+ Reuse the application's connection URI and session configuration; compare the reported role,
64
+ search path, and timezone with execution. Inspection uses a separate connection and Postgres.js
65
+ read mappings. Custom parsers and transaction-local state remain application responsibilities.
103
66
 
104
- - SQL errors identify problems in reconstructed SQL; review them before accepting inferred types.
105
- - Missing/mismatched annotations may reflect an outdated type, a deliberate application refinement,
106
- or a checker limitation. They are not all demonstrated application bugs.
107
- - `tenon/check-composition` means SQL analysis was skipped. Add inline selected columns for opaque
108
- mutation inputs where useful. Keep reusable authorization policy centralized when its fragments
109
- remain unsupported, with database tests for runtime variants.
67
+ Pair descriptions with durable database tests of actual rows and effects. For each converted
68
+ query, cover the meaningful filter/ownership branches, null relationships, JSON structure, empty
69
+ results, ordering/cursors, and transaction failure/replay behavior as applicable. Describe's success
70
+ does not verify a TypeScript annotation or prove that execution is permitted or succeeds. Preserve
71
+ existing authorization policy and transaction boundaries while making queries available to tests.
110
72
 
111
- Preview fixes on a small module first, without writing source. Invoke ESLint directly to select
112
- one file, since the script above already supplies a source glob:
113
-
114
- ```sh
115
- npx eslint --config eslint.sql.config.mjs src/database/profiles.ts --fix-dry-run --format json
116
- ```
117
-
118
- Review the proposed annotations, apply selected changes deliberately, and run TypeScript and
119
- database tests. Broader inferred types may require validation where raw database values enter the
120
- application. For example, require a finite `Date` before calling `.getTime()`, validate structured
121
- JSON from `unknown`, and justify CHECK-constrained string refinements with matching schema checks
122
- or a reviewed column override. Do not globally map timestamps to `Date`: pg can return numeric
123
- infinities. See [checker mappings](reference.md#supported-sql-and-limitations).
124
-
125
- Unsupported queries have not had their SQL verified. Keep database tests for their runtime variants
126
- and use explained suppressions only where appropriate. Once the intended scope passes, make SQL
127
- checking a required CI step. Fix commands can also apply other enabled ESLint rules.
73
+ Keep these tests in routine validation. A described query instance does not cover untested source
74
+ queries or detect stale annotations elsewhere.
128
75
 
129
76
  ## Move migration ownership to Tenon
130
77
 
@@ -144,6 +91,26 @@ server's major. Default scope is the whole database schema. Repeat `--schema nam
144
91
  explicitly, accounting for dependencies, extensions, and required roles. Tenon's selected history
145
92
  table is excluded; old ORM metadata is preserved for deliberate cleanup later.
146
93
 
94
+ The baseline recreates application schema, not a complete deployment environment. It omits
95
+ ownership, grants/revokes, and default privileges. PostgreSQL creates replayed objects under the
96
+ migration role. Role definitions and memberships also remain outside the dump. Functions,
97
+ policies, or other object definitions can still refer to roles; provision any referenced roles
98
+ before replay. See the [PostgreSQL pg_dump reference](https://www.postgresql.org/docs/16/app-pgdump.html).
99
+
100
+ For `SECURITY DEFINER` functions, the owner determines execution authority, and newly created
101
+ functions ordinarily permit `PUBLIC` execution. A captured function body alone does not preserve
102
+ its original security boundary. Keep fixed application policy, such as revoking `PUBLIC` execution,
103
+ in reviewed SQL in the baseline before its first adoption or in a subsequent application migration.
104
+ Deployment provisioning assigns the intended owners, schema access, and grants to runtime roles.
105
+ Keeping all security setup in provisioning is also supported, provided it runs before application access.
106
+
107
+ On fresh environments, provision required roles, apply migrations, then apply the deployment's
108
+ ownership/grant policy before starting the application. Verify both allowed and denied operations,
109
+ including execution of sensitive functions and access to their underlying tables. For a shared
110
+ database, apply only the selected application's policy. On existing environments, confirm the same
111
+ provisioning procedure is repeatable. After adoption, use subsequent migrations rather than editing
112
+ checksummed history.
113
+
147
114
  Capture changes no source data, migration history, or sequence positions. Review and commit the SQL.
148
115
  Keep the capture header: it identifies native dump SQL that bypasses the authored-migration scanner.
149
116
  It is a convention, not proof of provenance; do not use it on handwritten migrations. Normal
@@ -1,6 +1,6 @@
1
1
  # Tenon agent guide
2
2
 
3
- Tenon executes PostgreSQL SQL through `pg`, with parameter binding, transactions, optional write
3
+ Tenon executes PostgreSQL SQL through Postgres.js, with parameter binding, transactions, optional write
4
4
  limits, SQL migrations, and generated row and mutation input types. Use ordinary functions for reusable queries.
5
5
  This guide contains the setup and contracts needed for routine implementation; the
6
6
  [reference](reference.md) covers additional options.
@@ -16,8 +16,9 @@ This guide contains the setup and contracts needed for routine implementation; t
16
16
  composition.
17
17
  3. Put reusable queries in functions. Accept a `QueryClient` and named object inputs when useful;
18
18
  keep SQL and query options together. Call the same function with `db` or `tx`.
19
- 4. After SQL changes, run the configured checker, typechecking, and relevant database tests.
20
- Generate/repair annotations deliberately and inspect the diff.
19
+ 4. After SQL changes, inspect the completed queries and run typechecking and relevant database
20
+ assertions. Cover meaningful runtime variants and verify declared result contracts against
21
+ complete relevant rows.
21
22
  5. Keep validation, authorization, retries, and parser configuration in the application. A quoted
22
23
  identifier or correct row count does not prove authorization.
23
24
 
@@ -27,8 +28,7 @@ Requires Node.js 24+, PostgreSQL 16+, ESM, TypeScript 6+, and Node development t
27
28
  application's strict TypeScript configuration.
28
29
 
29
30
  ```sh
30
- npm install @kequtech/tenon-orm pg
31
- npm install --save-dev @types/pg
31
+ npm install @kequtech/tenon-orm
32
32
  ```
33
33
 
34
34
  ```ts
@@ -39,12 +39,18 @@ if (!connection) throw new Error('DATABASE_URL is required');
39
39
  export const db = createTenon({ connection });
40
40
  ```
41
41
 
42
- `connection` accepts a URL, `pg.PoolConfig`, or existing `pg.Pool`. An explicit `{}` selects pg's
42
+ `connection` accepts a URL, Postgres.js options, or an existing Postgres.js client. An explicit `{}` selects the driver's
43
43
  environment defaults. Reuse one instance per database connection. `close()` closes owned pools;
44
- supplied pools remain application-owned and need the application's idle-error handler. Await
44
+ supplied clients remain application-owned. The driver retains its native connection callbacks. Await
45
45
  outstanding work before closing. Environment files are not loaded automatically. Qualify SQL names
46
46
  or configure `search_path`; CLI `--namespace` does not change runtime query names.
47
47
 
48
+ Use `db.native` for Postgres.js features such as LISTEN, cursors, and COPY without another driver
49
+ installation. It is the existing pool-level client. Native calls bypass Tenon's guards and `onQuery`,
50
+ and do not join a Tenon transaction; transaction callbacks expose only `tx.query`. `db.close()` ends
51
+ owned clients and their listeners; borrowed clients stay application-owned. Follow the
52
+ [native access contract](reference.md#native-driver-access) for initialization and shutdown.
53
+
48
54
  ## Query contracts
49
55
 
50
56
  ```ts
@@ -53,10 +59,12 @@ import type { Rows } from './generated-schema.js';
53
59
 
54
60
  type Summary = Pick<Rows['users'], 'id' | 'email'>;
55
61
 
56
- function userById(client: QueryClient, { id }: { id: number }) {
57
- return client.query(sql<Summary>`SELECT id, email FROM public.users WHERE id = ${id}`, {
58
- returns: 'oneOrNone',
59
- });
62
+ function userByIdQuery({ id }: { id: number }) {
63
+ return sql<Summary>`SELECT id, email FROM public.users WHERE id = ${id}`;
64
+ }
65
+
66
+ function userById(client: QueryClient, input: { id: number }) {
67
+ return client.query(userByIdQuery(input), { returns: 'oneOrNone' });
60
68
  }
61
69
 
62
70
  const user = await userById(db, { id: 42 });
@@ -67,23 +75,29 @@ const user = await userById(db, { id: 42 });
67
75
  Unannotated fields stay `unknown`. Generated types describe full table rows; joins, aliases,
68
76
  aggregates, and expressions need projection-specific types.
69
77
 
70
- | `returns` | Result | Row-count contract |
71
- | ------------------ | --------------------- | ------------------ |
72
- | `native` (default) | pg `QueryResult<Row>` | Any |
73
- | `many` | `Row[]` | Any |
74
- | `oneOrNone` | `Row \| undefined` | At most one |
75
- | `one` | `Row` | Exactly one |
78
+ | `returns` | Result | Row-count contract |
79
+ | ------------------ | ------------------------------------ | ------------------ |
80
+ | `native` (default) | Postgres.js `QueryResult<Row>` array | Any |
81
+ | `many` | `Row[]` | Any |
82
+ | `oneOrNone` | `Row \| undefined` | At most one |
83
+ | `one` | `Row` | Exactly one |
76
84
 
77
85
  `query` options are `{ returns?, affectedRows?, label? }`; unknown options fail. Return modes never
78
86
  insert `LIMIT` or `RETURNING`. Returned-row checks alone do not undo autocommitted writes. `label`
79
- is an optional nonempty diagnostic string.
87
+ is an optional nonempty diagnostic string. Native results are row arrays with `.count`, `.command`,
88
+ and `.columns`; `many` returns a plain row array.
89
+
90
+ The Postgres.js driver is included. Undefined bound parameters fail. Pass JSON values directly,
91
+ including arrays, rather than calling `JSON.stringify`. Numeric and bigint scalars and arrays read
92
+ as strings. Dates read as `Date`, including invalid dates for timestamp infinities; check validity
93
+ before using them. SQL NULL array elements read as JavaScript null.
80
94
 
81
95
  ## Parameters and composition
82
96
 
83
97
  - Ordinary interpolations bind values. Do not quote them or author manual `$n` placeholders inside
84
- templates. `undefined` binds SQL null.
98
+ templates. `undefined` is rejected; use `null` for SQL null.
85
99
  - PostgreSQL infers parameter types from SQL. Cast standalone projections, e.g.
86
- `SELECT ${value}::float8 AS value`; without a cast, a standalone parameter returns text.
100
+ `SELECT ${value}::float8 AS value`, so inspection and execution use the same type.
87
101
  - Use `id = ANY(${ids}::integer[])` for an array of IDs. Do not build a comma-separated string of
88
102
  values.
89
103
  - `identifier(schema, table)` quotes separate application-approved components. Ordinary strings such
@@ -116,115 +130,74 @@ instead of application helpers that enumerate object keys. Keep tables, authoriz
116
130
  expressions, conflict behavior and `RETURNING` visible in SQL. Insert missing/undefined fields use
117
131
  PostgreSQL defaults; update missing/undefined fields are omitted. Explicit null stays null. Empty
118
132
  batches/updates throw; a single default-only insert works. Omit generated columns and batch large
119
- writes explicitly below the query-wide limit of 65,535 parameters. Expressions and queries are
133
+ writes explicitly below the query-wide limit of 65,533 parameters. Expressions and queries are
120
134
  rejected as cell values; write arithmetic, `DEFAULT`, and `excluded.column` in the surrounding SQL.
121
135
 
122
136
  Use generated `Rows.Insert['table']` and `Rows.Update['table']` for mutation inputs when available;
123
137
  narrow editable fields with `Pick`. They account for defaults, nullability, domains, and generated
124
138
  columns. Write mappings differ from returned rows: timestamps use `Date | string`, bigint uses
125
- `string | bigint`, and JSON uses serialized text. See the [input types](reference.md#insert-and-update-inputs).
139
+ `string | bigint`, and JSON accepts native JavaScript values through Tenon's exported `JSONValue` type. See the [input types](reference.md#insert-and-update-inputs).
126
140
 
127
141
  Selected columns choose and order fields, e.g. `sql.insertValues(rows, ['name', 'score'])`. Unlisted
128
142
  fields are ignored. See the [mutation contract](reference.md#object-based-mutation-fragments) and
129
143
  [cookbook](mutations.md).
130
144
 
131
- ## Configure and run SQL checking
132
-
133
- SafeQL checks SQL against the development/test schema and generates or verifies result annotations.
134
- Configure it normally and add both exports from `@kequtech/tenon-orm/safeql`:
135
-
136
- - `safeqlPlugin` goes in each SafeQL connection's `plugins`. It translates Tenon SQL and constant
137
- fragments and supplies pg type mappings. SafeQL performs result inference.
138
- - `eslintPlugin` goes in ESLint's `plugins`, with `tenon/check-composition` enabled. It reports
139
- unsupported forms that the adapter skips. It does not wrap SafeQL's rule.
145
+ ## Inspect and test completed queries
140
146
 
141
- These tools run during development and CI. Reuse the application's TypeScript parser settings and
142
- use a TypeScript version supported by the lint toolchain. Generated table types are optional.
147
+ Inspection ships with Tenon and needs no additional setup:
143
148
 
144
- Reuse existing configuration and scripts. For a new setup:
145
-
146
- ```sh
147
- npm install --save-dev eslint typescript-eslint @ts-safeql/eslint-plugin
148
- ```
149
+ ```ts
150
+ import { describeQuery } from '@kequtech/tenon-orm';
149
151
 
150
- ```js
151
- // eslint.config.mjs
152
- import { defineConfig } from 'eslint/config';
153
- import safeql from '@ts-safeql/eslint-plugin/config';
154
- import tseslint from 'typescript-eslint';
155
- import { safeqlPlugin, eslintPlugin } from '@kequtech/tenon-orm/safeql';
156
-
157
- const databaseUrl = process.env.DATABASE_URL;
158
- if (!databaseUrl) throw new Error('DATABASE_URL is required for SQL checking');
159
-
160
- export default defineConfig({
161
- files: ['src/**/*.ts'],
162
- languageOptions: {
163
- parser: tseslint.parser,
164
- parserOptions: { projectService: true },
165
- },
166
- plugins: { tenon: eslintPlugin },
167
- rules: { 'tenon/check-composition': 'error' },
168
- extends: [
169
- safeql.configs.connections({
170
- databaseUrl,
171
- plugins: [safeqlPlugin],
172
- targets: [{ tag: 'sql' }],
173
- }),
174
- ],
175
- });
152
+ const query = userByIdQuery({ id: 42 });
153
+ const description = await describeQuery({ connection, query });
176
154
  ```
177
155
 
178
- Point the URL at the intended development/test database with migrations applied. Supply environment
179
- variables through the application; no `.env` file is loaded here. `projectService` uses each file's
180
- nearest TypeScript project. Preserve existing type-aware parser settings if already configured.
156
+ Pass the actual query object that will go to `db.query`, including fragments and conditional
157
+ predicates. A small ordinary query-building function can make the same statement available to
158
+ inspection and execution tests. Keep authorization policy inside the builder or shared fragments.
181
159
 
182
- `targets` matches the tag spelling. Add aliases to the target list when using them. For a single
183
- database, `{ tag: { regex: '.*' } }` includes all Tenon aliases and typed object properties; the
184
- adapter excludes unrelated tags. The composition rule checks every Tenon query in its ESLint file
185
- scope, independently of SafeQL's targets.
160
+ For a SQL file with PostgreSQL `$n` placeholders:
186
161
 
187
- Reuse existing lint scripts or add:
188
-
189
- ```json
190
- {
191
- "scripts": {
192
- "lint": "eslint \"src/**/*.ts\"",
193
- "lint:fix": "eslint \"src/**/*.ts\" --fix"
194
- }
195
- }
162
+ ```sh
163
+ npx tenon describe query.sql
196
164
  ```
197
165
 
198
- SQL and annotation errors come from `@ts-safeql/check-sql`; unsupported composition errors come from
199
- `tenon/check-composition`. Keep both rules enabled.
166
+ Supply `DATABASE_URL` through the application's existing environment setup. Match its role,
167
+ `search_path`, and timezone through the connection URI or optional `session` settings. Review
168
+ the returned `context`; this is a separate connection with Postgres.js read mappings. It does not
169
+ inherit custom parsers, temporary tables, or transaction-local state.
200
170
 
201
- The checker supports constant fragments and `sql.join` with inline nonempty arrays and literal or
202
- const-string separators. It does not follow runtime-built lists or fragment-returning functions.
203
- For tested runtime variants, keep an explicit result type and suppress `tenon/check-composition`
204
- only on the particular query with a reason.
171
+ The report contains parameter/column types and findings with stable issue codes. Result nullability
172
+ is unknown, source `NOT NULL` is informational, and JSON remains unknown. CLI success means the
173
+ statement was described, not that `sql<Row>`, values, permissions, or business behavior were verified.
205
174
 
206
- Mutation helpers accept inline object literals for checking. Runtime inputs, spreads, and
207
- data-builder results need inline selected columns, e.g. `sql.insertValues(rows, ['name', 'score'])`.
208
- The check uses all selected columns; it does not validate runtime input values or omission decisions.
175
+ With a test fixture containing user 42, check the database projection and the actual value separately:
209
176
 
210
- For existing applications, use the [incremental audit workflow](adoption-guide.md#adopt-sql-checking-incrementally)
211
- to preview fixes and review database types versus application refinements.
177
+ ```ts
178
+ import assert from 'node:assert/strict';
212
179
 
213
- 1. Run the development `db:migrate` script below against the intended development/test database.
214
- 2. Run `npm run lint`. Fix SQL mistakes before accepting generated types.
215
- 3. Run `npm run lint:fix` for missing/stale annotations. It can add
216
- `sql<{ id: number; email: string }>` to a query selecting those columns. Review the diff; other
217
- enabled ESLint rules may also fix files.
218
- 4. Run the application's typecheck and database tests. CI runs `lint` without `--fix`.
180
+ assert.deepEqual(
181
+ description.columns.map(({ name, readType }) => [name, readType]),
182
+ [
183
+ ['id', 'number'],
184
+ ['email', 'string'],
185
+ ],
186
+ );
187
+ const expected = { id: 42, email: 'reader@example.test' } satisfies Summary;
188
+ assert.deepEqual(await userById(db, { id: expected.id }), expected);
189
+ ```
219
190
 
220
- For multiple databases, give each connection distinct tag targets or ESLint file scopes. Qualify
221
- same-named tables with their schemas. See the [connection reference](reference.md#configuration-choices-and-multiple-databases).
191
+ The builder keeps inspection and application SQL together; the query function keeps execution options
192
+ and its `db`/`tx` contract together. Keep simple queries inline when they do not need a shared builder.
193
+ The typed fixture checks the declared shape, the description checks PostgreSQL's projection, and
194
+ execution checks the actual row. None of these proves coverage of other inputs or authorization rules.
222
195
 
223
- Checking verifies SQL and result annotations. Input validation, authorization, JSON contents, and
224
- runtime query variants still need application checks and database tests. Keep shared policy in
225
- ordinary functions even when its fragments cannot be checked statically. Use literal newlines in
226
- SQL templates and bind string values. The [reference](reference.md#supported-sql-and-limitations)
227
- covers supported composition and parser overrides.
196
+ Run the same query through the real application connection against its test database. Assert full
197
+ relevant rows/effects and exercise meaningful branches: ownership, optional filters, absent joins,
198
+ empty aggregates, pagination ties, JSON validation, and failed/replayed/rolled-back writes as
199
+ applicable. Keep these tests alongside the query; describing a single instance covers only that
200
+ instance. See [query inspection](query-inspection.md) for session options, limits, and issue codes.
228
201
 
229
202
  ## Transactions and writes
230
203
 
@@ -241,7 +214,7 @@ savepoint names beginning with `tenon_affected_rows_`.
241
214
 
242
215
  Affected counts cover the final command, not extra trigger/cascade/CTE effects. They cannot detect
243
216
  the wrong single row. Sequence advances and external effects are not reversible. A lost commit
244
- acknowledgment may leave the outcome unknown; retry only according to application policy. Native pg
217
+ acknowledgment may leave the outcome unknown; retry only according to application policy. Native Postgres.js
245
218
  errors retain their details; `AffectedRowsError` has codes `TENON_AFFECTED_ROWS_MISMATCH` and
246
219
  `TENON_AFFECTED_ROWS_UNSUPPORTED`. Cleanup failures can produce `AggregateError`.
247
220
 
@@ -290,5 +263,7 @@ partitioned roots; views/foreign tables need authored query types.
290
263
  When replacing another ORM, preserve its schema and behavior. If transferring migration ownership,
291
264
  capture one reviewed baseline with `tenon baseline capture`, record it only on matching existing
292
265
  databases with `tenon baseline adopt`, and use `tenon migrate` on fresh databases. Adoption does not
293
- compare schemas or run baseline SQL. See [adoption](adoption-guide.md) for the full transition
294
- procedure. A down migration or schema-only baseline is not a data backup.
266
+ compare schemas or run baseline SQL. Fresh replay omits ownership and grants; apply deployment
267
+ security provisioning before application access, especially for `SECURITY DEFINER` functions.
268
+ See [adoption](adoption-guide.md) for the full transition procedure. A down migration or schema-only
269
+ baseline is not a data backup.