@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 +11 -9
- package/dist/generate.js +2 -2
- package/dist/generation/catalog.d.ts +3 -0
- package/dist/generation/catalog.js +5 -2
- package/dist/generation/table-types.d.ts +2 -0
- package/dist/generation/table-types.js +146 -0
- package/dist/mutations.d.ts +1 -1
- package/dist/query.d.ts +2 -2
- package/dist/query.js +1 -1
- package/dist/safeql/composition.js +9 -0
- package/dist/safeql/eslint.js +16 -11
- package/docs/adoption-guide.md +97 -4
- package/docs/agent-guide.md +21 -39
- package/docs/human-guide.md +22 -17
- package/docs/mutations.md +47 -5
- package/docs/reference.md +83 -40
- package/package.json +1 -1
- package/dist/generation/row-types.d.ts +0 -2
- package/dist/generation/row-types.js +0 -115
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
|
|
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
|
|
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
|
|
83
|
-
queries, constant fragments, and
|
|
84
|
-
|
|
85
|
-
|
|
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 {
|
|
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 =
|
|
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.
|
|
@@ -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,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
|
+
}
|
package/dist/mutations.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type MutationPart = string | Readonly<{
|
|
2
2
|
value: unknown;
|
|
3
3
|
}>;
|
|
4
|
-
export type MutationColumns<
|
|
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<
|
|
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<
|
|
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(
|
|
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()) {
|
package/dist/safeql/eslint.js
CHANGED
|
@@ -22,21 +22,26 @@ export const eslintPlugin = {
|
|
|
22
22
|
!ts.isTaggedTemplateExpression(template) ||
|
|
23
23
|
!isExport(template.tag, checker, 'sql'))
|
|
24
24
|
return;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
},
|
package/docs/adoption-guide.md
CHANGED
|
@@ -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
|
|
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)
|
|
29
|
-
|
|
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.
|
package/docs/agent-guide.md
CHANGED
|
@@ -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
|
|
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
|
|
144
|
-
|
|
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,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
```
|
package/docs/human-guide.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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.
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
93
|
-
|
|
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.
|
|
111
|
-
|
|
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.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
281
|
-
|
|
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.
|
|
293
|
-
|
|
294
|
-
`
|
|
295
|
-
|
|
296
|
-
use query-specific types. A missing namespace
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
|
300
|
-
|
|
|
301
|
-
|
|
|
302
|
-
|
|
|
303
|
-
|
|
|
304
|
-
|
|
|
305
|
-
|
|
|
306
|
-
|
|
|
307
|
-
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
|
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,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
|
-
}
|