@kequtech/tenon-orm 0.4.0 → 0.4.3

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.
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 `Database` 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,23 +17,41 @@ 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';
@@ -40,108 +61,124 @@ 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: Database['profiles']['insert'] = { 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
84
  const patch: Pick<Database['profiles']['update'], 'name' | 'bio'> = { bio: null };
67
- await db.query(sql`UPDATE profiles SET ${sql.assignments(patch)}, revision = revision + 1
68
- WHERE id = ${id}`);
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
109
146
  import { builtin, json, param } from '@kequtech/tenon-orm';
110
147
 
111
148
  const tags = ['ada', 'lin'];
112
- await db.query(
113
- sql`INSERT INTO deliveries ${sql.insertValues({ payload: json(tags), tags: param(tags, builtin.textArray) })}`,
114
- );
149
+ await db.query(sql`INSERT INTO public.deliveries
150
+ ${sql.insertValues({ payload: json(tags), tags: param(tags, builtin.textArray) })}`);
115
151
  ```
116
152
 
117
- Generated JSON writes accept pgclear parameters or encoded strings. `json(value)` encodes objects,
118
- arrays, strings, and JSON null; plain `null` means SQL NULL. Arrays need `param(value, codec)` or
119
- encoded PostgreSQL text. Plain arrays, objects, and undefined reject as bound values. Tenon passes
120
- explicit parameters unchanged to pgclear. `json()` still rejects bigint values, including bigint
121
- nested inside objects; bigint support for ordinary SQL parameters does not change JSON encoding.
122
-
123
- For bigint/bigserial fields, generated writes accept exact strings, bigint, or explicit parameters;
124
- reads remain strings by default. Use `param('9007199254740993', builtin.int8)` for an explicit int8
125
- parameter and `param(['9007199254740993', null], builtin.int8Array)` for an int8 array. The bigint
126
- output codec and matching array encoder are separate opt-in choices.
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.
127
158
 
128
- Date and supported Temporal values can be ordinary scalar inputs, including mutation fields. Date
129
- encodes in UTC; Temporal calendar dates, wall times, and instants retain their distinct meanings.
130
- Choose the value appropriate to the PostgreSQL column and retain application validation.
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).
131
163
 
132
- ## Batches in one transaction
164
+ ## Batch within one transaction
133
165
 
134
- For an array `rows` of generated profile inputs:
166
+ For `rows` containing generated profile insert inputs:
135
167
 
136
168
  ```ts
137
169
  await db.transaction(async (tx) => {
138
170
  for (let offset = 0; offset < rows.length; offset += 300) {
139
171
  const batch = rows.slice(offset, offset + 300);
140
- 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'])}`);
141
174
  }
142
175
  });
143
176
  ```
144
177
 
145
- A failure rolls back all batches. Empty input makes no insert calls. Choose a batch size below the
146
- query-wide 65,535-parameter limit, including surrounding parameters; default cells consume none.
147
- 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).
@@ -1,9 +1,16 @@
1
- # Check authored queries
1
+ # Checking query types
2
2
 
3
- `tenon check` compares recoverable Tenon SQL with declared TypeScript contracts and the selected
4
- PostgreSQL database. It reads source and types without importing the application, evaluating parameter
5
- expressions, binding values, or executing application SQL. Normal query execution continues to trust
6
- annotations and performs no additional runtime validation.
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 CLI imports the selected configuration module; the checker then reads query source without
8
+ importing those modules, evaluating input expressions, binding 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
+ Create [shared configuration](configuration.md), then select a project, file, or one query:
7
14
 
8
15
  ```sh
9
16
  npx tenon check
@@ -12,18 +19,22 @@ npx tenon check src/users.ts:23
12
19
  npx tenon check src/users.ts:23 --project tsconfig.json --json
13
20
  ```
14
21
 
15
- Omit the target to inspect the TypeScript project's source files. A file target checks all recognized
16
- queries in that file. A one-based line selects the complete query containing that line, including a
17
- multiline SQL template or its variable declaration. A line with no query or several queries fails
18
- explicitly. A fragment alone is not a complete query target.
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 CLI uses `check.project`, defaulting to `tsconfig.json` beside the shared config. Use `--project`
27
+ for another TypeScript config or project directory; targets must belong to that project. Configured
28
+ paths resolve from the shared config; CLI targets and path flags resolve from the working directory.
29
+ It uses the bundled TypeScript 6 compiler and the project's imports, aliases, indexed access,
30
+ `Pick`, unions, and path mappings. `strictNullChecks` is required. Invalid configuration and compiler
31
+ errors in selected files fail the report.
19
32
 
20
- The checker discovers tsconfig.json from the file's directory or current working directory. Use
21
- `--project` for another configuration or project directory. The target must belong to that project.
22
- The checker uses the bundled TypeScript 6 compiler with the project's context, imports, aliases,
23
- indexed access, Pick, unions, and path mappings. strictNullChecks is required. Compiler errors in
24
- selected source files and invalid compiler configuration fail the report.
33
+ Match runtime role, search path, timezone, and codecs. Session/timeout flags are the same as
34
+ [Describe](query-inspection.md#cli). CLI checks inherit the shared connection
35
+ and codec registry. Inspection-only session settings and flags override runtime startup settings.
25
36
 
26
- ## Contracts and findings
37
+ ## What gets checked
27
38
 
28
39
  ```ts
29
40
  import { sql } from '@kequtech/tenon-orm';
@@ -35,53 +46,45 @@ export function userQuery(id: number) {
35
46
  }
36
47
  ```
37
48
 
38
- Recognition follows imported symbols, including aliases, namespaces, barrel re-exports, and const
39
- aliases. A different library's function named sql is not a Tenon query. Both sql templates and
40
- unsafeSql with fixed SQL and a fixed values list are supported.
41
-
42
- PostgreSQL Describe parses/analyzes each complete recovered variant and returns parameter OIDs and
43
- ordered result metadata. It can take analysis locks. Run against the intended schema, database role,
44
- and search path; a representative development/test database is appropriate. Session and timeout
45
- flags are the same as [Describe](query-inspection.md#cli): --role, --search-path, --time-zone,
46
- --connect-timeout-ms, --statement-timeout-ms, and --lock-timeout-ms. DATABASE_URL is required by the CLI.
47
-
48
- Findings have three kinds:
49
-
50
- | Kind | Meaning | Exit status |
51
- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
52
- | mismatch | Definite contradiction, such as a missing required result property, int8 declared as number, incompatible array elements, invalid SQL, or an invalid driver input type | Failure |
53
- | unchecked | SQL structure cannot be recovered, a composition limit is exceeded, or inspection cannot complete | Failure |
54
- | unproven | Available evidence cannot prove or disprove the declared type | Does not fail |
55
-
56
- Declared types receive the benefit of the doubt when evidence is inconclusive. SQL result nullability,
57
- JSON members, branded/literal refinements, and unfamiliar custom decoder types remain unproven. A
58
- narrow declaration is not rejected merely because Describe supplies a broader representation. Each
59
- SQL variant must fit at least one member of a declared result union. Additional selected columns are
60
- allowed by structural result types; missing required properties fail.
61
-
62
- Input and read contracts are separate: int8 accepts exact text, bigint, or an explicit encoder and decodes
63
- as exact strings by default. Plain bigint, Date, and supported native/polyfilled Temporal inputs use
64
- OID 0 and server inference, just as strings and numbers do. Boolean, bytes, and recoverable pgclear
65
- json/param calls supply the same explicit OID hints as execution. Unions with different hints produce
66
- variants. Accepted input types do not prove server ranges, valid Date values, or textual formats.
67
- Plain objects, arrays, and undefined contradict pgclear's bind API; use explicit parameters or omission. A declared opaque pgclear Parameter is trusted, with its OID
68
- reported as unproven. Describe then uses server inference; dependent result comparisons and inference
69
- errors are unproven rather than contradictions. This is a stated limitation, not validation of the
70
- unknown encoder.
49
+ Recognition follows Tenon's imported symbols through aliases, namespaces, barrel re-exports, and
50
+ const aliases. Unrelated functions named `sql` are ignored. Fixed-text `unsafeSql` calls with fixed
51
+ values lists are also supported. Each recoverable SQL variant goes through PostgreSQL Describe;
52
+ parameter OIDs and result columns are compared with the declared contracts.
71
53
 
72
- ## Responding to findings
54
+ | Finding | Meaning | Fails the command? |
55
+ | ----------- | --------------------------------------------------------------------------- | ------------------ |
56
+ | `mismatch` | A definite contradiction, invalid SQL, or invalid input type | Yes |
57
+ | `unchecked` | SQL cannot be recovered, a limit is exceeded, or inspection cannot complete | Yes |
58
+ | `unproven` | Metadata cannot prove or disprove the declaration | No |
59
+
60
+ Missing required result properties fail; extra selected columns are allowed by structural types.
61
+ Each variant must fit at least one member of a declared result union. Nullability, JSON members,
62
+ literal/branded refinements, and unfamiliar decoder types may remain unproven. A broader metadata
63
+ type alone does not reject a narrower application declaration.
73
64
 
74
- Read each diagnostic's kind and code, then check the selected SQL and input/result declarations:
65
+ Input and output contracts are separate. For example, an int8 input accepts exact text, bigint, or
66
+ an explicit encoder; its default output is a string. Strings, numbers, bigint, Date, and supported
67
+ Temporal inputs use OID 0/server inference. Booleans, bytes, and recoverable `json`/`param` calls
68
+ supply the same OID hints as execution. Unions with different hints create variants. Input types do
69
+ not prove value ranges, valid Dates, or textual formats.
75
70
 
76
- | Finding | Action |
77
- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
78
- | `mismatch` | Correct the SQL or declared contract. For an int8 column, use a string result type; for an invalid bind, use the documented encoder. Recheck after the change. |
79
- | `unchecked` | Inspect the reason. Recoverable SQL may need an explicit shape or fixed composition; runtime-dependent SQL needs database tests. The command fails until unchecked sites are removed from the selected scope or made checkable. |
80
- | `unproven` | Retain the declared contract when appropriate, with application validation and tests for the property metadata cannot establish. This note alone does not fail the command. |
71
+ A plain object, array, or undefined input contradicts the driver's bind API. An opaque `Parameter`
72
+ is trusted, with its unknown OID reported as unproven. Describe uses server inference in that case;
73
+ dependent result comparisons and inference errors remain unproven. This does not validate the encoder.
74
+
75
+ ## Responding to findings
81
76
 
82
- For example, a generated `Database['users']['update']` makes `active` optional. An assignments
83
- fragment can omit it, so checking that shape is unsupported even when a particular object currently
84
- contains `active`. If a function always receives a validated boolean, declare that required shape:
77
+ - **Mismatch:** correct the SQL or declared contract, then recheck. For int8 output, use string under
78
+ default codecs; for arrays/objects as inputs, choose the documented encoder.
79
+ - **Unchecked:** read the reason. An explicit shape or fixed composition may make it recoverable.
80
+ Runtime-dependent SQL needs database tests. The command fails while unchecked sites remain in
81
+ the selected scope; narrow the target to check supported sites separately and retain visibility
82
+ of the remaining coverage.
83
+ - **Unproven:** retain the declaration when justified, with validation/tests for what metadata cannot
84
+ establish. A note alone does not fail the command.
85
+
86
+ For example, generated update fields are optional and can disappear from SQL. If application
87
+ validation always supplies a boolean, declare that required contract:
85
88
 
86
89
  ```ts
87
90
  type ActivePatch = { active: boolean };
@@ -92,19 +95,14 @@ function setActive(id: number, patch: ActivePatch) {
92
95
  }
93
96
  ```
94
97
 
95
- When omission is intentional, retain the optional input and test the resulting SQL and effects.
96
- An explicit column list does not make an optional property required. Avoid assertions that merely
97
- hide this difference. Narrow a CLI target to check supported sites independently; report the
98
- remaining unchecked sites in the project's validation workflow.
99
-
100
- Exit zero means no definite mismatch or unchecked site was reported in the selected scope. It does
101
- not mean JSON members, nullability, cardinality, or every runtime branch was verified. No query sites
102
- also fails, so an empty or incorrectly selected file cannot appear to pass.
98
+ If omission is intended, keep optional fields and test the SQL/effects. An explicit column list does
99
+ not make optional fields required. Avoid assertions that merely hide this distinction.
103
100
 
104
- The report includes source locations, parameter/column identifiers, expected and observed types, and
105
- variant numbers. An unsupported site does not stop independent sites. Recoverable branches can still
106
- expose mismatches when another branch is unchecked. The default output is readable diagnostics and
107
- counts; --json returns the structured report.
101
+ Exit 0 means no mismatch or unchecked site in the selected scope. It does not establish JSON shape,
102
+ nullability, cardinality, or every runtime branch. No query sites also fails. Independent queries
103
+ continue after an unsupported site, and a recoverable branch can expose a mismatch even when another
104
+ branch is unchecked. Diagnostics include locations, codes, parameter/column identifiers, expected
105
+ and observed types, and variant numbers. `--json` returns the structured report.
108
106
 
109
107
  ## Supported composition
110
108
 
@@ -116,21 +114,22 @@ const query = sql<Summary>`SELECT id, email FROM public.users
116
114
  ```
117
115
 
118
116
  The checker follows inline fragments and const bindings with recoverable initializers, fixed
119
- identifier names, fixed join lists/separators, and ternaries with recoverable branches. It checks
120
- both branches without evaluating the condition. Reusing the same conditional fragment preserves
121
- its branch choice. Separate ternaries using a const condition or an unwritten function parameter also share that choice,
122
- including simple negation. Repeated mutable guards with unresolved correlation are unchecked. Branch
123
- reachability through general application control flow is not inferred.
124
-
125
- Each query has a fixed budget of 16 complete variants, including parameter OID alternatives, and a
126
- 32-reference composition depth limit. The checker does not evaluate arbitrary fragment factories,
127
- walk function bodies to reconstruct returned SQL, execute loops, or accept lists that can be mutated
128
- or escape to arbitrary application code. A runtime fragment, identifier, mutable fragment binding,
129
- or dynamic join becomes unchecked. Unknown source is never replaced with invented SQL.
117
+ identifiers, fixed join lists/separators, and ternaries with recoverable branches. Both branches are
118
+ checked without evaluating the condition.
119
+
120
+ Reusing a conditional fragment preserves its branch choice. Separate ternaries using the same const
121
+ condition or an unwritten function parameter also share that choice, including simple negation.
122
+ Repeated mutable guards with unresolved correlation are unchecked. General control-flow reachability
123
+ is not inferred.
124
+
125
+ Limits are 16 complete variants per query, including parameter OID alternatives, and 32 levels of
126
+ composition references. Arbitrary fragment factories, function-body reconstruction, loops, dynamic
127
+ identifiers, mutable/escaping fragment lists, and runtime joins are unsupported. These can be valid
128
+ runtime SQL, but need database tests. Unknown source is never replaced with invented SQL.
130
129
 
131
130
  ## Object expansion
132
131
 
133
- For checking, insertValues and assignments require an explicit finite shape type:
132
+ For checking, `insertValues` and `assignments` need an explicit finite shape type:
134
133
 
135
134
  ```ts
136
135
  type NewUser = { email: string; active: boolean };
@@ -141,24 +140,40 @@ function createUser(input: NewUser) {
141
140
  }
142
141
  ```
143
142
 
144
- The type is a trusted expansion contract; the checker does not read the object. Explicit column
145
- lists give runtime and checker the same selected keys and order. Without a list, the declared
146
- shape is assumed to describe exactly the expanded fields; TypeScript's structural typing does not
147
- prove the absence of extra own properties or their enumeration order. This assumption is reported
148
- as unproven. Prefer an explicit list when passing application objects.
143
+ The shape is a trusted expansion contract; the checker does not read the object. Prefer an explicit
144
+ column list so runtime and checking select the same keys/order. Without one, the declared shape is
145
+ assumed to describe exactly the expanded fields. Structural typing cannot prove the absence of
146
+ extra own keys or their order, so that assumption is unproven.
149
147
 
150
- Selected fields must be required and exclude undefined. Optional presence changes SQL through
151
- omission or DEFAULT, so it is unchecked in this version. Unselected optional properties are fine.
152
- Index signatures, unresolved generic key sets, unions of shapes, and unknown shapes are unchecked.
153
- Fixed literal batches are supported; unknown batch lengths are unchecked. Runtime helpers can still
154
- be used with inferred shapes and optional fields, but those query sites cannot be checked yet.
148
+ Selected fields must be required and exclude undefined. Optional presence changes the SQL through
149
+ omission or DEFAULT and is unchecked. Unselected optional properties are fine. Index signatures,
150
+ unresolved generic key sets, shape unions, and unknown shapes are unsupported. Fixed literal batches
151
+ are supported; unknown batch lengths are not. Runtime helpers still accept inferred shapes and
152
+ optional fields.
155
153
 
156
154
  ## Programmatic use and custom codecs
157
155
 
158
- The checking API has its own entry point so ordinary execution does not load TypeScript:
156
+ The checking API has a separate entry point, so ordinary queries do not load TypeScript:
159
157
 
160
158
  ```ts
161
159
  import { checkQueries } from '@kequtech/tenon-orm/check';
160
+
161
+ const report = await checkQueries({
162
+ connection,
163
+ project: 'tsconfig.json',
164
+ target: 'src/users.ts',
165
+ });
166
+ if (!report.ok) process.exitCode = 1;
167
+ ```
168
+
169
+ The report has `ok`, `project`, `queries`, `diagnostics`, and available inspection `context`. Options
170
+ also accept `session`, `timeouts`, `codecs`, and `readTypes`. `connection` is a URI or pool-option
171
+ values; its `codecs` are inherited unless the explicit inspection `codecs` argument is supplied.
172
+ The programmatic API does not discover shared configuration.
173
+
174
+ For a custom codec, pass the same registry as runtime queries. A text codec may declare its output:
175
+
176
+ ```ts
162
177
  import { defaultCodecs } from '@kequtech/tenon-orm';
163
178
 
164
179
  const codecs = defaultCodecs.with({
@@ -169,32 +184,19 @@ const codecs = defaultCodecs.with({
169
184
  typescript: 'import("./src/types.js").DecodedInteger',
170
185
  },
171
186
  });
172
- const report = await checkQueries({
173
- connection,
174
- project: 'tsconfig.json',
175
- target: 'src/users.ts:23',
176
- codecs,
177
- });
178
- if (!report.ok) process.exitCode = 1;
187
+ const report = await checkQueries({ connection, project: 'tsconfig.json', codecs });
179
188
  ```
180
189
 
181
- The example assumes `src/types.ts` exports `DecodedInteger = { raw: string }`. Pass the same
182
- registry used by execution. Its known non-NULL read expressions, including declared custom types and
183
- derived arrays, come from `codecs.typescript(oid)` and `codecs.typescriptTypes()`. Tenon compiles them
184
- in the application's TypeScript context without importing or executing application modules.
185
-
186
- Supply `readTypes: { 23: 'import("./src/types.js").DecodedInteger' }` only when that decoder has no
187
- known representation, for example when an existing codec cannot be annotated. A mapping cannot
188
- replace a known registry representation or claim a JSON member shape for default JSON decoding.
189
- Type expressions resolve from the tsconfig directory; referenced application types/dependencies must
190
- be available there. A decoder declaration describes what conversion returns, not a validation pass
191
- performed by the checker.
192
-
193
- The CLI uses default codecs. Custom registry configuration belongs in an application-owned checking
194
- script calling this API. The application chooses that script's configuration; the checker does not
195
- import configuration or an application entry point automatically.
196
-
197
- pgclear owns execution, Describe, value conversion, and codec representation metadata. Tenon owns
198
- schema queries and declaration rendering as well as source discovery, composition recovery,
199
- TypeScript comparison, diagnostics, and file output.
200
- `tenon generate --check` remains a separate generated-file freshness check.
190
+ Here `src/types.ts` must export `DecodedInteger = { raw: string }`. Registry metadata describes
191
+ non-NULL output, including derived arrays. Type expressions compile in the application's project
192
+ without importing/executing its modules; referenced dependencies/types must be available.
193
+
194
+ Use `readTypes: { 23: 'import("./src/types.js").DecodedInteger' }` only for a decoder with no known
195
+ registry representation. It cannot replace known metadata or claim JSON members for default JSON
196
+ decoding. Expressions resolve from the tsconfig directory. A codec declaration is trusted output
197
+ metadata, not data validation.
198
+
199
+ The CLI loads the shared configuration for custom codecs; no checking script is required. An
200
+ application-owned script may still call the programmatic API with explicit options. Generated-file
201
+ freshness remains a separate `tenon generate --check` operation. For behavior tests, see
202
+ [query inspection and testing](query-inspection.md).