@kequtech/tenon-orm 0.1.1 → 0.1.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.
@@ -1,7 +1,7 @@
1
1
  # Tenon agent guide
2
2
 
3
3
  Tenon executes PostgreSQL SQL through `pg`, with parameter binding, transactions, optional write
4
- limits, SQL migrations, and generated table row types. Use ordinary functions for reusable queries.
4
+ limits, SQL migrations, and generated row and mutation input types. Use ordinary functions for reusable queries.
5
5
  This guide contains the setup and contracts needed for routine implementation; the
6
6
  [reference](reference.md) covers additional options.
7
7
 
@@ -88,11 +88,9 @@ is an optional nonempty diagnostic string.
88
88
  as `'DESC'` remain bound values, not SQL syntax.
89
89
  - `sql.fragment` carries SQL and bound values without a row type. Use it for shared predicates or
90
90
  partial SQL. Fragments cannot execute alone; full `sql` and `unsafeSql` queries cannot be nested.
91
- - `sql.join(fragments, separator)` requires a nonempty array of fragments and exactly `'AND'`,
92
- `'OR'`, or `','`. No fragment or arbitrary-string separator is accepted. Empty lists and blank
93
- fragments throw. Choose empty-filter behavior explicitly.
94
- - Joins add no parentheses. Group mixed `AND`/`OR` predicates explicitly and preserve authorization
95
- conditions when adding optional filters.
91
+ - `sql.join(fragments, separator)` combines a variable-length list of fragments using `'AND'`,
92
+ `'OR'`, or `','`. Use it for optional filters, search terms, or lists of SQL expressions. The list
93
+ must be nonempty; group mixed `AND`/`OR` predicates with parentheses.
96
94
  - `unsafeSql<Row>(text, values)` accepts trusted external SQL with an explicit values array. Never
97
95
  concatenate request input into its text. Each query executes one statement.
98
96
 
@@ -106,13 +104,8 @@ const count = sql<{ count: string }>`SELECT count(*) AS count FROM public."User"
106
104
  WHERE ${search}`;
