@kequtech/tenon-orm 0.2.2 → 0.3.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 (51) hide show
  1. package/CHANGELOG.md +46 -42
  2. package/README.md +4 -4
  3. package/dist/affected-rows.js +6 -6
  4. package/dist/baseline.js +4 -4
  5. package/dist/client.js +1 -1
  6. package/dist/connection.d.ts +15 -22
  7. package/dist/connection.js +122 -25
  8. package/dist/database.js +10 -17
  9. package/dist/driver/types.d.ts +1 -1
  10. package/dist/driver/types.js +11 -11
  11. package/dist/generate.js +7 -7
  12. package/dist/generation/catalog.d.ts +2 -2
  13. package/dist/generation/catalog.js +4 -4
  14. package/dist/generation/schema-summary.d.ts +3 -3
  15. package/dist/generation/schema-summary.js +11 -11
  16. package/dist/generation/table-types.js +1 -1
  17. package/dist/index.d.ts +1 -2
  18. package/dist/inspection/catalog.d.ts +4 -3
  19. package/dist/inspection/catalog.js +13 -10
  20. package/dist/inspection/connection.d.ts +2 -2
  21. package/dist/inspection/connection.js +22 -22
  22. package/dist/inspection/protocol.d.ts +7 -0
  23. package/dist/inspection/protocol.js +45 -0
  24. package/dist/inspection/result.d.ts +1 -1
  25. package/dist/inspection/result.js +6 -6
  26. package/dist/inspection/types.d.ts +2 -2
  27. package/dist/migrations/connection.d.ts +3 -2
  28. package/dist/migrations/connection.js +18 -20
  29. package/dist/migrations/history.d.ts +4 -4
  30. package/dist/migrations/history.js +5 -5
  31. package/dist/migrations.js +7 -7
  32. package/dist/query-options.js +4 -4
  33. package/dist/query.js +2 -2
  34. package/dist/transaction.d.ts +2 -2
  35. package/dist/transaction.js +19 -14
  36. package/dist/types.d.ts +2 -2
  37. package/docs/adoption-guide.md +40 -27
  38. package/docs/agent-guide.md +55 -48
  39. package/docs/human-guide.md +25 -20
  40. package/docs/mutations.md +13 -10
  41. package/docs/query-inspection.md +12 -14
  42. package/docs/reference.md +304 -131
  43. package/package.json +3 -2
  44. package/dist/driver/array.d.ts +0 -5
  45. package/dist/driver/array.js +0 -70
  46. package/dist/driver/lifecycle.d.ts +0 -15
  47. package/dist/driver/lifecycle.js +0 -57
  48. package/dist/driver/listener.d.ts +0 -3
  49. package/dist/driver/listener.js +0 -63
  50. package/dist/driver/session.d.ts +0 -8
  51. package/dist/driver/session.js +0 -92
@@ -1,11 +1,11 @@
1
- import { reserveConnection } from './driver/session.js';
2
- import { executeSql } from './connection.js';
1
+ import { executeSql, watchClient } from './connection.js';
3
2
  /** The callback owns one connection and expires before commit or rollback begins. */
