@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.
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
@@ -68,7 +68,9 @@ migrations and table type generation are independent conveniences.
68
68
 
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
- support selected columns and [SafeQL checking](docs/reference.md#object-based-mutation-fragments).
71
+ support selected columns and [SafeQL checking](docs/reference.md#checking-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/cli.js CHANGED
@@ -5,6 +5,7 @@ import { dirname, resolve } from 'node:path';
5
5
  import { parseArgs } from 'node:util';
6
6
  import { adoptBaseline, captureBaseline } from './baseline.js';
7
7
  import { generateDefinitions } from './generate.js';
8
+ import { definitionDiff } from './generation/diff.js';
8
9
  import { migrate, migrationStatus, rollback } from './migrations.js';
9
10
  import { createMigration } from './migrations/files.js';
10
11
  const help = `tenon migrate|status|rollback [--directory migrations] [--namespace public]
@@ -30,9 +31,10 @@ async function writeDefinitions(source, filename, check) {
30
31
  if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
31
32
  throw error;
32
33
  }
34
+ throw new Error(`Generated definitions file is missing: ${output}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
33
35
  }
34
36
  if (existing !== source) {
35
- throw new Error('Generated definitions are stale or missing; run tenon generate and commit the result.');
37
+ throw new Error(`Generated definitions differ: ${output}\n${definitionDiff(existing, source)}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
36
38
  }
37
39
  process.stdout.write('Generated definitions are current.\n');
38
40
  return;
@@ -152,7 +154,7 @@ async function main() {
152
154
  }
153
155
  }
154
156
  main().catch((error) => {
155
- // Do not print connection configuration or raw SQL/parameter payloads.
157
+ // Report diagnostics without connection configuration or query parameter payloads.
156
158
  process.stderr.write(`${error instanceof Error ? error.message : 'Database command failed.'}\n`);
157
159
  process.exitCode = 1;
158
160
  });
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
+ /** A bounded text excerpt, not a schema comparison or an applicable patch. */
2
+ export declare function definitionDiff(saved: string, generated: string): string;
@@ -0,0 +1,30 @@
1
+ /** A bounded text excerpt, not a schema comparison or an applicable patch. */
2
+ export function definitionDiff(saved, generated) {
3
+ const before = saved.split('\n');
4
+ const after = generated.split('\n');
5
+ let start = 0;
6
+ while (start < before.length && start < after.length && before[start] === after[start]) {
7
+ start++;
8
+ }
9
+ if (start === before.length && start === after.length)
10
+ return '';
11
+ let beforeEnd = before.length;
12
+ let afterEnd = after.length;
13
+ while (beforeEnd > start && afterEnd > start && before[beforeEnd - 1] === after[afterEnd - 1]) {
14
+ beforeEnd--;
15
+ afterEnd--;
16
+ }
17
+ function excerpt(lines, end, marker) {
18
+ const shown = lines.slice(start, Math.min(end, start + 8));
19
+ const output = shown.map((line) => `${marker} ${line.length > 200 ? line.slice(0, 200) + '…' : line}`);
20
+ if (end - start > shown.length) {
21
+ output.push(` … ${end - start - shown.length} more ${marker} lines omitted`);
22
+ }
23
+ return output;
24
+ }
25
+ return [
26
+ `Diff excerpt from line ${start + 1} (- saved, + generated):`,
27
+ ...excerpt(before, beforeEnd, '-'),
28
+ ...excerpt(after, afterEnd, '+'),
29
+ ].join('\n');
30
+ }
@@ -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.