@kequtech/tenon-orm 0.2.1 → 0.3.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 (55) hide show
  1. package/CHANGELOG.md +52 -36
  2. package/README.md +27 -70
  3. package/dist/affected-rows.js +6 -6
  4. package/dist/baseline.js +4 -4
  5. package/dist/client.js +1 -1
  6. package/dist/connection.d.ts +15 -16
  7. package/dist/connection.js +124 -14
  8. package/dist/database.js +36 -17
  9. package/dist/driver/types.d.ts +1 -1
  10. package/dist/driver/types.js +11 -11
  11. package/dist/errors.d.ts +8 -0
  12. package/dist/errors.js +15 -0
  13. package/dist/generate.js +7 -7
  14. package/dist/generation/catalog.d.ts +2 -2
  15. package/dist/generation/catalog.js +4 -4
  16. package/dist/generation/schema-summary.d.ts +3 -3
  17. package/dist/generation/schema-summary.js +11 -11
  18. package/dist/generation/table-types.js +1 -1
  19. package/dist/index.d.ts +2 -3
  20. package/dist/index.js +1 -1
  21. package/dist/inspection/catalog.d.ts +4 -3
  22. package/dist/inspection/catalog.js +13 -10
  23. package/dist/inspection/cli.js +3 -3
  24. package/dist/inspection/connection.d.ts +2 -2
  25. package/dist/inspection/connection.js +22 -22
  26. package/dist/inspection/options.d.ts +3 -3
  27. package/dist/inspection/options.js +9 -1
  28. package/dist/inspection/protocol.d.ts +7 -0
  29. package/dist/inspection/protocol.js +45 -0
  30. package/dist/inspection/result.d.ts +1 -1
  31. package/dist/inspection/result.js +6 -6
  32. package/dist/inspection/types.d.ts +10 -10
  33. package/dist/migrations/connection.d.ts +3 -2
  34. package/dist/migrations/connection.js +18 -20
  35. package/dist/migrations/history.d.ts +4 -4
  36. package/dist/migrations/history.js +5 -5
  37. package/dist/migrations.js +7 -7
  38. package/dist/query-options.js +5 -5
  39. package/dist/query.js +2 -2
  40. package/dist/transaction.d.ts +2 -2
  41. package/dist/transaction.js +19 -14
  42. package/dist/types.d.ts +7 -3
  43. package/docs/adoption-guide.md +109 -122
  44. package/docs/agent-guide.md +124 -228
  45. package/docs/human-guide.md +44 -55
  46. package/docs/mutations.md +58 -76
  47. package/docs/query-inspection.md +97 -89
  48. package/docs/reference.md +264 -319
  49. package/package.json +4 -4
  50. package/dist/driver/array.d.ts +0 -5
  51. package/dist/driver/array.js +0 -70
  52. package/dist/driver/lifecycle.d.ts +0 -15
  53. package/dist/driver/lifecycle.js +0 -57
  54. package/dist/driver/session.d.ts +0 -8
  55. package/dist/driver/session.js +0 -92
package/docs/mutations.md CHANGED
@@ -1,8 +1,18 @@
1
1
  # Mutation cookbook
2
2
 
3
- Keep SQL structure visible and let the official helpers handle object fields. See the
4
- [reference](reference.md#object-based-mutation-fragments) for the complete contract. Examples use
5
- application-owned inputs and clients; add the authorization predicates required by your product.
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:
6
+
7
+ ```sql
8
+ CREATE TABLE profiles (
9
+ id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
10
+ name text NOT NULL UNIQUE,
11
+ bio text DEFAULT 'unwritten',
12
+ score integer NOT NULL DEFAULT 0,
13
+ revision integer NOT NULL DEFAULT 0
14
+ );
15
+ ```
6
16
 
7
17
  ## Defaults, undefined, and null in batches
8
18
 
@@ -12,36 +22,16 @@ await db.query(
12
22
  { name: 'Ada', bio: null },
13
23
  { name: 'Lin', score: 4, bio: undefined },
14
24
  ])} RETURNING id`,
25
+ { returns: 'many' },
15
26
  );
16
27
  ```
