@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
@@ -1,7 +1,7 @@
1
1
  # Using Tenon: a human guide
2
2
 
3
3
  Tenon lets you use PostgreSQL directly from TypeScript. You write SQL; Tenon binds its inputs and
4
- executes it through `pg`. This guide follows a small application from its first table to reusable
4
+ executes it through Postgres.js. This guide follows a small application from its first table to reusable
5
5
  queries, transactions, and result checking.
6
6
 
7
7
  Use the [reference](reference.md) when you need exact options or unusual cases. The
@@ -13,8 +13,7 @@ Use the [reference](reference.md) when you need exact options or unusual cases.
13
13
  In an existing Node.js 24+ TypeScript project using ESM:
14
14
 
15
15
  ```sh
16
- npm install @kequtech/tenon-orm pg
17
- npm install --save-dev @types/pg
16
+ npm install @kequtech/tenon-orm
18
17
  ```
19
18
 
20
19
  Use PostgreSQL 16+ and TypeScript 6+. Keep your existing strict TypeScript configuration and Node
@@ -31,13 +30,14 @@ export const db = createTenon({ connection });
31
30
  ```
32
31
 
33
32
  Reuse this instance across the application and call `await db.close()` at shutdown. For multiple
34
- databases, create an instance for each connection. You can also supply an existing `pg.Pool`; your
33
+ databases, create an instance for each connection. You can also supply an existing Postgres.js client; your
35
34
  application then closes that pool.
36
35
 
37
36
  ## Create a table
38
37
 
39
- If you already have a schema and migration runner, you can keep them. For a new schema, create a
40
- migration:
38
+ If you already have a schema and migration runner, you can keep them. For new schemas, prefer plural
39
+ `snake_case` table names and `snake_case` column names. Tenon preserves your schema's exact names.
40
+ Create a migration:
41
41
 
42
42
  ```sh
43
43
  npx tenon new create_users
@@ -47,7 +47,7 @@ Fill the generated files:
47
47
 
48
48
  ```sql
49
49
  -- migrations/0001_create_users.up.sql
50
- CREATE TABLE public."User" (
50
+ CREATE TABLE public.users (
51
51
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
52
52
  email text NOT NULL UNIQUE,
53
53
  active boolean NOT NULL DEFAULT true
@@ -56,7 +56,7 @@ CREATE TABLE public."User" (
56
56
 
57
57
  ```sql
58
58
  -- migrations/0001_create_users.down.sql
59
- DROP TABLE public."User";
59
+ DROP TABLE public.users;
60
60
  ```
61
61
 
62
62
  For development, add a script to `package.json` that applies migrations and updates generated types:
@@ -87,10 +87,10 @@ import { sql } from '@kequtech/tenon-orm';
87
87
  import { db } from './db.js';
88
88
 
89
89
  const email = 'reader@example.test';
90
- await db.query(sql`INSERT INTO public."User" (email) VALUES (${email})`);
90
+ await db.query(sql`INSERT INTO public.users (email) VALUES (${email})`);
91
91
  const user = await db.query(
92
92
  sql<{ id: number; email: string }>`
93
- SELECT id, email FROM public."User" WHERE email = ${email}
93
+ SELECT id, email FROM public.users WHERE email = ${email}
94
94
  `,
95
95
  { returns: 'oneOrNone' },
96
96
  );
@@ -101,20 +101,20 @@ separately from the SQL text, even when the value contains SQL.
101
101
 
102
102
  The type describes one selected row. `returns: 'oneOrNone'` gives you that row or `undefined`; it
103
103
  throws if the query returns more than one row. Use `'one'` when absence should also be an error,
104
- `'many'` for an array, or omit `returns` for the full driver result with `.rows` and `.rowCount`.
104
+ `'many'` for an array, or omit `returns` for the native result array with `.count` and `.columns`.
105
105
  These options do not change your SQL.
106
106
 
107
107
  Generated table types can help describe projections without repeating every field:
108
108
 
109
109
  ```ts
