@kequtech/tenon-orm 0.1.1 → 0.1.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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  A PostgreSQL ORM designed for coding agents. Built for Node.js and TypeScript.
4
4
 
5
5
  Your coding agent already knows SQL. Tenon gives it a small API for parameterized queries,
6
- transactions, and migrations, with optional tooling to check SQL and generate result types. Queries
6
+ transactions, and migrations, with optional tooling to check SQL and generate database types. Queries
7
7
  stay readable, reusable operations are ordinary functions, and humans can review exactly what will
8
8
  run against the database.
9
9
 
@@ -47,7 +47,7 @@ Tenon also provides:
47
47
 
48
48
  - Transactions and affected-row limits that roll back rejected writes.
49
49
  - SQL migration files and a CLI to apply, inspect, and roll back migrations.
50
- - TypeScript table row types generated from your database.
50
+ - TypeScript row, insert, and update types generated from your database.
51
51
  - SQL fragments for sharing query parts or assembling variable conditions.
52
52
 
53
53
  Joins, relationships, aggregates, and upserts use ordinary PostgreSQL SQL. Tenon does not introduce
@@ -69,6 +69,8 @@ migrations and table type generation are independent conveniences.
69
69
  For object-based writes, `sql.insertValues(rows)` preserves database defaults across heterogeneous
70
70
  rows and `sql.assignments(patch)` omits undefined fields. Keep the surrounding SQL explicit. Both