17
28
 
18
- The first row uses the score default. The second uses the bio default. Explicit null in the first
19
- row stays null. Columns are aligned across rows. Omit generated columns and let PostgreSQL enforce
20
- required fields and constraints.
21
-
22
- Use a column list to select and order fields in runtime-built rows:
23
-
24
- ```ts
25
- import type { Rows } from './generated-schema.js';
26
-
27
- const rows: Rows.Insert['profiles'][] = buildRows();
28
- await db.query(sql`INSERT INTO profiles ${sql.insertValues(rows, ['name', 'bio', 'score'])}`);
29
- ```
30
-
31
- This list selects and orders fields; unlisted fields are ignored. It does not assert that the
32
- entire input object contains only allowed keys. Keep narrower application input validation.
33
-
34
- `tenon generate` also provides `Rows.Update['profiles']`. Use `Pick` to limit a patch to fields
35
- the caller may edit, for example `Pick<Rows.Update['profiles'], 'name' | 'bio'>`. Generated inputs
36
- understand defaults, nullability, domains, and generated columns. They use write representations:
37
- timestamps accept `Date | string`, bigint accepts `string | bigint`, and JSON accepts native JavaScript values through Tenon's exported `JSONValue` type.
38
- See the [input-type reference](reference.md#insert-and-update-inputs) for the complete mapping.
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.
39
32
 
40
33
  ## Input types and runtime validation
41
34
 
42
- Apply input types with an annotation, `satisfies`, or a helper generic argument. Generating the
43
- file alone does not check every write. Narrow the fields each operation accepts:
44
-
45
35
  ```ts
46
36
  import { sql, type QueryClient } from '@kequtech/tenon-orm';
47
37
  import type { Rows } from './generated-schema.js';
@@ -49,46 +39,44 @@ import type { Rows } from './generated-schema.js';
49
39
  type ProfileInput = Pick<Rows.Insert['profiles'], 'name' | 'score'>;
50
40
 
51
41
  function createProfile(client: QueryClient, input: ProfileInput) {
52
- return client.query(sql<{ id: number }>`INSERT INTO profiles
53
- ${sql.insertValues(input, ['name', 'score'])} RETURNING id`);
42
+ return client.query(
43
+ sql<{ id: number }>`INSERT INTO profiles
44
+ ${sql.insertValues(input, ['name', 'score'])} RETURNING id`,
45
+ { returns: 'one' },
46
+ );
54
47
  }
55
48
  ```
56
49
 
57
- Here `id` and `score` are assumed to be integers. TypeScript rejects an incompatible typed input
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.
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.
61
53
 
62
- **Selected columns can discard required fields.** For a table requiring integer `id` and text
63
- `name` and `location`, this input is valid but the query fails:
54
+ Selected columns choose and order fields, ignoring unlisted keys. They can discard a required field
55
+ even when the original object is valid:
64
56
 
65
57
  ```ts
66
- const row: Rows.Insert['profiles'] = { id: 1, name: 'Ada', location: 'London' };
67
- const query = sql`INSERT INTO profiles ${sql.insertValues(row, ['id', 'name'])}`;
68
- // PostgreSQL rejects the insert: location was dropped and has no default.
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.
69
61
  ```
70
62
 
71
- A column list selects fields; it does not prove that all required fields will be written.
72
-
73
- ## Patches mixed with SQL expressions
63
+ ## Patches mixed with expressions
74
64
 
75
65
  ```ts
76
- await db.query(sql`UPDATE profiles
77
- SET ${sql.assignments({ name, bio })}, revision = revision + 1, score = DEFAULT
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
78
68
  WHERE id = ${id}`);
79
69
  ```
80
70
 
81
- Undefined fields are omitted; null clears a field. Expressions, `DEFAULT`, and references to
82
- `excluded.column` belong in SQL, not in mutation objects.
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.
83
73
 
84
- An empty patch throws even if the surrounding statement has other assignments. Choose an
85
- expression-only query explicitly when that is the intended behavior:
74
+ An empty patch fails even if the surrounding SQL has other assignments. Branch when an
75
+ expression-only update is intended:
86
76
 
87
77
  ```ts
88
- const patch: { name?: string; bio?: string | null } = readPatch();
89
78
  if (patch.name !== undefined || patch.bio !== undefined) {
90
- await db.query(sql`UPDATE profiles
91
- SET ${sql.assignments(patch, ['name', 'bio'])}, revision = revision + 1
79
+ await db.query(sql`UPDATE profiles SET ${sql.assignments(patch)}, revision = revision + 1
92
80
  WHERE id = ${id}`);
93
81
  } else {
94
82
  await db.query(sql`UPDATE profiles SET revision = revision + 1 WHERE id = ${id}`);
@@ -97,44 +85,40 @@ if (patch.name !== undefined || patch.bio !== undefined) {
97
85
 
98
86
  ## Preserve an omitted field during an upsert
99
87
 
100
- Use separate queries when an option changes conflict behavior:
88
+ Given validated `name`, `score`, and optional `bio`, choose conflict behavior explicitly:
101
89
 
102
90
  ```ts
103
- if (email !== undefined) {
104
- await db.query(sql`INSERT INTO preferences ${sql.insertValues({ profile_id, in_app, email })}
105
- ON CONFLICT (profile_id) DO UPDATE
106
- SET in_app = excluded.in_app, email = excluded.email`);
91
+ if (bio !== undefined) {
92
+ await db.query(sql`INSERT INTO profiles ${sql.insertValues({ name, score, bio })}
93
+ ON CONFLICT (name) DO UPDATE SET score = excluded.score, bio = excluded.bio`);
107
94
  } else {
108
- await db.query(sql`INSERT INTO preferences ${sql.insertValues({ profile_id, in_app })}
109
- ON CONFLICT (profile_id) DO UPDATE SET in_app = excluded.in_app`);
95
+ await db.query(sql`INSERT INTO profiles ${sql.insertValues({ name, score })}
96
+ ON CONFLICT (name) DO UPDATE SET score = excluded.score`);
110
97
  }
111
98
  ```
112
99
 
113
- The second query leaves an existing email value unchanged. Missing an insert field uses its
114
- database default; that alone does not decide what an upsert should overwrite.
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.
115
102
 
116
- ## JSONB arrays and PostgreSQL arrays
103
+ ## JSON and PostgreSQL arrays
117
104
 
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:
105
+ For a table with `payload jsonb` and `tags text[]`, encode JSON separately from the PostgreSQL
106
+ array:
120
107
 
121
108
  ```ts
122
- const handles = ['ada', 'lin'];
109
+ const tags = ['ada', 'lin'];
123
110
  await db.query(
124
- sql`INSERT INTO deliveries ${sql.insertValues({
125
- recipients_json: handles, // jsonb column
126
- recipient_handles: handles, // text[] column
127
- })}`,
111
+ sql`INSERT INTO deliveries ${sql.insertValues({ payload: JSON.stringify(tags), tags })}`,
128
112
  );
129
113
  ```
130
114
 
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.
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.
134
118
 
135
119
  ## Batches in one transaction
136
120
 
137
- Choose the batch size in the caller and keep all writes on the transaction client:
121
+ For an array `rows` of generated profile inputs:
138
122
 
139
123
  ```ts
140
124
  await db.transaction(async (tx) => {
@@ -145,8 +129,6 @@ await db.transaction(async (tx) => {
145
129
  });
146
130
  ```
147
131
 
148
- A failure rolls back all batches. The helper rejects an empty batch; this loop makes no insert
149
- calls for empty input. Each query accepts at most 65,533 bound parameters, including parameters outside
150
- the mutation fragment. An oversized query reports its actual count, for example
151
- `at most 65533 bound parameters; received 65534`. Defaults consume no parameters. Account for the
152
- selected columns and surrounding query when choosing a batch size; Tenon never splits queries automatically.
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.
@@ -1,12 +1,10 @@
1
1
  # Inspect and test queries
2
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.
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.
8
6
 
9
- ## Inspect the query you will execute
7
+ ## Inspect completed SQL
10
8
 
11
9
  ```ts
12
10
  import { describeQuery, sql } from '@kequtech/tenon-orm';
@@ -18,111 +16,121 @@ const query = sql<{ id: number; total: string }>`
18
16
  SELECT ${42}::integer AS id, count(*) AS total FROM pg_catalog.pg_class
19
17
  `;
20
18
  const description = await describeQuery({ connection, query });
21
- // description.columns reports number/string read types; nullability remains unknown.
22
19
  ```
23
20
 
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.
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.
26
25
 
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
26
+ ## Session and timeouts
37
27
 
38
28
  ```ts
39
29
  const description = await describeQuery({
40
30
  connection,
41
31
  query,
42
- session: { searchPath: 'mail,public', timeZone: 'UTC' },
32
+ session: { searchPath: 'app,public', timeZone: 'UTC' },
43
33
  timeouts: { connectMs: 3000, statementMs: 5000, lockMs: 1000 },
44
34
  });
45
35
  ```
46
36
 
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.
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.
40
+
41
+ `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 |
75
+
76
+ ## Test the same statement
77
+
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:
81
+
82
+ ```ts
83
+ import assert from 'node:assert/strict';
84
+
85
+ function usersQuery(activeOnly: boolean) {
86
+ const filters = [sql.fragment`email LIKE ${'%@example.test'}`];
87
+ if (activeOnly) filters.push(sql.fragment`active = ${true}`);
88
+ return sql<{ id: number; email: string }>`SELECT id, email FROM public.users
89
+ WHERE ${sql.join(filters, 'AND')} ORDER BY id`;
90
+ }
91
+
92
+ const ada = { id: 1, email: 'ada@example.test' };
93
+ const lin = { id: 2, email: 'lin@example.test' };
94
+ for (const activeOnly of [false, true]) {
95
+ const query = usersQuery(activeOnly);
96
+ const description = await describeQuery({ connection, query });
97
+ assert.deepEqual(
98
+ description.columns.map(({ name, readType }) => [name, readType]),
99
+ [
100
+ ['id', 'number'],
101
+ ['email', 'string'],
102
+ ],
103
+ );
104
+ assert.deepEqual(
105
+ description.issues.map(({ code }) => code),
106
+ ['nullability-unknown'],
107
+ );
108
+ assert.deepEqual(await db.query(query, { returns: 'many' }), activeOnly ? [ada] : [ada, lin]);
109
+ }
110
+ ```
111
+
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.
89
117
 
90
118
  ## CLI
91
119
 
92
- Save the statement in `query.sql`, using `$1`, `$2`, and so on for parameters:
120
+ Save SQL with PostgreSQL placeholders in `query.sql`:
93
121
 
94
122
  ```sql
95
123
  SELECT $1::integer AS id, count(*) AS total FROM pg_catalog.pg_class;
96
124
  ```
97
125
 
98
- Supply `DATABASE_URL` through the application's normal environment setup:
99
-
100
126
  ```sh
101
127
  npx tenon describe query.sql
102
- npx tenon describe query.sql --search-path mail,public --time-zone UTC
128
+ npx tenon describe query.sql --search-path app,public --time-zone UTC
103
129
  ```
104
130
 
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.
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.
125
134
 
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.
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.