110
- import type { User } from './generated-schema.js';
111
- type UserSummary = Pick<User, 'id' | 'email'>;
110
+ import type { Rows } from './generated-schema.js';
111
+ type UserSummary = Pick<Rows['users'], 'id' | 'email'>;
112
112
  ```
113
113
 
114
114
  You can then write `sql<UserSummary>`. A table type describes the full table; a join or aggregate
115
115
  needs a type for what that particular query selects. An annotation is a declaration, not proof that
116
- the SQL matches. Without one, result fields are `unknown`. The checking workflow below can generate
117
- and verify it.
116
+ the SQL matches. Without one, result fields are `unknown`. Use the inspection and database-test
117
+ workflow below to check the declared contract against the query's behavior.
118
118
 
119
119
  ## Reuse a query with a function
120
120
 
@@ -123,15 +123,14 @@ supplied values easy to read:
123
123
 
124
124
  ```ts
125
125
  import { sql, type QueryClient } from '@kequtech/tenon-orm';
126
- import type { User } from './generated-schema.js';
126
+ import type { Rows } from './generated-schema.js';
127
127
 
128
- type UserSummary = Pick<User, 'id' | 'email'>;
128
+ type UserSummary = Pick<Rows['users'], 'id' | 'email'>;
129
129
 
130
130
  export function userByEmail(client: QueryClient, { email }: { email: string }) {
131
- return client.query(
132
- sql<UserSummary>`SELECT id, email FROM public."User" WHERE email = ${email}`,
133
- { returns: 'oneOrNone' },
134
- );
131
+ return client.query(sql<UserSummary>`SELECT id, email FROM public.users WHERE email = ${email}`, {
132
+ returns: 'oneOrNone',
133
+ });
135
134
  }
136
135
 
137
136
  const user = await userByEmail(db, { email: 'reader@example.test' });
