@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.
Files changed (72) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +105 -2
  3. package/dist/affected-rows.d.ts +11 -0
  4. package/dist/affected-rows.js +30 -0
  5. package/dist/baseline.d.ts +19 -0
  6. package/dist/baseline.js +100 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +158 -0
  9. package/dist/client.d.ts +4 -0
  10. package/dist/client.js +54 -0
  11. package/dist/connection.d.ts +15 -0
  12. package/dist/connection.js +51 -0
  13. package/dist/database.d.ts +7 -0
  14. package/dist/database.js +44 -0
  15. package/dist/errors.d.ts +9 -0
  16. package/dist/errors.js +18 -0
  17. package/dist/generate.d.ts +9 -0
  18. package/dist/generate.js +34 -0
  19. package/dist/generation/catalog.d.ts +19 -0
  20. package/dist/generation/catalog.js +35 -0
  21. package/dist/generation/row-types.d.ts +2 -0
  22. package/dist/generation/row-types.js +115 -0
  23. package/dist/generation/schema-summary.d.ts +6 -0
  24. package/dist/generation/schema-summary.js +114 -0
  25. package/dist/index.d.ts +18 -0
  26. package/dist/index.js +7 -0
  27. package/dist/migrations/baseline-sql.d.ts +3 -0
  28. package/dist/migrations/baseline-sql.js +38 -0
  29. package/dist/migrations/connection.d.ts +9 -0
  30. package/dist/migrations/connection.js +65 -0
  31. package/dist/migrations/files.d.ts +21 -0
  32. package/dist/migrations/files.js +131 -0
  33. package/dist/migrations/history.d.ts +12 -0
  34. package/dist/migrations/history.js +30 -0
  35. package/dist/migrations/sql.d.ts +2 -0
  36. package/dist/migrations/sql.js +94 -0
  37. package/dist/migrations.d.ts +10 -0
  38. package/dist/migrations.js +68 -0
  39. package/dist/mutations.d.ts +11 -0
  40. package/dist/mutations.js +87 -0
  41. package/dist/query-options.d.ts +15 -0
  42. package/dist/query-options.js +35 -0
  43. package/dist/query.d.ts +38 -0
  44. package/dist/query.js +129 -0
  45. package/dist/safeql/composition.d.ts +9 -0
  46. package/dist/safeql/composition.js +159 -0
  47. package/dist/safeql/eslint.d.ts +2 -0
  48. package/dist/safeql/eslint.js +45 -0
  49. package/dist/safeql/mutations.d.ts +3 -0
  50. package/dist/safeql/mutations.js +74 -0
  51. package/dist/safeql/plugin.d.ts +3 -0
  52. package/dist/safeql/plugin.js +43 -0
  53. package/dist/safeql/static.d.ts +10 -0
  54. package/dist/safeql/static.js +59 -0
  55. package/dist/safeql.d.ts +6 -0
  56. package/dist/safeql.js +7 -0
  57. package/dist/sql.d.ts +5 -0
  58. package/dist/sql.js +28 -0
  59. package/dist/transaction-sql.d.ts +2 -0
  60. package/dist/transaction-sql.js +67 -0
  61. package/dist/transaction.d.ts +4 -0
  62. package/dist/transaction.js +75 -0
  63. package/dist/types.d.ts +28 -0
  64. package/dist/types.js +1 -0
  65. package/dist/validation.d.ts +1 -0
  66. package/dist/validation.js +10 -0
  67. package/docs/adoption-guide.md +91 -0
  68. package/docs/agent-guide.md +310 -0
  69. package/docs/human-guide.md +271 -0
  70. package/docs/mutations.md +115 -0
  71. package/docs/reference.md +568 -0
  72. 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.