@kequtech/tenon-orm 0.4.0 → 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.
@@ -0,0 +1,185 @@
1
+ # Connections and driver access
2
+
3
+ `createTenon` uses a pgclear connection pool. Tenon includes the driver; a separate installation is
4
+ needed only when your application imports native pgclear APIs directly.
5
+
6
+ ## Configure a database instance
7
+
8
+ ```ts
9
+ import { createTenon } from '@kequtech/tenon-orm';
10
+
11
+ const connection = process.env.DATABASE_URL;
12
+ if (!connection) throw new Error('DATABASE_URL is required');
13
+
14
+ export const db = createTenon({
15
+ connection: {
16
+ connectionString: connection,
17
+ maxConnections: 10,
18
+ connectTimeoutMs: 5000,
19
+ acquireTimeoutMs: 5000,
20
+ idleTimeoutMs: 30_000,
21
+ startupParameters: { search_path: 'app,public', TimeZone: 'UTC' },
22
+ },
23
+ });
24
+ ```
25
+
26
+ `connection` accepts a PostgreSQL URI, pgclear `PoolOptions`, or an existing pgclear `Pool`.
27
+ `ConnectionOptions` is Tenon's alias for `PoolOptions`. Object fields override URI settings.
28
+ Reuse an instance for each database configuration.
29
+
30
+ The driver does not load environment variables, `.env`, pgpass, or service files. Tenon CLI commands
31
+ read `DATABASE_URL`; application code passes its configuration explicitly. An empty options object
32
+ uses localhost:5432, the current OS user and a database with that user's name.
33
+
34
+ Use `tls: {}` for TLS with certificate and hostname verification using system trust. Supply the
35
+ provider's CA or other required TLS settings through pgclear's `tls` options. Preserve the
36
+ connection requirements of your deployment. The complete option list is in the installed driver's
37
+ `docs/public-api.md`; unknown options reject.
38
+
39
+ Qualify table names in SQL or set runtime `search_path`. Tooling `namespace` options select the
40
+ schema to migrate or generate; they do not configure your application's queries. The example above
41
+ assumes an `app` schema already exists.
42
+
43
+ ## Close resources
44
+
45
+ For a short-lived script, use `try/finally`:
46
+
47
+ ```ts
48
+ try {
49
+ // Await database work here.
50
+ } finally {
51
+ await db.close();
52
+ }
53
+ ```
54
+
55
+ For a server, stop accepting work and finish requests before closing the database. A close call
56
+ immediately rejects new Tenon work and gives an owned pool a default 5,000 ms grace period. Set
57
+ `db.close({ timeoutMs: 1000 })` to change it; `0` requests immediate shutdown. Remaining driver work
58
+ and checkouts expire at the deadline. This bounds database resources, not arbitrary JavaScript
59
+ callbacks or proof that a remote rollback completed.
60
+
61
+ The first valid close call fixes the timeout. Concurrent and later calls share its promise and
62
+ outcome. Timeout must be an integer from 0 to 2,147,483,647 ms; invalid initial options reject
63
+ without closing the instance.
64
+
65
+ ### Use an application-owned pool
66
+
67
+ When another part of the application already owns a pgclear pool:
68
+
69
+ ```ts
70
+ import { createPool } from '@kequtech/pgclear';
71
+ import { createTenon } from '@kequtech/tenon-orm';
72
+
73
+ const pool = createPool(connection);
74
+ const db = createTenon({ connection: pool });
75
+ // db.native === pool
76
+
77
+ await db.close(); // Disables this wrapper; pool remains open.
78
+ await pool.close(); // The application owns the pool's shutdown.
79
+ ```
80
+
81
+ Tenon borrows the supplied pool by identity. Closing the wrapper does not cancel that pool's
82
+ outstanding work. Tool APIs that accept `Connection` follow the same ownership rule.
83
+
84
+ ## Choose output conversions
85
+
86
+ Outputs follow the pool's codec registry. For example, to read int8 as JavaScript bigint and
87
+ `timestamptz` as Date:
88
+
89
+ ```ts
90
+ import { builtin, createTenon, defaultCodecs } from '@kequtech/tenon-orm';
91
+
92
+ const codecs = defaultCodecs.with(builtin.int8BigInt).with(builtin.timestamptzDate);
93
+ const db = createTenon({ connection: { connectionString: connection, codecs } });
94
+ ```
95
+
96
+ These choices apply across the database instance, including callback transactions. Tenon's query
97
+ options have no per-query codec override. Input encoders remain separate: a `param(value, codec)`
98
+ carries its own encoder, and passing a Date does not select a Date output decoder.
99
+
100
+ Use `db.generateTypes` with this instance to inherit its read mappings. Pass the same registry
101
+ explicitly to `describeQuery` and `checkQueries`; their CLI forms use default codecs. See
102
+ [schema types and values](schema-types.md#custom-codecs) for custom representations and Temporal.
103
+
104
+ ## Observe completed queries
105
+
106
+ ```ts
107
+ const db = createTenon({
108
+ connection,
109
+ onQuery(event) {
110
+ console.log(event.label, event.durationMs, event.outcome, event.rowCount);
111
+ },
112
+ });
113
+ ```
114
+
115
+ Set `{ label: 'user-by-email' }` on a query to identify it. `onQuery` receives duration, outcome,
116
+ and available command/count/error-code metadata, with no SQL text or bound values. Validation
117
+ failures before execution are not observed. Native driver calls and tooling are outside this hook.
118
+
119
+ Observers are not awaited. A thrown error or rejected observer promise emits a warning and leaves
120
+ the query outcome unchanged. A successful query event inside a transaction does not mean the outer
121
+ transaction committed. Guarded-call duration includes its commit or rollback work.
122
+
123
+ ## Use native driver features
124
+
125
+ `db.native` is the actual pgclear pool. Use it for prepared statements, streams, COPY, or held
126
+ sessions. For a finished SQL string:
127
+
128
+ ```ts
129
+ const result = await db.native.queryClassic('SELECT $1::integer AS value', [42]);
130
+ ```
131
+
132
+ Native calls bypass Tenon's result modes, affected-row guards, SQL boundary checks, and `onQuery`.
133
+ They do not join a Tenon callback transaction. A callback's `tx` exposes only `query`.
134
+
135
+ Use the public driver session APIs and release held leases. For example:
136
+
137
+ ```ts
138
+ await db.native.withSession(async (session) => {
139
+ const prepared = await session.prepare('SELECT $1::integer AS value');
140
+ try {
141
+ await prepared.query<{ value: number }>([42]);
142
+ } finally {
143
+ await prepared.close();
144
+ }
145
+ });
146
+ ```
147
+
148
+ Native `query` accepts pgclear's own SQL tag. Tenon's `sql` and `unsafeSql` results belong in
149
+ `db.query`; use `queryClassic` for text to avoid needing a second tag. When both tags are used,
150
+ import the native one as `pgSql`.
151
+
152
+ ### Listen for notifications
153
+
154
+ Import `listen` directly from pgclear for a dedicated connection:
155
+
156
+ ```ts
157
+ import { listen } from '@kequtech/pgclear';
158
+
159
+ const listener = await listen(connection, {
160
+ channels: ['users_changed'],
161
+ onNotification: ({ payload }) => console.log(payload),
162
+ reconnect: true,
163
+ });
164
+ // At shutdown:
165
+ await listener.close();
166
+ ```
167
+
168
+ A listener has a separate lifetime and is not closed by `db.close()`. Notifications are not replayed
169
+ after a disconnection; the application owns its recovery policy.
170
+
171
+ ## Update the driver
172
+
173
+ Tenon depends on `@kequtech/pgclear: ^0.3.0`, which permits compatible 0.3.x patch updates. With npm:
174
+
175
+ ```sh
176
+ npm update @kequtech/pgclear
177
+ npm ls @kequtech/pgclear
178
+ ```
179
+
180
+ Review the resolved version and lockfile, regenerate affected declarations, and run application
181
+ checks before committing. A lockfile keeps its chosen version until updated. Pgclear 0.4 is outside
182
+ this range and requires an explicit Tenon compatibility update.
183
+
184
+ If the application imports pgclear directly, declare it as a dependency with a compatible version
185
+ so the package manager can share a copy. See the [changelog](../CHANGELOG.md) when updating Tenon.
@@ -1,26 +1,28 @@
1
- # Using Tenon: a human guide
1
+ # Getting started
2
2
 
3
- Tenon lets you use PostgreSQL directly from TypeScript. You write SQL; Tenon binds its inputs and
4
- executes it through pgclear. This guide follows a small application from its first table to reusable
5
- queries, transactions, and result checking.
3
+ This guide creates a table, inserts and reads a user, reuses a query in a transaction, and generates
4
+ TypeScript types. You need an existing PostgreSQL database. If your application already has tables
5
+ and a migration runner, keep them and start at [Write and read a row](#write-and-read-a-row).
6
6
 
7
- Use the [reference](reference.md) for exact contracts and [adoption](adoption-guide.md) when
8
- replacing an existing ORM. Code blocks build on the connection and table introduced below;
9
- query-function examples belong in their own module.
7
+ ## Install and connect
10
8
 
11
- ## Connect to your database
12
-
13
- In an existing Node.js 24+ TypeScript project using ESM:
9
+ Use Node.js 24+, PostgreSQL 16+, ESM, and TypeScript 6+ with Node.js development typings. In an
10
+ existing TypeScript project:
14
11
 
15
12
  ```sh
16
13
  npm install @kequtech/tenon-orm
17
14
  ```
18
15
 
19
- Use PostgreSQL 16+ and TypeScript 6+. Keep your existing strict TypeScript configuration and Node
20
- typings. Tenon includes pgclear 0.3.x. Supply `DATABASE_URL` through your application's
21
- environment setup; Tenon does not load `.env` files. Import `sql`, `json`, `param`, and codec helpers
22
- from `@kequtech/tenon-orm` for application queries. See [driver updates](reference.md#driver-updates)
23
- when updating Pgclear independently.
16
+ Keep the project's TypeScript configuration; a typical Node.js application uses `module: "NodeNext"`
17
+ and `strict: true`. Tenon includes its PostgreSQL driver, pgclear.
18
+
19
+ Supply `DATABASE_URL` through your environment setup. For a local example database:
20
+
21
+ ```sh
22
+ export DATABASE_URL=postgresql://postgres:postgres@localhost:5432/your_database
23
+ ```
24
+
25
+ Tenon does not create the database or load `.env` files. Create one shared database module:
24
26
 
25
27
  ```ts
26
28
  // src/db.ts
@@ -31,19 +33,18 @@ if (!connection) throw new Error('DATABASE_URL is required');
31
33
  export const db = createTenon({ connection });
32
34
  ```
33
35
 
34
- Reuse this instance across the application and call `await db.close()` at shutdown. You can also
35
- supply an existing pgclear `Pool`; your application then owns its shutdown.
36
+ Reuse `db` across requests. Call `await db.close()` when a standalone script finishes or a server
37
+ shuts down. See [connections](connections.md) for pool options and application-owned pools.
36
38
 
37
- ## Create a table
39
+ ## Create and apply a migration
38
40
 
39
- If you already have a schema and migration runner, keep them. For a fresh example database, create a
40
- migration:
41
+ In a fresh migrations directory:
41
42
 
42
43
  ```sh
43
44
  npx tenon new create_users
44
45
  ```
45
46
 
46
- Fill the generated files:
47
+ This creates two empty files. Put the table definition in the up file:
47
48
 
48
49
  ```sql
49
50
  -- migrations/0001_create_users.up.sql
@@ -54,91 +55,74 @@ CREATE TABLE public.users (
54
55
  );
55
56
  ```
56
57
 
58
+ Put the reverse operation in the down file:
59
+
57
60
  ```sql
58
61
  -- migrations/0001_create_users.down.sql
59
62
  DROP TABLE public.users;
60
63
  ```
61
64
 
62
- For development, add a script to `package.json` that applies migrations and updates generated types:
63
-
64
- ```json
65
- {
66
- "scripts": {
67
- "db:migrate": "tenon migrate && tenon generate --out src/generated-schema.d.ts"
68
- }
69
- }
70
- ```
65
+ Apply it to the database selected by `DATABASE_URL`:
71
66
 
72
67
  ```sh
73
- npm run db:migrate
68
+ npx tenon migrate
69
+ npx tenon status
74
70
  ```
75
71
 
76
- Commit migrations and generated types together. In production, run only `npx tenon migrate`; deploy
77
- the already-built application. If you do not use generated table types, omit generation from the
78
- development script. For later schema changes, add a new migration instead of editing an applied one.
79
- If a change cannot be reversed, omit its down file. A down migration is not a backup.
80
-
81
- ## Write a query
72
+ For later changes, create a new migration. Applied files are checked against recorded checksums and
73
+ must stay unchanged. If a migration cannot be reversed, remove its down file before applying it.
74
+ See [migrations](migrations.md) for rollback and deployment behavior.
82
75
 
83
- Create a row, then read the fields you need:
76
+ ## Write and read a row
84
77
 
85
78
  ```ts
79
+ // src/example.ts
86
80
  import { sql } from '@kequtech/tenon-orm';
87
81
  import { db } from './db.js';
88
82
 
89
- const email = 'reader@example.test';
90
- await db.query(sql`INSERT INTO public.users (email) VALUES (${email})`);
91
- const user = await db.query(
92
- sql<{ id: number; email: string }>`
93
- SELECT id, email FROM public.users WHERE email = ${email}
94
- `,
95
- { returns: 'oneOrNone' },
96
- );
97
- ```
98
-
99
- `${email}` becomes a bound parameter. Do not put quotes around it: the driver passes the value
100
- separately from the SQL text, even when the value contains SQL.
101
-
102
- The type describes one selected row. `returns: 'oneOrNone'` gives you that row or `undefined`; it
103
- throws if the query returns more than one row. Use `'one'` when absence should also be an error,
104
- `'many'` for an array, or omit `returns` for the native result object with `.rows`, `.rowCount`, and
105
- `.fields`. These options do not change your SQL.
106
-
107
- `rowCount` is a number or null when the command has no count. SQL `count(*)` and bigint/bigserial
108
- column values are exact strings, including values beyond JavaScript's safe integer range. Use
109
- strings, bigint, or pgclear `param(text, builtin.int8)` for int8 parameters. Plain Date and supported
110
- Temporal inputs also work; they do not change the default string outputs. Date inputs encode in UTC.
111
- Opt-in codecs can return Date, bigint, or Temporal values when the application needs them.
112
-
113
- Generated table types can help describe projections without repeating every field:
114
-
115
- ```ts
116
- import type { Database } from './generated-schema.js';
117
- type UserSummary = Pick<Database['users']['row'], 'id' | 'email'>;
83
+ try {
84
+ const email = 'reader@example.test';
85
+ const created = await db.query(
86
+ sql<{ id: number }>`INSERT INTO public.users (email) VALUES (${email}) RETURNING id`,
87
+ { returns: 'one' },
88
+ );
89
+
90
+ const user = await db.query(
91
+ sql<{ id: number; email: string; active: boolean }>`
92
+ SELECT id, email, active FROM public.users WHERE id = ${created.id}
93
+ `,
94
+ { returns: 'oneOrNone' },
95
+ );
96
+ console.log(user);
97
+ } finally {
98
+ await db.close();
99
+ }
118
100
  ```
119
101
 
120
- For frequently used types, define [application aliases](reference.md#application-aliases) such as
121
- `UserRow`, `UserInsert`, and `UserUpdate` in a handwritten module.
102
+ This inserts a new email; rerunning it with the same email will fail the unique constraint. Run it
103
+ through your project's TypeScript runner or compile it with your application. The `.js` imports
104
+ follow NodeNext's convention for compiled TypeScript modules.
122
105
 
123
- You can then write `sql<UserSummary>`. A table type describes the full table; a join or aggregate
124
- needs a type for what that particular query selects. An annotation is a declaration, not proof that
125
- the SQL matches. Without one, result fields are `unknown`. Use the inspection and database-test
126
- workflow below to check the declared contract against the query's behavior.
106
+ The template binds `${email}` separately from the SQL. Do not put quotes around an interpolation.
107
+ `sql<{ id: number }>` describes one returned row; `RETURNING id` asks PostgreSQL to return it.
108
+ `returns: 'one'` requires exactly one row, while `oneOrNone` permits absence as `undefined`.
109
+ Use `many` for an array or omit `returns` for the driver result with `.rows` and `.rowCount`.
127
110
 
128
- Keep storage types conservative and validate them when projecting domain values. The
129
- [persistence-to-domain example](reference.md#persistence-and-domain-values) shows validated JSON
130
- writes and reads, temporal strings, and an SQL predicate that rejects infinite session expiry.
111
+ The annotation does not check SQL or validate the row at runtime. Without an annotation, field values
112
+ are `unknown`. Bigint columns and SQL `count(*)` return strings by default; the integer ID in this
113
+ example returns a number. See [value mappings](schema-types.md#value-mappings) for JSON, arrays,
114
+ decimals, and dates.
131
115
 
132
- ## Reuse a query with a function
116
+ ## Reuse a query
133
117
 
134
- Use an ordinary function when several callers need the same query. An object input makes the
135
- supplied values easy to read:
118
+ Put reusable queries in ordinary functions. Accept `QueryClient` when a caller may supply either
119
+ `db` or a transaction's `tx`:
136
120
 
137
121
  ```ts
122
+ // src/queries.ts
138
123
  import { sql, type QueryClient } from '@kequtech/tenon-orm';
139
- import type { Database } from './generated-schema.js';
140
124
 
141
- type UserSummary = Pick<Database['users']['row'], 'id' | 'email'>;
125
+ type UserSummary = { id: number; email: string };
142
126
 
143
127
  export function userByEmail(client: QueryClient, { email }: { email: string }) {
144
128
  return client.query(sql<UserSummary>`SELECT id, email FROM public.users WHERE email = ${email}`, {
@@ -147,12 +131,15 @@ export function userByEmail(client: QueryClient, { email }: { email: string }) {
147
131
  }
148
132
  ```
149
133
 
150
- Call `userByEmail(db, { email: 'reader@example.test' })` at the call site, or pass a transaction's
151
- `tx`. Keep the SQL and execution options together in the function.
134
+ Call `userByEmail(db, { email: 'reader@example.test' })`. Keep the SQL and result options together in
135
+ the function. Joins and aggregates work the same way; see [writing queries](sql.md).
152
136
 
153
- ## Change data in a transaction
137
+ ## Use a transaction and a write guard
138
+
139
+ In code using the shared `db`:
154
140
 
155
141
  ```ts
142
+ import { sql } from '@kequtech/tenon-orm';
156
143
  import { userByEmail } from './queries.js';
157
144
 
158
145
  await db.transaction(async (tx) => {
@@ -165,117 +152,86 @@ await db.transaction(async (tx) => {
165
152
  });
166
153
  ```
167
154
 
168
- Use `tx` for all work in the callback and await it. Tenon commits when the callback succeeds and
169
- rolls back when an error escapes.
155
+ Run all participating queries through `tx` and await them. The callback commits on success and rolls
156
+ back when an error escapes.
170
157
 
171
- `affectedRows: { exact: 1 }` requires exactly one affected row. `{ max: 1 }` permits zero or one.
172
- Tenon rolls back a write that violates the bound. You can also use these guards on `db.query`
173
- outside a callback transaction. The guard checks a count; you still need the correct `WHERE` clause
174
- and application authorization. Checking returned rows with `returns` alone does not undo an
175
- autocommitted write.
158
+ The guard requires exactly one affected row and rolls back the update if the count differs. Use
159
+ `{ max: 1 }` to allow zero or one. Guards also work with `db.query` outside a callback. Result modes
160
+ such as `one` check returned rows; by themselves they do not undo an autocommitted write.
176
161
 
177
- ## Write objects without building column lists by hand
162
+ You still author the `WHERE` clause and application authorization. The
163
+ [transaction reference](reference.md#transactions-guarded-writes-and-errors) covers caught errors,
164
+ savepoints, and retry limits. For object-based inserts and patches, see [writing data](mutations.md).
178
165
 
179
- Use `sql.insertValues(rowOrRows)` inside an authored `INSERT` and `sql.assignments(patch)` after
180
- `SET`. Inserts use database defaults for missing/undefined fields; updates omit those fields.
181
- Explicit `null` stays null. Empty batches and updates throw. Heterogeneous insert rows remain
182
- aligned, with `DEFAULT` for missing cells.
166
+ ## Generate types
183
167
 
184
- ```ts
185
- import type { Database } from './generated-schema.js';
168
+ Generation is optional. It reads the migrated database and writes table read/insert/update types.
169
+ Run it through the configured instance so the types use the same value conversions as your queries:
186
170
 
187
- const input: Database['users']['insert'] = { email: 'another@example.test' };
188
- await db.query(sql`INSERT INTO public.users ${sql.insertValues(input, ['email'])}`);
189
-
190
- const patch: Pick<Database['users']['update'], 'active'> = { active: false };
191
- await db.query(sql`UPDATE public.users SET ${sql.assignments(patch, ['active'])}
192
- WHERE email = ${input.email}`);
171
+ ```ts
172
+ // scripts/generate-types.ts
173
+ import { parseArgs } from 'node:util';
174
+ import { db } from '../src/db.ts';
175
+
176
+ const { values } = parseArgs({ options: { check: { type: 'boolean', default: false } } });
177
+ try {
178
+ await db.generateTypes({ out: 'src/generated-schema.d.ts', check: values.check });
179
+ } finally {
180
+ await db.close();
181
+ }
193
182
  ```
194
183
 
195
- Use `Pick` to limit editable fields. Keep expressions, predicates, conflict handling, and
196
- `RETURNING` in SQL. Selected columns choose fields; they do not validate the complete object. See
197
- [input types](reference.md#insert-and-update-inputs) for mappings and the
198
- [mutation cookbook](mutations.md) for defaults, batches, and upserts.
184
+ This script runs directly on Node.js 24+ using its TypeScript support. For this execution path,
185
+ `src/db.ts` must be runnable without build-time transformations or extension rewriting. If your
186
+ project compiles scripts, use its normal runner and import conventions instead. To typecheck `.ts`
187
+ imports with `noEmit`, enable `allowImportingTsExtensions`; emitting projects can use
188
+ `rewriteRelativeImportExtensions`.
199
189
 
200
- ## Check your SQL and result types
190
+ Add these scripts to your existing `package.json`:
201
191
 
202
- The three database tools answer different questions: `tenon check` compares authored queries with
203
- their TypeScript contracts, `tenon describe` reports metadata for finished SQL, and
204
- `tenon generate --check` checks whether saved database declarations are current. See
205
- [validation tools](reference.md#choosing-a-validation-tool) for their limits.
206
-
207
- After migrating a development/test database, run the optional source-aware checker:
208
-
209
- ```sh
210
- npx tenon check src/queries.ts
211
- npx tenon check src/queries.ts:23
192
+ ```json
193
+ {
194
+ "scripts": {
195
+ "db:generate": "node scripts/generate-types.ts",
196
+ "db:check-types": "npm run db:generate -- --check",
197
+ "db:migrate": "tenon migrate && npm run db:generate"
198
+ }
199
+ }
212
200
  ```
213
201
 
214
- A line selects one complete query. Omit the file to check the TypeScript project. The checker reads
215
- source and types without importing the application or executing SQL. It reports definite
216
- contradictions and unsupported composition; nullability, JSON members, and other inconclusive
217
- contracts receive the benefit of the declared type and remain unproven. See
218
- [query checking](query-checking.md) for supported fragments and explicit object expansion shapes.
219
-
220
- Use describeQuery on finished SQL, or tenon describe query.sql, when you only need metadata and a
221
- conservative row declaration. Match runtime role/session settings. Retain TypeScript and database
222
- tests for actual rows, effects, permissions, and rollback. The checker does not replace those tests.
223
- Generated patch types contain optional fields and can therefore be unchecked even when execution is
224
- valid. Keep optional patches when needed; see [responding to findings](query-checking.md#responding-to-findings).
225
- For rejected values, result-count errors, and other common failures, see
226
- [troubleshooting](reference.md#troubleshooting).
227
-
228
- ## Reuse or assemble parts of SQL when needed
229
-
230
- A fragment is a piece of SQL that carries its bound values with it. It is useful when multiple
231
- queries share a condition. For example, a search can use the same predicate for its result page and
232
- its count:
202
+ Run `npm run db:generate` now. The output exports a `Database` type:
233
203
 
234
204
  ```ts
235
- const pattern = '%example%';
236
- const limit = 20;
237
- const search = sql.fragment`active = ${true} AND email ILIKE ${pattern}`;
238
- const page = sql<UserSummary>`SELECT id, email FROM public.users
239
- WHERE ${search} ORDER BY id LIMIT ${limit}`;
240
- const count = sql<{ count: string }>`SELECT count(*) AS count FROM public.users
241
- WHERE ${search}`;
242
- ```
205
+ import type { Database } from './generated-schema.js';
243
206
 
244
- Execute each query with `db.query`. Validate application-supplied patterns and page sizes before
245
- building queries. The complete query supplies the result type.
246
- The SQL aggregate's `count` is a string; the native result's `rowCount` counts returned rows.
207
+ type UserRow = Database['users']['row'];
208
+ type UserSummary = Pick<UserRow, 'id' | 'email'>;
209
+ type UserInsert = Database['users']['insert'];
210
+ type UserUpdate = Database['users']['update'];
211
+ ```
247
212
 
248
- Use `sql.join` when the number of fragments varies, such as one search condition per word or a set
249
- of optional filters. It combines fragments with `'AND'`, `'OR'`, or `','` and preserves their bound
250
- values:
213
+ Use `UserSummary` with `sql<UserSummary>` or `UserInsert` to type an insert input. These types
214
+ follow table columns, defaults, nullability, and codec representations. They do not validate
215
+ application inputs. Keep aliases in handwritten files and leave generated output unchanged.
216
+ See [schema types](schema-types.md) for an output example and mapping details.
251
217
 
252
- ```ts
253
- const terms = ['reader', 'example'];
254
- const filters = [sql.fragment`active = ${true}`];
255
- for (const term of terms) {
256
- filters.push(sql.fragment`email ILIKE ${'%' + term + '%'}`);
257
- }
218
+ ## Keep schema and code in step
258
219
 
259
- const query = sql<UserSummary>`SELECT id, email FROM public.users
260
- WHERE ${sql.join(filters, 'AND')} ORDER BY id`;
261
- ```
220
+ During development, run `npm run db:migrate`, review the generated diff, and commit migrations and
221
+ generated declarations together. Exclude the generated file from automatic formatting.
262
222
 
263
- This matches active users whose email contains every term. With no terms, the active condition still
264
- applies. For an entirely optional set of filters, omit the whole `WHERE` clause when the list is
265
- empty:
223
+ In CI, migrate a dedicated test database and check committed output without rewriting it:
266
224
 
267
- ```ts
268
- const optionalFilters = terms.map((term) => sql.fragment`email ILIKE ${'%' + term + '%'}`);
269
- const unfilteredOrMatching = optionalFilters.length
270
- ? sql<UserSummary>`SELECT id, email FROM public.users WHERE ${sql.join(optionalFilters, 'AND')} ORDER BY id`
271
- : sql<UserSummary>`SELECT id, email FROM public.users ORDER BY id`;
225
+ ```sh
226
+ npx tenon migrate
227
+ npm run db:check-types
228
+ npx tsc --noEmit
272
229
  ```
273
230
 
274
- `sql.join` and fragments require nonempty input. Keep mandatory authorization predicates in the
275
- initial list when applicable; an empty optional-filter list must not remove them. Use parentheses
276
- when mixing `AND` and `OR`. See the [composition reference](reference.md#sql-fragments-and-joins).
231
+ Do not run the regenerating `db:migrate` script before the freshness check: it would replace the file
232
+ you meant to verify. Production runs migrations and deploys the already-built application.
277
233
 
278
- Use fixed SQL when composition is unnecessary. See the [reference](reference.md) for arrays,
279
- identifiers, value mappings, CLI options, and error behavior.
280
- Runtime loops and dynamic fragment lists work during execution but remain unchecked by `tenon check`;
281
- use database tests for those queries.
234
+ Optionally run `npx tenon check src/queries.ts` against the migrated database to compare source query
235
+ types with PostgreSQL metadata. Keep database tests for rows, permissions, and rollback. See
236
+ [choosing a validation tool](reference.md#choosing-a-validation-tool) and
237
+ [troubleshooting](reference.md#troubleshooting) as needed.