@@ -147,7 +146,7 @@ await db.transaction(async (tx) => {
147
146
  const user = await userByEmail(tx, { email: 'reader@example.test' });
148
147
  if (!user) throw new Error('User not found');
149
148
 
150
- await tx.query(sql`UPDATE public."User" SET active = false WHERE id = ${user.id}`, {
149
+ await tx.query(sql`UPDATE public.users SET active = false WHERE id = ${user.id}`, {
151
150
  affectedRows: { exact: 1 },
152
151
  });
153
152
  });
@@ -172,11 +171,11 @@ aligned, with `DEFAULT` for missing cells.
172
171
  ```ts
173
172
  import type { Rows } from './generated-schema.js';
174
173
 
175
- const input: Rows.Insert['User'] = { email: 'another@example.test' };
176
- await db.query(sql`INSERT INTO public."User" ${sql.insertValues(input, ['email'])}`);
174
+ const input: Rows.Insert['users'] = { email: 'another@example.test' };
175
+ await db.query(sql`INSERT INTO public.users ${sql.insertValues(input, ['email'])}`);
177
176
 
178
- const patch: Pick<Rows.Update['User'], 'active'> = { active: false };
179
- await db.query(sql`UPDATE public."User" SET ${sql.assignments(patch, ['active'])}
177
+ const patch: Pick<Rows.Update['users'], 'active'> = { active: false };
178
+ await db.query(sql`UPDATE public.users SET ${sql.assignments(patch, ['active'])}
180
179
  WHERE email = ${input.email}`);
181
180
  ```
182
181
 
@@ -190,36 +189,23 @@ choose which fields to write; they do not validate the complete object. See the
190
189
 
191
190
  ## Check your SQL and result types
192
191
 
193
- There are three different jobs here:
194
-
195
- - Tenon executes queries and binds values.
196
- - TypeScript checks the application code and the result types you declare.
197
- - SafeQL checks the SQL against your database and verifies or generates those types.
192
+ Use `describeQuery({ connection, query })` on the completed query object, or
193
+ `npx tenon describe query.sql` on a SQL file, to inspect the server's parameter and result types.
194
+ This works with dynamic fragments and needs no additional checking setup. Match the application's
195
+ connection settings and keep database assertions for actual rows, nulls, JSON, permissions, and
196
+ transaction effects. See [query inspection](query-inspection.md).
198
197
 
199
- SafeQL is optional development tooling run by ESLint. Tenon's adapter supports queries, constant
200
- fragments, and mutation helpers; `tenon/check-composition` reports queries it cannot inspect.
201
- Follow the one-time [SQL checking setup](reference.md#sql-checking-with-safeql).
202
-
203
- Use the application's existing lint commands. If it has none, the setup shows `lint` and `lint:fix`.
204
- They run ESLint, including all other enabled rules. Once configured, the normal workflow is:
198
+ TypeScript checks the application code and the result types you declare. Describe reports
199
+ PostgreSQL metadata; it does not generate or verify a TypeScript annotation. Use generated table
200
+ types where appropriate, declare projections explicitly, and check complete returned rows in tests.
205
201
 
206
202
  1. Run `npm run db:migrate` against the development/test database.
207
- 2. Run `npm run lint` to find SQL errors and missing or incorrect annotations.
208
- 3. Use `npm run lint:fix` to generate or repair annotations, then review the diff.
209
- 4. Run TypeScript and database tests. CI runs the check without fixing files.
210
-
211
- For example, SafeQL can turn `sql` followed by a query selecting `id, email` into
212
- `sql<{ id: number; email: string }>` based on your actual schema. It can detect a renamed column or
213
- an incorrect declared result type before the query runs.
214
-
215
- Input validation and database tests still matter. Checking a query does not prove that a request is
216
- authorized, that its values are valid, or that returned JSON has an application-specific shape.
203
+ 2. Inspect the completed SQL and review the reported types and unresolved findings.
204
+ 3. Run TypeScript and database tests. Keep these checks in routine CI validation.
217
205
 
218
- The checker supports constant fragments and `sql.join` with inline arrays. Runtime-built lists and
219
- fragment-returning functions remain outside static analysis. Keep result annotations and database
220
- tests for those queries; when using the checker, suppress `tenon/check-composition` only on the
221
- tested query with a reason. Runtime-built mutation inputs need inline selected columns for SQL
222
- checking. See the [checking reference](reference.md#supported-sql-and-limitations) for details.
206
+ Input validation and authorization remain application responsibilities. Describe cannot establish
207
+ that a request is authorized, that its values are valid, or that returned JSON has an
208
+ application-specific shape.
223
209
 
224
210
  ## Reuse or assemble parts of SQL when needed
225
211
 
@@ -227,7 +213,7 @@ For fixed conditions, write them directly:
227
213
 
228
214
  ```ts
229
215
  const users = await db.query(
230
- sql<UserSummary>`SELECT id, email FROM public."User" WHERE active = ${true}`,
216
+ sql<UserSummary>`SELECT id, email FROM public.users WHERE active = ${true}`,
231
217
  { returns: 'many' },
232
218
  );
233
219
  ```
@@ -238,9 +224,9 @@ its count:
238
224
 
239
225
  ```ts
240
226
  const search = sql.fragment`active = ${true} AND email ILIKE ${pattern}`;
241
- const page = sql<UserSummary>`SELECT id, email FROM public."User"
227
+ const page = sql<UserSummary>`SELECT id, email FROM public.users
242
228
  WHERE ${search} ORDER BY id LIMIT ${limit}`;
243
- const count = sql<{ count: string }>`SELECT count(*) AS count FROM public."User"
229
+ const count = sql<{ count: string }>`SELECT count(*) AS count FROM public.users
244
230
  WHERE ${search}`;
245
231
  ```
246
232
 
@@ -258,7 +244,7 @@ for (const term of terms) {
258
244
  filters.push(sql.fragment`email ILIKE ${'%' + term + '%'}`);
259
245
  }
260
246
 
261
- const query = sql<UserSummary>`SELECT id, email FROM public."User"
247
+ const query = sql<UserSummary>`SELECT id, email FROM public.users
262
248
  WHERE ${sql.join(filters, 'AND')} ORDER BY id`;
263
249
  ```
264
250
 
package/docs/mutations.md CHANGED
@@ -34,10 +34,10 @@ entire input object contains only allowed keys. Keep narrower application input
34
34
  `tenon generate` also provides `Rows.Update['profiles']`. Use `Pick` to limit a patch to fields
35
35
  the caller may edit, for example `Pick<Rows.Update['profiles'], 'name' | 'bio'>`. Generated inputs
36
36
  understand defaults, nullability, domains, and generated columns. They use write representations:
37
- timestamps accept `Date | string`, bigint accepts `string | bigint`, and JSON uses serialized text.
37
+ timestamps accept `Date | string`, bigint accepts `string | bigint`, and JSON accepts native JavaScript values through Tenon's exported `JSONValue` type.
38
38
  See the [input-type reference](reference.md#insert-and-update-inputs) for the complete mapping.
39
39
 
40
- ## Input types, SQL checking, and runtime validation
40
+ ## Input types and runtime validation
41
41
 
42
42
  Apply input types with an annotation, `satisfies`, or a helper generic argument. Generating the
43
43
  file alone does not check every write. Narrow the fields each operation accepts:
@@ -55,9 +55,9 @@ function createProfile(client: QueryClient, input: ProfileInput) {
55
55
  ```
56
56
 
57
57
  Here `id` and `score` are assumed to be integers. TypeScript rejects an incompatible typed input
58
- such as `{ name: 'Ada', score: true }`. SafeQL checks selected columns and `RETURNING`; it does not
59
- inspect runtime values. Validate external inputs before calling this function and keep database
60
- constraints and application authorization in place.
58
+ such as `{ name: 'Ada', score: true }`. Inspect the completed SQL with `describeQuery` and test its
59
+ returned rows and write effects against the database. Validate external inputs before calling this
60
+ function and keep database constraints and application authorization in place.
61
61
 
62
62
  **Selected columns can discard required fields.** For a table requiring integer `id` and text
63
63
  `name` and `location`, this input is valid but the query fails:
@@ -115,22 +115,22 @@ database default; that alone does not decide what an upsert should overwrite.
115
115
 
116
116
  ## JSONB arrays and PostgreSQL arrays
117
117
 
118
- Tenon preserves pg's value conversion. A JavaScript array binds as a PostgreSQL array. To write an
119
- array into a JSON/JSONB column, serialize it explicitly:
118
+ SQL context determines whether a bound JavaScript array is a PostgreSQL array or a JSON value.
119
+ Pass native JavaScript values; Postgres.js serializes them for the target type:
120
120
 
121
121
  ```ts
122
122
  const handles = ['ada', 'lin'];
123
123
  await db.query(
124
124
  sql`INSERT INTO deliveries ${sql.insertValues({
125
- recipients_json: JSON.stringify(handles), // jsonb column
125
+ recipients_json: handles, // jsonb column
126
126
  recipient_handles: handles, // text[] column
127
127
  })}`,
128
128
  );
129
129
  ```
130
130
 
131
- Objects used as JSON values retain pg's ordinary JSON conversion. The helper does not infer JSON
132
- versus array encoding from the table schema. Generated JSON input types use serialized text for
133
- consistent handling of objects and arrays. Validate JSON contents in the application.
131
+ Objects used as JSON values retain Postgres.js's ordinary JSON conversion. The helper does not need runtime schema metadata. Generated JSON input types use Tenon's exported
132
+ `JSONValue` type, excluding SQL null for nonnullable columns. Pre-serialized text becomes a JSON
133
+ string; use SQL such as `'null'::jsonb` when JSON null is required. Validate JSON contents in the application.
134
134
 
135
135
  ## Batches in one transaction
136
136
 
@@ -146,7 +146,7 @@ await db.transaction(async (tx) => {
146
146
  ```
147
147
 
148
148
  A failure rolls back all batches. The helper rejects an empty batch; this loop makes no insert
149
- calls for empty input. The query-wide limit is 65,535 bound parameters, including parameters outside
149
+ calls for empty input. Each query accepts at most 65,533 bound parameters, including parameters outside
150
150
  the mutation fragment. An oversized query reports its actual count, for example
151
- `at most 65535 bound parameters; received 65536`. Defaults consume no parameters. Account for the
151
+ `at most 65533 bound parameters; received 65534`. Defaults consume no parameters. Account for the
152
152
  selected columns and surrounding query when choosing a batch size; Tenon never splits queries automatically.
@@ -0,0 +1,128 @@
1
+ # Inspect and test queries
2
+
3
+ `describeQuery` asks PostgreSQL what a completed SQL statement accepts and returns, without
4
+ binding its values or executing it. Use it while writing a query or in a database test. It handles
5
+ the actual SQL produced by fragments, helpers, loops, and conditional filters.
6
+ Its `postgres` driver installs with Tenon; no extra
7
+ consumer dependency or configuration is needed. Calling inspection opens a separate connection.
8
+
9
+ ## Inspect the query you will execute
10
+
11
+ ```ts
12
+ import { describeQuery, sql } from '@kequtech/tenon-orm';
13
+
14
+ const connection = process.env.DATABASE_URL;
15
+ if (!connection) throw new Error('DATABASE_URL is required');
16
+
17
+ const query = sql<{ id: number; total: string }>`
18
+ SELECT ${42}::integer AS id, count(*) AS total FROM pg_catalog.pg_class
19
+ `;
20
+ const description = await describeQuery({ connection, query });
21
+ // description.columns reports number/string read types; nullability remains unknown.
22
+ ```
23
+
24
+ For an application, pass the same query object that will go to `db.query`. Use representative inputs when calling its query-building
25
+ function. Keep authorization policy centralized inside that builder or its fragments.
26
+
27
+ `query` accepts a SQL string or an object with a `text` property, including a Tenon `SqlQuery`.
28
+ For SQL strings, use PostgreSQL placeholders such as `$1`. PostgreSQL infers their types from SQL
29
+ context; `SELECT $1::integer AS id` can be described without supplying a value. Ordinary Tenon
30
+ templates bind values with `${value}`.
31
+
32
+ Inspection does not read the type argument of `sql<Row>`, validate `query.values`, or compare an
33
+ annotation with the result. A successful description is useful schema/type evidence, not proof
34
+ that the query will execute or do the intended job.
35
+
36
+ ## Match the application's context
37
+
38
+ ```ts
39
+ const description = await describeQuery({
40
+ connection,
41
+ query,
42
+ session: { searchPath: 'mail,public', timeZone: 'UTC' },
43
+ timeouts: { connectMs: 3000, statementMs: 5000, lockMs: 1000 },
44
+ });
45
+ ```
46
+
47
+ Reuse the application's connection URI and the source of its session settings. All overrides are
48
+ optional. Connection URI startup options are preserved. `session.role` can assume a role when the
49
+ connecting account has permission; omitted settings use the new connection's defaults. `SET ROLE`
50
+ does not load that role's login defaults. Unknown options and invalid settings fail explicitly.
51
+
52
+ The result's `context` reports the database, login/effective roles, configured and resolved search
53
+ paths, timezone, and effective server timeouts. Compare these with the execution connection.
54
+ Inspection reports `mode: 'separate-session'` and `readMappings: 'postgres'`. SQL NULL array
55
+ elements are represented as JavaScript null.
56
+
57
+ The default connection/authentication limit is 3 seconds. Every server statement, including
58
+ metadata reads, has a 5-second limit, and each lock wait a 1-second limit. Cleanup is bounded to
59
+ 1 second. Timeout overrides are positive integer milliseconds; URI settings cannot disable these
60
+ bounds. Limits apply to individual operations, not one overall deadline.
61
+
62
+ The API owns and closes its connection. It does not borrow an application client or a Tenon transaction.
63
+ Temporary tables, uncommitted changes, transaction-local settings, custom parsers, and other
64
+ pool-only state are not inherited. `PGOPTIONS` environment settings are not automatically copied;
65
+ use URI startup options or explicit session values. Standard read mappings describe Postgres.js
66
+ values, so custom parser behavior needs a deliberate application decision.
67
+
68
+ ## Read the result
69
+
70
+ | Field | Meaning |
71
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
72
+ | `parameters` | Parameter type OIDs, qualified PostgreSQL type names, and Postgres.js read representations. These are not accepted JavaScript input contracts. |
73
+ | `columns` | Ordered output names, PostgreSQL types, Postgres.js read representations, and source-column metadata where available. |
74
+ | `columns[].nullability` | Always `"unknown"`. A source column's `notNull` constraint does not establish result nullability. |
75
+ | `rowType` | A conservative `DescribedRow` TypeScript declaration. Fields include null; JSON/unmapped types stay unknown. Null when there are no output columns or names are duplicated. |
76
+ | `issues` | Findings with stable `code` fields, human-readable messages, and relevant column/type details. |
77
+ | `context` | The separate inspection session's effective settings and read mapping profile. |
78
+
79
+ Use issue codes for tooling; message text is descriptive:
80
+
81
+ - `nullability-unknown`: result nullability has not been established.
82
+ - `shape-unknown`: listed columns have unknown read types; JSON object structure is not inferred.
83
+ - `unmapped-type`: the PostgreSQL type/OID lacks a verified Postgres.js mapping.
84
+ - `duplicate-column`: repeated names would overwrite earlier values in object rows.
85
+
86
+ Source `notNull` remains informational. Outer joins, grouping, and expressions can introduce nulls.
87
+ An empty result set supplies no runtime samples. Unknown mappings and duplicate names are findings
88
+ to handle explicitly, not a successful verification of a handwritten result type.
89
+
90
+ ## CLI
91
+
92
+ Save the statement in `query.sql`, using `$1`, `$2`, and so on for parameters:
93
+
94
+ ```sql
95
+ SELECT $1::integer AS id, count(*) AS total FROM pg_catalog.pg_class;
96
+ ```
97
+
98
+ Supply `DATABASE_URL` through the application's normal environment setup:
99
+
100
+ ```sh
101
+ npx tenon describe query.sql
102
+ npx tenon describe query.sql --search-path mail,public --time-zone UTC
103
+ ```
104
+
105
+ The CLI prints the description as JSON. Options are `--role`, `--search-path`, `--time-zone`,
106
+ `--connect-timeout-ms`, `--statement-timeout-ms`, and `--lock-timeout-ms`. Migration/generation
107
+ options such as `--namespace` and `--check` are rejected. No environment files are loaded.
108
+
109
+ Exit code 0 means PostgreSQL described the statement, even if the report contains unresolved
110
+ issues. Exit code 1 means input, connection, timeout, or PostgreSQL analysis failed. No query values
111
+ are bound and no inspected statement is executed, including writes and locking statements.
112
+
113
+ ## Keep execution assertions
114
+
115
+ For each meaningful query-builder variant, describe the finished SQL and run it through the
116
+ application's actual Tenon/Postgres.js connection against its test database. Assert complete relevant rows
117
+ and effects: nulls, JSON contents, precision, ordering, empty results, ownership, and rollback.
118
+ Include absent relationships, pagination ties, and empty aggregates where relevant. Keep those
119
+ tests alongside the query and run the application's normal TypeScript compiler.
120
+
121
+ Description can succeed for an invalid bound value, a write that violates a constraint, or an
122
+ operation the runtime role may not execute. Execution tests establish behavior for the exercised
123
+ inputs; they are not a proof for every possible input. Inspecting one variant does not cover
124
+ other branches or detect stale TypeScript annotations elsewhere in the application.
125
+
126
+ Describe does not bind runtime values. Cast standalone parameter projections, for example
127
+ `SELECT $1::boolean AS enabled`, so SQL establishes their types. Execution can otherwise infer a
128
+ type from a JavaScript value that an inspection without values cannot establish.