4
3
  export async function withTransaction(pool, work) {
5
- const connection = await reserveConnection(pool, 'BEGIN');
6
- const client = connection.sql;
4
+ const client = await pool.connect();
5
+ const connection = watchClient(client);
7
6
  let active = true;
8
- let discard = false;
7
+ let began = false;
8
+ let releaseError;
9
9
  let savepointId = 0;
10
10
  let pending = Promise.resolve();
11
11
  let recoveryFailure;
@@ -21,16 +21,16 @@ export async function withTransaction(pool, work) {
21
21
  if (!guarded)
22
22
  return readResult(await executeSql(client, text, input));
23
23
  const savepoint = `tenon_affected_rows_${++savepointId}`;
24
- await client.unsafe(`SAVEPOINT ${savepoint}`);
24
+ await client.query(`SAVEPOINT ${savepoint}`);
25
25
  try {
26
26
  const result = readResult(await executeSql(client, text, input));
27
- await client.unsafe(`RELEASE SAVEPOINT ${savepoint}`);
27
+ await client.query(`RELEASE SAVEPOINT ${savepoint}`);
28
28
  return result;
29
29
  }
30
30
  catch (error) {
31
31
  try {
32
- await client.unsafe(`ROLLBACK TO SAVEPOINT ${savepoint}`);
33
- await client.unsafe(`RELEASE SAVEPOINT ${savepoint}`);
32
+ await client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
33
+ await client.query(`RELEASE SAVEPOINT ${savepoint}`);
34
34
  }
35
35
  catch (rollbackError) {
36
36
  // Even if application code catches this error, the outer transaction
@@ -45,13 +45,15 @@ export async function withTransaction(pool, work) {
45
45
  return operation;
46
46
  };
47
47
  try {
48
+ await client.query('BEGIN');
49
+ began = true;
48
50
  const result = await work(query);
49
51
  active = false;
50
52
  await pending;
51
53
  connection.check();
52
54
  if (recoveryFailure)
53
55
  throw recoveryFailure;
54
- const committed = await client.unsafe('COMMIT');
56
+ const committed = await client.query('COMMIT');
55
57
  if (committed.command !== 'COMMIT') {
56
58
  throw new Error('PostgreSQL rolled back the aborted transaction.');
57
59
  }
@@ -60,17 +62,20 @@ export async function withTransaction(pool, work) {
60
62
  catch (error) {
61
63
  active = false;
62
64
  await pending;
65
+ if (!began) {
66
+ releaseError = error instanceof Error ? error : new Error('BEGIN failed.');
67
+ throw error;
68
+ }
63
69
  try {
64
- connection.check();
65
- await client.unsafe('ROLLBACK');
70
+ await client.query('ROLLBACK');
66
71
  }
67
72
  catch (rollbackError) {
68
- discard = true;
73
+ releaseError = rollbackError instanceof Error ? rollbackError : new Error('Rollback failed.');
69
74
  throw new AggregateError([error, rollbackError], 'Transaction and rollback failed.');
70
75
  }
71
76
  throw error;
72
77
  }
73
78
  finally {
74
- await connection.release(discard);
79
+ connection.release(releaseError);
75
80
  }
76
81
  }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type postgres from 'postgres';
1
+ import type { Pool } from 'pg';
2
2
  import type { QueryResult } from './connection.js';
3
3
  import type { QueryOptions, QueryOutput, QueryReturnMode } from './query-options.js';
4
4
  import type { SqlQuery } from './query.js';
@@ -18,7 +18,7 @@ export interface CloseOptions {
18
18
  }
19
19
  export type Database = Transaction & {
20
20
  /** The underlying pool-level driver client; native calls bypass Tenon's query and transaction guards. */
21
- readonly native: postgres.Sql;
21
+ readonly native: Pool;
22
22
  /** Await all work inside the callback. Commit on success; rollback on an escaping error. */
23
23
  transaction<T>(callback: (tx: Transaction) => Promise<T>): Promise<T>;
24
24
  close(options?: CloseOptions): Promise<void>;
@@ -1,32 +1,34 @@
1
1
  # Adopting Tenon
2
2
 
3
- Replace application queries independently of migration ownership. Tenon can query an existing
4
- schema and generate types while the current migration runner stays in place. For a Tenon version
5
- upgrade, read the [Changelog](../CHANGELOG.md).
3
+ Replace application queries independently of migration ownership. Tenon can query an existing schema
4
+ and generate types while the current migration runner stays in place. For a Tenon version upgrade,
5
+ read the [Changelog](../CHANGELOG.md).
6
6
 
7
7
  ## Preserve connections and behavior
8
8
 
9
9
  Keep the existing database URI, credentials, TLS requirements, schema, and data. Tenon can add
10
- driver settings without parsing the URI:
10
+ driver settings through pg configuration:
11
11
 
12
12
  ```ts
13
13
  import { createTenon } from '@kequtech/tenon-orm';
14
14
 
15
15
  const db = createTenon({
16
- connection: { url: existingUri, options: { connect_timeout: 5, idle_timeout: 10 } },
16
+ connection: {
17
+ connectionString: existingUri,
18
+ connectionTimeoutMillis: 5000,
19
+ },
17
20
  });
18
21
  ```
19
22
 
20
- Translate old driver configuration into Postgres.js options rather than passing it through.
21
- For example, pg's `connectionTimeoutMillis: 5000` becomes `connect_timeout: 5`, and
22
- `idleTimeoutMillis: 10000` becomes `idle_timeout: 10`; both native options use seconds. Preserve
23
- runtime `search_path` through URI startup parameters or `options.connection.search_path`.
24
- URI startup parameters take precedence when both are supplied. CLI `--namespace` does not change
25
- runtime queries. See [connection options](reference.md#installation-and-connection).
23
+ Translate old driver configuration into pg `PoolConfig` rather than passing it through. Pool
24
+ timeouts use milliseconds. Preserve runtime `search_path` through the pg startup options string, for
25
+ example `options: '-c search_path=app,public'`. URI startup options take precedence when both are
26
+ supplied. CLI `--namespace` does not change runtime queries. See
27
+ [connection options](reference.md#installation-and-connection).
26
28
 
27
- Before conversion, identify behavior supplied by the old ORM: IDs/defaults, timestamps,
28
- validation, hooks, serialization, relationships, and transaction boundaries. Preserve it explicitly
29
- in application code or SQL where required.
29
+ Before conversion, identify behavior supplied by the old ORM: IDs/defaults, timestamps, validation,
30
+ hooks, serialization, relationships, and transaction boundaries. Preserve it explicitly in
31
+ application code or SQL where required.
30
32
 
31
33
  ## Replace queries
32
34
 
@@ -41,7 +43,7 @@ in application code or SQL where required.
41
43
  2. Replace queries with parameterized SQL in ordinary functions accepting `QueryClient` where
42
44
  callers may supply `db` or `tx`. Retain projections, ordering, null handling, and authorization.
43
45
  3. Review value conversions against [Tenon's mappings](reference.md#generated-types-and-checking):
44
- bigint/numeric strings, dates, arrays, and native JSON may differ from the previous driver.
46
+ numeric values, dates, arrays, and JSON serialization may differ from the previous driver.
45
47
  Generated declarations do not replace domain validation.
46
48
  4. Inspect meaningful completed query variants and run database assertions for actual rows,
47
49
  permissions, write effects, and rollback. See [query inspection](query-inspection.md).
@@ -74,9 +76,9 @@ For an application owning `app` in a shared database:
74
76
  npx tenon baseline capture --directory migrations --namespace app --schema app
75
77
  ```
76
78
 
77
- The directory must contain no SQL files. Capture uses native `pg_dump --schema-only --no-owner
78
- --no-privileges` and writes `0001_baseline.up.sql` with no down file. Install compatible PostgreSQL
79
- client tools on PATH; prefer the server's major version.
79
+ The directory must contain no SQL files. Capture uses native
80
+ `pg_dump --schema-only --no-owner --no-privileges` and writes `0001_baseline.up.sql` with no down
81
+ file. Install compatible PostgreSQL client tools on PATH; prefer the server's major version.
80
82
 
81
83
  | Option | Scope |
82
84
  | --------------------- | ------------------------------------------------------------------------------------ |
@@ -101,20 +103,30 @@ reference definitions rather than comparing against types regenerated after late
101
103
  `generateDefinitions` covers relation metadata, not functions or privileges; include reviewed
102
104
  catalog checks for other objects the application needs. Tenon does not perform these comparisons.
103
105
 
106
+ Generated declaration text is not a version-stable schema fingerprint: driver mappings and generator
107
+ changes can alter it without schema drift. The schema-summary comments expose SQL definitions, but
108
+ their formatting and PostgreSQL's rendering of those definitions are not a stable comparison format
109
+ either. If an existing adoption guard hashes generated text, preserve its original reference and
110
+ freeze a separate reference for each supported generator version from a disposable replay of the
111
+ immutable checkpoint. Compare the replay's metadata with the reviewed checkpoint before accepting
112
+ the new reference; never derive a replacement from whichever live database is configured. Keep
113
+ independent function and privilege checks, and test both matching adoption and refusal after drift.
114
+ This application guard is separate from Tenon's migration checksums; keep applied SQL unchanged.
115
+
104
116
  ```sh
105
117
  npx tenon baseline adopt --directory migrations --namespace app
106
118
  npx tenon migrate --directory migrations --namespace app
107
119
  ```
108
120
 
109
- Adoption records the baseline checksum without executing its SQL. The namespace must already
110
- exist. Repeating matching adoption is harmless, including after later migrations; history conflicts
111
- fail. Once Tenon owns history, add forward migrations rather than editing the baseline.
121
+ Adoption records the baseline checksum without executing its SQL. The namespace must already exist.
122
+ Repeating matching adoption is harmless, including after later migrations; history conflicts fail.
123
+ Once Tenon owns history, add forward migrations rather than editing the baseline.
112
124
 
113
125
  ### Replay a fresh database
114
126
 
115
127
  Run `tenon migrate --directory migrations --namespace app`, without adoption. It executes the same
116
- committed baseline and subsequent migrations. Apply the deployment's required roles, ownership,
117
- and grants before application access.
128
+ committed baseline and subsequent migrations. Apply the deployment's required roles, ownership, and
129
+ grants before application access.
118
130
 
119
131
  The portable baseline omits ownership, grants/revokes, and default privileges. Objects are recreated
120
132
  under the migration role. For `SECURITY DEFINER` functions, that owner controls execution authority,
@@ -129,7 +141,8 @@ types. Test existing-row preservation, repeatable adoption, subsequent migration
129
141
  A schema-only baseline and down migration are not backups. The missing down file prevents rollback
130
142
  across the baseline; use the application's backup/recovery procedure for that boundary.
131
143
 
132
- Programmatic capture is `captureBaseline({ connection, directory?, namespace?, schemas?, onWarning? })`
133
- and returns `{ id, upPath }`. It requires a native libpq connection string; an explicitly empty
134
- `schemas` list fails. `adoptBaseline({ connection, directory?, namespace? })` accepts Tenon's runtime
135
- connection forms and returns newly recorded IDs.
144
+ Programmatic capture is
145
+ `captureBaseline({ connection, directory?, namespace?, schemas?, onWarning? })` and returns
146
+ `{ id, upPath }`. It requires a native libpq connection string; an explicitly empty `schemas` list
147
+ fails. `adoptBaseline({ connection, directory?, namespace? })` accepts Tenon's runtime connection
148
+ forms and returns newly recorded IDs.
@@ -1,8 +1,8 @@
1
1
  # Tenon agent guide
2
2
 
3
- Tenon executes explicit PostgreSQL SQL through Postgres.js. Use ordinary functions for reusable
4
- queries. This guide covers routine implementation; the [reference](reference.md) defines the full
5
- API and the [human guide](human-guide.md) gives a step-by-step introduction.
3
+ Tenon executes explicit PostgreSQL SQL through pg. Use ordinary functions for reusable queries. This
4
+ guide covers routine implementation; the [reference](reference.md) defines the full API and the
5
+ [human guide](human-guide.md) gives a step-by-step introduction.
6
6
 
7
7
  ## Start with the existing project
8
8
 
@@ -11,16 +11,17 @@ commands. Preserve those conventions. Tenon queries and type generation do not r
11
11
  migration history. Keep input validation, authorization, domain conversion, and retry policy in the
12
12
  application.
13
13
 
14
- For an upgrade, record the previous resolved Tenon version from the lockfile or installed package and read the
15
- [Changelog](../CHANGELOG.md) entries through the target version before changing code. It ships with
16
- the package alongside this guide. Follow its upgrade instructions, regenerate types when mappings
17
- change, and run the project's typechecks and relevant database tests. Use the
14
+ For an upgrade, record the previous resolved Tenon version from the lockfile or installed package
15
+ and read the [Changelog](../CHANGELOG.md) entries through the target version before changing code.
16
+ It ships with the package alongside this guide. Follow its upgrade instructions, regenerate types
17
+ when mappings change, and run the project's typechecks and relevant database tests. Use the
18
18
  [adoption guide](adoption-guide.md) when replacing another ORM or transferring migration ownership.
19
19
 
20
20
  ## Connect
21
21
 
22
22
  Requires Node.js 24+, PostgreSQL 16+, ESM, TypeScript 6+, and Node development typings. Install with
23
- `npm install @kequtech/tenon-orm`; retain the project's package manager and TypeScript configuration.
23
+ `npm install @kequtech/tenon-orm`; retain the project's package manager and TypeScript
24
+ configuration.
24
25
 
25
26
  ```ts
26
27
  import { createTenon } from '@kequtech/tenon-orm';
@@ -30,13 +31,13 @@ if (!connection) throw new Error('DATABASE_URL is required');
30
31
  export const db = createTenon({ connection });
31
32
  ```
32
33
 
33
- `connection` accepts a URI string, `{ url, options? }`, Postgres.js options, or an existing driver
34
- client. Reuse an instance for each database configuration. `db.close()` ends internally created
35
- pools with a default 5,000 ms grace period; supplied clients remain application-owned. Configure
34
+ `connection` accepts a URI string, pg `PoolConfig` (including `connectionString`), or an existing
35
+ `pg.Pool`. Reuse an instance for each database configuration. `db.close()` ends internally created
36
+ pools with a default 5,000 ms grace period; supplied pools remain application-owned. Configure
36
37
  shutdown with `db.close({ timeoutMs })`; concurrent callers share its outcome. Finish outstanding
37
- work before closing. Tenon
38
- loads no environment files. Qualify table names or set runtime `search_path`; CLI `--namespace`
39
- selects tooling scope, not runtime query names. See [connection options](reference.md#installation-and-connection).
38
+ work before closing. Tenon loads no environment files. Qualify table names or set runtime
39
+ `search_path`; CLI `--namespace` selects tooling scope, not runtime query names. See
40
+ [connection options](reference.md#installation-and-connection).
40
41
 
41
42
  ## Query and result contracts
42
43
 
@@ -60,23 +61,23 @@ aggregates need types for their actual output.
60
61
 
61
62
  | `returns` | Result | Required returned rows |
62
63
  | ------------------ | -------------------------------------------------- | ---------------------- |
63
- | `native` (default) | Driver array with `.count`, `.command`, `.columns` | Any |
64
+ | `native` (default) | Driver object with `.rows`, `.rowCount`, `.fields` | Any |
64
65
  | `many` | Plain `Row[]` | Any |
65
66
  | `oneOrNone` | `Row \| undefined` | Zero or one |
66
67
  | `one` | `Row` | Exactly one |
67
68
 
68
- Options are `{ returns?, affectedRows?, label? }`; unknown keys fail. Modes do not add SQL limits
69
- or `RETURNING`. A returned-row mismatch throws `CardinalityError` with code `TENON_CARDINALITY_MISMATCH`;
70
- it alone does not undo an autocommitted write.
69
+ Options are `{ returns?, affectedRows?, label? }`; unknown keys fail. Modes do not add SQL limits or
70
+ `RETURNING`. A returned-row mismatch throws `CardinalityError` with code
71
+ `TENON_CARDINALITY_MISMATCH`; it alone does not undo an autocommitted write.
71
72
 
72
73
  ## Parameters and composition
73
74
 
74
- - Interpolate values without quotes. Do not author `$n` placeholders inside templates.
75
- `undefined` bound values fail; `null` means SQL null.
75
+ - Interpolate values without quotes. Do not author `$n` placeholders inside templates. `undefined`
76
+ and `null` bind as SQL null, following pg.
76
77
  - Use `id = ANY(${ids}::integer[])` for a set of IDs. Cast standalone parameters such as
77
78
  `SELECT ${value}::integer AS value` when SQL must establish the type for inspection.
78
- - `identifier(schema, table)` quotes separate approved names. Strings such as `'DESC'` remain
79
- bound values; choose SQL syntax through fixed query alternatives or fragments.
79
+ - `identifier(schema, table)` quotes separate approved names. Strings such as `'DESC'` remain bound
80
+ values; choose SQL syntax through fixed query alternatives or fragments.
80
81
  - `sql.fragment` creates partial SQL with bound values; `sql.join(parts, 'AND' | 'OR' | ',')`
81
82
  combines a nonempty list. Blank fragments fail. Group mixed conditions with parentheses.
82
83
  - Full `sql` or `unsafeSql` queries cannot be nested. `unsafeSql<Row>(text, values)` is for trusted
@@ -87,20 +88,24 @@ mandatory authorization predicates when optional filters are absent.
87
88
 
88
89
  ## Writes and generated values
89
90
 
90
- `sql.insertValues(rowOrRows, columns?)` builds column/value fragments; `sql.assignments(patch,
91
- columns?)` builds assignments. Missing/undefined insert fields use defaults; update fields are
92
- omitted. Explicit null stays null. Empty batches and empty assignments fail. Selected columns
93
- ignore unlisted fields and can discard required inputs. Put expressions, conflict policy, and
94
- `RETURNING` in the surrounding SQL. Batch in the caller below the query-wide 65,533-parameter limit.
91
+ `sql.insertValues(rowOrRows, columns?)` builds column/value fragments;
92
+ `sql.assignments(patch, columns?)` builds assignments. Missing/undefined insert fields use defaults;
93
+ update fields are omitted. Explicit null stays null. Empty batches and empty assignments fail.
94
+ Selected columns ignore unlisted fields and can discard required inputs. Put expressions, conflict
95
+ policy, and `RETURNING` in the surrounding SQL. Batch in the caller below the query-wide
96
+ 65,535-parameter limit.
95
97
 
96
98
  Use generated `Rows.Insert['users']` and `Rows.Update['users']` inputs; narrow editable fields with
97
99
  `Pick`. These types account for defaults, writable columns, domains, and driver representations.
98
100
  They do not validate constraints or JSON contents. See [mutation examples](mutations.md).
99
101
 
100
- Pass native JSON objects/arrays rather than pre-serialized text. Bigint and numeric reads are
101
- strings; bigint writes accept `string | bigint`. Dates read as `Date`, including invalid dates for
102
- infinities; validate before using date methods. SQL NULL array elements are JavaScript null.
103
- Keep domain validation at the persistence boundary. See [value mappings](reference.md#generated-types-and-checking).
102
+ Use `JSON.stringify(value)` for JSON write inputs; arrays otherwise bind as PostgreSQL arrays.
103
+ Bigint and numeric scalars read as strings, while numeric arrays read as numbers and can lose
104
+ precision. Temporal reads use `Date | number` for finite dates and infinities. SQL NULL array
105
+ elements remain JavaScript null. Keep domain validation at the persistence boundary. See
106
+ [value mappings](reference.md#generated-types-and-checking) and the
107
+ [JSON/finite-date projection](reference.md#persistence-and-domain-values). For authentication policy
108
+ requiring finite expiry, include `isfinite(expires_at)` alongside the SQL expiry comparison.
104
109
 
105
110
  ## Transactions and native access
106
111
 
@@ -127,34 +132,36 @@ and CTE effects. Reserve `tenon_affected_rows_` savepoint names for Tenon. A los
127
132
  acknowledgment can leave the outcome unknown.
128
133
 
129
134
  `db.native` exposes the existing pool for driver features. Native calls bypass Tenon's guards and
130
- `onQuery`; they do not join a Tenon transaction. Close owned clients through `db.close()`. Follow
131
- the [native access contract](reference.md#native-driver-access) for parser initialization and LISTEN
132
- limitations. Optional `onQuery` reports completion metadata without SQL or values; success inside
133
- a callback does not establish that the outer transaction committed.
135
+ `onQuery`; they do not join a Tenon transaction. Close owned pools through `db.close()`. Follow the
136
+ [native access contract](reference.md#native-driver-access) for resource ownership and LISTEN.
137
+ Optional `onQuery` reports completion metadata without SQL or values; success inside a callback does
138
+ not establish that the outer transaction committed.
134
139
 
135
140
  ## Inspect and validate
136
141
 
137
142
  Use `describeQuery({ connection, query })` on completed SQL or `tenon describe query.sql` on a file.
138
- Inspection owns a separate connection. Match the runtime role, search path, and timezone; custom
139
- parsers, temporary tables, and transaction-local state are not inherited. Result nullability is
140
- unknown and JSON structure is unchecked. A successful description does not verify `sql<Row>`,
141
- bound values, execution permission, or effects.
143
+ Inspection owns a separate connection. Match the runtime role, search path, and timezone. Temporary
144
+ tables and transaction-local state are not inherited; reported mappings assume pg's default parsers.
145
+ Result nullability is unknown and JSON structure is unchecked. A successful description does not
146
+ verify `sql<Row>`, bound values, execution permission, or effects.
142
147
 
143
- Pair inspection with database assertions for complete relevant rows, meaningful query variants,
144
- and rollback. A small query-building function can share the same statement with inspection and
145
- execution; keep simple queries inline when that is sufficient. See [query inspection](query-inspection.md).
148
+ Pair inspection with database assertions for complete relevant rows, meaningful query variants, and
149
+ rollback. A small query-building function can share the same statement with inspection and
150
+ execution; keep simple queries inline when that is sufficient. See
151
+ [query inspection](query-inspection.md).
146
152
 
147
153
  ## Schema changes
148
154
 
149
- With Tenon's runner, development can use `tenon migrate && tenon generate --out
150
- src/generated-schema.d.ts`. Commit migrations and exact generated output together; exclude the
151
- generated file from automatic formatting. Production runs migrations and deploys committed types.
152
- `generate --check` detects representation drift, not pending migrations or every schema change.
155
+ With Tenon's runner, development can use
156
+ `tenon migrate && tenon generate --out src/generated-schema.d.ts`. Commit migrations and exact
157
+ generated output together; exclude the generated file from automatic formatting. Production runs
158
+ migrations and deploys committed types. `generate --check` detects representation drift, not pending
159
+ migrations or every schema change.
153
160
 
154
161
  All database CLI commands require `DATABASE_URL`. `tenon new <name>` creates empty up/down files
155
162
  offline; fill them before migrating. Migration commands use `--directory migrations` and
156
163
  `--namespace public` by default; generation also accepts `--namespace`. Pass a custom namespace to
157
164
  both migration and generation. Omit the down file for an irreversible migration; never edit applied
158
165
  history. Each file commits independently with its history entry. Tools use session advisory locks,
159
- so use a direct or session-pooled connection. Nontransactional DDL needs a separate procedure.
160
- See the [command reference](reference.md#command-reference).
166
+ so use a direct or session-pooled connection. Nontransactional DDL needs a separate procedure. See
167
+ the [command reference](reference.md#command-reference).
@@ -1,7 +1,7 @@
1
1
  # Using Tenon: a human guide
2
2
 
3
3
  Tenon lets you use PostgreSQL directly from TypeScript. You write SQL; Tenon binds its inputs and
4
- executes it through Postgres.js. This guide follows a small application from its first table to reusable
4
+ executes it through pg. This guide follows a small application from its first table to reusable
5
5
  queries, transactions, and result checking.
6
6
 
7
7
  Use the [reference](reference.md) for exact contracts and [adoption](adoption-guide.md) when
@@ -29,12 +29,13 @@ if (!connection) throw new Error('DATABASE_URL is required');
29
29
  export const db = createTenon({ connection });
30
30
  ```
31
31
 
32
- Reuse this instance across the application and call `await db.close()` at shutdown. You can also supply an existing Postgres.js client; your application then owns its shutdown.
32
+ Reuse this instance across the application and call `await db.close()` at shutdown. You can also
33
+ supply an existing `pg.Pool`; your application then owns its shutdown.
33
34
 
34
35
  ## Create a table
35
36
 
36
- If you already have a schema and migration runner, keep them. For a fresh example database,
37
- create a migration:
37
+ If you already have a schema and migration runner, keep them. For a fresh example database, create a
38
+ migration:
38
39
 
39
40
  ```sh
40
41
  npx tenon new create_users
@@ -98,8 +99,8 @@ separately from the SQL text, even when the value contains SQL.
98
99
 
99
100
  The type describes one selected row. `returns: 'oneOrNone'` gives you that row or `undefined`; it
100
101
  throws if the query returns more than one row. Use `'one'` when absence should also be an error,
101
- `'many'` for an array, or omit `returns` for the native result array with `.count` and `.columns`.
102
- These options do not change your SQL.
102
+ `'many'` for an array, or omit `returns` for the native result object with `.rows`, `.rowCount`, and
103
+ `.fields`. These options do not change your SQL.
103
104
 
104
105
  Generated table types can help describe projections without repeating every field:
105
106
 
@@ -113,6 +114,10 @@ needs a type for what that particular query selects. An annotation is a declarat
113
114
  the SQL matches. Without one, result fields are `unknown`. Use the inspection and database-test
114
115
  workflow below to check the declared contract against the query's behavior.
115
116
 
117
+ Keep storage types conservative and validate them when projecting domain values. The
118
+ [persistence-to-domain example](reference.md#persistence-and-domain-values) shows validated JSON
119
+ writes and reads, finite dates, and an SQL predicate that rejects infinite session expiry.
120
+
116
121
  ## Reuse a query with a function
117
122
 
118
123
  Use an ordinary function when several callers need the same query. An object input makes the
@@ -129,16 +134,16 @@ export function userByEmail(client: QueryClient, { email }: { email: string }) {
129
134
  returns: 'oneOrNone',
130
135
  });
131
136
  }
132
-
133
- const user = await userByEmail(db, { email: 'reader@example.test' });
134
137
  ```
135
138
 
136
- `QueryClient` means the function accepts either your database instance or a transaction client. Keep
137
- the SQL and execution options together in the function.
139
+ Call `userByEmail(db, { email: 'reader@example.test' })` at the call site, or pass a transaction's
140
+ `tx`. Keep the SQL and execution options together in the function.
138
141
 
139
142
  ## Change data in a transaction
140
143
 
141
144
  ```ts
145
+ import { userByEmail } from './queries.js';
146
+
142
147
  await db.transaction(async (tx) => {
143
148
  const user = await userByEmail(tx, { email: 'reader@example.test' });
144
149
  if (!user) throw new Error('User not found');
@@ -177,20 +182,20 @@ await db.query(sql`UPDATE public.users SET ${sql.assignments(patch, ['active'])}
177
182
  ```
178
183
 
179
184
  Use `Pick` to limit editable fields. Keep expressions, predicates, conflict handling, and
180
- `RETURNING` in SQL. Selected columns choose fields; they do not validate the complete object.
181
- See [input types](reference.md#insert-and-update-inputs) for mappings and the
185
+ `RETURNING` in SQL. Selected columns choose fields; they do not validate the complete object. See
186
+ [input types](reference.md#insert-and-update-inputs) for mappings and the
182
187
  [mutation cookbook](mutations.md) for defaults, batches, and upserts.
183
188
 
184
189
  ## Check your SQL and result types
185
190
 
186
191
  Use `describeQuery({ connection, query })` on completed SQL, or `npx tenon describe query.sql` on a
187
- file. It reports PostgreSQL metadata and a conservative `rowType` declaration; it does not update
188
- or verify the application's `sql<Row>` annotation. Inspection uses a separate session, so match
189
- the runtime role and settings. See [query inspection](query-inspection.md) for a worked test.
192
+ file. It reports PostgreSQL metadata and a conservative `rowType` declaration; it does not update or
193
+ verify the application's `sql<Row>` annotation. Inspection uses a separate session, so match the
194
+ runtime role and settings. See [query inspection](query-inspection.md) for a worked test.
190
195
 
191
196
  After migrating the development/test database, inspect meaningful query variants, run TypeScript,
192
- and test actual rows and effects. Keep those checks in CI. Execution assertions cover values,
193
- nulls, JSON, authorization, and rollback that description alone cannot establish.
197
+ and test actual rows and effects. Keep those checks in CI. Execution assertions cover values, nulls,
198
+ JSON, authorization, and rollback that description alone cannot establish.
194
199
 
195
200
  ## Reuse or assemble parts of SQL when needed
196
201
 
@@ -226,9 +231,9 @@ const query = sql<UserSummary>`SELECT id, email FROM public.users
226
231
  WHERE ${sql.join(filters, 'AND')} ORDER BY id`;
227
232
  ```
228
233
 
229
- This matches active users whose email contains every term. With no terms, the active condition
230
- still applies. For an entirely optional set of filters, omit the whole `WHERE` clause when the list
231
- is empty:
234
+ This matches active users whose email contains every term. With no terms, the active condition still
235
+ applies. For an entirely optional set of filters, omit the whole `WHERE` clause when the list is
236
+ empty:
232
237
 
233
238
  ```ts
234
239
  const optionalFilters = terms.map((term) => sql.fragment`email ILIKE ${'%' + term + '%'}`);
package/docs/mutations.md CHANGED
@@ -51,8 +51,8 @@ Annotations, `satisfies`, or helper generic arguments apply generated input type
51
51
  the file does not check writes. Use `Pick` to narrow accepted fields and retain runtime validation.
52
52
  Generated inputs describe defaults and driver representations, not authorization or constraints.
53
53
 
54
- Selected columns choose and order fields, ignoring unlisted keys. They can discard a required
55
- field even when the original object is valid:
54
+ Selected columns choose and order fields, ignoring unlisted keys. They can discard a required field
55
+ even when the original object is valid:
56
56
 
57
57
  ```ts
58
58
  const input: Rows.Insert['profiles'] = { name: 'Ada' };
@@ -97,21 +97,24 @@ if (bio !== undefined) {
97
97
  }
98
98
  ```
99
99
 
100
- The second query preserves an existing bio. Omitting an insert value uses its database default;
101
- that alone does not decide which fields a conflict update overwrites.
100
+ The second query preserves an existing bio. Omitting an insert value uses its database default; that
101
+ alone does not decide which fields a conflict update overwrites.
102
102
 
103
103
  ## JSON and PostgreSQL arrays
104
104
 
105
- For a table with `payload jsonb` and `tags text[]`, native arrays work in either context:
105
+ For a table with `payload jsonb` and `tags text[]`, encode JSON separately from the PostgreSQL
106
+ array:
106
107
 
107
108
  ```ts
108
109
  const tags = ['ada', 'lin'];
109
- await db.query(sql`INSERT INTO deliveries ${sql.insertValues({ payload: tags, tags })}`);
110
+ await db.query(
111
+ sql`INSERT INTO deliveries ${sql.insertValues({ payload: JSON.stringify(tags), tags })}`,
112
+ );
110
113
  ```
111
114
 
112
- PostgreSQL establishes the target types and the driver serializes values. Pass native JSON rather
113
- than `JSON.stringify` output, which would store a JSON string. Generated JSON writes accept Tenon's
114
- `JSONValue` type, excluding SQL null for nonnullable columns. Use `'null'::jsonb` for JSON null.
115
+ Generated JSON writes accept encoded JSON strings. Use `JSON.stringify(value)` for objects, arrays,
116
+ strings, and JSON null; a bound JavaScript null means SQL null. Arrays passed directly are
117
+ PostgreSQL arrays. Tenon leaves serialization to pg.
115
118
 
116
119
  ## Batches in one transaction
117
120
 
@@ -127,5 +130,5 @@ await db.transaction(async (tx) => {
127
130
  ```
128
131
 
129
132
  A failure rolls back all batches. Empty input makes no insert calls. Choose a batch size below the
130
- query-wide 65,533-parameter limit, including surrounding parameters; default cells consume none.
133
+ query-wide 65,535-parameter limit, including surrounding parameters; default cells consume none.
131
134
  Tenon does not split batches or choose transaction boundaries for you.
@@ -34,20 +34,22 @@ const description = await describeQuery({
34
34
  });
35
35
  ```
36
36
 
37
- Reuse the runtime URI and session settings. Optional `session.role` uses SET ROLE when permitted;
38
- it does not load that role's login defaults. Omitted settings use the inspection connection's
39
- defaults. Explicit undefined behaves like omission. Unknown keys and invalid settings fail.
37
+ Reuse the runtime URI and session settings. Optional `session.role` uses SET ROLE when permitted; it
38
+ does not load that role's login defaults. Omitted settings use the inspection connection's defaults.
39
+ Explicit undefined behaves like omission. Unknown keys and invalid settings fail.
40
40
 
41
41
  `context` reports login/effective roles, configured/resolved search paths, timezone, database, and
42
- effective statement/lock timeouts. Its mode is `separate-session` and read mapping profile is
43
- `postgres`. Custom parsers, temporary tables, uncommitted changes, and transaction-local settings
44
- are not inherited. `PGOPTIONS` is not automatically copied; use URI startup options or session
45
- settings. Array mappings include Tenon's SQL NULL correction.
42
+ effective statement/lock timeouts. Its mode is `separate-session` and read mapping profile is `pg`;
43
+ reported read types assume pg's default parsers and do not infer application overrides. Temporary
44
+ tables, uncommitted changes, and transaction-local settings are not inherited. pg reads its normal
45
+ connection environment, including `PGOPTIONS` when URI options are absent. Use explicit URI/session
46
+ settings for reproducibility.
46
47
 
47
48
  Defaults are a 3-second connection/authentication timeout, a 5-second limit per server statement,
48
- and a 1-second lock wait. Overrides are positive integer milliseconds, at most 2,147,483,647.
49
- URI settings cannot disable the statement/lock bounds. Cleanup uses a 1-second driver shutdown
50
- timeout. These are operation limits, not a single overall deadline.
49
+ and a 1-second lock wait. Overrides are positive integer milliseconds, at most 2,147,483,647. URI
50
+ settings cannot disable the statement/lock bounds. A URI `query_timeout` remains pg's separate
51
+ client read timeout. The owned client closes after inspection. These are operation limits, not a
52
+ single overall deadline.
51
53
 
52
54
  ## Result fields
53
55
 
@@ -113,10 +115,6 @@ rows/effects, JSON, permissions, and rollback in database tests, and run TypeScr
113
115
  meaningful query variants; one instance does not cover unexercised branches. Expect only reviewed
114
116
  findings and keep unexpected ones visible.
115
117
 
116
- A query builder is optional. When a test needs to retain executed statements, a test-only
117
- `QueryClient` wrapper can forward them and retain `{ text }` without parameter values. Production
118
- `onQuery` deliberately supplies neither SQL nor values.
119
-
120
118
  ## CLI
121
119
 
122
120
  Save SQL with PostgreSQL placeholders in `query.sql`: