@kequtech/tenon-orm 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ # Schema types and values
2
+
3
+ Tenon can generate TypeScript declarations from an existing PostgreSQL schema. Generation is
4
+ optional and works with any migration runner. It describes table storage; your query still defines
5
+ the selected columns, joins, and expressions.
6
+
7
+ ## Generate and check a file
8
+
9
+ Configure `generate.out` in [shared configuration](configuration.md), then run after migrations
10
+ have committed:
11
+
12
+ ```sh
13
+ npx tenon generate
14
+ npx tenon generate --check
15
+ ```
16
+
17
+ The first command writes declarations. The second rejects missing or stale output without rewriting
18
+ it. Both use the configured connection and codecs. Relative configured paths resolve from the config
19
+ file; `--out` paths resolve from the working directory. The generation namespace comes from
20
+ `generate.namespace`, then `migrations.namespace`, then `public`; `--namespace` overrides it. It is
21
+ independent of runtime `search_path`.
22
+
23
+ Keep exact output unchanged and exclude it from automatic formatting. See
24
+ [getting started](human-guide.md#generate-types) for npm commands.
25
+
26
+ Generation runs outside callback transactions. It uses a repeatable-read catalog snapshot and
27
+ shares the migration advisory lock; use a direct or session-pooled connection. It needs no migration
28
+ files or Tenon history.
29
+
30
+ Tenon creates parent directories and replaces the entire output file atomically. With `--check`,
31
+ it creates no directories, writes no files, and includes a bounded diff for stale output. The CLI
32
+ prints warnings to stderr. Generation failures preserve the existing file; filesystem errors propagate.
33
+
34
+ For source text without file handling, use
35
+ `generateDefinitions({ connection, namespace?, onWarning? }): Promise<string>`. Pass the configured
36
+ pool or connection options to retain runtime read mappings. Its optional `onWarning` callback may
37
+ be synchronous or asynchronous and defaults to `console.warn`; callback failures reject generation.
38
+
39
+ ## Read the generated types
40
+
41
+ For the table from [Getting started](human-guide.md#create-and-apply-a-migration), the important
42
+ parts of the generated declaration look like this:
43
+
44
+ ```ts
45
+ import type { Parameter } from '@kequtech/tenon-orm';
46
+
47
+ export type Database = {
48
+ users: {
49
+ row: {
50
+ id: number;
51
+ email: string;
52
+ active: boolean;
53
+ };
54
+ insert: {
55
+ id?: never;
56
+ email: string | Parameter;
57
+ active?: boolean | Parameter | undefined;
58
+ };
59
+ update: {
60
+ id?: never;
61
+ email?: string | Parameter | undefined;
62
+ active?: boolean | Parameter | undefined;
63
+ };
64
+ };
65
+ };
66
+ ```
67
+
68
+ The real file also contains schema-summary comments for the tables, columns, keys, indexes,
69
+ constraints, and relevant domain definitions. Generation includes ordinary tables and partitioned
70
+ roots in one schema; it excludes attached partitions, views, materialized views, and Tenon's history
71
+ table. It does not generate models, relationship methods, migrations, or validators.
72
+
73
+ Use the types explicitly:
74
+
75
+ ```ts
76
+ import { sql } from '@kequtech/tenon-orm';
77
+ import type { Database } from './generated-schema.js';
78
+
79
+ type UserSummary = Pick<Database['users']['row'], 'id' | 'email'>;
80
+ const input = { email: 'reader@example.test' } satisfies Database['users']['insert'];
81
+
82
+ const user = await db.query(
83
+ sql<UserSummary>`INSERT INTO public.users ${sql.insertValues(input)} RETURNING id, email`,
84
+ { returns: 'one' },
85
+ );
86
+ ```
87
+
88
+ A table's `row` type describes its complete stored row. Use `Pick` for a projection and a separate
89
+ type for joins or aggregates. `sql<Row>` does not check that your SQL returns that shape. See
90
+ [query checking](query-checking.md) and [database tests](query-inspection.md#test-the-same-statement).
91
+
92
+ ### Application aliases
93
+
94
+ Keep shorter names in a handwritten module so regeneration preserves them:
95
+
96
+ ```ts
97
+ // src/schema-types.ts
98
+ import type { Database } from './generated-schema.js';
99
+
100
+ export type UserRow = Database['users']['row'];
101
+ export type UserInsert = Database['users']['insert'];
102
+ export type UserUpdate = Database['users']['update'];
103
+ ```
104
+
105
+ The generated `Database` is a schema map, distinct from Tenon's runtime `Database` interface. If a
106
+ module needs both, alias one of the imports.
107
+
108
+ ## Insert and update inputs
109
+
110
+ Insert fields are required when writable, nonnullable, and without a default. Other writable insert
111
+ fields and all writable update fields are optional, with explicit undefined accepted even under
112
+ `exactOptionalPropertyTypes`.
113
+
114
+ - Computed columns and `GENERATED ALWAYS` identity columns use optional `never` fields.
115
+ - Serial and `GENERATED BY DEFAULT` identity fields are optional on insert and writable on update.
116
+ Other primary keys remain writable.
117
+ - Domain defaults count; column defaults take precedence. Write nullability respects the domain
118
+ chain as well as the column.
119
+ - Every writable field accepts an explicit `Parameter` in addition to its plain input types.
120
+
121
+ Apply types through an annotation, `satisfies`, or a helper generic. Merely generating a file does
122
+ not check writes. Use `Pick` or `Omit` for application restrictions, and validate external input.
123
+ The types do not enforce CHECK constraints, JSON members, authorization, or a nonempty patch.
124
+
125
+ The [mutation helpers](mutations.md) omit undefined fields for updates and use defaults for missing
126
+ insert values. A selected column list can discard a required input even when the full object is
127
+ well typed; PostgreSQL still enforces constraints.
128
+
129
+ ## Value mappings
130
+
131
+ These are the default driver representations. SQL NULL is `null`; nullable generated columns add
132
+ null to their type. Input encoding and output decoding are separate choices.
133
+
134
+ | PostgreSQL value | Default read | Plain write input |
135
+ | ----------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
136
+ | smallint, integer, serial, oid | `number` | `number` |
137
+ | bigint, bigserial, `count(*)` | Exact `string` | Exact string or bigint |
138
+ | numeric/decimal | `string` | String or bigint |
139
+ | real, double precision | `number` | Finite number |
140
+ | boolean | `boolean` | Boolean |
141
+ | text, UUID, other textual types | `string` | String |
142
+ | enum | Catalog label union in default-registry generated rows | Label union |
143
+ | date, timestamp, timestamptz | `string` | String, Date, or supported Temporal value |
144
+ | time, interval | `string` | String or supported Temporal value |
145
+ | timetz | `string` | String |
146
+ | json/jsonb | `unknown` | Encoded JSON string; use `json(value)` for a parameter |
147
+ | bytea | `Uint8Array` | Uint8Array |
148
+ | Supported built-in arrays | Recursive readonly `PgArray<T>` | Encoded PostgreSQL array text; use `param` for an array value |
149
+ | Unrecognized types and dynamic enum/domain/extension arrays | Wire-text `string` | Encoded string or explicit parameter |
150
+
151
+ Generated inputs conservatively describe column-compatible representations; an explicit `Parameter`
152
+ is accepted for every writable column. PostgreSQL checks ranges and text formats. Plain undefined,
153
+ objects, and arrays are rejected as bound parameters. See [JSON and arrays](mutations.md#json-and-postgresql-arrays).
154
+
155
+ Bigint and numeric arrays retain exact strings. Use `BigInt(value)` for exact integer arithmetic;
156
+ convert to number only after checking the safe integer range. Command `rowCount` is a number or null,
157
+ independent of SQL aggregate values; counts beyond the safe integer range reject rather than round.
158
+
159
+ Temporal strings preserve precision and infinity spellings. Date inputs encode in UTC; use a calendar
160
+ date string or corresponding Temporal plain value when an instant is not intended. Inputs do not
161
+ change output types. JSON numbers follow JavaScript number precision; parsed JSON null and SQL NULL
162
+ both become null. For raw JSON text or Date/bigint outputs, configure codecs.
163
+
164
+ ## Custom codecs
165
+
166
+ Configure the registry on the application's connection. For example:
167
+
168
+ ```ts
169
+ import { builtin, createTenon, defaultCodecs } from '@kequtech/tenon-orm';
170
+
171
+ const codecs = defaultCodecs.with(builtin.int8BigInt).with(builtin.timestamptzDate);
172
+ const db = createTenon({ connection: { connectionString: connection, codecs } });
173
+ ```
174
+
175
+ Int8 now reads as bigint, and timestamptz reads as a millisecond-precision Date. Regenerate using this
176
+ instance. Scalar overrides affect derived built-in arrays; explicit array overrides win. Date and
177
+ Temporal output codecs reject infinity and values outside their supported ranges.
178
+
179
+ For Temporal, import `createTemporalCodecs` from Tenon and supply a native or compatible polyfill
180
+ implementation. Tenon does not install a polyfill or change globals. A polyfill can provide its own
181
+ generated type namespace:
182
+
183
+ ```ts
184
+ import { Temporal } from '@js-temporal/polyfill';
185
+ import { createTemporalCodecs, defaultCodecs } from '@kequtech/tenon-orm';
186
+
187
+ const temporal = createTemporalCodecs(Temporal, {
188
+ typescriptNamespace: 'import("@js-temporal/polyfill").Temporal',
189
+ });
190
+ const codecs = defaultCodecs.with(temporal.date).with(temporal.timestamptz);
191
+ ```
192
+
193
+ Install the polyfill only if the application needs it. With native Temporal, the consuming TypeScript
194
+ configuration needs the corresponding Temporal library declarations.
195
+
196
+ A custom text codec can declare `text.typescript` to describe its non-NULL output. Generation and
197
+ checking consume the registry's metadata. An unidentified decoder warns and becomes `unknown`;
198
+ a declared representation is trusted, not runtime validation. Under a custom registry, enum output
199
+ may remain string because a decoder can transform labels; enum write inputs retain catalog choices.
200
+
201
+ Place the registry in shared `connection.codecs` so runtime, CLI generation, inspection, and source
202
+ checking use it consistently. Programmatic inspection/checking inherit `connection.codecs` too. Relative type-import
203
+ expressions resolve from the generated file for saved declarations and from the tsconfig directory
204
+ for checking. Prefer a package or configured alias that resolves in both places. Resolving these
205
+ type expressions does not execute their referenced modules. See [custom-codec checking](query-checking.md#programmatic-use-and-custom-codecs).
206
+
207
+ Catalog queries use default result decoding on the same borrowed session, so application codecs do
208
+ not alter Tenon's metadata inputs. Output codec choices do not select input encoders.
209
+
210
+ ## Persistence and domain values
211
+
212
+ Storage types can be broader than application types. Text CHECK constraints do not become unions,
213
+ and JSON reads stay unknown. Validate external data before writing and database data before narrowing
214
+ it to a domain type.
215
+
216
+ For an additional `public.sessions(id text PRIMARY KEY, data jsonb NOT NULL, expires_at timestamptz
217
+ NOT NULL)` table, a JSON validator can be shared by writes and reads:
218
+
219
+ ```ts
220
+ import { json, sql } from '@kequtech/tenon-orm';
221
+
222
+ function sessionData(value: unknown): { userId: string } {
223
+ if (
224
+ typeof value !== 'object' ||
225
+ value === null ||
226
+ Array.isArray(value) ||
227
+ !('userId' in value) ||
228
+ typeof value.userId !== 'string' ||
229
+ value.userId.length === 0
230
+ ) {
231
+ throw new Error('Expected session data with a nonempty userId');
232
+ }
233
+ return { userId: value.userId };
234
+ }
235
+
236
+ const id = 'example-session';
237
+ const expiresAt = new Date(Date.now() + 60_000);
238
+ await db.query(sql`INSERT INTO public.sessions (id, data, expires_at)
239
+ VALUES (${id}, ${json(sessionData({ userId: 'user-42' }))}, ${expiresAt})`);
240
+
241
+ const row = await db.query(
242
+ sql<{ id: string; data: unknown; expires_at: string }>`
243
+ SELECT id, data, expires_at FROM public.sessions
244
+ WHERE id = ${id} AND isfinite(expires_at) AND expires_at > now()
245
+ `,
246
+ { returns: 'oneOrNone' },
247
+ );
248
+ if (row) {
249
+ const data = sessionData(row.data);
250
+ const expiry = new Date(row.expires_at);
251
+ if (!Number.isFinite(expiry.getTime())) throw new Error('Expected a finite timestamp');
252
+ // data.userId and expiry now have validated application representations.
253
+ }
254
+ ```
255
+
256
+ `isfinite` excludes positive infinity, which would otherwise pass `expires_at > now()`. Keep the
257
+ same policy in expiry-sensitive updates/deletes. Test expired and infinite values, and retain the
258
+ application's authentication and authorization checks.
259
+
260
+ ## What freshness checks establish
261
+
262
+ `tenon generate --check` compares the exact generated file with output from this database and codec registry.
263
+ It does not check query annotations, pending migrations, functions, privileges, or every database
264
+ object. A driver or generator update can change output without schema drift. Review generated diffs
265
+ after upgrades. Generated text and schema-summary formatting are not version-stable schema
266
+ fingerprints; see [baseline verification](adoption-guide.md#check-and-adopt-an-existing-database).
package/docs/sql.md ADDED
@@ -0,0 +1,212 @@
1
+ # Writing queries
2
+
3
+ Tenon executes PostgreSQL SQL. A query has SQL text, separately bound values, and an optional
4
+ TypeScript type for one returned row. Examples use the `public.users` table from
5
+ [Getting started](human-guide.md) and an existing `db` instance.
6
+
7
+ ## Bind values
8
+
9
+ ```ts
10
+ import { sql } from '@kequtech/tenon-orm';
11
+
12
+ const email = 'reader@example.test';
13
+ const query = sql<{ id: number; email: string }>`
14
+ SELECT id, email FROM public.users WHERE email = ${email}
15
+ `;
16
+ // query.text: SQL containing WHERE email = $1
17
+ // query.values: ['reader@example.test']
18
+ const user = await db.query(query, { returns: 'oneOrNone' });
19
+ ```
20
+
21
+ Constructing `query` does not contact PostgreSQL. Execute it with `db.query` or `tx.query`. Do not
22
+ quote interpolations or write `$1` placeholders inside templates.
23
+
24
+ Plain inputs accept null, strings, finite numbers, bigint, booleans, valid Date values, supported
25
+ Temporal values, and Uint8Array. Arrays and objects need explicit encoders; undefined is not a bound
26
+ SQL value. See [JSON and arrays](mutations.md#json-and-postgresql-arrays). Bound data must remain
27
+ unchanged until the query settles.
28
+
29
+ ## Choose the result you need
30
+
31
+ Use `one` for exactly one row, `oneOrNone` for zero or one, and `many` for an array. The default
32
+ `native` result provides rows and command metadata:
33
+
34
+ ```ts
35
+ const result = await db.query(sql`UPDATE public.users SET active = false WHERE id = ${42}`);
36
+ console.log(result.command, result.rowCount); // UPDATE, number of affected rows
37
+ ```
38
+
39
+ The modes check rows actually returned. An `UPDATE` without `RETURNING` returns no rows even when
40
+ it changes data. Modes do not add `RETURNING` or `LIMIT`. See the
41
+ [result reference](reference.md#queries-and-result-modes) for exact types.
42
+
43
+ Reuse queries with functions that accept `QueryClient`, so the same function can receive `db` or
44
+ `tx`. The [getting-started example](human-guide.md#reuse-a-query) shows this pattern.
45
+
46
+ ## Join related tables
47
+
48
+ Add this example table in a migration:
49
+
50
+ ```sql
51
+ CREATE TABLE public.posts (
52
+ id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
53
+ user_id integer NOT NULL REFERENCES public.users(id),
54
+ title text NOT NULL
55
+ );
56
+ ```
57
+
58
+ Select and alias the columns you want. There is no relationship configuration or automatic loading:
59
+
60
+ ```ts
61
+ type PostWithAuthor = { id: number; title: string; authorEmail: string };
62
+
63
+ const posts = await db.query(
64
+ sql<PostWithAuthor>`
65
+ SELECT p.id, p.title, u.email AS "authorEmail"
66
+ FROM public.posts AS p
67
+ JOIN public.users AS u ON u.id = p.user_id
68
+ WHERE u.active = ${true}
69
+ ORDER BY p.id
70
+ `,
71
+ { returns: 'many' },
72
+ );
73
+ ```
74
+
75
+ Use unique column names; selecting both `p.id` and `u.id` without aliases produces duplicate names,
76
+ which the driver rejects. Quoted aliases preserve camelCase. An outer join can introduce null even
77
+ for a source column declared `NOT NULL`; reflect that in the query's result type:
78
+
79
+ ```ts
80
+ type UserWithPost = { userId: number; title: string | null };
81
+ const rows = await db.query(
82
+ sql<UserWithPost>`
83
+ SELECT u.id AS "userId", p.title
84
+ FROM public.users AS u
85
+ LEFT JOIN public.posts AS p ON p.user_id = u.id
86
+ ORDER BY u.id, p.id
87
+ `,
88
+ { returns: 'many' },
89
+ );
90
+ ```
91
+
92
+ A user with several posts appears several times. Group rows in application code or use PostgreSQL
93
+ aggregation when a nested result is needed. JSON aggregate output is `unknown` by default; validate
94
+ it before narrowing to an application type.
95
+
96
+ ## Aggregate values
97
+
98
+ ```ts
99
+ const totals = await db.query(
100
+ sql<{ userId: number; total: string }>`
101
+ SELECT u.id AS "userId", count(p.id) AS total
102
+ FROM public.users AS u
103
+ LEFT JOIN public.posts AS p ON p.user_id = u.id
104
+ GROUP BY u.id
105
+ ORDER BY u.id
106
+ `,
107
+ { returns: 'many' },
108
+ );
109
+ ```
110
+
111
+ `count(p.id)` counts matched posts, including zero for a user without posts. `count(*)` would also
112
+ count that user's outer-join row. Both return PostgreSQL bigint, decoded as an exact string. Other
113
+ aggregate result types depend on PostgreSQL's selected function and input type; use
114
+ [inspection](query-inspection.md) when unsure.
115
+
116
+ ## Paginate with an explicit order
117
+
118
+ For an integer ID cursor, validate `afterId` and `pageSize` in application code, then bind them:
119
+
120
+ ```ts
121
+ async function usersAfter(afterId: number, pageSize: number) {
122
+ if (!Number.isInteger(afterId) || afterId < 0) throw new Error('Invalid cursor');
123
+ if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
124
+ throw new Error('Page size must be between 1 and 100');
125
+ }
126
+ return db.query(
127
+ sql<{ id: number; email: string }>`
128
+ SELECT id, email FROM public.users
129
+ WHERE id > ${afterId} ORDER BY id LIMIT ${pageSize}
130
+ `,
131
+ { returns: 'many' },
132
+ );
133
+ }
134
+ ```
135
+
136
+ Start with `afterId = 0`, then use the last returned ID for the next page. A unique ordering makes
137
+ page boundaries deterministic. For another sort column, include a unique tiebreaker and carry both
138
+ values in the cursor. Tenon does not add pagination or ordering.
139
+
140
+ ## Compose SQL fragments
141
+
142
+ A fragment carries SQL and its bound values. It cannot execute alone and has no result type:
143
+
144
+ ```ts
145
+ const pattern = '%example%';
146
+ const condition = sql.fragment`active = ${true} AND email ILIKE ${pattern}`;
147
+ const page = sql<{ id: number; email: string }>`
148
+ SELECT id, email FROM public.users WHERE ${condition} ORDER BY id LIMIT ${20}
149
+ `;
150
+ const total = sql<{ total: string }>`SELECT count(*) AS total FROM public.users WHERE ${condition}`;
151
+ ```
152
+
153
+ Use `sql.join` for a list of conditions. It supports `'AND'`, `'OR'`, and `','`, without adding
154
+ parentheses:
155
+
156
+ ```ts
157
+ const terms = ['reader', 'example'];
158
+ const filters = [sql.fragment`active = ${true}`];
159
+ for (const term of terms) {
160
+ filters.push(sql.fragment`email ILIKE ${'%' + term + '%'}`);
161
+ }
162
+ const query = sql<{ id: number }>`
163
+ SELECT id FROM public.users WHERE ${sql.join(filters, 'AND')} ORDER BY id
164
+ `;
165
+ ```
166
+
167
+ The initial condition keeps the list nonempty. If every condition is optional, choose a complete
168
+ query without `WHERE` when the list is empty. Retain required authorization conditions in every
169
+ branch. `sql.join([])` and blank fragments fail. Parenthesize groups when mixing `AND` and `OR`.
170
+ Parameters are numbered correctly across nested and repeated fragments.
171
+
172
+ Runtime loops and dynamic fragment lists execute normally but remain unchecked by the optional
173
+ [source checker](query-checking.md#supported-composition). Test their meaningful variants against
174
+ the database. Fixed queries are easier to follow when composition is unnecessary.
175
+
176
+ ## Select names and SQL syntax safely
177
+
178
+ Values and identifiers are different. Use `identifier` for approved schema/table/column names:
179
+
180
+ ```ts
181
+ import { identifier } from '@kequtech/tenon-orm';
182
+
183
+ const table = identifier('public', 'users');
184
+ const query = sql<{ id: number }>`SELECT id FROM ${table} WHERE active = ${true}`;
185
+ ```
186
+
187
+ This quotes `"public"."users"` as two components. It does not authorize access; map application
188
+ choices to approved names before using it. Each component must contain 1–63 UTF-8 bytes and no NUL.
189
+
190
+ Choose SQL syntax such as sort direction from fixed alternatives:
191
+
192
+ ```ts
193
+ const descending = true;
194
+ const direction = descending ? sql.fragment`DESC` : sql.fragment`ASC`;
195
+ const query = sql<{ id: number }>`SELECT id FROM public.users ORDER BY id ${direction}`;
196
+ ```
197
+
198
+ Interpolating the string `'DESC'` binds a value and does not create SQL syntax. Complete `sql`
199
+ queries cannot be nested; use fragments for partial statements.
200
+
201
+ ## Execute SQL supplied by another component
202
+
203
+ ```ts
204
+ import { unsafeSql } from '@kequtech/tenon-orm';
205
+
206
+ const query = unsafeSql<{ id: number }>('SELECT id FROM public.users WHERE email = $1', [email]);
207
+ ```
208
+
209
+ Use trusted SQL text and an explicit values array, including `[]` when there are no parameters.
210
+ Never concatenate request data into the text. Tenon executes one statement per query; migrations
211
+ have a separate multi-statement runner. For an adapter inside a transaction, see
212
+ [transactional result adapters](reference.md#adapting-transactional-results).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kequtech/tenon-orm",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "A PostgreSQL ORM designed for coding agents, with explicit SQL, typed queries, and migrations",