71
71
  support selected columns and [SafeQL checking](docs/reference.md#object-based-mutation-fragments).
72
+ Use [generated input types](docs/reference.md#insert-and-update-inputs) to check those objects
73
+ against your schema.
72
74
 
73
75
  ## Checking query results
74
76
 
@@ -79,12 +81,10 @@ For that, Tenon provides an optional integration with [SafeQL](https://safeql.de
79
81
  SQL against your development database and can write or repair the result annotation. ESLint runs the
80
82
  check during development and CI; it is not part of query execution.
81
83
 
82
- Configure SafeQL in your application's ESLint setup. Tenon supplies a `safeqlPlugin` adapter for its
83
- queries, constant fragments, and official mutation helpers, plus an `eslintPlugin` rule that flags
84
- queries the adapter cannot check. SafeQL handles database analysis and result annotations; Tenon keeps the SQL and pg type
85
- mappings consistent with execution. Both integrations are optional development tooling. See
86
- [SQL checking setup](docs/reference.md#sql-checking-with-safeql) for installation and a complete
87
- configuration, including multiple databases.
84
+ Configure SafeQL in your application's ESLint setup with Tenon's `safeqlPlugin` adapter and
85
+ `eslintPlugin` companion rule. The adapter supports queries, constant fragments, and mutation
86
+ helpers; the rule reports queries it cannot inspect. See the
87
+ [SQL checking setup](docs/reference.md#sql-checking-with-safeql).
88
88
 
89
89
  Checking complements typed inputs and database tests. It does not validate request values or
90
90
  arbitrary JSON, and runtime-built query variants need their own tests.
@@ -97,7 +97,9 @@ arbitrary JSON, and runtime-built query variants need their own tests.
97
97
  - **[Reference](docs/reference.md)** — complete options, SQL checking setup, type mappings, and
98
98
  limitations.
99
99
  - **[Adoption guide](docs/adoption-guide.md)** — replace an existing ORM and, if needed, transfer
100
- migration ownership.
100
+ migration ownership; upgrade local write helpers and adopt SQL checking incrementally.
101
+ - **[Mutation cookbook](docs/mutations.md)** — defaults, patches, upserts, arrays, batching, and
102
+ the boundaries between input typing and SQL checking.
101
103
 
102
104
  The complete `docs/` folder ships with the npm package. Maintainer instructions are in
103
105
  [CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md), which is not
package/dist/generate.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readTables } from './generation/catalog.js';
2
- import { renderRowTypes } from './generation/row-types.js';
2
+ import { renderTableTypes } from './generation/table-types.js';
3
3
  import { domainSummaries, schemaSummary, tableSummary } from './generation/schema-summary.js';
4
4
  import { withMigrationLock } from './migrations/connection.js';
5
5
  import { quote } from './sql.js';
@@ -24,7 +24,7 @@ export async function generateDefinitions(options) {
24
24
  summaries.push(await tableSummary(client, table.oid, table.name));
25
25
  }
26
26
  const domains = await domainSummaries(client, definitions.flatMap((table) => table.columns.map((column) => column.declaredOid)));
27
- const types = renderRowTypes(definitions, namespace, onWarning);
27
+ const types = renderTableTypes(definitions, namespace, onWarning);
28
28
  await client.query('COMMIT');
29
29
  return `// Generated by Tenon from the PostgreSQL schema. Do not edit.
30
30
  // Commit this file and regenerate it after changing the schema.
@@ -9,6 +9,9 @@ export interface ColumnRow {
9
9
  declaredType: string;
10
10
  declaredSchema: string;
11
11
  nullable: boolean;
12
+ writeNullable: boolean;
13
+ hasDefault: boolean;
14
+ writable: boolean;
12
15
  choices: string[] | null;
13
16
  }
14
17
  export interface TableRow {
@@ -11,16 +11,19 @@ export async function readTables(client, namespace) {
11
11
  // Attached descendants are storage details, not separate generated models.
12
12
  const columns = await client.query(`
13
13
  WITH RECURSIVE resolved AS (
14
- SELECT a.attnum, t.oid, t.typbasetype
14
+ SELECT a.attnum, t.oid, t.typbasetype, t.typnotnull AS domain_not_null
15
15
  FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
16
16
  WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped
17
17
  UNION ALL
18
- SELECT r.attnum, t.oid, t.typbasetype
18
+ SELECT r.attnum, t.oid, t.typbasetype, r.domain_not_null OR t.typnotnull
19
19
  FROM resolved r JOIN pg_catalog.pg_type t ON t.oid = r.typbasetype
20
20
  )
21
21
  SELECT a.attname AS name, t.oid::text AS oid, t.typname AS type, tn.nspname AS "typeSchema", t.typtype AS "typeKind",
22
22
  declared.oid::text AS "declaredOid", declared.typname AS "declaredType", dn.nspname AS "declaredSchema",
23
23
  NOT a.attnotnull AS nullable,
24
+ NOT (a.attnotnull OR r.domain_not_null) AS "writeNullable",
25
+ (a.atthasdef OR declared.typdefaultbin IS NOT NULL OR a.attidentity <> '') AS "hasDefault",
26
+ (a.attgenerated = '' AND a.attidentity <> 'a') AS writable,
24
27
  (SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder) FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS choices
25
28
  FROM pg_catalog.pg_attribute a
26
29
  JOIN resolved r ON r.attnum = a.attnum AND r.typbasetype = 0
@@ -0,0 +1,2 @@
1
+ import type { TableRow } from './catalog.js';
2
+ export declare function renderTableTypes(tables: readonly TableRow[], namespace: string, onWarning: (message: string) => void): string;
@@ -0,0 +1,146 @@
1
+ // Every generated table remains accessible through Rows["exact SQL name"].
2
+ const reservedTypeNames = new Set(`
3
+ as await break case catch class const continue debugger default delete do else enum
4
+ export extends false finally for function if implements import in instanceof
5
+ interface let new null package private protected public return static super
6
+ switch this throw true try typeof var void while with yield any bigint boolean
7
+ intrinsic never number object string symbol undefined unknown
8
+ Rows globalThis PgArray
9
+ `
10
+ .trim()
11
+ .split(/\s+/));
12
+ function canExportRowName(name) {
13
+ return (/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(name) && !reservedTypeNames.has(name));
14
+ }
15
+ // A string means the read and write representations match. Arrays use verified
16
+ // built-in OIDs because their parsers can differ from scalar parsers (numeric[]).
17
+ const arrayTypes = {
18
+ '1000': 'boolean',
19
+ '1001': 'import("node:buffer").Buffer',
20
+ '1005': 'number',
21
+ '1007': 'number',
22
+ '1021': 'number',
23
+ '1022': 'number',
24
+ '1028': 'number',
25
+ '1016': { read: 'string', write: 'string | bigint' },
26
+ '1231': { read: 'number', write: 'string | number' },
27
+ '1009': 'string',
28
+ '1014': 'string',
29
+ '1015': 'string',
30
+ '1183': 'string',
31
+ '1270': 'string',
32
+ '2951': 'string',
33
+ '1182': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
34
+ '1115': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
35
+ '1185': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
36
+ '199': { read: 'unknown', write: 'string' },
37
+ '3807': { read: 'unknown', write: 'string' },
38
+ };
39
+ const scalarTypes = {
40
+ int2: 'number',
41
+ int4: 'number',
42
+ oid: 'number',
43
+ float4: 'number',
44
+ float8: 'number',
45
+ bool: 'boolean',
46
+ text: 'string',
47
+ name: 'string',
48
+ varchar: 'string',
49
+ bpchar: 'string',
50
+ time: 'string',
51
+ timetz: 'string',
52
+ uuid: 'string',
53
+ // pg returns +/-Infinity as numbers. TypeScript has no infinity literal type.
54
+ date: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
55
+ timestamp: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
56
+ timestamptz: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
57
+ int8: { read: 'string', write: 'string | bigint' },
58
+ numeric: { read: 'string', write: 'string | number' },
59
+ json: { read: 'unknown', write: 'string' },
60
+ jsonb: { read: 'unknown', write: 'string' },
61
+ bytea: 'import("node:buffer").Buffer',
62
+ };
63
+ function valueTypes(mapping) {
64
+ return typeof mapping === 'string' ? { read: mapping, write: mapping } : mapping;
65
+ }
66
+ function isSupportedArray(value) {
67
+ return Object.hasOwn(arrayTypes, value.oid);
68
+ }
69
+ function columnTypes(value, column, onWarning) {
70
+ let types;
71
+ if (isSupportedArray(value)) {
72
+ const element = valueTypes(arrayTypes[value.oid]);
73
+ types = { read: `PgArray<${element.read}>`, write: `PgArray<${element.write}>` };
74
+ }
75
+ else if (value.typeKind === 'e') {
76
+ const choice = value.choices?.map((choice) => JSON.stringify(choice)).join(' | ') || 'never';
77
+ types = { read: choice, write: choice };
78
+ }
79
+ else {
80
+ const type = value.typeSchema === 'pg_catalog' && Object.hasOwn(scalarTypes, value.type)
81
+ ? scalarTypes[value.type]
82
+ : undefined;
83
+ if (type) {
84
+ types = valueTypes(type);
85
+ }
86
+ else {
87
+ const underlying = value.declaredOid === value.oid
88
+ ? ''
89
+ : ` with underlying type ${JSON.stringify(value.typeSchema)}.${JSON.stringify(value.type)} (OID ${value.oid})`;
90
+ onWarning(`Column ${column} uses PostgreSQL type ${JSON.stringify(value.declaredSchema)}.${JSON.stringify(value.declaredType)} (OID ${value.declaredOid})${underlying} without a verified pg mapping; generated as unknown for reads and never for non-null writes.`);
91
+ types = { read: 'unknown', write: 'never' };
92
+ }
93
+ }
94
+ // unknown already includes null, including for nullable JSON and unmapped types.
95
+ return {
96
+ read: `${types.read}${value.nullable && types.read !== 'unknown' ? ' | null' : ''}`,
97
+ write: `${types.write}${value.writeNullable ? ' | null' : ''}`,
98
+ };
99
+ }
100
+ export function renderTableTypes(tables, namespace, onWarning) {
101
+ const definitions = tables.map((table) => {
102
+ const tableName = JSON.stringify(table.name);
103
+ const columns = table.columns.map((column) => {
104
+ const columnName = JSON.stringify(column.name);
105
+ const path = `${JSON.stringify(namespace)}.${tableName}.${columnName}`;
106
+ const types = columnTypes(column, path, onWarning);
107
+ const requiredOnInsert = column.writable && !column.hasDefault && !column.writeNullable;
108
+ const optionalInputType = column.writable ? `${types.write} | undefined` : 'never';
109
+ return {
110
+ row: ` ${columnName}: ${types.read};`,
111
+ insert: ` ${columnName}${requiredOnInsert ? '' : '?'}: ${requiredOnInsert ? types.write : optionalInputType};`,
112
+ update: ` ${columnName}?: ${optionalInputType};`,
113
+ };
114
+ });
115
+ return { tableName, columns };
116
+ });
117
+ const render = (kind, indent = '') => definitions
118
+ .map(({ tableName, columns }) => `${indent} ${tableName}: {
119
+ ${columns.map((column) => indent + column[kind]).join('\n')}
120
+ ${indent} };`)
121
+ .join('\n');
122
+ const aliases = tables
123
+ .filter((table) => canExportRowName(table.name))
124
+ .map((table) => `export type ${table.name} = Rows[${JSON.stringify(table.name)}];`);
125
+ const arrays = tables.some((table) => table.columns.some(isSupportedArray))
126
+ ? 'export type PgArray<T> = (T | null | PgArray<T>)[];\n\n'
127
+ : '';
128
+ return `${arrays}// Full database rows. Compose query types with Pick, indexed access, and intersections.
129
+ export type Rows = {
130
+ ${render('row')}
131
+ };
132
+
133
+ export declare namespace Rows {
134
+ // Bound inputs for sql.insertValues. Missing/undefined fields use database defaults.
135
+ type Insert = {
136
+ ${render('insert', ' ')}
137
+ };
138
+
139
+ // Bound inputs for sql.assignments. Missing/undefined fields are omitted.
140
+ type Update = {
141
+ ${render('update', ' ')}
142
+ };
143
+ }
144
+ ${aliases.join('\n')}
145
+ `;
146
+ }
@@ -1,7 +1,7 @@
1
1
  export type MutationPart = string | Readonly<{
2
2
  value: unknown;
3
3
  }>;
4
- export type MutationColumns<Row> = Row extends object ? Extract<keyof Row, string> : never;
4
+ export type MutationColumns<Input> = Input extends object ? Extract<keyof Input, string> : never;
5
5
  /** Validate and quote one explicit column list, shared by runtime and SQL checking. */
6
6
  export declare function mutationColumns(columns: readonly string[]): string[];
7
7
  /** SQL structure depends only on these columns and cells, never on JavaScript source. */
package/dist/query.d.ts CHANGED
@@ -21,9 +21,9 @@ export interface SqlFragment {
21
21
  declare function fragment(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlFragment;
22
22
  declare function join(fragments: readonly SqlFragment[], separator: 'AND' | 'OR' | ','): SqlFragment;
23
23
  /** Object fields become bound values; absent insert cells use PostgreSQL defaults. */
24
- declare function insertValues<Row extends object>(input: Row | readonly Row[], columns?: readonly NoInfer<MutationColumns<Row>>[]): SqlFragment;
24
+ declare function insertValues<Input extends object>(input: Input | readonly Input[], columns?: readonly NoInfer<MutationColumns<Input>>[]): SqlFragment;
25
25
  /** Omit undefined fields; null remains a value. Empty assignments reject. */
26
- declare function assignments<Row extends object>(input: Row, columns?: readonly NoInfer<MutationColumns<Row>>[]): SqlFragment;
26
+ declare function assignments<Input extends object>(input: Input, columns?: readonly NoInfer<MutationColumns<Input>>[]): SqlFragment;
27
27
  declare function createQuery<Row extends QueryResultRow = UnknownRow>(strings: TemplateStringsArray, ...values: readonly unknown[]): NoInfer<SqlQuery<Row>>;
28
28
  /** Values bind parameters; identifiers quote names; fragments compose authored SQL. */
29
29
  export declare const sql: typeof createQuery & {
package/dist/query.js CHANGED
@@ -111,7 +111,7 @@ export function unsafeSql(text, values) {
111
111
  if (!Array.isArray(values))
112
112
  throw new Error('SQL input must be an explicit array.');
113
113
  if (values.length > 65535)
114
- throw new Error('A PostgreSQL query supports at most 65535 bound parameters; split large batches explicitly.');
114
+ throw new Error(`A PostgreSQL query supports at most 65535 bound parameters; received ${values.length}. Split large batches explicitly.`);
115
115
  return Object.freeze({ [queryTag]: true, text, values: Object.freeze([...values]) });
116
116
  }
117
117
  export function assertQuery(value) {
@@ -106,6 +106,15 @@ export function renderFragment(node, checker, seen = new Set()) {
106
106
  }
107
107
  return list.elements.map((item) => renderFragment(item, checker, visited)).join(between);
108
108
  }
109
+ if (ts.isCallExpression(node)) {
110
+ throw new CompositionError(`Function-generated fragments from ${node.expression.getText()}() are not analyzed; the checker does not inspect function bodies.`);
111
+ }
112
+ if (ts.isConditionalExpression(node)) {
113
+ throw new CompositionError('Conditional fragments are not analyzed; the checker does not choose JavaScript branches, even for literal conditions.');
114
+ }
115
+ if (ts.isIdentifier(node)) {
116
+ throw new CompositionError(`Fragment "${node.text}" has no inspectable const initializer; mutable and runtime-supplied fragments are not analyzed.`);
117
+ }
109
118
  throw new CompositionError('Only constant sql.fragment templates, literal sql.join arrays, and official mutation helpers are supported. Function-generated fragments and conditional SQL structure are not analyzed.');
110
119
  }
111
120
  export function renderIdentifier(node, checker, seen = new Set()) {
@@ -22,21 +22,26 @@ export const eslintPlugin = {
22
22
  !ts.isTaggedTemplateExpression(template) ||
23
23
  !isExport(template.tag, checker, 'sql'))
24
24
  return;
25
- try {
26
- if (node.quasi.quasis.some(({ value }) => value.raw !== value.cooked)) {
27
- throw new CompositionError('JavaScript escapes in an outer sql template would be checked as different SQL. Use literal newlines and bound string values instead.');
28
- }
29
- if (ts.isTemplateExpression(template.template)) {
30
- for (const span of template.template.templateSpans) {
25
+ if (node.quasi.quasis.some(({ value }) => value.raw !== value.cooked)) {
26
+ context.report({
27
+ node,
28
+ message: new CompositionError('JavaScript escapes in an outer sql template would be checked as different SQL. Use literal newlines and bound string values instead.').message,
29
+ });
30
+ return;
31
+ }
32
+ if (ts.isTemplateExpression(template.template)) {
33
+ for (const [index, span] of template.template.templateSpans.entries()) {
34
+ try {
31
35
  renderValue(span.expression, checker);
32
36
  }
37
+ catch (error) {
38
+ if (!(error instanceof CompositionError))
39
+ throw error;
40
+ context.report({ node: node.quasi.expressions[index], message: error.message });
41
+ return;
42
+ }
33
43
  }
34
44
  }
35
- catch (error) {
36
- if (!(error instanceof CompositionError))
37
- throw error;
38
- context.report({ node, message: error.message });
39
- }
40
45
  },
41
46
  };
42
47
  },
@@ -11,10 +11,10 @@ queries or generate types; keep the existing migration runner if that is the int
11
11
  1. Locate the old ORM's connection setup, schema, migrations, and call sites. Identify behavior
12
12
  supplied by the ORM: validation, IDs, timestamps, hooks, defaults, relation loading,
13
13
  serialization, and transaction boundaries.
14
- 2. If table row types will help your application, generate them from the existing database:
14
+ 2. If row and mutation input types will help your application, generate them from the existing database:
15
15
 
16
16
  ```sh
17
- npx tenon generate --out src/generated-schema.ts
17
+ npx tenon generate --out src/generated-schema.d.ts
18
18
  ```
19
19
 
20
20
  Use `--namespace name` for a non-public schema. Generation reads the actual database and requires
@@ -25,14 +25,107 @@ queries or generate types; keep the existing migration runner if that is the int
25
25
  Generated types describe pg values: check bigint/numeric strings, dates, JSON, and arrays when
26
26
  the old ORM transformed them.
27
27
  4. If using optional SQL checking, configure the
28
- [SafeQL integration](reference.md#sql-checking-with-safeql) to generate query annotations and
29
- check them against the intended schema. Run tests against a dedicated test database. Verify
28
+ [SafeQL integration](reference.md#sql-checking-with-safeql) and follow the
29
+ [incremental audit workflow](#adopt-sql-checking-incrementally) below before accepting generated
30
+ annotations. Run tests against a dedicated test database. Verify
30
31
  writes and rollback, relationships, validation, and any hooks you moved into application code or
31
32
  SQL. A schema-preserving first release makes application rollback easier.
32
33
  5. Remove the old runtime dependency and unused generated code after its callers are gone. Keep the
33
34
  migration tooling until the migration transition below is complete, if that transition is part of
34
35
  the work.
35
36
 
37
+ ## Replace local write helpers
38
+
39
+ Use `sql.insertValues(rows)` and `sql.assignments(patch)` in place of local object-to-SQL utilities:
40
+
41
+ ```ts
42
+ import { sql } from '@kequtech/tenon-orm';
43
+
44
+ const insert = sql`INSERT INTO profiles ${sql.insertValues(rows)} RETURNING id`;
45
+ const update = sql`UPDATE profiles SET ${sql.assignments(patch)} WHERE id = ${id}`;
46
+ ```
47
+
48
+ Compare omission, defaults, nulls, and empty-input behavior with the old helpers. Keep expressions
49
+ in SQL and remove the local utilities after their callers move. Generated `Rows.Insert` and
50
+ `Rows.Update` types can replace handwritten table inputs; retain application-specific refinements
51
+ and validation. Run the existing write and rollback workflows. See the [mutation cookbook](mutations.md).
52
+
53
+ ## Adopt SQL checking incrementally
54
+
55
+ For an existing application, including Next.js, SQL checking can run as a separate audit while the
56
+ normal lint/build commands keep their existing database requirements. Extend the existing flat
57
+ ESLint configuration rather than replacing it. After installing the
58
+ [checking dependencies](reference.md#install-and-configure), a single-database setup can use:
59
+
60
+ ```js
61
+ // eslint.sql.config.mjs
62
+ import { defineConfig } from 'eslint/config';
63
+ import baseConfig from './eslint.config.mjs';
64
+ import safeql from '@ts-safeql/eslint-plugin/config';
65
+ import tseslint from 'typescript-eslint';
66
+ import { safeqlPlugin, eslintPlugin } from '@kequtech/tenon-orm/safeql';
67
+
68
+ const databaseUrl = process.env.DATABASE_URL;
69
+ if (!databaseUrl) throw new Error('DATABASE_URL is required for SQL checking');
70
+
71
+ export default defineConfig(baseConfig, {
72
+ files: ['src/**/*.ts', 'src/**/*.tsx'],
73
+ languageOptions: {
74
+ parser: tseslint.parser,
75
+ parserOptions: { projectService: true },
76
+ },
77
+ plugins: { tenon: eslintPlugin },
78
+ rules: { 'tenon/check-composition': 'error' },
79
+ extends: [
80
+ safeql.configs.connections({
81
+ databaseUrl,
82
+ plugins: [safeqlPlugin],
83
+ targets: [{ tag: { regex: '.*' } }],
84
+ }),
85
+ ],
86
+ });
87
+ ```
88
+
89
+ Preserve existing type-aware parser settings where configured. The broad target above lets Tenon
90
+ recognize aliases; use distinct targets for [multiple databases](reference.md#configuration-choices-and-multiple-databases).
91
+ The example does not load environment files. Use the application's existing environment loader
92
+ and URL selection to supply its migrated development/test database through `DATABASE_URL`.
93
+
94
+ ```json
95
+ {
96
+ "scripts": {
97
+ "lint:sql": "eslint --config eslint.sql.config.mjs \"src/**/*.{ts,tsx}\""
98
+ }
99
+ }
100
+ ```
101
+
102
+ Start with `npm run lint:sql`. Keep both checking rules enabled and distinguish the findings:
103
+
104
+ - SQL errors identify problems in reconstructed SQL; review them before accepting inferred types.
105
+ - Missing/mismatched annotations may reflect an outdated type, a deliberate application refinement,
106
+ or a checker limitation. They are not all demonstrated application bugs.
107
+ - `tenon/check-composition` means SQL analysis was skipped. Add inline selected columns for opaque
108
+ mutation inputs where useful. Keep reusable authorization policy centralized when its fragments
109
+ remain unsupported, with database tests for runtime variants.
110
+
111
+ Preview fixes on a small module first, without writing source. Invoke ESLint directly to select
112
+ one file, since the script above already supplies a source glob:
113
+
114
+ ```sh
115
+ npx eslint --config eslint.sql.config.mjs src/database/profiles.ts --fix-dry-run --format json
116
+ ```
117
+
118
+ Review the proposed annotations, apply selected changes deliberately, and run TypeScript and
119
+ database tests. Broader inferred types may require validation where raw database values enter the
120
+ application. For example, require a finite `Date` before calling `.getTime()`, validate structured
121
+ JSON from `unknown`, and justify CHECK-constrained string refinements with matching schema checks
122
+ or a reviewed column override. Do not globally map timestamps to `Date`: pg can return numeric
123
+ infinities. See [checker mappings](reference.md#supported-sql-and-limitations).
124
+
125
+ Unsupported queries have not had their SQL verified. Keep database tests for their runtime variants
126
+ and use explained suppressions only where appropriate. Once the intended scope passes, make SQL
127
+ checking a required CI step. Fix commands can also apply other enabled ESLint rules.
128
+
36
129
  ## Move migration ownership to Tenon
37
130
 
38
131
  Choose one final schema checkpoint for the old runner and bring existing target databases to it.
@@ -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
 
@@ -124,6 +124,11 @@ batches/updates throw; a single default-only insert works. Omit generated column
124
124
  writes explicitly below the query-wide limit of 65,535 parameters. Expressions and queries are
125
125
  rejected as cell values; write arithmetic, `DEFAULT`, and `excluded.column` in the surrounding SQL.
126
126
 
127
+ Use generated `Rows.Insert['table']` and `Rows.Update['table']` for mutation inputs when available;
128
+ narrow editable fields with `Pick`. They account for defaults, nullability, domains, and generated
129
+ columns. Write mappings differ from returned rows: timestamps use `Date | string`, bigint uses
130
+ `string | bigint`, and JSON uses serialized text. See the [input types](reference.md#insert-and-update-inputs).
131
+
127
132
  SafeQL reads inline object/array literals directly. Runtime inputs, spreads and data-builder results
128
133
  need explicit inline selected columns, e.g. `sql.insertValues(rows, ['name', 'score'])`. Checking
129
134
  uses all selected columns conservatively, without interpreting application JavaScript. It verifies
@@ -140,10 +145,8 @@ Configure it normally and add both exports from `@kequtech/tenon-orm/safeql`:
140
145
  - `eslintPlugin` goes in ESLint's `plugins`, with `tenon/check-composition` enabled. It reports
141
146
  unsupported forms that the adapter skips. It does not wrap SafeQL's rule.
142
147
 
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.
148
+ These tools run during development and CI. Reuse the application's TypeScript parser settings and
149
+ use a TypeScript version supported by the lint toolchain. Generated table types are optional.
147
150
 
148
151
  Reuse existing configuration and scripts. For a new setup:
149
152
 
@@ -202,6 +205,9 @@ Reuse existing lint scripts or add:
202
205
  SQL and annotation errors come from `@ts-safeql/check-sql`; unsupported composition errors come from
203
206
  `tenon/check-composition`. Keep both rules enabled.
204
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. The selected column lists
188
+ above also let the optional SQL checker inspect runtime-built inputs. They select fields rather
189
+ than validate the complete object. See the [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:
@@ -255,9 +259,10 @@ works, but it does not prove that every variant is valid SQL or returns the decl
255
259
  const filters = [sql.fragment`active = ${true}`];
256
260
  if (email !== undefined) filters.push(sql.fragment`email = ${email}`);
257
261
 
258
- // eslint-disable-next-line tenon/check-composition -- Runtime filters are covered by database tests.
262
+ /* eslint-disable tenon/check-composition -- Runtime filters are covered by database tests. */
259
263
  const query = sql<UserSummary>`SELECT id, email FROM public."User"
260
264
  WHERE ${sql.join(filters, 'AND')} ORDER BY id`;
265
+ /* eslint-enable tenon/check-composition */
261
266
  ```
262
267
 
263
268
  The initial condition keeps this list nonempty. Empty lists throw so that missing filters cannot
package/docs/mutations.md CHANGED
@@ -22,13 +22,54 @@ required fields and constraints.
22
22
  For runtime-built rows, supply an inline column list for SafeQL:
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
@@ -89,8 +130,8 @@ await db.query(
89
130
  ```
90
131
 
91
132
  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.
133
+ versus array encoding from the table schema. Generated JSON input types use serialized text for
134
+ consistent handling of objects and arrays. Validate JSON contents in the application.
94
135
 
95
136
  ## Batches in one transaction
96
137
 
@@ -107,8 +148,9 @@ await db.transaction(async (tx) => {
107
148
 
108
149
  A failure rolls back all batches. The helper rejects an empty batch; this loop makes no insert
109
150
  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.
151
+ the mutation fragment. An oversized query reports its actual count, for example
152
+ `at most 65535 bound parameters; received 65536`. Defaults consume no parameters. Account for the
153
+ selected columns and surrounding query when choosing a batch size; Tenon never splits queries automatically.
112
154
 
113
155
  Reusable authorization fragments remain valid at runtime. Keep shared policy in application
114
156
  functions and test it rather than duplicating it for static coverage. Function-generated SQL,
package/docs/reference.md CHANGED
@@ -199,10 +199,10 @@ const patch: { name?: string; bio?: string | null } = readPatch();
199
199
  const update = sql`UPDATE profiles SET ${sql.assignments(patch, ['name', 'bio'])} WHERE id = ${id}`;
200
200
  ```
201
201
 
202
- The helpers have no schema metadata. Omit generated columns; PostgreSQL enforces identity,
203
- generation, required-column, and constraint rules. They do not generate insert/update input types
204
- or prove that the JavaScript input values match database column types. Retain application input
205
- contracts and validation. To reset an update field to its default, write `column = DEFAULT` in the
202
+ The helpers have no runtime schema metadata. Use [generated input types](#insert-and-update-inputs)
203
+ or application-owned contracts to check JavaScript values; PostgreSQL enforces identity,
204
+ generation, required-column, and constraint rules. Retain application validation.
205
+ To reset an update field to its default, write `column = DEFAULT` in the
206
206
  surrounding SQL. Put arithmetic and `excluded.column` expressions there too; these helpers accept
207
207
  values only, with no expression/default sentinel.
208
208
 
@@ -277,42 +277,84 @@ transaction has committed.
277
277
 
278
278
  `generateDefinitions({ connection, namespace?, onWarning? })` returns TypeScript source for the
279
279
  connected schema. It does not need migration files or history. Table type generation is optional.
280
- SafeQL reads the database directly and does not require these generated files; they are useful when
281
- reusing table row shapes. The CLI writes that source atomically. This command checks the existing
282
- file without rewriting it:
280
+ SafeQL reads the database directly and does not require these generated files; they provide
281
+ reusable row and mutation input types. The CLI writes that source atomically. This command checks
282
+ the existing file without rewriting it:
283
283
 
284
284
  ```sh
285
- npx tenon generate --out src/generated-schema.ts --check
285
+ npx tenon generate --out src/generated-schema.d.ts --check
286
286
  ```
287
287
 
288
288
  `--check` compares the generated representation with the current database. Pending migration files
289
289
  are a separate concern. The representation includes table columns, constraints, indexes, and
290
290
  referenced domains, not every PostgreSQL object.
291
291
 
292
- Generation reads a consistent snapshot of ordinary tables and partitioned roots. Use the generated
293
- named type in queries, for example `sql<User>`. For other table names, define an alias such as
294
- `type AuditRow = Rows['audit-log']` and use `sql<AuditRow>`; SafeQL resolves named aliases but not
295
- indexed access directly in `sql<Rows['audit-log']>`. Views, foreign tables, and attached partitions
296
- use query-specific types. A missing namespace fails; an existing empty schema is valid.
297
-
298
- | PostgreSQL type | Default pg value |
299
- | ---------------------------------------------- | ----------------------------------------------- |
300
- | smallint, integer, oid, real, double precision | `number` |
301
- | bigint, numeric | `string` |
302
- | boolean | `boolean` |
303
- | text, varchar, char, uuid, time, timetz | `string` |
304
- | date, timestamp, timestamptz | `Date \| number` (numbers represent infinities) |
305
- | enum | String literal union; `never` for an empty enum |
306
- | json, jsonb | `unknown` |
307
- | bytea | `Buffer` |
308
-
309
- Nullable columns add null. Domains use verified underlying mappings and the column's own
310
- nullability. Unverified types warn and become `unknown`. Verified arrays use
292
+ Generation reads a consistent snapshot of ordinary tables and partitioned roots. `Rows['User']`
293
+ describes one returned row; the generated `User` alias means the same thing. Use `sql<User>` for a
294
+ full row or `Pick<User, 'id' | 'email'>` for a projection. Table keys preserve exact SQL names;
295
+ `type AuditRow = Rows['audit-log']` works for names that cannot be exported as identifiers.
296
+ Views, foreign tables, and attached partitions use query-specific types. A missing namespace
297
+ fails; an existing empty schema is valid.
298
+
299
+ | PostgreSQL type | Returned value | Generated write input |
300
+ | ---------------------------------------------- | --------------------------------------------- | ------------------------------------- |
301
+ | smallint, integer, oid, real, double precision | `number` | `number` |
302
+ | bigint | `string` | `string \| bigint` |
303
+ | numeric | `string` | `string \| number` |
304
+ | boolean | `boolean` | `boolean` |
305
+ | text, name, varchar, char, uuid, time, timetz | `string` | `string` |
306
+ | date, timestamp, timestamptz | `Date \| number` (numbers include infinities) | `Date \| string` |
307
+ | enum | String literal union | Same union; `never` for an empty enum |
308
+ | json, jsonb | `unknown` | Serialized JSON text (`string`) |
309
+ | bytea | `Buffer` | `Buffer` |
310
+
311
+ For returned rows, nullable columns add null. Domains use verified underlying mappings and the
312
+ column's own nullability. Unverified types warn and become `unknown`. Verified arrays use
311
313
  `PgArray<T> = (T | null | PgArray<T>)[]` for null elements and multiple dimensions. Numeric arrays
312
314
  contain numbers and can lose precision; cast to text[] for exact values. Enum arrays and arrays of
313
315
  domains are unverified. Finite dates and timestamps without time zone use Node's local timezone.
314
316
  Custom pg parsers need matching application types.
315
317
 
318
+ ### Insert and update inputs
319
+
320
+ The same file provides `Rows.Insert['table']` and `Rows.Update['table']` for bound write inputs.
321
+ These are ordinary TypeScript types. `--out` accepts `.ts` or `.d.ts`; the examples use `.d.ts`
322
+ because the file contains only declarations.
323
+
324
+ ```ts
325
+ import type { Rows } from './generated-schema.js';
326
+
327
+ type NewProfile = Rows.Insert['profiles'];
328
+ type ProfilePatch = Pick<Rows.Update['profiles'], 'name' | 'bio'>;
329
+
330
+ const profile: NewProfile = { name: 'Ada' };
331
+ const patch: ProfilePatch = { name: undefined, bio: null };
332
+ sql.insertValues(profile, ['name', 'bio', 'score']);
333
+ sql.assignments(patch, ['name', 'bio']);
334
+ ```
335
+
336
+ Insert fields are required when nonnullable and without a default. Other writable insert fields
337
+ are optional; every writable update field is optional. `undefined` requests a default on insert
338
+ and omits an assignment on update. Explicit `null` is accepted only when the column and its domain
339
+ chain permit it. Domain defaults count; column defaults take precedence.
340
+
341
+ Computed columns and `GENERATED ALWAYS` identity columns cannot receive values. Serial and
342
+ `GENERATED BY DEFAULT` identity columns are optional on insert and writable on update. Other
343
+ primary keys remain writable. Use `Pick` or `Omit` for application-specific restrictions and keep
344
+ expressions, `DEFAULT`, and special identity overrides in SQL.
345
+
346
+ Write representations in the table above also apply to verified array elements. Serialize JSON
347
+ with `JSON.stringify`, including JSON arrays; a JSON string containing `null` differs from SQL null.
348
+ Use timestamp strings for infinities. Unverified non-null write values use `never` and warn;
349
+ nullable fields still accept null. Supply an application-owned replacement when needed, for example
350
+ `Omit<Rows.Insert['events'], 'delay'> & { delay: string }` for a reviewed interval-text input.
351
+
352
+ Apply these types through annotations, `satisfies`, or helper generic arguments such as
353
+ `sql.insertValues<NewProfile>(input)`. They check shapes and representations, not authorization,
354
+ constraints, JSON contents, or nonempty inputs. Optional fields accept explicit `undefined`,
355
+ including with `exactOptionalPropertyTypes`. Selected columns can discard required fields; see
356
+ the [cookbook example](mutations.md#input-types-sql-checking-and-runtime-validation).
357
+
316
358
  ## SQL checking with SafeQL
317
359
 
318
360
  SafeQL checks SQL against a real PostgreSQL schema and infers result types. ESLint runs that check
@@ -341,11 +383,8 @@ existing TypeScript installation:
341
383
  npm install --save-dev eslint typescript-eslint @ts-safeql/eslint-plugin
342
384
  ```
343
385
 
344
- SafeQL and ESLint are the integration's hosts, and the adapter imports TypeScript to inspect query
345
- expressions. They are optional peers because Tenon's database runtime does not need them.
346
- `typescript-eslint` is an application-owned parser dependency, not a Tenon peer. Your ESLint
347
- configuration imports SafeQL and the parser explicitly. Use a TypeScript version supported by that
348
- lint toolchain. The `pg` driver remains Tenon's required runtime peer.
386
+ These are development dependencies. Reuse your existing type-aware parser configuration and a
387
+ TypeScript version supported by the lint toolchain. Tenon's database runtime does not load them.
349
388
 
350
389
  For a project with `src/**/*.ts` and a `tsconfig.json`:
351
390
 
@@ -382,9 +421,6 @@ configuration. `projectService` uses the nearest TypeScript project for each fil
382
421
  `DATABASE_URL` through your application's environment setup and point it at a development/test
383
422
  database with migrations applied. This example does not load `.env` files.
384
423
 
385
- The `.mjs` example avoids a current declaration mismatch between SafeQL's configuration helper and
386
- ESLint's `defineConfig`. This affects configuration typechecking, not query result types.
387
-
388
424
  `targets` selects the SQL tag spelling. If you alias `sql`, add the alias to the targets. For a
389
425
  single database, `targets: [{ tag: { regex: '.*' } }]` selects all tags; Tenon's adapter filters
390
426
  that down to actual Tenon queries, including aliases and object properties. Neither the adapter nor
@@ -404,6 +440,9 @@ They run all enabled ESLint rules, and `lint:fix` can change non-SQL code too:
404
440
 
405
441
  ### Generate and check annotations
406
442
 
443
+ For an existing persistence layer, start with the [incremental audit workflow](adoption-guide.md#adopt-sql-checking-incrementally).
444
+ It covers a separate SQL lint command, fix previews, and application refinements.
445
+
407
446
  Write a query without a result annotation:
408
447
 
409
448
  ```ts
@@ -465,10 +504,8 @@ file group, each extending its own `safeql.configs.connections({ ... })`. Use no
465
504
  `files` patterns when database settings differ. Normal SafeQL configurations for other libraries can
466
505
  coexist; add Tenon's adapter only to connections checking Tenon queries.
467
506
 
468
- If a database has same-named tables in different schemas, qualify them in SQL, for example
469
- `SELECT id FROM billing.accounts`. SafeQL can infer the wrong type for an unqualified table even
470
- when the connection's `search_path` selects the correct table at execution. Explicit schema names
471
- avoid that ambiguity.
507
+ Qualify same-named tables with their schemas, for example `SELECT id FROM billing.accounts`, so
508
+ the intended relation is explicit in both checking and execution.
472
509
 
473
510
  ### Supported SQL and limitations
474
511
 
@@ -488,6 +525,12 @@ references, runtime filter lists, and dynamic identifiers are also unsupported.
488
525
  expressions remain bound parameters; calls that produce data for an official mutation helper can
489
526
  use its explicit column-list contract.
490
527
 
528
+ `tenon/check-composition` points to the first unsupported interpolation in each query and explains
529
+ why it cannot be checked. For example, opaque mutation inputs need an inline column list, while
530
+ fragment-returning functions remain outside static analysis. Errors reached through shared constants
531
+ are reported at the consuming query. ESLint suppressions must cover the reported expression's line;
532
+ a scoped disable/enable can cover one multiline query.
533
+
491
534
  Reusable authorization and other application policy can still live in fragment-returning functions
492
535
  at runtime. Keep that policy centralized and cover its behavior with database tests. Suppress
493
536
  `tenon/check-composition` only on the particular query, with a reason, when its variants are tested.
@@ -548,7 +591,7 @@ tenon new <name> [--directory migrations]
548
591
  tenon migrate [--directory migrations] [--namespace public]
549
592
  tenon status [--directory migrations] [--namespace public]
550
593
  tenon rollback [--directory migrations] [--namespace public] [--steps 1]
551
- tenon generate [--namespace public] [--out src/generated-schema.ts] [--check]
594
+ tenon generate [--namespace public] [--out path] [--check]
552
595
  tenon baseline capture|adopt [--directory migrations] [--namespace public]
553
596
  ```
554
597
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kequtech/tenon-orm",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "A PostgreSQL ORM designed for coding agents, with explicit SQL, typed queries, and migrations",
@@ -1,2 +0,0 @@
1
- import type { TableRow } from './catalog.js';
2
- export declare function renderRowTypes(tables: readonly TableRow[], namespace: string, onWarning: (message: string) => void): string;
@@ -1,115 +0,0 @@
1
- // Every generated table remains accessible through Rows["exact SQL name"].
2
- const reservedTypeNames = new Set(`
3
- as await break case catch class const continue debugger default delete do else enum
4
- export extends false finally for function if implements import in instanceof
5
- interface let new null package private protected public return static super
6
- switch this throw true try typeof var void while with yield any bigint boolean
7
- intrinsic never number object string symbol undefined unknown
8
- Rows globalThis PgArray
9
- `
10
- .trim()
11
- .split(/\s+/));
12
- function canExportRowName(name) {
13
- return (/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(name) && !reservedTypeNames.has(name));
14
- }
15
- // Exact built-in OIDs with verified default pg text parsers. Array parsers do
16
- // not necessarily use their scalar type's representation (notably numeric[]).
17
- const arrayTypes = {
18
- '1000': 'boolean',
19
- '1001': 'import("node:buffer").Buffer',
20
- '1005': 'number',
21
- '1007': 'number',
22
- '1021': 'number',
23
- '1022': 'number',
24
- '1028': 'number',
25
- '1016': 'string',
26
- '1231': 'number',
27
- '1009': 'string',
28
- '1014': 'string',
29
- '1015': 'string',
30
- '1183': 'string',
31
- '1270': 'string',
32
- '2951': 'string',
33
- '1182': 'globalThis.Date | number',
34
- '1115': 'globalThis.Date | number',
35
- '1185': 'globalThis.Date | number',
36
- '199': 'unknown',
37
- '3807': 'unknown',
38
- };
39
- const scalarTypes = {
40
- int2: 'number',
41
- int4: 'number',
42
- oid: 'number',
43
- float4: 'number',
44
- float8: 'number',
45
- bool: 'boolean',
46
- text: 'string',
47
- varchar: 'string',
48
- bpchar: 'string',
49
- time: 'string',
50
- timetz: 'string',
51
- uuid: 'string',
52
- // pg returns +/-Infinity as numbers. TypeScript has no infinity literal type.
53
- date: 'globalThis.Date | number',
54
- timestamp: 'globalThis.Date | number',
55
- timestamptz: 'globalThis.Date | number',
56
- int8: 'string',
57
- numeric: 'string',
58
- json: 'unknown',
59
- jsonb: 'unknown',
60
- bytea: 'import("node:buffer").Buffer',
61
- };
62
- function isSupportedArray(value) {
63
- return Object.hasOwn(arrayTypes, value.oid);
64
- }
65
- function columnType(value, column, onWarning) {
66
- let scalar;
67
- if (isSupportedArray(value)) {
68
- scalar = `PgArray<${arrayTypes[value.oid]}>`;
69
- }
70
- else if (value.typeKind === 'e') {
71
- scalar = value.choices?.map((choice) => JSON.stringify(choice)).join(' | ') || 'never';
72
- }
73
- else {
74
- const type = value.typeSchema === 'pg_catalog' && Object.hasOwn(scalarTypes, value.type)
75
- ? scalarTypes[value.type]
76
- : undefined;
77
- if (type) {
78
- scalar = type;
79
- }
80
- else {
81
- const underlying = value.declaredOid === value.oid
82
- ? ''
83
- : ` with underlying type ${JSON.stringify(value.typeSchema)}.${JSON.stringify(value.type)} (OID ${value.oid})`;
84
- onWarning(`Column ${column} uses PostgreSQL type ${JSON.stringify(value.declaredSchema)}.${JSON.stringify(value.declaredType)} (OID ${value.declaredOid})${underlying} without a verified pg mapping; generated as unknown.`);
85
- scalar = 'unknown';
86
- }
87
- }
88
- // unknown already includes null, including for nullable JSON and unmapped types.
89
- return `${scalar}${value.nullable && scalar !== 'unknown' ? ' | null' : ''}`;
90
- }
91
- export function renderRowTypes(tables, namespace, onWarning) {
92
- const rows = tables.map((table) => {
93
- const tableName = JSON.stringify(table.name);
94
- const columns = table.columns.map((column) => {
95
- const columnName = JSON.stringify(column.name);
96
- const path = `${JSON.stringify(namespace)}.${tableName}.${columnName}`;
97
- return ` ${columnName}: ${columnType(column, path, onWarning)};`;
98
- });
99
- return ` ${tableName}: {
100
- ${columns.join('\n')}
101
- };`;
102
- });
103
- const aliases = tables
104
- .filter((table) => canExportRowName(table.name))
105
- .map((table) => `export type ${table.name} = Rows[${JSON.stringify(table.name)}];`);
106
- const arrays = tables.some((table) => table.columns.some(isSupportedArray))
107
- ? 'export type PgArray<T> = (T | null | PgArray<T>)[];\n\n'
108
- : '';
109
- return `${arrays}// Full database rows. Compose query types with Pick, indexed access, and intersections.
110
- export type Rows = {
111
- ${rows.join('\n')}
112
- };
113
- ${aliases.join('\n')}
114
- `;
115
- }