107
105
  ```
108
106
 
109
- `pattern` and `limit` are validated application inputs. To compose existing predicates, use
110
- `sql.join([first, second], 'AND')`. The checker supports literal join arrays, constant fragments,
111
- literal/const-string separators, and [official mutation helpers](reference.md#object-based-mutation-fragments).
112
- It rejects mutable or runtime-dependent SQL structure. Prefer fixed SQL for simple optional filters, e.g.
113
- `(${email}::text IS NULL OR email = ${email})`. If runtime composition is required, retain an
114
- explicit result type, suppress `tenon/check-composition` only on that query with a reason, and test
115
- each branch. Parameter binding alone does not establish result correctness.
107
+ `pattern` and `limit` are validated application inputs. See the
108
+ [composition reference](reference.md#sql-fragments-and-joins) for grouping and list examples.
116
109
 
117
110
  ## Object-based writes
118
111
 
@@ -124,11 +117,14 @@ batches/updates throw; a single default-only insert works. Omit generated column
124
117
  writes explicitly below the query-wide limit of 65,535 parameters. Expressions and queries are
125
118
  rejected as cell values; write arithmetic, `DEFAULT`, and `excluded.column` in the surrounding SQL.
126
119
 
127
- SafeQL reads inline object/array literals directly. Runtime inputs, spreads and data-builder results
128
- need explicit inline selected columns, e.g. `sql.insertValues(rows, ['name', 'score'])`. Checking
129
- uses all selected columns conservatively, without interpreting application JavaScript. It verifies
130
- SQL and result annotations, not input values, constraints, or nonempty runtime input. See the
131
- [mutation contract](reference.md#object-based-mutation-fragments) and [cookbook](mutations.md).
120
+ Use generated `Rows.Insert['table']` and `Rows.Update['table']` for mutation inputs when available;
121
+ narrow editable fields with `Pick`. They account for defaults, nullability, domains, and generated
122
+ columns. Write mappings differ from returned rows: timestamps use `Date | string`, bigint uses
123
+ `string | bigint`, and JSON uses serialized text. See the [input types](reference.md#insert-and-update-inputs).
124
+
125
+ Selected columns choose and order fields, e.g. `sql.insertValues(rows, ['name', 'score'])`. Unlisted
126
+ fields are ignored. See the [mutation contract](reference.md#object-based-mutation-fragments) and
127
+ [cookbook](mutations.md).
132
128
 
133
129
  ## Configure and run SQL checking
134
130
 
@@ -140,10 +136,8 @@ Configure it normally and add both exports from `@kequtech/tenon-orm/safeql`:
140
136
  - `eslintPlugin` goes in ESLint's `plugins`, with `tenon/check-composition` enabled. It reports
141
137
  unsupported forms that the adapter skips. It does not wrap SafeQL's rule.
142
138
 
143
- These tools are optional development tooling; the database runtime does not import them. SafeQL,
144
- ESLint, and TypeScript are optional peers. The application owns the TypeScript parser configuration
145
- and imports SafeQL and typescript-eslint itself. Use a TypeScript version supported by the lint
146
- toolchain. Generated table types are optional and not required by SafeQL.
139
+ These tools run during development and CI. Reuse the application's TypeScript parser settings and
140
+ use a TypeScript version supported by the lint toolchain. Generated table types are optional.
147
141
 
148
142
  Reuse existing configuration and scripts. For a new setup:
149
143
 
@@ -202,6 +196,18 @@ Reuse existing lint scripts or add:
202
196
  SQL and annotation errors come from `@ts-safeql/check-sql`; unsupported composition errors come from
203
197
  `tenon/check-composition`. Keep both rules enabled.
204
198
 
199
+ The checker supports constant fragments and `sql.join` with inline nonempty arrays and literal or
200
+ const-string separators. It does not follow runtime-built lists or fragment-returning functions.
201
+ For tested runtime variants, keep an explicit result type and suppress `tenon/check-composition`
202
+ only on the particular query with a reason.
203
+
204
+ Mutation helpers accept inline object literals for checking. Runtime inputs, spreads, and
205
+ data-builder results need inline selected columns, e.g. `sql.insertValues(rows, ['name', 'score'])`.
206
+ The check uses all selected columns; it does not validate runtime input values or omission decisions.
207
+
208
+ For existing applications, use the [incremental audit workflow](adoption-guide.md#adopt-sql-checking-incrementally)
209
+ to preview fixes and review database types versus application refinements.
210
+
205
211
  1. Run the development `db:migrate` script below against the intended development/test database.
206
212
  2. Run `npm run lint`. Fix SQL mistakes before accepting generated types.
207
213
  3. Run `npm run lint:fix` for missing/stale annotations. It can add
@@ -209,38 +215,14 @@ SQL and annotation errors come from `@ts-safeql/check-sql`; unsupported composit
209
215
  enabled ESLint rules may also fix files.
210
216
  4. Run the application's typecheck and database tests. CI runs `lint` without `--fix`.
211
217
 
212
- For multiple databases, replace the connection object with an array. Each connection has its own
213
- URL, `plugins: [safeqlPlugin]`, and distinct `targets`, for example `{ tag: 'usersSql' }` and
214
- `{ tag: 'billingSql' }`. Import `sql as usersSql` and `sql as billingSql` to select them, even
215
- within one file. Alternatively, scope separate SafeQL configurations with non-overlapping ESLint
216
- `files` patterns and keep the `sql` tag. Runtime connections remain independent: execute each query
217
- with the right database instance. The checker does not trace `db.query` destinations.
218
-
219
- Qualify tables with their schema when names repeat across schemas in a database. SafeQL can infer
220
- the wrong type for an unqualified table despite the connection's `search_path`; explicit schema
221
- names avoid that ambiguity.
222
-
223
- The checker recognizes Tenon imports, aliases, and typed object properties. It resolves constant
224
- fragments and literal/const identifier names without executing application code. Imported
225
- fragments need inspectable source initializers. It recognizes the official mutation helpers
226
- directly; it never inspects function bodies or substitutes helper arguments/defaults. Conditional
227
- fragments, function-generated fragments, and runtime filter lists require narrow explained
228
- suppressions and database tests. Ordinary functions containing complete `sql` templates remain
229
- checkable. Keep reusable authorization policy centralized even when it is test-owned rather than
230
- statically checked. Use literal newlines and bound string values: outer-template JavaScript escapes
231
- are rejected because SafeQL reads raw text while Tenon executes decoded text. Bound values and
232
- constant fragments are decoded consistently.
233
-
234
- Checking verifies SQL and result annotations, not JavaScript input compatibility, authorization, or
235
- JSON contents. PostgreSQL owns parameter inference; the checker adds no casts based on JavaScript
236
- types. `unsafeSql` is unchecked. Never use `any` to hide an unsupported query.
237
-
238
- Default scalar mappings: bigint/numeric are strings; JSON is `unknown`; temporal values are
239
- `Date | number` because infinities are numbers. Generated `PgArray<T>` supports recursive dimensions
240
- and null elements. Numeric arrays contain numbers and can lose precision. Custom pg parsers and
241
- recursive array projections need matching checker overrides and tests, for example
242
- `overrides: { columns: { 'User.tags': 'PgArray<string>' } }` on the SafeQL connection. Import
243
- `PgArray` from the generated definitions when using that annotation.
218
+ For multiple databases, give each connection distinct tag targets or ESLint file scopes. Qualify
219
+ same-named tables with their schemas. See the [connection reference](reference.md#configuration-choices-and-multiple-databases).
220
+
221
+ Checking verifies SQL and result annotations. Input validation, authorization, JSON contents, and
222
+ runtime query variants still need application checks and database tests. Keep shared policy in
223
+ ordinary functions even when its fragments cannot be checked statically. Use literal newlines in
224
+ SQL templates and bind string values. The [reference](reference.md#supported-sql-and-limitations)
225
+ covers supported composition and parser overrides.
244
226
 
245
227
  ## Transactions and writes
246
228
 
@@ -273,7 +255,7 @@ development script in `package.json`:
273
255
  ```json
