@kequtech/tenon-orm 0.3.1 → 0.4.2

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 (68) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +81 -26
  3. package/dist/affected-rows.js +0 -1
  4. package/dist/baseline.js +3 -5
  5. package/dist/check/contracts.d.ts +6 -0
  6. package/dist/check/contracts.js +206 -0
  7. package/dist/check/project.d.ts +18 -0
  8. package/dist/check/project.js +224 -0
  9. package/dist/check/source.d.ts +51 -0
  10. package/dist/check/source.js +629 -0
  11. package/dist/check/types.d.ts +36 -0
  12. package/dist/check/types.js +1 -0
  13. package/dist/check.d.ts +4 -0
  14. package/dist/check.js +147 -0
  15. package/dist/cli.js +40 -27
  16. package/dist/connection.d.ts +5 -12
  17. package/dist/connection.js +12 -121
  18. package/dist/database.js +10 -8
  19. package/dist/describe.js +10 -3
  20. package/dist/generate.d.ts +13 -3
  21. package/dist/generate.js +52 -25
  22. package/dist/generation/catalog.d.ts +5 -6
  23. package/dist/generation/catalog.js +7 -9
  24. package/dist/generation/output.js +2 -4
  25. package/dist/generation/render.d.ts +3 -0
  26. package/dist/generation/render.js +60 -0
  27. package/dist/generation/schema-summary.d.ts +3 -3
  28. package/dist/generation/schema-summary.js +2 -2
  29. package/dist/index.d.ts +3 -1
  30. package/dist/index.js +2 -0
  31. package/dist/inspection/catalog.d.ts +3 -6
  32. package/dist/inspection/catalog.js +9 -18
  33. package/dist/inspection/cli.d.ts +12 -0
  34. package/dist/inspection/cli.js +9 -4
  35. package/dist/inspection/connection.d.ts +2 -3
  36. package/dist/inspection/connection.js +20 -22
  37. package/dist/inspection/options.js +0 -6
  38. package/dist/inspection/result.d.ts +3 -2
  39. package/dist/inspection/result.js +13 -26
  40. package/dist/inspection/types.d.ts +6 -4
  41. package/dist/migrations/connection.d.ts +3 -3
  42. package/dist/migrations/connection.js +14 -21
  43. package/dist/migrations/history.d.ts +4 -4
  44. package/dist/migrations/history.js +5 -5
  45. package/dist/migrations.js +7 -9
  46. package/dist/query-options.d.ts +1 -1
  47. package/dist/query.js +1 -1
  48. package/dist/transaction.d.ts +2 -2
  49. package/dist/transaction.js +21 -77
  50. package/dist/types.d.ts +4 -1
  51. package/docs/adoption-guide.md +88 -111
  52. package/docs/agent-guide.md +94 -147
  53. package/docs/connections.md +185 -0
  54. package/docs/human-guide.md +133 -146
  55. package/docs/migrations.md +133 -0
  56. package/docs/mutations.md +110 -60
  57. package/docs/query-checking.md +199 -0
  58. package/docs/query-inspection.md +89 -58
  59. package/docs/reference.md +232 -437
  60. package/docs/schema-types.md +263 -0
  61. package/docs/sql.md +212 -0
  62. package/package.json +11 -7
  63. package/dist/driver/types.d.ts +0 -10
  64. package/dist/driver/types.js +0 -49
  65. package/dist/generation/table-types.d.ts +0 -2
  66. package/dist/generation/table-types.js +0 -96
  67. package/dist/inspection/protocol.d.ts +0 -7
  68. package/dist/inspection/protocol.js +0 -45
package/docs/mutations.md CHANGED
@@ -1,11 +1,14 @@
1
- # Mutation cookbook
1
+ # Writing data
2
2
 
