@kequtech/tenon-orm 0.0.0-stage → 0.1.1
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/LICENSE +15 -0
- package/README.md +105 -2
- package/dist/affected-rows.d.ts +11 -0
- package/dist/affected-rows.js +30 -0
- package/dist/baseline.d.ts +19 -0
- package/dist/baseline.js +100 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +158 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +54 -0
- package/dist/connection.d.ts +15 -0
- package/dist/connection.js +51 -0
- package/dist/database.d.ts +7 -0
- package/dist/database.js +44 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +18 -0
- package/dist/generate.d.ts +9 -0
- package/dist/generate.js +34 -0
- package/dist/generation/catalog.d.ts +19 -0
- package/dist/generation/catalog.js +35 -0
- package/dist/generation/row-types.d.ts +2 -0
- package/dist/generation/row-types.js +115 -0
- package/dist/generation/schema-summary.d.ts +6 -0
- package/dist/generation/schema-summary.js +114 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +7 -0
- package/dist/migrations/baseline-sql.d.ts +3 -0
- package/dist/migrations/baseline-sql.js +38 -0
- package/dist/migrations/connection.d.ts +9 -0
- package/dist/migrations/connection.js +65 -0
- package/dist/migrations/files.d.ts +21 -0
- package/dist/migrations/files.js +131 -0
- package/dist/migrations/history.d.ts +12 -0
- package/dist/migrations/history.js +30 -0
- package/dist/migrations/sql.d.ts +2 -0
- package/dist/migrations/sql.js +94 -0
- package/dist/migrations.d.ts +10 -0
- package/dist/migrations.js +68 -0
- package/dist/mutations.d.ts +11 -0
- package/dist/mutations.js +87 -0
- package/dist/query-options.d.ts +15 -0
- package/dist/query-options.js +35 -0
- package/dist/query.d.ts +38 -0
- package/dist/query.js +129 -0
- package/dist/safeql/composition.d.ts +9 -0
- package/dist/safeql/composition.js +159 -0
- package/dist/safeql/eslint.d.ts +2 -0
- package/dist/safeql/eslint.js +45 -0
- package/dist/safeql/mutations.d.ts +3 -0
- package/dist/safeql/mutations.js +74 -0
- package/dist/safeql/plugin.d.ts +3 -0
- package/dist/safeql/plugin.js +43 -0
- package/dist/safeql/static.d.ts +10 -0
- package/dist/safeql/static.js +59 -0
- package/dist/safeql.d.ts +6 -0
- package/dist/safeql.js +7 -0
- package/dist/sql.d.ts +5 -0
- package/dist/sql.js +28 -0
- package/dist/transaction-sql.d.ts +2 -0
- package/dist/transaction-sql.js +67 -0
- package/dist/transaction.d.ts +4 -0
- package/dist/transaction.js +75 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +1 -0
- package/dist/validation.d.ts +1 -0
- package/dist/validation.js +10 -0
- package/docs/adoption-guide.md +91 -0
- package/docs/agent-guide.md +310 -0
- package/docs/human-guide.md +271 -0
- package/docs/mutations.md +115 -0
- package/docs/reference.md +568 -0
- package/package.json +92 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Adopting Tenon from another ORM
|
|
2
|
+
|
|
3
|
+
This guide covers replacing an existing ORM and, separately, transferring migration ownership. For a
|
|
4
|
+
query tutorial use the [human guide](human-guide.md); for API details use the
|
|
5
|
+
[reference](reference.md). Preserve the existing schema, connection configuration, data, and
|
|
6
|
+
application behavior while replacing the old ORM. Tenon does not need to own migrations to execute
|
|
7
|
+
queries or generate types; keep the existing migration runner if that is the intended scope.
|
|
8
|
+
|
|
9
|
+
## Replace application queries
|
|
10
|
+
|
|
11
|
+
1. Locate the old ORM's connection setup, schema, migrations, and call sites. Identify behavior
|
|
12
|
+
supplied by the ORM: validation, IDs, timestamps, hooks, defaults, relation loading,
|
|
13
|
+
serialization, and transaction boundaries.
|
|
14
|
+
2. If table row types will help your application, generate them from the existing database:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx tenon generate --out src/generated-schema.ts
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use `--namespace name` for a non-public schema. Generation reads the actual database and requires
|
|
21
|
+
no Tenon migration history.
|
|
22
|
+
|
|
23
|
+
3. Replace queries with parameterized SQL in ordinary functions with named object inputs where
|
|
24
|
+
useful. Preserve each caller's projection, null handling, ordering, and transaction behavior.
|
|
25
|
+
Generated types describe pg values: check bigint/numeric strings, dates, JSON, and arrays when
|
|
26
|
+
the old ORM transformed them.
|
|
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
|
|
30
|
+
writes and rollback, relationships, validation, and any hooks you moved into application code or
|
|
31
|
+
SQL. A schema-preserving first release makes application rollback easier.
|
|
32
|
+
5. Remove the old runtime dependency and unused generated code after its callers are gone. Keep the
|
|
33
|
+
migration tooling until the migration transition below is complete, if that transition is part of
|
|
34
|
+
the work.
|
|
35
|
+
|
|
36
|
+
## Move migration ownership to Tenon
|
|
37
|
+
|
|
38
|
+
Choose one final schema checkpoint for the old runner and bring existing target databases to it.
|
|
39
|
+
Disable its migration/schema synchronization before handing ownership to Tenon. The tools do not
|
|
40
|
+
share locks.
|
|
41
|
+
|
|
42
|
+
Capture one baseline from the reference database into an empty migration directory:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npx tenon baseline capture --directory migrations --namespace public
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This uses native `pg_dump --schema-only --no-owner --no-privileges` and writes
|
|
49
|
+
`0001_baseline.up.sql` without a down file. It requires a compatible `pg_dump` on PATH; prefer the
|
|
50
|
+
server's major. Default scope is the whole database schema. Repeat `--schema name` to select schemas
|
|
51
|
+
explicitly, accounting for dependencies, extensions, and required roles. Tenon's selected history
|
|
52
|
+
table is excluded; old ORM metadata is preserved for deliberate cleanup later.
|
|
53
|
+
|
|
54
|
+
Capture changes no source data, migration history, or sequence positions. Review and commit the SQL.
|
|
55
|
+
Keep the capture header: it identifies native dump SQL that bypasses the authored-migration scanner.
|
|
56
|
+
It is a convention, not proof of provenance; do not use it on handwritten migrations. Normal
|
|
57
|
+
transactional execution and checksums still apply.
|
|
58
|
+
|
|
59
|
+
On each existing database already at that checkpoint:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npx tenon baseline adopt
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Adoption records the baseline checksum without executing the SQL or comparing schemas. It requires
|
|
66
|
+
the selected namespace to exist. Repeating matching adoption is harmless, including after later
|
|
67
|
+
migrations; conflicting history fails.
|
|
68
|
+
|
|
69
|
+
On a fresh database, run migrations instead:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npx tenon migrate
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
That executes the baseline and subsequent migrations. Use the same committed baseline for every
|
|
76
|
+
environment. Do not run adoption unconditionally on fresh databases. Existing databases adopt once
|
|
77
|
+
and then use ordinary `migrate`.
|
|
78
|
+
|
|
79
|
+
Use the development [`db:migrate` script](human-guide.md#create-a-table) to apply subsequent
|
|
80
|
+
migrations and update table types together. Production continues to run only `npx tenon migrate`.
|
|
81
|
+
Verify both an existing database's upgrade and a fresh replay. Baselines contain schema only; they
|
|
82
|
+
do not back up rows or sequence positions. The missing down file prevents normal rollback through
|
|
83
|
+
the baseline. Keep the application's backup and recovery procedure.
|
|
84
|
+
|
|
85
|
+
Programmatic equivalents:
|
|
86
|
+
|
|
87
|
+
- `captureBaseline({ connection, directory?, namespace?, schemas?, onWarning? })` requires a
|
|
88
|
+
nonempty native libpq connection string and returns `{ id, upPath }`. Omit `schemas` for the whole
|
|
89
|
+
database schema; an explicitly empty list is rejected.
|
|
90
|
+
- `adoptBaseline({ connection, directory?, namespace? })` accepts the usual Tenon connection options
|
|
91
|
+
and returns newly recorded migration IDs.
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# Tenon agent guide
|
|
2
|
+
|
|
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.
|
|
5
|
+
This guide contains the setup and contracts needed for routine implementation; the
|
|
6
|
+
[reference](reference.md) covers additional options.
|
|
7
|
+
|
|
8
|
+
## Working conventions
|
|
9
|
+
|
|
10
|
+
1. Inspect the application's connection setup, migrations, generated types, query modules, and check
|
|
11
|
+
commands. Reuse them. Do not replace a working migration runner just to use Tenon queries.
|
|
12
|
+
2. Write straightforward SQL with explicit selected columns. Use separate complete queries when
|
|
13
|
+
variants return different shapes. Introduce fragments only for actual reuse or variable
|
|
14
|
+
composition.
|
|
15
|
+
3. Put reusable queries in functions. Accept a `QueryClient` and named object inputs when useful;
|
|
16
|
+
keep SQL and query options together. Call the same function with `db` or `tx`.
|
|
17
|
+
4. After SQL changes, run the configured checker, typechecking, and relevant database tests.
|
|
18
|
+
Generate/repair annotations deliberately and inspect the diff.
|
|
19
|
+
5. Keep validation, authorization, retries, and parser configuration in the application. A quoted
|
|
20
|
+
identifier or correct row count does not prove authorization.
|
|
21
|
+
|
|
22
|
+
## Install and connect
|
|
23
|
+
|
|
24
|
+
Requires Node.js 24+, PostgreSQL 16+, ESM, TypeScript 6+, and Node development typings. Keep the
|
|
25
|
+
application's strict TypeScript configuration.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install @kequtech/tenon-orm pg
|
|
29
|
+
npm install --save-dev @types/pg
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { createTenon } from '@kequtech/tenon-orm';
|
|
34
|
+
|
|
35
|
+
const connection = process.env.DATABASE_URL;
|
|
36
|
+
if (!connection) throw new Error('DATABASE_URL is required');
|
|
37
|
+
export const db = createTenon({ connection });
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`connection` accepts a URL, `pg.PoolConfig`, or existing `pg.Pool`. An explicit `{}` selects pg's
|
|
41
|
+
environment defaults. Reuse one instance per database connection. `close()` closes owned pools;
|
|
42
|
+
supplied pools remain application-owned and need the application's idle-error handler. Await
|
|
43
|
+
outstanding work before closing. Environment files are not loaded automatically. Qualify SQL names
|
|
44
|
+
or configure `search_path`; CLI `--namespace` does not change runtime query names.
|
|
45
|
+
|
|
46
|
+
## Query contracts
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { sql, type QueryClient } from '@kequtech/tenon-orm';
|
|
50
|
+
import type { User } from './generated-schema.js';
|
|
51
|
+
|
|
52
|
+
type Summary = Pick<User, 'id' | 'email'>;
|
|
53
|
+
|
|
54
|
+
function userById(client: QueryClient, { id }: { id: number }) {
|
|
55
|
+
return client.query(sql<Summary>`SELECT id, email FROM public."User" WHERE id = ${id}`, {
|
|
56
|
+
returns: 'oneOrNone',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const user = await userById(db, { id: 42 });
|
|
61
|
+
// In a transaction, call userById(tx, { id: 42 }).
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`sql<Row>` describes one selected row. It does not prove SQL/type agreement or validate JSON.
|
|
65
|
+
Unannotated fields stay `unknown`. Generated types describe full table rows; joins, aliases,
|
|
66
|
+
aggregates, and expressions need projection-specific types.
|
|
67
|
+
|
|
68
|
+
| `returns` | Result | Row-count contract |
|
|
69
|
+
| ------------------ | --------------------- | ------------------ |
|
|
70
|
+
| `native` (default) | pg `QueryResult<Row>` | Any |
|
|
71
|
+
| `many` | `Row[]` | Any |
|
|
72
|
+
| `oneOrNone` | `Row \| undefined` | At most one |
|
|
73
|
+
| `one` | `Row` | Exactly one |
|
|
74
|
+
|
|
75
|
+
`query` options are `{ returns?, affectedRows?, label? }`; unknown options fail. Return modes never
|
|
76
|
+
insert `LIMIT` or `RETURNING`. Returned-row checks alone do not undo autocommitted writes. `label`
|
|
77
|
+
is an optional nonempty diagnostic string.
|
|
78
|
+
|
|
79
|
+
## Parameters and composition
|
|
80
|
+
|
|
81
|
+
- Ordinary interpolations bind values. Do not quote them or author manual `$n` placeholders inside
|
|
82
|
+
templates. `undefined` binds SQL null.
|
|
83
|
+
- PostgreSQL infers parameter types from SQL. Cast standalone projections, e.g.
|
|
84
|
+
`SELECT ${value}::float8 AS value`; without a cast, a standalone parameter returns text.
|
|
85
|
+
- Use `id = ANY(${ids}::integer[])` for an array of IDs. Do not build a comma-separated string of
|
|
86
|
+
values.
|
|
87
|
+
- `identifier(schema, table)` quotes separate application-approved components. Ordinary strings such
|
|
88
|
+
as `'DESC'` remain bound values, not SQL syntax.
|
|
89
|
+
- `sql.fragment` carries SQL and bound values without a row type. Use it for shared predicates or
|
|
90
|
+
partial SQL. Fragments cannot execute alone; full `sql` and `unsafeSql` queries cannot be nested.
|
|
91
|
+
- `sql.join(fragments, separator)` requires a nonempty array of fragments and exactly `'AND'`,
|
|
92
|
+
`'OR'`, or `','`. No fragment or arbitrary-string separator is accepted. Empty lists and blank
|
|
93
|
+
fragments throw. Choose empty-filter behavior explicitly.
|
|
94
|
+
- Joins add no parentheses. Group mixed `AND`/`OR` predicates explicitly and preserve authorization
|
|
95
|
+
conditions when adding optional filters.
|
|
96
|
+
- `unsafeSql<Row>(text, values)` accepts trusted external SQL with an explicit values array. Never
|
|
97
|
+
concatenate request input into its text. Each query executes one statement.
|
|
98
|
+
|
|
99
|
+
For example, share a search condition between a page and count query:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
const search = sql.fragment`active = ${true} AND email ILIKE ${pattern}`;
|
|
103
|
+
const page = sql<Summary>`SELECT id, email FROM public."User"
|
|
104
|
+
WHERE ${search} ORDER BY id LIMIT ${limit}`;
|
|
105
|
+
const count = sql<{ count: string }>`SELECT count(*) AS count FROM public."User"
|
|
106
|
+
WHERE ${search}`;
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`pattern` and `limit` are validated application inputs. To compose existing predicates, use
|
|
110
|
+
`sql.join([first, second], 'AND')`. The checker supports literal join arrays, constant fragments,
|
|
111
|
+
literal/const-string separators, and [official mutation helpers](reference.md#object-based-mutation-fragments).
|
|
112
|
+
It rejects mutable or runtime-dependent SQL structure. Prefer fixed SQL for simple optional filters, e.g.
|
|
113
|
+
`(${email}::text IS NULL OR email = ${email})`. If runtime composition is required, retain an
|
|
114
|
+
explicit result type, suppress `tenon/check-composition` only on that query with a reason, and test
|
|
115
|
+
each branch. Parameter binding alone does not establish result correctness.
|
|
116
|
+
|
|
117
|
+
## Object-based writes
|
|
118
|
+
|
|
119
|
+
Use official `sql.insertValues(rowOrRows, columns?)` and `sql.assignments(patch, columns?)` fragments
|
|
120
|
+
instead of application helpers that enumerate object keys. Keep tables, authorization predicates,
|
|
121
|
+
expressions, conflict behavior and `RETURNING` visible in SQL. Insert missing/undefined fields use
|
|
122
|
+
PostgreSQL defaults; update missing/undefined fields are omitted. Explicit null stays null. Empty
|
|
123
|
+
batches/updates throw; a single default-only insert works. Omit generated columns and batch large
|
|
124
|
+
writes explicitly below the query-wide limit of 65,535 parameters. Expressions and queries are
|
|
125
|
+
rejected as cell values; write arithmetic, `DEFAULT`, and `excluded.column` in the surrounding SQL.
|
|
126
|
+
|
|
127
|
+
SafeQL reads inline object/array literals directly. Runtime inputs, spreads and data-builder results
|
|
128
|
+
need explicit inline selected columns, e.g. `sql.insertValues(rows, ['name', 'score'])`. Checking
|
|
129
|
+
uses all selected columns conservatively, without interpreting application JavaScript. It verifies
|
|
130
|
+
SQL and result annotations, not input values, constraints, or nonempty runtime input. See the
|
|
131
|
+
[mutation contract](reference.md#object-based-mutation-fragments) and [cookbook](mutations.md).
|
|
132
|
+
|
|
133
|
+
## Configure and run SQL checking
|
|
134
|
+
|
|
135
|
+
SafeQL checks SQL against the development/test schema and generates or verifies result annotations.
|
|
136
|
+
Configure it normally and add both exports from `@kequtech/tenon-orm/safeql`:
|
|
137
|
+
|
|
138
|
+
- `safeqlPlugin` goes in each SafeQL connection's `plugins`. It translates Tenon SQL and constant
|
|
139
|
+
fragments and supplies pg type mappings. SafeQL performs result inference.
|
|
140
|
+
- `eslintPlugin` goes in ESLint's `plugins`, with `tenon/check-composition` enabled. It reports
|
|
141
|
+
unsupported forms that the adapter skips. It does not wrap SafeQL's rule.
|
|
142
|
+
|
|
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.
|
|
147
|
+
|
|
148
|
+
Reuse existing configuration and scripts. For a new setup:
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
npm install --save-dev eslint typescript-eslint @ts-safeql/eslint-plugin
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
// eslint.config.mjs
|
|
156
|
+
import { defineConfig } from 'eslint/config';
|
|
157
|
+
import safeql from '@ts-safeql/eslint-plugin/config';
|
|
158
|
+
import tseslint from 'typescript-eslint';
|
|
159
|
+
import { safeqlPlugin, eslintPlugin } from '@kequtech/tenon-orm/safeql';
|
|
160
|
+
|
|
161
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
162
|
+
if (!databaseUrl) throw new Error('DATABASE_URL is required for SQL checking');
|
|
163
|
+
|
|
164
|
+
export default defineConfig({
|
|
165
|
+
files: ['src/**/*.ts'],
|
|
166
|
+
languageOptions: {
|
|
167
|
+
parser: tseslint.parser,
|
|
168
|
+
parserOptions: { projectService: true },
|
|
169
|
+
},
|
|
170
|
+
plugins: { tenon: eslintPlugin },
|
|
171
|
+
rules: { 'tenon/check-composition': 'error' },
|
|
172
|
+
extends: [
|
|
173
|
+
safeql.configs.connections({
|
|
174
|
+
databaseUrl,
|
|
175
|
+
plugins: [safeqlPlugin],
|
|
176
|
+
targets: [{ tag: 'sql' }],
|
|
177
|
+
}),
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Point the URL at the intended development/test database with migrations applied. Supply environment
|
|
183
|
+
variables through the application; no `.env` file is loaded here. `projectService` uses each file's
|
|
184
|
+
nearest TypeScript project. Preserve existing type-aware parser settings if already configured.
|
|
185
|
+
|
|
186
|
+
`targets` matches the tag spelling. Add aliases to the target list when using them. For a single
|
|
187
|
+
database, `{ tag: { regex: '.*' } }` includes all Tenon aliases and typed object properties; the
|
|
188
|
+
adapter excludes unrelated tags. The composition rule checks every Tenon query in its ESLint file
|
|
189
|
+
scope, independently of SafeQL's targets.
|
|
190
|
+
|
|
191
|
+
Reuse existing lint scripts or add:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{
|
|
195
|
+
"scripts": {
|
|
196
|
+
"lint": "eslint \"src/**/*.ts\"",
|
|
197
|
+
"lint:fix": "eslint \"src/**/*.ts\" --fix"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
SQL and annotation errors come from `@ts-safeql/check-sql`; unsupported composition errors come from
|
|
203
|
+
`tenon/check-composition`. Keep both rules enabled.
|
|
204
|
+
|
|
205
|
+
1. Run the development `db:migrate` script below against the intended development/test database.
|
|
206
|
+
2. Run `npm run lint`. Fix SQL mistakes before accepting generated types.
|
|
207
|
+
3. Run `npm run lint:fix` for missing/stale annotations. It can add
|
|
208
|
+
`sql<{ id: number; email: string }>` to a query selecting those columns. Review the diff; other
|
|
209
|
+
enabled ESLint rules may also fix files.
|
|
210
|
+
4. Run the application's typecheck and database tests. CI runs `lint` without `--fix`.
|
|
211
|
+
|
|
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.
|
|
244
|
+
|
|
245
|
+
## Transactions and writes
|
|
246
|
+
|
|
247
|
+
Use `db.transaction(async (tx) => ...)`; use `tx` for every query in scope and await all work. The
|
|
248
|
+
callback commits on success and rolls back on an escaping error. Do not issue direct
|
|
249
|
+
BEGIN/COMMIT/ROLLBACK boundaries or use `tx` after return. Transactions do not span database
|
|
250
|
+
instances. Nested callback transactions and automatic retries are not provided.
|
|
251
|
+
|
|
252
|
+
Use `affectedRows: { exact: n }` or `{ max: n }` for INSERT/UPDATE/DELETE/MERGE when a write must
|
|
253
|
+
respect a count. Bounds are nonnegative safe integers. A guarded `db.query` uses a transaction; a
|
|
254
|
+
guarded `tx.query` uses a savepoint. Rejected writes are rolled back before throwing. A caught
|
|
255
|
+
guarded failure permits continued work; ordinary SQL failures may abort the transaction. Reserve
|
|
256
|
+
savepoint names beginning with `tenon_affected_rows_`.
|
|
257
|
+
|
|
258
|
+
Affected counts cover the final command, not extra trigger/cascade/CTE effects. They cannot detect
|
|
259
|
+
the wrong single row. Sequence advances and external effects are not reversible. A lost commit
|
|
260
|
+
acknowledgment may leave the outcome unknown; retry only according to application policy. Native pg
|
|
261
|
+
errors retain their details; `AffectedRowsError` has codes `TENON_AFFECTED_ROWS_MISMATCH` and
|
|
262
|
+
`TENON_AFFECTED_ROWS_UNSUPPORTED`. Cleanup failures can produce `AggregateError`.
|
|
263
|
+
|
|
264
|
+
Optional `onQuery` observes labels, duration, outcome, and available command/count/ error code,
|
|
265
|
+
without SQL or values. Observers are not awaited. Query success inside a callback does not mean the
|
|
266
|
+
outer transaction committed.
|
|
267
|
+
|
|
268
|
+
## Schema changes
|
|
269
|
+
|
|
270
|
+
Reuse the application's migration setup. With Tenon's runner and generated table types, use this
|
|
271
|
+
development script in `package.json`:
|
|
272
|
+
|
|
273
|
+
```json
|
|
274
|
+
{
|
|
275
|
+
"scripts": {
|
|
276
|
+
"db:migrate": "tenon migrate && tenon generate --out src/generated-schema.ts"
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
```sh
|
|
282
|
+
npx tenon new create_users
|
|
283
|
+
# Fill the generated up/down SQL before migrating.
|
|
284
|
+
npm run db:migrate
|
|
285
|
+
npx tenon generate --out src/generated-schema.ts --check
|
|
286
|
+
npx tenon status
|
|
287
|
+
npx tenon rollback --steps 1
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Commit migrations and generated types together. Production runs only `npx tenon migrate` and deploys
|
|
291
|
+
the already-built application. Omit generation from the development script if table types are
|
|
292
|
+
unused.
|
|
293
|
+
|
|
294
|
+
Database commands require `DATABASE_URL`. Migration commands accept `--directory` (default
|
|
295
|
+
`migrations`) and `--namespace` (default `public`); generation accepts `--namespace`. Files use
|
|
296
|
+
`<numeric-id>_<name>.up.sql` with optional matching down SQL. Omit a down file for an intentionally
|
|
297
|
+
irreversible migration. Never edit applied files. For a custom namespace, pass it to both commands
|
|
298
|
+
in the development script.
|
|
299
|
+
|
|
300
|
+
Each migration commits with its history entry; earlier files remain applied if a later one fails.
|
|
301
|
+
Tools use session advisory locks: use a direct connection or session pooling and application
|
|
302
|
+
timeouts. Nontransactional DDL needs a separate procedure. `generate --check` detects representation
|
|
303
|
+
drift, not pending migrations or every possible schema change. Generation covers ordinary tables and
|
|
304
|
+
partitioned roots; views/foreign tables need authored query types.
|
|
305
|
+
|
|
306
|
+
When replacing another ORM, preserve its schema and behavior. If transferring migration ownership,
|
|
307
|
+
capture one reviewed baseline with `tenon baseline capture`, record it only on matching existing
|
|
308
|
+
databases with `tenon baseline adopt`, and use `tenon migrate` on fresh databases. Adoption does not
|
|
309
|
+
compare schemas or run baseline SQL. See [adoption](adoption-guide.md) for the full transition
|
|
310
|
+
procedure. A down migration or schema-only baseline is not a data backup.
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# Using Tenon: a human guide
|
|
2
|
+
|
|
3
|
+
Tenon lets you use PostgreSQL directly from TypeScript. You write SQL; Tenon binds its inputs and
|
|
4
|
+
executes it through `pg`. This guide follows a small application from its first table to reusable
|
|
5
|
+
queries, transactions, and result checking.
|
|
6
|
+
|
|
7
|
+
Use the [reference](reference.md) when you need exact options or unusual cases. The
|
|
8
|
+
[agent guide](agent-guide.md) is a separate working guide for coding agents;
|
|
9
|
+
[adoption](adoption-guide.md) covers moving an existing application to Tenon.
|
|
10
|
+
|
|
11
|
+
## Connect to your database
|
|
12
|
+
|
|
13
|
+
In an existing Node.js 24+ TypeScript project using ESM:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install @kequtech/tenon-orm pg
|
|
17
|
+
npm install --save-dev @types/pg
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Use PostgreSQL 16+ and TypeScript 6+. Keep your existing strict TypeScript configuration and Node
|
|
21
|
+
typings. Supply `DATABASE_URL` through your application's environment setup; Tenon does not load
|
|
22
|
+
`.env` files.
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// src/db.ts
|
|
26
|
+
import { createTenon } from '@kequtech/tenon-orm';
|
|
27
|
+
|
|
28
|
+
const connection = process.env.DATABASE_URL;
|
|
29
|
+
if (!connection) throw new Error('DATABASE_URL is required');
|
|
30
|
+
export const db = createTenon({ connection });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Reuse this instance across the application and call `await db.close()` at shutdown. For multiple
|
|
34
|
+
databases, create an instance for each connection. You can also supply an existing `pg.Pool`; your
|
|
35
|
+
application then closes that pool.
|
|
36
|
+
|
|
37
|
+
## Create a table
|
|
38
|
+
|
|
39
|
+
If you already have a schema and migration runner, you can keep them. For a new schema, create a
|
|
40
|
+
migration:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npx tenon new create_users
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Fill the generated files:
|
|
47
|
+
|
|
48
|
+
```sql
|
|
49
|
+
-- migrations/0001_create_users.up.sql
|
|
50
|
+
CREATE TABLE public."User" (
|
|
51
|
+
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
52
|
+
email text NOT NULL UNIQUE,
|
|
53
|
+
active boolean NOT NULL DEFAULT true
|
|
54
|
+
);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```sql
|
|
58
|
+
-- migrations/0001_create_users.down.sql
|
|
59
|
+
DROP TABLE public."User";
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For development, add a script to `package.json` that applies migrations and updates table row types:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"scripts": {
|
|
67
|
+
"db:migrate": "tenon migrate && tenon generate --out src/generated-schema.ts"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
npm run db:migrate
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Commit migrations and generated types together. In production, run only `npx tenon migrate`; deploy
|
|
77
|
+
the already-built application. If you do not use generated table types, omit generation from the
|
|
78
|
+
development script. For later schema changes, add a new migration instead of editing an applied one.
|
|
79
|
+
If a change cannot be reversed, omit its down file. A down migration is not a backup.
|
|
80
|
+
|
|
81
|
+
## Write a query
|
|
82
|
+
|
|
83
|
+
Create a row, then read the fields you need:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { sql } from '@kequtech/tenon-orm';
|
|
87
|
+
import { db } from './db.js';
|
|
88
|
+
|
|
89
|
+
const email = 'reader@example.test';
|
|
90
|
+
await db.query(sql`INSERT INTO public."User" (email) VALUES (${email})`);
|
|
91
|
+
const user = await db.query(
|
|
92
|
+
sql<{ id: number; email: string }>`
|
|
93
|
+
SELECT id, email FROM public."User" WHERE email = ${email}
|
|
94
|
+
`,
|
|
95
|
+
{ returns: 'oneOrNone' },
|
|
96
|
+
);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`${email}` becomes a bound parameter. Do not put quotes around it: the driver passes the value
|
|
100
|
+
separately from the SQL text, even when the value contains SQL.
|
|
101
|
+
|
|
102
|
+
The type describes one selected row. `returns: 'oneOrNone'` gives you that row or `undefined`; it
|
|
103
|
+
throws if the query returns more than one row. Use `'one'` when absence should also be an error,
|
|
104
|
+
`'many'` for an array, or omit `returns` for the full driver result with `.rows` and `.rowCount`.
|
|
105
|
+
These options do not change your SQL.
|
|
106
|
+
|
|
107
|
+
Generated table types can help describe projections without repeating every field:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import type { User } from './generated-schema.js';
|
|
111
|
+
type UserSummary = Pick<User, 'id' | 'email'>;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
You can then write `sql<UserSummary>`. A table type describes the full table; a join or aggregate
|
|
115
|
+
needs a type for what that particular query selects. An annotation is a declaration, not proof that
|
|
116
|
+
the SQL matches. Without one, result fields are `unknown`. The checking workflow below can generate
|
|
117
|
+
and verify it.
|
|
118
|
+
|
|
119
|
+
## Reuse a query with a function
|
|
120
|
+
|
|
121
|
+
Use an ordinary function when several callers need the same query. An object input makes the
|
|
122
|
+
supplied values easy to read:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { sql, type QueryClient } from '@kequtech/tenon-orm';
|
|
126
|
+
import type { User } from './generated-schema.js';
|
|
127
|
+
|
|
128
|
+
type UserSummary = Pick<User, 'id' | 'email'>;
|
|
129
|
+
|
|
130
|
+
export function userByEmail(client: QueryClient, { email }: { email: string }) {
|
|
131
|
+
return client.query(
|
|
132
|
+
sql<UserSummary>`SELECT id, email FROM public."User" WHERE email = ${email}`,
|
|
133
|
+
{ returns: 'oneOrNone' },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const user = await userByEmail(db, { email: 'reader@example.test' });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`QueryClient` means the function accepts either your database instance or a transaction client. Keep
|
|
141
|
+
the SQL and execution options together in the function.
|
|
142
|
+
|
|
143
|
+
## Change data in a transaction
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
await db.transaction(async (tx) => {
|
|
147
|
+
const user = await userByEmail(tx, { email: 'reader@example.test' });
|
|
148
|
+
if (!user) throw new Error('User not found');
|
|
149
|
+
|
|
150
|
+
await tx.query(sql`UPDATE public."User" SET active = false WHERE id = ${user.id}`, {
|
|
151
|
+
affectedRows: { exact: 1 },
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Use `tx` for all work in the callback and await it. Tenon commits when the callback succeeds and
|
|
157
|
+
rolls back when an error escapes.
|
|
158
|
+
|
|
159
|
+
`affectedRows: { exact: 1 }` requires exactly one affected row. `{ max: 1 }` permits zero or one.
|
|
160
|
+
Tenon rolls back a write that violates the bound. You can also use these guards on `db.query`
|
|
161
|
+
outside a callback transaction. The guard checks a count; you still need the correct `WHERE` clause
|
|
162
|
+
and application authorization. Checking returned rows with `returns` alone does not undo an
|
|
163
|
+
autocommitted write.
|
|
164
|
+
|
|
165
|
+
## Write objects without building column lists by hand
|
|
166
|
+
|
|
167
|
+
Use `sql.insertValues(rowOrRows)` inside an authored `INSERT` and `sql.assignments(patch)` after
|
|
168
|
+
`SET`. Inserts use database defaults for missing/undefined fields; updates omit those fields.
|
|
169
|
+
Explicit `null` stays null. Empty batches and updates throw. Heterogeneous insert rows remain
|
|
170
|
+
aligned, with `DEFAULT` for missing cells.
|
|
171
|
+
|
|
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}`);
|
|
175
|
+
```
|
|
176
|
+
|
|
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).
|
|
182
|
+
|
|
183
|
+
## Check your SQL and result types
|
|
184
|
+
|
|
185
|
+
There are three different jobs here:
|
|
186
|
+
|
|
187
|
+
- Tenon executes queries and binds values.
|
|
188
|
+
- TypeScript checks the application code and the result types you declare.
|
|
189
|
+
- SafeQL checks the SQL against your database and verifies or generates those types.
|
|
190
|
+
|
|
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).
|
|
198
|
+
|
|
199
|
+
Use the application's existing lint commands. If it has none, the setup shows `lint` and `lint:fix`.
|
|
200
|
+
They run ESLint, including all other enabled rules. Once configured, the normal workflow is:
|
|
201
|
+
|
|
202
|
+
1. Run `npm run db:migrate` against the development/test database.
|
|
203
|
+
2. Run `npm run lint` to find SQL errors and missing or incorrect annotations.
|
|
204
|
+
3. Use `npm run lint:fix` to generate or repair annotations, then review the diff.
|
|
205
|
+
4. Run TypeScript and database tests. CI runs the check without fixing files.
|
|
206
|
+
|
|
207
|
+
For example, SafeQL can turn `sql` followed by a query selecting `id, email` into
|
|
208
|
+
`sql<{ id: number; email: string }>` based on your actual schema. It can detect a renamed column or
|
|
209
|
+
an incorrect declared result type before the query runs.
|
|
210
|
+
|
|
211
|
+
Input validation and database tests still matter. Checking a query does not prove that a request is
|
|
212
|
+
authorized, that its values are valid, or that returned JSON has an application-specific shape.
|
|
213
|
+
|
|
214
|
+
## Reuse or assemble parts of SQL when needed
|
|
215
|
+
|
|
216
|
+
For fixed conditions, write them directly:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const users = await db.query(
|
|
220
|
+
sql<UserSummary>`SELECT id, email FROM public."User" WHERE active = ${true}`,
|
|
221
|
+
{ returns: 'many' },
|
|
222
|
+
);
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
A fragment is a piece of SQL that carries its bound values with it. It is useful when multiple
|
|
226
|
+
queries share a condition. For example, a search can use the same predicate for its result page and
|
|
227
|
+
its count:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const search = sql.fragment`active = ${true} AND email ILIKE ${pattern}`;
|
|
231
|
+
const page = sql<UserSummary>`SELECT id, email FROM public."User"
|
|
232
|
+
WHERE ${search} ORDER BY id LIMIT ${limit}`;
|
|
233
|
+
const count = sql<{ count: string }>`SELECT count(*) AS count FROM public."User"
|
|
234
|
+
WHERE ${search}`;
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Here `pattern` is an application-supplied search pattern and `limit` is a validated page size.
|
|
238
|
+
Execute each query with `db.query`. A fragment cannot execute alone; the complete query supplies the
|
|
239
|
+
result type. The checker can inspect these constant fragments.
|
|
240
|
+
|
|
241
|
+
For a simple optional filter, a fixed SQL predicate often suffices:
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
// email is string | null; null means no email filter.
|
|
245
|
+
const query = sql<UserSummary>`SELECT id, email FROM public."User"
|
|
246
|
+
WHERE active = ${true} AND (${email}::text IS NULL OR email = ${email})`;
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
If you need a variable list of conditions, `sql.join` combines fragments using `'AND'`, `'OR'`, or
|
|
250
|
+
`','`. **SafeQL cannot check runtime-built lists.** Keep an explicit result type and test each
|
|
251
|
+
variant; if you use SafeQL, suppress only this query with an explanation. Parameter binding still
|
|
252
|
+
works, but it does not prove that every variant is valid SQL or returns the declared columns.
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
const filters = [sql.fragment`active = ${true}`];
|
|
256
|
+
if (email !== undefined) filters.push(sql.fragment`email = ${email}`);
|
|
257
|
+
|
|
258
|
+
// eslint-disable-next-line tenon/check-composition -- Runtime filters are covered by database tests.
|
|
259
|
+
const query = sql<UserSummary>`SELECT id, email FROM public."User"
|
|
260
|
+
WHERE ${sql.join(filters, 'AND')} ORDER BY id`;
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The initial condition keeps this list nonempty. Empty lists throw so that missing filters cannot
|
|
264
|
+
silently remove a condition. `join` adds separators, not parentheses; wrap groups explicitly when
|
|
265
|
+
mixing `AND` and `OR`. Full queries cannot be used as fragments. See the
|
|
266
|
+
[composition reference](reference.md#sql-fragments-and-joins) for static checking rules and other
|
|
267
|
+
details.
|
|
268
|
+
|
|
269
|
+
For arrays, identifiers, type mappings, CLI options, and error behavior, use the
|
|
270
|
+
[reference](reference.md). You do not need fragments or a query builder to use PostgreSQL joins,
|
|
271
|
+
aggregates, upserts, or other SQL features.
|