@kequtech/tenon-orm 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,263 @@
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
+ Use the application's configured database instance, after migrations have committed:
10
+
11
+ ```ts
12
+ await db.generateTypes({ out: 'src/generated-schema.d.ts' });
13
+ await db.generateTypes({ out: 'src/generated-schema.d.ts', check: true });
14
+ ```
15
+
16
+ The first call writes declarations. The second rejects if they are missing or stale, without
17
+ rewriting them. Use it in CI to verify committed output. The
18
+ [getting-started script](human-guide.md#generate-types) provides a complete command-line wrapper.
19
+
20
+ `namespace` defaults to `public`, independently of runtime `search_path`. Pass it explicitly for a
21
+ different schema. Paths resolve from the working directory. Keep exact output unchanged and exclude
22
+ it from automatic formatting.
23
+
24
+ Generation uses the instance's pool and codecs. It is available on `db`, outside callback
25
+ transactions. It uses a repeatable-read catalog snapshot and shares the migration advisory lock;
26
+ use a direct or session-pooled connection. It needs no migration files or Tenon history.
27
+
28
+ Tenon creates parent directories and replaces the entire output file atomically. With `check: true`,
29
+ it creates no directories, writes no files, and includes a bounded diff for stale output. An optional
30
+ `onWarning(message)` can be synchronous or asynchronous; the default is `console.warn`. Warning
31
+ handler failures reject generation before output changes. Other filesystem errors propagate.
32
+
33
+ For source text without file handling, use
34
+ `generateDefinitions({ connection, namespace?, onWarning? }): Promise<string>`. Pass the configured
35
+ pool or connection options to retain runtime read mappings.
36
+
37
+ ## Read the generated types
38
+
39
+ For the table from [Getting started](human-guide.md#create-and-apply-a-migration), the important
40
+ parts of the generated declaration look like this:
41
+
42
+ ```ts
43
+ import type { Parameter } from '@kequtech/tenon-orm';
44
+
45
+ export type Database = {
46
+ users: {
47
+ row: {
48
+ id: number;
49
+ email: string;
50
+ active: boolean;
51
+ };
52
+ insert: {
53
+ id?: never;
54
+ email: string | Parameter;
55
+ active?: boolean | Parameter | undefined;
56
+ };
57
+ update: {
58
+ id?: never;
59
+ email?: string | Parameter | undefined;
60
+ active?: boolean | Parameter | undefined;
61
+ };
62
+ };
63
+ };
64
+ ```
65
+
66
+ The real file also contains schema-summary comments for the tables, columns, keys, indexes,
67
+ constraints, and relevant domain definitions. Generation includes ordinary tables and partitioned
68
+ roots in one schema; it excludes attached partitions, views, materialized views, and Tenon's history
69
+ table. It does not generate models, relationship methods, migrations, or validators.
70
+
71
+ Use the types explicitly:
72
+
73
+ ```ts
74
+ import { sql } from '@kequtech/tenon-orm';
75
+ import type { Database } from './generated-schema.js';
76
+
77
+ type UserSummary = Pick<Database['users']['row'], 'id' | 'email'>;
78
+ const input = { email: 'reader@example.test' } satisfies Database['users']['insert'];
79
+
80
+ const user = await db.query(
81
+ sql<UserSummary>`INSERT INTO public.users ${sql.insertValues(input)} RETURNING id, email`,
82
+ { returns: 'one' },
83
+ );
84
+ ```
85
+
86
+ A table's `row` type describes its complete stored row. Use `Pick` for a projection and a separate
87
+ type for joins or aggregates. `sql<Row>` does not check that your SQL returns that shape. See
88
+ [query checking](query-checking.md) and [database tests](query-inspection.md#test-the-same-statement).
89
+
90
+ ### Application aliases
91
+
92
+ Keep shorter names in a handwritten module so regeneration preserves them:
93
+
94
+ ```ts
95
+ // src/schema-types.ts
96
+ import type { Database } from './generated-schema.js';
97
+
98
+ export type UserRow = Database['users']['row'];
99
+ export type UserInsert = Database['users']['insert'];
100
+ export type UserUpdate = Database['users']['update'];
101
+ ```
102
+
103
+ The generated `Database` is a schema map, distinct from Tenon's runtime `Database` interface. If a
104
+ module needs both, alias one of the imports.
105
+
106
+ ## Insert and update inputs
107
+
108
+ Insert fields are required when writable, nonnullable, and without a default. Other writable insert
109
+ fields and all writable update fields are optional, with explicit undefined accepted even under
110
+ `exactOptionalPropertyTypes`.
111
+
112
+ - Computed columns and `GENERATED ALWAYS` identity columns use optional `never` fields.
113
+ - Serial and `GENERATED BY DEFAULT` identity fields are optional on insert and writable on update.
114
+ Other primary keys remain writable.
115
+ - Domain defaults count; column defaults take precedence. Write nullability respects the domain
116
+ chain as well as the column.
117
+ - Every writable field accepts an explicit `Parameter` in addition to its plain input types.
118
+
119
+ Apply types through an annotation, `satisfies`, or a helper generic. Merely generating a file does
120
+ not check writes. Use `Pick` or `Omit` for application restrictions, and validate external input.
121
+ The types do not enforce CHECK constraints, JSON members, authorization, or a nonempty patch.
122
+
123
+ The [mutation helpers](mutations.md) omit undefined fields for updates and use defaults for missing
124
+ insert values. A selected column list can discard a required input even when the full object is
125
+ well typed; PostgreSQL still enforces constraints.
126
+
127
+ ## Value mappings
128
+
129
+ These are the default driver representations. SQL NULL is `null`; nullable generated columns add
130
+ null to their type. Input encoding and output decoding are separate choices.
131
+
132
+ | PostgreSQL value | Default read | Plain write input |
133
+ | ----------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
134
+ | smallint, integer, serial, oid | `number` | `number` |
135
+ | bigint, bigserial, `count(*)` | Exact `string` | Exact string or bigint |
136
+ | numeric/decimal | `string` | String or bigint |
137
+ | real, double precision | `number` | Finite number |
138
+ | boolean | `boolean` | Boolean |
139
+ | text, UUID, other textual types | `string` | String |
140
+ | enum | Catalog label union in default-registry generated rows | Label union |
141
+ | date, timestamp, timestamptz | `string` | String, Date, or supported Temporal value |
142
+ | time, interval | `string` | String or supported Temporal value |
143
+ | timetz | `string` | String |
144
+ | json/jsonb | `unknown` | Encoded JSON string; use `json(value)` for a parameter |
145
+ | bytea | `Uint8Array` | Uint8Array |
146
+ | Supported built-in arrays | Recursive readonly `PgArray<T>` | Encoded PostgreSQL array text; use `param` for an array value |
147
+ | Unrecognized types and dynamic enum/domain/extension arrays | Wire-text `string` | Encoded string or explicit parameter |
148
+
149
+ Generated inputs conservatively describe column-compatible representations; an explicit `Parameter`
150
+ is accepted for every writable column. PostgreSQL checks ranges and text formats. Plain undefined,
151
+ objects, and arrays are rejected as bound parameters. See [JSON and arrays](mutations.md#json-and-postgresql-arrays).
152
+
153
+ Bigint and numeric arrays retain exact strings. Use `BigInt(value)` for exact integer arithmetic;
154
+ convert to number only after checking the safe integer range. Command `rowCount` is a number or null,
155
+ independent of SQL aggregate values; counts beyond the safe integer range reject rather than round.
156
+
157
+ Temporal strings preserve precision and infinity spellings. Date inputs encode in UTC; use a calendar
158
+ date string or corresponding Temporal plain value when an instant is not intended. Inputs do not
159
+ change output types. JSON numbers follow JavaScript number precision; parsed JSON null and SQL NULL
160
+ both become null. For raw JSON text or Date/bigint outputs, configure codecs.
161
+
162
+ ## Custom codecs
163
+
164
+ Configure the registry on the application's connection. For example:
165
+
166
+ ```ts
167
+ import { builtin, createTenon, defaultCodecs } from '@kequtech/tenon-orm';
168
+
169
+ const codecs = defaultCodecs.with(builtin.int8BigInt).with(builtin.timestamptzDate);
170
+ const db = createTenon({ connection: { connectionString: connection, codecs } });
171
+ ```
172
+
173
+ Int8 now reads as bigint, and timestamptz reads as a millisecond-precision Date. Regenerate using this
174
+ instance. Scalar overrides affect derived built-in arrays; explicit array overrides win. Date and
175
+ Temporal output codecs reject infinity and values outside their supported ranges.
176
+
177
+ For Temporal, import `createTemporalCodecs` from Tenon and supply a native or compatible polyfill
178
+ implementation. Tenon does not install a polyfill or change globals. A polyfill can provide its own
179
+ generated type namespace:
180
+
181
+ ```ts
182
+ import { Temporal } from '@js-temporal/polyfill';
183
+ import { createTemporalCodecs, defaultCodecs } from '@kequtech/tenon-orm';
184
+
185
+ const temporal = createTemporalCodecs(Temporal, {
186
+ typescriptNamespace: 'import("@js-temporal/polyfill").Temporal',
187
+ });
188
+ const codecs = defaultCodecs.with(temporal.date).with(temporal.timestamptz);
189
+ ```
190
+
191
+ Install the polyfill only if the application needs it. With native Temporal, the consuming TypeScript
192
+ configuration needs the corresponding Temporal library declarations.
193
+
194
+ A custom text codec can declare `text.typescript` to describe its non-NULL output. Generation and
195
+ checking consume the registry's metadata. An unidentified decoder warns and becomes `unknown`;
196
+ a declared representation is trusted, not runtime validation. Under a custom registry, enum output
197
+ may remain string because a decoder can transform labels; enum write inputs retain catalog choices.
198
+
199
+ Use the same registry for execution, generation, inspection, and source checking. Relative type-import
200
+ expressions resolve from the generated file for saved declarations and from the tsconfig directory
201
+ for checking. Prefer a package or configured alias that resolves in both places. Tenon does not
202
+ execute referenced modules. See [custom-codec checking](query-checking.md#programmatic-use-and-custom-codecs).
203
+
204
+ Catalog queries use default result decoding on the same borrowed session, so application codecs do
205
+ not alter Tenon's metadata inputs. Output codec choices do not select input encoders.
206
+
207
+ ## Persistence and domain values
208
+
209
+ Storage types can be broader than application types. Text CHECK constraints do not become unions,
210
+ and JSON reads stay unknown. Validate external data before writing and database data before narrowing
211
+ it to a domain type.
212
+
213
+ For an additional `public.sessions(id text PRIMARY KEY, data jsonb NOT NULL, expires_at timestamptz
214
+ NOT NULL)` table, a JSON validator can be shared by writes and reads:
215
+
216
+ ```ts
217
+ import { json, sql } from '@kequtech/tenon-orm';
218
+
219
+ function sessionData(value: unknown): { userId: string } {
220
+ if (
221
+ typeof value !== 'object' ||
222
+ value === null ||
223
+ Array.isArray(value) ||
224
+ !('userId' in value) ||
225
+ typeof value.userId !== 'string' ||
226
+ value.userId.length === 0
227
+ ) {
228
+ throw new Error('Expected session data with a nonempty userId');
229
+ }
230
+ return { userId: value.userId };
231
+ }
232
+
233
+ const id = 'example-session';
234
+ const expiresAt = new Date(Date.now() + 60_000);
235
+ await db.query(sql`INSERT INTO public.sessions (id, data, expires_at)
236
+ VALUES (${id}, ${json(sessionData({ userId: 'user-42' }))}, ${expiresAt})`);
237
+
238
+ const row = await db.query(
239
+ sql<{ id: string; data: unknown; expires_at: string }>`
240
+ SELECT id, data, expires_at FROM public.sessions
241
+ WHERE id = ${id} AND isfinite(expires_at) AND expires_at > now()
242
+ `,
243
+ { returns: 'oneOrNone' },
244
+ );
245
+ if (row) {
246
+ const data = sessionData(row.data);
247
+ const expiry = new Date(row.expires_at);
248
+ if (!Number.isFinite(expiry.getTime())) throw new Error('Expected a finite timestamp');
249
+ // data.userId and expiry now have validated application representations.
250
+ }
251
+ ```
252
+
253
+ `isfinite` excludes positive infinity, which would otherwise pass `expires_at > now()`. Keep the
254
+ same policy in expiry-sensitive updates/deletes. Test expired and infinite values, and retain the
255
+ application's authentication and authorization checks.
256
+
257
+ ## What freshness checks establish
258
+
259
+ `check: true` compares the exact generated file with output from this database and codec registry.
260
+ It does not check query annotations, pending migrations, functions, privileges, or every database
261
+ object. A driver or generator update can change output without schema drift. Review generated diffs
262
+ after upgrades. Generated text and schema-summary formatting are not version-stable schema
263
+ 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.2",
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",