3
- Use `sql.insertValues` and `sql.assignments` for object fields while keeping SQL structure visible.
4
- The [reference](reference.md#object-based-mutation-fragments) defines the complete contract.
5
- Examples assume an existing `db` and this table, with generated `Rows` from the same schema:
3
+ Write `INSERT`, `UPDATE`, `DELETE`, and `ON CONFLICT` in SQL. Use `sql.insertValues` and
4
+ `sql.assignments` when you have an object of field values; they return fragments for the column/value
5
+ parts of your statement. The [API reference](reference.md#object-based-mutation-fragments) defines
6
+ edge cases and validation rules.
7
+
8
+ Examples use an existing `db`, import `sql` from Tenon, and assume this table:
6
9
 
7
10
  ```sql
8
- CREATE TABLE profiles (
11
+ CREATE TABLE public.profiles (
9
12
  id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
10
13
  name text NOT NULL UNIQUE,
11
14
  bio text DEFAULT 'unwritten',
@@ -14,121 +17,168 @@ CREATE TABLE profiles (
14
17
  );
15
18
  ```
16
19
 
17
- ## Defaults, undefined, and null in batches
20
+ ## Insert and return a row
21
+
22
+ ```ts
23
+ import { sql } from '@kequtech/tenon-orm';
24
+
25
+ const profile = await db.query(
26
+ sql<{ id: number; name: string }>`INSERT INTO public.profiles
27
+ ${sql.insertValues({ name: 'Ada' })} RETURNING id, name`,
28
+ { returns: 'one' },
29
+ );
30
+ ```
31
+
32
+ The helper emits `("name") VALUES ($1)` and binds `'Ada'`. PostgreSQL fills defaults. You choose
33
+ `RETURNING` and the result mode explicitly.
34
+
35
+ ## Defaults, undefined, and null
18
36
 
19
37
  ```ts
20
38
  await db.query(
21
- sql`INSERT INTO profiles ${sql.insertValues([
22
- { name: 'Ada', bio: null },
23
- { name: 'Lin', score: 4, bio: undefined },
24
- ])} RETURNING id`,
25
- { returns: 'many' },
39
+ sql`INSERT INTO public.profiles ${sql.insertValues([
40
+ { name: 'Lin', bio: null },
41
+ { name: 'Jo', score: 4, bio: undefined },
42
+ ])}`,
26
43
  );
27
44
  ```
28
45
 
29
- Ada gets the score default and SQL null for bio. Lin gets score 4 and the bio default. Columns stay
30
- aligned across heterogeneous rows. Empty batches fail; a single default-only object emits
31
- `DEFAULT VALUES` and still depends on the table's required-column rules.
46
+ Lin gets score 0 and SQL null for bio. Jo gets score 4 and the bio default. Heterogeneous rows stay
47
+ aligned. Missing/undefined insert cells use `DEFAULT`; explicit null remains SQL NULL.
48
+
49
+ A single default-only object emits `DEFAULT VALUES`, which still fails if the table has an unmet
50
+ required column. Empty batches fail. Multiple default-only rows need an explicit column list.
51
+
52
+ ## Type and select accepted fields
32
53
 
33
- ## Input types and runtime validation
54
+ Generate `Database` from the same schema, then narrow the fields the application accepts:
34
55
 
35
56
  ```ts
36
57
  import { sql, type QueryClient } from '@kequtech/tenon-orm';
37
- import type { Rows } from './generated-schema.js';
58
+ import type { Database } from './generated-schema.js';
38
59
 
39
- type ProfileInput = Pick<Rows.Insert['profiles'], 'name' | 'score'>;
60
+ type ProfileInput = Pick<Database['profiles']['insert'], 'name' | 'score'>;
40
61
 
41
62
  function createProfile(client: QueryClient, input: ProfileInput) {
42
63
  return client.query(
43
- sql<{ id: number }>`INSERT INTO profiles
44
- ${sql.insertValues(input, ['name', 'score'])} RETURNING id`,
64
+ sql<{ id: number }>`INSERT INTO public.profiles
65
+ ${sql.insertValues(input, ['name', 'score'])} RETURNING id`,
45
66
  { returns: 'one' },
46
67
  );
47
68
  }
48
69
  ```
49
70
 
50
- Annotations, `satisfies`, or helper generic arguments apply generated input types; merely generating
51
- the file does not check writes. Use `Pick` to narrow accepted fields and retain runtime validation.
52
- Generated inputs describe defaults and driver representations, not authorization or constraints.
71
+ The explicit column list selects and orders fields and ignores unlisted keys. Keep required insert
72
+ fields in that list: a well-typed object can still produce an invalid insert if selection discards
73
+ its required `name`. Helpers have no schema metadata; PostgreSQL enforces constraints.
53
74
 
54
- Selected columns choose and order fields, ignoring unlisted keys. They can discard a required field
55
- even when the original object is valid:
75
+ Types apply through annotations, `satisfies`, or helper generic arguments. They do not validate
76
+ external data, authorization, JSON contents, or CHECK constraints. See
77
+ [generated inputs](schema-types.md#insert-and-update-inputs).
56
78
 
57
- ```ts
58
- const input: Rows.Insert['profiles'] = { name: 'Ada' };
59
- const query = sql`INSERT INTO profiles ${sql.insertValues(input, ['bio', 'score'])}`;
60
- // Executing this fails: name was discarded and has no default.
61
- ```
79
+ ## Patch fields and use expressions
62
80
 
63
- ## Patches mixed with expressions
81
+ For a validated `id` and patch:
64
82
 
65
83
  ```ts
66
- const patch: Pick<Rows.Update['profiles'], 'name' | 'bio'> = { bio: null };
67
- await db.query(sql`UPDATE profiles SET ${sql.assignments(patch)}, revision = revision + 1
68
- WHERE id = ${id}`);
84
+ const patch: Pick<Database['profiles']['update'], 'name' | 'bio'> = { bio: null };
85
+ await db.query(
86
+ sql`UPDATE public.profiles SET ${sql.assignments(patch)}, revision = revision + 1
87
+ WHERE id = ${id}`,
88
+ { affectedRows: { exact: 1 } },
89
+ );
69
90
  ```
70
91
 
71
- Missing/undefined fields are omitted; null clears a nullable field. Put arithmetic and
72
- `column = DEFAULT` in SQL. Helpers accept values, not SQL expression objects.
92
+ Assignments omit missing/undefined fields. Null clears a nullable field. Put arithmetic,
93
+ `column = DEFAULT`, and other SQL expressions in the surrounding statement; helper cells accept
94
+ bound values, not SQL fragments or expression objects.
73
95
 
74
- An empty patch fails even if the surrounding SQL has other assignments. Branch when an
75
- expression-only update is intended:
96
+ `sql.assignments` throws if no defined fields remain, even if the surrounding query has another
97
+ assignment. Skip an empty patch, or branch when an expression-only update is intended:
76
98
 
77
99
  ```ts
78
100
  if (patch.name !== undefined || patch.bio !== undefined) {
79
- await db.query(sql`UPDATE profiles SET ${sql.assignments(patch)}, revision = revision + 1
80
- WHERE id = ${id}`);
101
+ await db.query(
102
+ sql`UPDATE public.profiles SET ${sql.assignments(patch)}, revision = revision + 1
103
+ WHERE id = ${id}`,
104
+ { affectedRows: { exact: 1 } },
105
+ );
81
106
  } else {
82
- await db.query(sql`UPDATE profiles SET revision = revision + 1 WHERE id = ${id}`);
107
+ await db.query(sql`UPDATE public.profiles SET revision = revision + 1 WHERE id = ${id}`, {
108
+ affectedRows: { exact: 1 },
109
+ });
83
110
  }
84
111
  ```
85
112
 
86
- ## Preserve an omitted field during an upsert
113
+ ## Upsert without overwriting an omitted field
87
114
 
88
- Given validated `name`, `score`, and optional `bio`, choose conflict behavior explicitly:
115
+ For validated `name`, `score`, and optional `bio`, choose conflict behavior explicitly:
89
116
 
90
117
  ```ts
91
118
  if (bio !== undefined) {
92
- await db.query(sql`INSERT INTO profiles ${sql.insertValues({ name, score, bio })}
119
+ await db.query(sql`INSERT INTO public.profiles ${sql.insertValues({ name, score, bio })}
93
120
  ON CONFLICT (name) DO UPDATE SET score = excluded.score, bio = excluded.bio`);
94
121
  } else {
95
- await db.query(sql`INSERT INTO profiles ${sql.insertValues({ name, score })}
122
+ await db.query(sql`INSERT INTO public.profiles ${sql.insertValues({ name, score })}
96
123
  ON CONFLICT (name) DO UPDATE SET score = excluded.score`);
97
124
  }
98
125
  ```
99
126
 
100
- The second query preserves an existing bio. Omitting an insert value uses its database default; that
101
- alone does not decide which fields a conflict update overwrites.
127
+ The second branch preserves an existing bio. Omitting an insert field uses its database default;
128
+ it does not decide whether an upsert overwrites that field.
129
+
130
+ ## Delete with a bound
131
+
132
+ ```ts
133
+ await db.query(sql`DELETE FROM public.profiles WHERE id = ${id}`, {
134
+ affectedRows: { max: 1 },
135
+ });
136
+ ```
137
+
138
+ Zero or one deletion is allowed; more causes rollback. Use `exact: 1` when absence is also an error.
139
+ Guards work without `RETURNING`. See [transactions and guards](reference.md#transactions-guarded-writes-and-errors).
102
140
 
103
141
  ## JSON and PostgreSQL arrays
104
142
 
105
- For a table with `payload jsonb` and `tags text[]`, encode JSON separately from the PostgreSQL
106
- array:
143
+ For an additional table with `payload jsonb` and `tags text[]`:
107
144
 
108
145
  ```ts
146
+ import { builtin, json, param } from '@kequtech/tenon-orm';
147
+
109
148
  const tags = ['ada', 'lin'];
110
- await db.query(
111
- sql`INSERT INTO deliveries ${sql.insertValues({ payload: JSON.stringify(tags), tags })}`,
112
- );
149
+ await db.query(sql`INSERT INTO public.deliveries
150
+ ${sql.insertValues({ payload: json(tags), tags: param(tags, builtin.textArray) })}`);
113
151
  ```
114
152
 
115
- Generated JSON writes accept encoded JSON strings. Use `JSON.stringify(value)` for objects, arrays,
116
- strings, and JSON null; a bound JavaScript null means SQL null. Arrays passed directly are
117
- PostgreSQL arrays. Tenon leaves serialization to pg.
153
+ `json(value)` encodes a JSON document; `param(value, codec)` chooses a PostgreSQL type and encoder.
154
+ A JSON array and a PostgreSQL array are separate formats. Generated writes accept encoded strings
155
+ or explicit parameters; plain objects/arrays reject. `json(null)` means JSON null, while plain
156
+ `null` means SQL NULL. `json('text')` encodes a JSON string, not an already-encoded document.
157
+ JSON encoding rejects bigint, including nested bigint values.
158
+
159
+ For int8, use exact strings, bigint, or `param('9007199254740993', builtin.int8)`. For an int8 array,
160
+ use `param(['9007199254740993', null], builtin.int8Array)`. Reads remain strings by default. Plain
161
+ Date and supported Temporal values work as scalar inputs; Date encodes in UTC. Neither input form
162
+ changes the output decoder. See [value mappings](schema-types.md#value-mappings).
118
163
 
119
- ## Batches in one transaction
164
+ ## Batch within one transaction
120
165
 
121
- For an array `rows` of generated profile inputs:
166
+ For `rows` containing generated profile insert inputs:
122
167
 
123
168
  ```ts
124
169
  await db.transaction(async (tx) => {
125
170
  for (let offset = 0; offset < rows.length; offset += 300) {
126
171
  const batch = rows.slice(offset, offset + 300);
127
- await tx.query(sql`INSERT INTO profiles ${sql.insertValues(batch, ['name', 'bio', 'score'])}`);
172
+ await tx.query(sql`INSERT INTO public.profiles
173
+ ${sql.insertValues(batch, ['name', 'bio', 'score'])}`);
128
174
  }
129
175
  });
130
176
  ```
131
177
 
132
- A failure rolls back all batches. Empty input makes no insert calls. Choose a batch size below the
133
- query-wide 65,535-parameter limit, including surrounding parameters; default cells consume none.
134
- Tenon does not split batches or choose transaction boundaries for you.
178
+ A failure rolls back every batch; empty input makes no calls. Choose a batch size below the
179
+ query-wide 65,535-parameter limit, counting surrounding interpolations too. Default cells consume
180
+ no parameters. Tenon does not split batches or choose transaction boundaries.
181
+
182
+ These helpers support optional fields at runtime. The optional source checker needs explicit shape
183
+ types with required selected fields and fixed batch lengths; ordinary generated patch types can
184
+ remain unchecked. See [object expansion checking](query-checking.md#object-expansion).
@@ -0,0 +1,199 @@
1
+ # Checking query types
2
+
3
+ `tenon check` compares Tenon SQL in TypeScript source with declared input/result types and PostgreSQL
4
+ metadata. It can catch a missing result property, an int8 result declared as number, or an unsupported
5
+ bound input. It is optional; normal execution still trusts `sql<Row>` annotations.
6
+
7
+ The checker reads source without importing the application, evaluating input expressions, binding
8
+ values, or executing application SQL. PostgreSQL still parses/analyzes each recovered statement,
9
+ which can take locks. Use a representative migrated development/test database.
10
+
11
+ ## Run a check
12
+
13
+ Set `DATABASE_URL`, then select a project, file, or one query:
14
+
15
+ ```sh
16
+ npx tenon check
17
+ npx tenon check src/users.ts
18
+ npx tenon check src/users.ts:23
19
+ npx tenon check src/users.ts:23 --project tsconfig.json --json
20
+ ```
21
+
22
+ A one-based line selects the complete query containing it, including multiline templates and their
23
+ variable declaration line. A line with no query or multiple queries fails. A fragment alone is not
24
+ a complete query target.
25
+
26
+ The checker discovers `tsconfig.json` from the target's directory or current working directory.
27
+ Use `--project` for another config or project directory; targets must belong to that project.
28
+ It uses the bundled TypeScript 6 compiler and the project's imports, aliases, indexed access,
29
+ `Pick`, unions, and path mappings. `strictNullChecks` is required. Invalid configuration and compiler
30
+ errors in selected files fail the report.
31
+
32
+ Match runtime role, search path, timezone, and codecs. Session/timeout flags are the same as
33
+ [Describe](query-inspection.md#cli). CLI checks use default codecs; use the programmatic API for
34
+ a custom registry.
35
+
36
+ ## What gets checked
37
+
38
+ ```ts
39
+ import { sql } from '@kequtech/tenon-orm';
40
+ import type { Database } from './generated-schema.js';
41
+
42
+ type Summary = Pick<Database['users']['row'], 'id' | 'email'>;
43
+ export function userQuery(id: number) {
44
+ return sql<Summary>`SELECT id, email FROM public.users WHERE id = ${id}`;
45
+ }
46
+ ```
47
+
48
+ Recognition follows Tenon's imported symbols through aliases, namespaces, barrel re-exports, and
49
+ const aliases. Unrelated functions named `sql` are ignored. Fixed-text `unsafeSql` calls with fixed
50
+ values lists are also supported. Each recoverable SQL variant goes through PostgreSQL Describe;
51
+ parameter OIDs and result columns are compared with the declared contracts.
52
+
53
+ | Finding | Meaning | Fails the command? |
54
+ | ----------- | --------------------------------------------------------------------------- | ------------------ |
55
+ | `mismatch` | A definite contradiction, invalid SQL, or invalid input type | Yes |
56
+ | `unchecked` | SQL cannot be recovered, a limit is exceeded, or inspection cannot complete | Yes |
57
+ | `unproven` | Metadata cannot prove or disprove the declaration | No |
58
+
59
+ Missing required result properties fail; extra selected columns are allowed by structural types.
60
+ Each variant must fit at least one member of a declared result union. Nullability, JSON members,
61
+ literal/branded refinements, and unfamiliar decoder types may remain unproven. A broader metadata
62
+ type alone does not reject a narrower application declaration.
63
+
64
+ Input and output contracts are separate. For example, an int8 input accepts exact text, bigint, or
65
+ an explicit encoder; its default output is a string. Strings, numbers, bigint, Date, and supported
66
+ Temporal inputs use OID 0/server inference. Booleans, bytes, and recoverable `json`/`param` calls
67
+ supply the same OID hints as execution. Unions with different hints create variants. Input types do
68
+ not prove value ranges, valid Dates, or textual formats.
69
+
70
+ A plain object, array, or undefined input contradicts the driver's bind API. An opaque `Parameter`
71
+ is trusted, with its unknown OID reported as unproven. Describe uses server inference in that case;
72
+ dependent result comparisons and inference errors remain unproven. This does not validate the encoder.
73
+
74
+ ## Responding to findings
75
+
76
+ - **Mismatch:** correct the SQL or declared contract, then recheck. For int8 output, use string under
77
+ default codecs; for arrays/objects as inputs, choose the documented encoder.
78
+ - **Unchecked:** read the reason. An explicit shape or fixed composition may make it recoverable.
79
+ Runtime-dependent SQL needs database tests. The command fails while unchecked sites remain in
80
+ the selected scope; narrow the target to check supported sites separately and retain visibility
81
+ of the remaining coverage.
82
+ - **Unproven:** retain the declaration when justified, with validation/tests for what metadata cannot
83
+ establish. A note alone does not fail the command.
84
+
85
+ For example, generated update fields are optional and can disappear from SQL. If application
86
+ validation always supplies a boolean, declare that required contract:
87
+
88
+ ```ts
89
+ type ActivePatch = { active: boolean };
90
+
91
+ function setActive(id: number, patch: ActivePatch) {
92
+ return sql`UPDATE public.users SET ${sql.assignments<ActivePatch>(patch, ['active'])}
93
+ WHERE id = ${id}`;
94
+ }
95
+ ```
96
+
97
+ If omission is intended, keep optional fields and test the SQL/effects. An explicit column list does
98
+ not make optional fields required. Avoid assertions that merely hide this distinction.
99
+
100
+ Exit 0 means no mismatch or unchecked site in the selected scope. It does not establish JSON shape,
101
+ nullability, cardinality, or every runtime branch. No query sites also fails. Independent queries
102
+ continue after an unsupported site, and a recoverable branch can expose a mismatch even when another
103
+ branch is unchecked. Diagnostics include locations, codes, parameter/column identifiers, expected
104
+ and observed types, and variant numbers. `--json` returns the structured report.
105
+
106
+ ## Supported composition
107
+
108
+ ```ts
109
+ const direction = descending ? sql.fragment`DESC` : sql.fragment`ASC`;
110
+ const predicates = [sql.fragment`active = ${true}`, sql.fragment`email <> ${''}`] as const;
111
+ const query = sql<Summary>`SELECT id, email FROM public.users
112
+ WHERE ${sql.join(predicates, 'AND')} ORDER BY email ${direction}`;
113
+ ```
114
+
115
+ The checker follows inline fragments and const bindings with recoverable initializers, fixed
116
+ identifiers, fixed join lists/separators, and ternaries with recoverable branches. Both branches are
117
+ checked without evaluating the condition.
118
+
119
+ Reusing a conditional fragment preserves its branch choice. Separate ternaries using the same const
120
+ condition or an unwritten function parameter also share that choice, including simple negation.
121
+ Repeated mutable guards with unresolved correlation are unchecked. General control-flow reachability
122
+ is not inferred.
123
+
124
+ Limits are 16 complete variants per query, including parameter OID alternatives, and 32 levels of
125
+ composition references. Arbitrary fragment factories, function-body reconstruction, loops, dynamic
126
+ identifiers, mutable/escaping fragment lists, and runtime joins are unsupported. These can be valid
127
+ runtime SQL, but need database tests. Unknown source is never replaced with invented SQL.
128
+
129
+ ## Object expansion
130
+
131
+ For checking, `insertValues` and `assignments` need an explicit finite shape type:
132
+
133
+ ```ts
134
+ type NewUser = { email: string; active: boolean };
135
+
136
+ function createUser(input: NewUser) {
137
+ return sql<{ id: number }>`INSERT INTO public.users
138
+ ${sql.insertValues<NewUser>(input, ['email', 'active'])} RETURNING id`;
139
+ }
140
+ ```
141
+
142
+ The shape is a trusted expansion contract; the checker does not read the object. Prefer an explicit
143
+ column list so runtime and checking select the same keys/order. Without one, the declared shape is
144
+ assumed to describe exactly the expanded fields. Structural typing cannot prove the absence of
145
+ extra own keys or their order, so that assumption is unproven.
146
+
147
+ Selected fields must be required and exclude undefined. Optional presence changes the SQL through
148
+ omission or DEFAULT and is unchecked. Unselected optional properties are fine. Index signatures,
149
+ unresolved generic key sets, shape unions, and unknown shapes are unsupported. Fixed literal batches
150
+ are supported; unknown batch lengths are not. Runtime helpers still accept inferred shapes and
151
+ optional fields.
152
+
153
+ ## Programmatic use and custom codecs
154
+
155
+ The checking API has a separate entry point, so ordinary queries do not load TypeScript:
156
+
157
+ ```ts
158
+ import { checkQueries } from '@kequtech/tenon-orm/check';
159
+
160
+ const report = await checkQueries({
161
+ connection,
162
+ project: 'tsconfig.json',
163
+ target: 'src/users.ts',
164
+ });
165
+ if (!report.ok) process.exitCode = 1;
166
+ ```
167
+
168
+ The report has `ok`, `project`, `queries`, `diagnostics`, and available inspection `context`. Options
169
+ also accept `session`, `timeouts`, `codecs`, and `readTypes`. `connection` is a URI.
170
+
171
+ For a custom codec, pass the same registry as runtime queries. A text codec may declare its output:
172
+
173
+ ```ts
174
+ import { defaultCodecs } from '@kequtech/tenon-orm';
175
+
176
+ const codecs = defaultCodecs.with({
177
+ oid: 23,
178
+ text: {
179
+ encode: (value: { raw: string }) => value.raw,
180
+ decode: (raw) => ({ raw }),
181
+ typescript: 'import("./src/types.js").DecodedInteger',
182
+ },
183
+ });
184
+ const report = await checkQueries({ connection, project: 'tsconfig.json', codecs });
185
+ ```
186
+
187
+ Here `src/types.ts` must export `DecodedInteger = { raw: string }`. Registry metadata describes
188
+ non-NULL output, including derived arrays. Type expressions compile in the application's project
189
+ without importing/executing its modules; referenced dependencies/types must be available.
190
+
191
+ Use `readTypes: { 23: 'import("./src/types.js").DecodedInteger' }` only for a decoder with no known
192
+ registry representation. It cannot replace known metadata or claim JSON members for default JSON
193
+ decoding. Expressions resolve from the tsconfig directory. A codec declaration is trusted output
194
+ metadata, not data validation.
195
+
196
+ Custom configuration belongs in an application-owned checking script; the checker does not load
197
+ application configuration automatically. Generated-file freshness remains a separate
198
+ `db.generateTypes({ out, check: true })` operation. For behavior tests, see
199
+ [query inspection and testing](query-inspection.md).
@@ -1,8 +1,8 @@
1
- # Inspect and test queries
1
+ # Inspecting and testing queries
2
2
 
3
- `describeQuery` asks PostgreSQL for a completed statement's parameter/result metadata without
4
- binding its values or executing it. Inspection is included with Tenon and opens a separate,
5
- short-lived connection. It works with SQL assembled from fragments and mutation helpers.
3
+ `describeQuery` asks PostgreSQL for a finished statement's parameter and result metadata. It does
4
+ not bind values or execute the statement. Use it to inspect SQL and to support database tests. To
5
+ compare TypeScript source annotations instead, use [query checking](query-checking.md).
6
6
 
7
7
  ## Inspect completed SQL
8
8
 
@@ -18,13 +18,19 @@ const query = sql<{ id: number; total: string }>`
18
18
  const description = await describeQuery({ connection, query });
19
19
  ```
20
20
 
21
- `query` accepts SQL text or an object with `text`, including `SqlQuery`. Pass the same completed
22
- query to inspection and execution. Strings use PostgreSQL `$1` placeholders; templates use
23
- `${value}`. Cast standalone parameters so PostgreSQL can infer their types without runtime values.
24
- Inspection does not read `sql<Row>`, check `query.values`, or compare an annotation with SQL.
21
+ Pass the same completed query to inspection and execution. `query` accepts SQL text or an object
22
+ with `text`, including a Tenon `SqlQuery`. Raw SQL uses `$1` placeholders; templates use `${value}`.
23
+ Inspection checks neither `query.values` nor its `sql<Row>` annotation.
24
+
25
+ Cast standalone parameters so PostgreSQL can infer their types without values. Alternatively pass
26
+ `query: { text, parameterTypes: [oid, ...] }` for explicit Describe hints. PostgreSQL still parses
27
+ and analyzes the statement, which can take locks.
25
28
 
26
29
  ## Session and timeouts
27
30
 
31
+ Inspection opens and closes its own short-lived session. `connection` must be a PostgreSQL URI;
32
+ it does not inherit a runtime pool or transaction:
33
+
28
34
  ```ts
29
35
  const description = await describeQuery({
30
36
  connection,
@@ -34,50 +40,73 @@ const description = await describeQuery({
34
40
  });
35
41
  ```
36
42
 
37
- Reuse the runtime URI and session settings. Optional `session.role` uses SET ROLE when permitted; it
38
- does not load that role's login defaults. Omitted settings use the inspection connection's defaults.
39
- Explicit undefined behaves like omission. Unknown keys and invalid settings fail.
43
+ Match runtime settings. Optional `session.role` uses SET ROLE when permitted; it does not load that
44
+ role's login defaults. Omitted settings use the inspection connection's defaults. Explicit undefined
45
+ behaves like omission. Unknown keys and invalid settings reject.
40
46
 
41
47
  `context` reports login/effective roles, configured/resolved search paths, timezone, database, and
42
- effective statement/lock timeouts. Its mode is `separate-session` and read mapping profile is `pg`;
43
- reported read types assume pg's default parsers and do not infer application overrides. Temporary
44
- tables, uncommitted changes, and transaction-local settings are not inherited. pg reads its normal
45
- connection environment, including `PGOPTIONS` when URI options are absent. Use explicit URI/session
46
- settings for reproducibility.
47
-
48
- Defaults are a 3-second connection/authentication timeout, a 5-second limit per server statement,
49
- and a 1-second lock wait. Overrides are positive integer milliseconds, at most 2,147,483,647. URI
50
- settings cannot disable the statement/lock bounds. A URI `query_timeout` remains pg's separate
51
- client read timeout. The owned client closes after inspection. These are operation limits, not a
52
- single overall deadline.
53
-
54
- ## Result fields
55
-
56
- | Field | Meaning |
57
- | ----------------------- | ------------------------------------------------------------------------------------------ |
58
- | `parameters` | OIDs, qualified PostgreSQL names, and read representations; not JavaScript input contracts |
59
- | `columns` | Ordered names, PostgreSQL types, read representations, and source metadata where available |
60
- | `columns[].nullability` | Always `"unknown"`; source `notNull` is informational |
61
- | `rowType` | Conservative `DescribedRow` declaration, or null for no columns/duplicate names |
62
- | `issues` | Findings with stable `code` fields and descriptive messages |
63
- | `context` | Effective inspection-session settings |
64
-
65
- Known read types in `rowType` include null; unknown types already include it. Source constraints
66
- cannot establish result nullability through joins, expressions, or grouping. JSON structure stays
67
- unknown. Handle findings by code:
68
-
69
- | Code | Meaning |
70
- | --------------------- | --------------------------------------------------------------- |
71
- | `nullability-unknown` | Result nullability is unresolved |
72
- | `shape-unknown` | Listed output columns have unknown shapes/read types |
73
- | `unmapped-type` | A parameter or result type has no verified driver mapping |
74
- | `duplicate-column` | Repeated result names would overwrite earlier object properties |
48
+ effective statement/lock timeouts. Mode is `separate-session`, read mapping profile `pgclear`.
49
+ Temporary tables, uncommitted changes, and transaction-local state are not inherited. PG environment
50
+ variables, pgpass, service files, and `.env` are not loaded.
51
+
52
+ | Timeout | Default | Scope |
53
+ | ------------- | -------- | ----------------------------- |
54
+ | `connectMs` | 3,000 ms | Connection and authentication |
55
+ | `statementMs` | 5,000 ms | Each server statement |
56
+ | `lockMs` | 1,000 ms | Lock wait |
57
+
58
+ Overrides are positive integer milliseconds up to 2,147,483,647. These are operation bounds, not a
59
+ single overall deadline. URI settings cannot disable statement/lock bounds. pg-specific
60
+ `query_timeout` and `options` URI parameters are unsupported; use the options above.
61
+
62
+ ## Read the report
63
+
64
+ | Field | Meaning |
65
+ | ----------------------- | ----------------------------------------------------------------------------------------- |
66
+ | `parameters` | OIDs, qualified PostgreSQL names, and read representations; these are not input contracts |
67
+ | `columns` | Ordered names, PostgreSQL types, read representations, and available source metadata |
68
+ | `columns[].nullability` | Always `"unknown"`; source `notNull` is informational |
69
+ | `rowType` | Conservative `DescribedRow` declaration, or null for no columns/duplicate names |
70
+ | `issues` | Findings with stable `code` and descriptive messages |
71
+ | `context` | Effective inspection-session settings |
72
+
73
+ Known read types in `rowType` include null; unknown already includes it. A source column's NOT NULL
74
+ constraint cannot prove result nullability through outer joins, expressions, or grouping. JSON
75
+ members remain unknown.
76
+
77
+ | Finding code | Meaning |
78
+ | --------------------- | ----------------------------------------------------- |
79
+ | `nullability-unknown` | Result nullability is unresolved |
80
+ | `shape-unknown` | Listed columns have unknown shapes/read types |
81
+ | `unmapped-type` | A custom decoder has no known representation |
82
+ | `duplicate-column` | Repeated result names would fail object-row execution |
83
+
84
+ ## Custom codecs
85
+
86
+ Pass the runtime registry as `codecs` to report its read representations; omission uses defaults:
87
+
88
+ ```ts
89
+ import { builtin, defaultCodecs } from '@kequtech/tenon-orm';
90
+
91
+ const codecs = defaultCodecs.with(builtin.jsonbText);
92
+ const description = await describeQuery({ connection, query, codecs });
93
+ ```
94
+
95
+ That registry reports JSONB output as string. Scalar overrides affect derived built-in arrays;
96
+ explicit array overrides win. Custom `text.typescript` expressions appear in the conservative row
97
+ declaration and are shared with generation/source checking. Without known metadata, a custom
98
+ decoder remains unknown even for a familiar scalar/enum/domain OID. Catalog base types do not
99
+ replace the wire OID's decoder contract.
100
+
101
+ Inspection's own catalog/context queries use default decoding. Reported parameter read types are
102
+ not encoding rules: exact text can be a valid int8 input while its output also uses text by default.
103
+ See [schema mappings and codecs](schema-types.md#custom-codecs).
75
104
 
76
105
  ## Test the same statement
77
106
 
78
- For the `public.users` table from the [human guide](human-guide.md), assume a dedicated test fixture
79
- has rows `{ id: 1, email: 'ada@example.test', active: true }` and
80
- `{ id: 2, email: 'lin@example.test', active: false }`, and `db` is its runtime connection:
107
+ Use a dedicated test database. For `public.users` from [Getting started](human-guide.md), assume a
108
+ fixture contains `{ id: 1, email: 'ada@example.test', active: true }` and
109
+ `{ id: 2, email: 'lin@example.test', active: false }`. Here `db` and `connection` select that database:
81
110
 
82
111
  ```ts
83
112
  import assert from 'node:assert/strict';
@@ -109,15 +138,15 @@ for (const activeOnly of [false, true]) {
109
138
  }
110
139
  ```
111
140
 
112
- Description and execution establish different facts. Describe can succeed for invalid bound values,
113
- a write that violates constraints, or an operation the runtime role cannot execute. Check actual
114
- rows/effects, JSON, permissions, and rollback in database tests, and run TypeScript. Inspect
115
- meaningful query variants; one instance does not cover unexercised branches. Expect only reviewed
116
- findings and keep unexpected ones visible.
141
+ Describe establishes metadata; execution tests establish behavior. Describe may succeed even when
142
+ bound values are invalid, constraints would reject a write, or the role lacks execution permission.
143
+ Test actual rows, write effects, authorization, JSON validation, and rollback. Exercise meaningful
144
+ composition variants and keep unexpected findings visible. Do not test query behavior by matching
145
+ words in documentation or SQL strings.
117
146
 
118
147
  ## CLI
119
148
 
120
- Save SQL with PostgreSQL placeholders in `query.sql`:
149
+ Save raw SQL in `query.sql`:
121
150
 
122
151
  ```sql
123
152
  SELECT $1::integer AS id, count(*) AS total FROM pg_catalog.pg_class;
@@ -128,9 +157,11 @@ npx tenon describe query.sql
128
157
  npx tenon describe query.sql --search-path app,public --time-zone UTC
129
158
  ```
130
159
 
131
- The CLI uses `DATABASE_URL` and prints JSON. Session flags are `--role`, `--search-path`, and
132
- `--time-zone`. Timeout flags are `--connect-timeout-ms`, `--statement-timeout-ms`, and
133
- `--lock-timeout-ms`. Migration/generation flags such as `--namespace` and `--check` are rejected.
160
+ The CLI reads `DATABASE_URL` and prints JSON using default codecs. Session flags are `--role`,
161
+ `--search-path`, and `--time-zone`; timeout flags are `--connect-timeout-ms`,
162
+ `--statement-timeout-ms`, and `--lock-timeout-ms`. Unsupported flags, including `--namespace` and
163
+ `--check`, reject.
134
164
 
135
- Exit 0 means description succeeded, including reports with unresolved findings. Exit 1 means input,
136
- connection, timeout, or PostgreSQL analysis failed. The inspected statement is never executed.
165
+ Exit 0 means inspection succeeded, even with unresolved findings. Input, connection, timeout, or
166
+ PostgreSQL analysis failures exit 1. The inspected statement is never executed. Generated-file
167
+ freshness uses `db.generateTypes({ out, check: true })`, a separate operation.