274
256
  {
275
257
  "scripts": {
276
- "db:migrate": "tenon migrate && tenon generate --out src/generated-schema.ts"
258
+ "db:migrate": "tenon migrate && tenon generate --out src/generated-schema.d.ts"
277
259
  }
278
260
  }
279
261
  ```
@@ -282,7 +264,7 @@ development script in `package.json`:
282
264
  npx tenon new create_users
283
265
  # Fill the generated up/down SQL before migrating.
284
266
  npm run db:migrate
285
- npx tenon generate --out src/generated-schema.ts --check
267
+ npx tenon generate --out src/generated-schema.d.ts --check
286
268
  npx tenon status
287
269
  npx tenon rollback --steps 1
288
270
  ```
@@ -59,12 +59,12 @@ CREATE TABLE public."User" (
59
59
  DROP TABLE public."User";
60
60
  ```
61
61
 
62
- For development, add a script to `package.json` that applies migrations and updates table row types:
62
+ For development, add a script to `package.json` that applies migrations and updates generated types:
63
63
 
64
64
  ```json
65
65
  {
66
66
  "scripts": {
67
- "db:migrate": "tenon migrate && tenon generate --out src/generated-schema.ts"
67
+ "db:migrate": "tenon migrate && tenon generate --out src/generated-schema.d.ts"
68
68
  }
69
69
  }
70
70
  ```
@@ -170,15 +170,23 @@ Explicit `null` stays null. Empty batches and updates throw. Heterogeneous inser
170
170
  aligned, with `DEFAULT` for missing cells.
171
171
 
172
172
  ```ts
173
- await db.query(sql`INSERT INTO users ${sql.insertValues({ email, active: true })}`);
174
- await db.query(sql`UPDATE users SET ${sql.assignments({ email, active })} WHERE id = ${id}`);
173
+ import type { Rows } from './generated-schema.js';
174
+
175
+ const input: Rows.Insert['User'] = { email: 'another@example.test' };
176
+ await db.query(sql`INSERT INTO public."User" ${sql.insertValues(input, ['email'])}`);
177
+
178
+ const patch: Pick<Rows.Update['User'], 'active'> = { active: false };
179
+ await db.query(sql`UPDATE public."User" SET ${sql.assignments(patch, ['active'])}
180
+ WHERE email = ${input.email}`);
175
181
  ```
176
182
 
177
- These helpers bind values only. Keep expressions, authorization, conflict handling and `RETURNING`
178
- in SQL. For SafeQL checking of runtime-built objects or batches, supply literal selected columns,
179
- for example `sql.assignments(patch, ['email', 'active'])`. The checker validates all selected columns
180
- without inspecting the code that built the patch. See the
181
- [mutation contract and checking limits](reference.md#object-based-mutation-fragments).
183
+ Generated insert types distinguish required fields from defaults and nullable fields. Update types
184
+ make writable fields optional; use `Pick` to limit what an action may edit. See the
185
+ [input-type reference](reference.md#insert-and-update-inputs) for write-value mappings.
186
+
187
+ Keep expressions, authorization, conflict handling and `RETURNING` in SQL. Selected column lists
188
+ choose which fields to write; they do not validate the complete object. See the
189
+ [mutation cookbook](mutations.md) for batches and upserts.
182
190
 
183
191
  ## Check your SQL and result types
184
192
 
@@ -188,13 +196,9 @@ There are three different jobs here:
188
196
  - TypeScript checks the application code and the result types you declare.
189
197
  - SafeQL checks the SQL against your database and verifies or generates those types.
190
198
 
191
- SafeQL is optional development tooling run by ESLint. Configure it normally, with Tenon's
192
- `safeqlPlugin` adapter to translate queries, constant fragments, and official mutation helpers. A small
193
- companion ESLint rule, `tenon/check-composition`, flags queries that cannot be checked statically. SafeQL performs the
194
- database analysis and annotation fixes. Your application's ESLint configuration imports these tools;
195
- Tenon's database runtime does not need them. Generated table types are a separate convenience and
196
- are not required by SafeQL. Follow the one-time
197
- [SQL checking setup](reference.md#sql-checking-with-safeql).
199
+ SafeQL is optional development tooling run by ESLint. Tenon's adapter supports queries, constant
200
+ fragments, and mutation helpers; `tenon/check-composition` reports queries it cannot inspect.
201
+ Follow the one-time [SQL checking setup](reference.md#sql-checking-with-safeql).
198
202
 
199
203
  Use the application's existing lint commands. If it has none, the setup shows `lint` and `lint:fix`.
200
204
  They run ESLint, including all other enabled rules. Once configured, the normal workflow is:
@@ -211,6 +215,12 @@ an incorrect declared result type before the query runs.
211
215
  Input validation and database tests still matter. Checking a query does not prove that a request is
212
216
  authorized, that its values are valid, or that returned JSON has an application-specific shape.
213
217
 
218
+ The checker supports constant fragments and `sql.join` with inline arrays. Runtime-built lists and
219
+ fragment-returning functions remain outside static analysis. Keep result annotations and database
220
+ tests for those queries; when using the checker, suppress `tenon/check-composition` only on the
221
+ tested query with a reason. Runtime-built mutation inputs need inline selected columns for SQL
222
+ checking. See the [checking reference](reference.md#supported-sql-and-limitations) for details.
223
+
214
224
  ## Reuse or assemble parts of SQL when needed
215
225
 
216
226
  For fixed conditions, write them directly:
@@ -235,36 +245,26 @@ const count = sql<{ count: string }>`SELECT count(*) AS count FROM public."User"
235
245
  ```
236
246
 
237
247
  Here `pattern` is an application-supplied search pattern and `limit` is a validated page size.
238
- Execute each query with `db.query`. A fragment cannot execute alone; the complete query supplies the
239
- result type. The checker can inspect these constant fragments.
240
-
241
- For a simple optional filter, a fixed SQL predicate often suffices:
248
+ Execute each query with `db.query`. The complete query supplies the result type.
242
249
 
243
- ```ts
244
- // email is string | null; null means no email filter.
245
- const query = sql<UserSummary>`SELECT id, email FROM public."User"
246
- WHERE active = ${true} AND (${email}::text IS NULL OR email = ${email})`;
247
- ```
248
-
249
- If you need a variable list of conditions, `sql.join` combines fragments using `'AND'`, `'OR'`, or
250
- `','`. **SafeQL cannot check runtime-built lists.** Keep an explicit result type and test each
251
- variant; if you use SafeQL, suppress only this query with an explanation. Parameter binding still
252
- works, but it does not prove that every variant is valid SQL or returns the declared columns.
250
+ Use `sql.join` when the number of fragments varies, such as one search condition per word or a set
251
+ of optional filters. It combines fragments with `'AND'`, `'OR'`, or `','` and preserves their bound
252
+ values:
253
253
 
254
254
  ```ts
255
+ const terms = ['reader', 'example'];
255
256
  const filters = [sql.fragment`active = ${true}`];
256
- if (email !== undefined) filters.push(sql.fragment`email = ${email}`);
257
+ for (const term of terms) {
258
+ filters.push(sql.fragment`email ILIKE ${'%' + term + '%'}`);
259
+ }
257
260
 
258
- // eslint-disable-next-line tenon/check-composition -- Runtime filters are covered by database tests.
259
261
  const query = sql<UserSummary>`SELECT id, email FROM public."User"
260
262
  WHERE ${sql.join(filters, 'AND')} ORDER BY id`;
261
263
  ```
262
264
 
263
- The initial condition keeps this list nonempty. Empty lists throw so that missing filters cannot
264
- silently remove a condition. `join` adds separators, not parentheses; wrap groups explicitly when
265
- mixing `AND` and `OR`. Full queries cannot be used as fragments. See the
266
- [composition reference](reference.md#sql-fragments-and-joins) for static checking rules and other
267
- details.
265
+ This matches active users whose email contains every term. With no terms, the active condition
266
+ still applies. The list must be nonempty; `join` inserts separators, so use parentheses as usual
267
+ when mixing `AND` and `OR`. See the [composition reference](reference.md#sql-fragments-and-joins).
268
268
 
269
269
  For arrays, identifiers, type mappings, CLI options, and error behavior, use the
270
270
  [reference](reference.md). You do not need fragments or a query builder to use PostgreSQL joins,
package/docs/mutations.md CHANGED
@@ -19,16 +19,57 @@ The first row uses the score default. The second uses the bio default. Explicit
19
19
  row stays null. Columns are aligned across rows. Omit generated columns and let PostgreSQL enforce
20
20
  required fields and constraints.
21
21
 
22
- For runtime-built rows, supply an inline column list for SafeQL:
22
+ Use a column list to select and order fields in runtime-built rows:
23
23
 
24
24
  ```ts
25
- const rows: { name: string; bio?: string | null; score?: number }[] = buildRows();
25
+ import type { Rows } from './generated-schema.js';
26
+
27
+ const rows: Rows.Insert['profiles'][] = buildRows();
26
28
  await db.query(sql`INSERT INTO profiles ${sql.insertValues(rows, ['name', 'bio', 'score'])}`);
27
29
  ```
28
30
 
29
31
  This list selects and orders fields; unlisted fields are ignored. It does not assert that the
30
32
  entire input object contains only allowed keys. Keep narrower application input validation.
31
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 uses serialized text.
38
+ See the [input-type reference](reference.md#insert-and-update-inputs) for the complete mapping.
39
+
40
+ ## Input types, SQL checking, and runtime validation
41
+
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
+ ```ts
46
+ import { sql, type QueryClient } from '@kequtech/tenon-orm';
47
+ import type { Rows } from './generated-schema.js';
48
+
49
+ type ProfileInput = Pick<Rows.Insert['profiles'], 'name' | 'score'>;
50
+
51
+ function createProfile(client: QueryClient, input: ProfileInput) {
52
+ return client.query(sql<{ id: number }>`INSERT INTO profiles
53
+ ${sql.insertValues(input, ['name', 'score'])} RETURNING id`);
54
+ }
55
+ ```
56
+
57
+ Here `id` and `score` are assumed to be integers. TypeScript rejects an incompatible typed input
58
+ such as `{ name: 'Ada', score: true }`. SafeQL checks selected columns and `RETURNING`; it does not
59
+ inspect runtime values. Validate external inputs before calling this function and keep database
60
+ constraints and application authorization in place.
61
+
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:
64
+
65
+ ```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.
69
+ ```
70
+
71
+ A column list selects fields; it does not prove that all required fields will be written.
72
+
32
73
  ## Patches mixed with SQL expressions
33
74
 
34
75
  ```ts
@@ -56,8 +97,7 @@ if (patch.name !== undefined || patch.bio !== undefined) {
56
97
 
57
98
  ## Preserve an omitted field during an upsert
58
99
 
59
- Use complete query variants when an option changes conflict behavior. Both templates can be
60
- checked independently; the checker need not choose a JavaScript branch:
100
+ Use separate queries when an option changes conflict behavior:
61
101
 
62
102
  ```ts
63
103
  if (email !== undefined) {
@@ -89,8 +129,8 @@ await db.query(
89
129
  ```
90
130
 
91
131
  Objects used as JSON values retain pg's ordinary JSON conversion. The helper does not infer JSON
92
- versus array encoding from the table schema. SafeQL's projection checks do not validate stored JSON
93
- contents or runtime input values.
132
+ versus array encoding from the table schema. Generated JSON input types use serialized text for
133
+ consistent handling of objects and arrays. Validate JSON contents in the application.
94
134
 
95
135
  ## Batches in one transaction
96
136
 
@@ -107,9 +147,6 @@ await db.transaction(async (tx) => {
107
147
 
108
148
  A failure rolls back all batches. The helper rejects an empty batch; this loop makes no insert
109
149
  calls for empty input. The query-wide limit is 65,535 bound parameters, including parameters outside
110
- the mutation fragment. Defaults consume no parameters. Account for your selected column count and
111
- surrounding query when choosing a batch size; Tenon never splits queries automatically.
112
-
113
- Reusable authorization fragments remain valid at runtime. Keep shared policy in application
114
- functions and test it rather than duplicating it for static coverage. Function-generated SQL,
115
- conditional fragments, and dynamic filter lists remain outside static checking.
150
+ the mutation fragment. An oversized query reports its actual count, for example
151
+ `at most 65535 bound parameters; received 65536`. Defaults consume no parameters. Account for the
152
+ selected columns and surrounding query when choosing a batch size; Tenon never splits queries automatically.