@kequtech/tenon-orm 0.2.1 → 0.3.0

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 (55) hide show
  1. package/CHANGELOG.md +52 -36
  2. package/README.md +27 -70
  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 -16
  7. package/dist/connection.js +124 -14
  8. package/dist/database.js +36 -17
  9. package/dist/driver/types.d.ts +1 -1
  10. package/dist/driver/types.js +11 -11
  11. package/dist/errors.d.ts +8 -0
  12. package/dist/errors.js +15 -0
  13. package/dist/generate.js +7 -7
  14. package/dist/generation/catalog.d.ts +2 -2
  15. package/dist/generation/catalog.js +4 -4
  16. package/dist/generation/schema-summary.d.ts +3 -3
  17. package/dist/generation/schema-summary.js +11 -11
  18. package/dist/generation/table-types.js +1 -1
  19. package/dist/index.d.ts +2 -3
  20. package/dist/index.js +1 -1
  21. package/dist/inspection/catalog.d.ts +4 -3
  22. package/dist/inspection/catalog.js +13 -10
  23. package/dist/inspection/cli.js +3 -3
  24. package/dist/inspection/connection.d.ts +2 -2
  25. package/dist/inspection/connection.js +22 -22
  26. package/dist/inspection/options.d.ts +3 -3
  27. package/dist/inspection/options.js +9 -1
  28. package/dist/inspection/protocol.d.ts +7 -0
  29. package/dist/inspection/protocol.js +45 -0
  30. package/dist/inspection/result.d.ts +1 -1
  31. package/dist/inspection/result.js +6 -6
  32. package/dist/inspection/types.d.ts +10 -10
  33. package/dist/migrations/connection.d.ts +3 -2
  34. package/dist/migrations/connection.js +18 -20
  35. package/dist/migrations/history.d.ts +4 -4
  36. package/dist/migrations/history.js +5 -5
  37. package/dist/migrations.js +7 -7
  38. package/dist/query-options.js +5 -5
  39. package/dist/query.js +2 -2
  40. package/dist/transaction.d.ts +2 -2
  41. package/dist/transaction.js +19 -14
  42. package/dist/types.d.ts +7 -3
  43. package/docs/adoption-guide.md +109 -122
  44. package/docs/agent-guide.md +124 -228
  45. package/docs/human-guide.md +44 -55
  46. package/docs/mutations.md +58 -76
  47. package/docs/query-inspection.md +97 -89
  48. package/docs/reference.md +264 -319
  49. package/package.json +4 -4
  50. package/dist/driver/array.d.ts +0 -5
  51. package/dist/driver/array.js +0 -70
  52. package/dist/driver/lifecycle.d.ts +0 -15
  53. package/dist/driver/lifecycle.js +0 -57
  54. package/dist/driver/session.d.ts +0 -8
  55. package/dist/driver/session.js +0 -92
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - **Breaking:** `pg@8.23.1` replaces Postgres.js. Use pg `PoolConfig` (`connectionString` for a URI)
6
+ or `pg.Pool`; removed `{ url, options }` and `ConnectionUri`. `db.native` is the actual pool;
7
+ results use `rows`, `rowCount`, and `fields`, and error fields use pg names (`table`,
8
+ `constraint`). Native LISTEN uses checked-out clients with application-owned resubscription.
9
+ - Driver defaults apply, including no acquisition timeout and SQL null for bound `undefined`. The
10
+ parameter ceiling now matches PostgreSQL's 65,535 limit.
11
+ - Regenerate types: temporal reads include numeric infinities, date-only values use Node's local
12
+ timezone, and numeric arrays contain numbers that can lose precision. JSON write inputs are
13
+ encoded strings; use `JSON.stringify`, especially for arrays and strings. Removed `JSONValue`.
14
+
15
+ ## 0.2.2
16
+
17
+ - Closing a Tenon-owned client through `db.close()` prevents native LISTEN reconnects from restoring
18
+ connections after shutdown, including pending subscription setup. Borrowed clients retain
19
+ application ownership.
20
+ - Added `connection: { url, options? }` to preserve a deployment URI while configuring the native
21
+ driver. `ConnectionUri` is exported; `ConnectionOptions` includes the pinned driver's constructor
22
+ `onnotify` callback. Internally created clients remain Tenon-owned.
23
+ - Inspection accepts explicit `undefined` for optional session and timeout settings, including in
24
+ strict TypeScript configurations; unknown keys and invalid defined values still fail.
25
+ - `db.close({ timeoutMs? })` now shares one shutdown outcome across concurrent/later callers and
26
+ defaults to a 5,000 ms grace period before requesting owned driver termination; `0` skips the
27
+ wait.
28
+ - Added `CardinalityError` for `one`/`oneOrNone` mismatches, with code `TENON_CARDINALITY_MISMATCH`,
29
+ expected mode, actual count, and optional label.
30
+
3
31
  ## 0.2.1
4
32
 
5
33
  - Added readonly `db.native` access to Tenon's existing Postgres.js client for LISTEN, cursors,
@@ -14,29 +42,29 @@ Changes since 0.1.0, including improvements shipped in 0.1.1–0.1.4.
14
42
 
15
43
  ### Breaking changes
16
44
 
17
- - Tenon includes `postgres@3.4.9` as its only runtime dependency. Removed `pg`, `@types/pg`,
18
- and driver peer requirements. Connections accept a PostgreSQL URI, Postgres.js options, or
19
- an existing Postgres.js client.
45
+ - Tenon includes `postgres@3.4.9` as its only runtime dependency. Removed `pg`, `@types/pg`, and
46
+ driver peer requirements. Connections accept a PostgreSQL URI, Postgres.js options, or an existing
47
+ Postgres.js client.
20
48
  - Native results are row arrays with `count`, `command`, and `columns` metadata. Replace
21
- `result.rows` with the result itself, `rowCount` with `count`, and `fields` with `columns`.
22
- The `many`, `oneOrNone`, and `one` result modes remain available. Native error fields use
23
- Postgres.js names such as `table_name` and `constraint_name`.
24
- - Value conversions follow Postgres.js defaults: undefined parameters reject, JSON inputs use
25
- native JavaScript values, numeric arrays return strings, and timestamp infinities return
26
- invalid `Date` objects. Pre-serialized JSON text becomes a JSON string.
27
- - Removed the `/safeql` entry point, `safeqlPlugin`, `eslintPlugin`, and their dependencies and
28
- peer requirements. Tenon no longer checks or fixes SQL result annotations automatically.
49
+ `result.rows` with the result itself, `rowCount` with `count`, and `fields` with `columns`. The
50
+ `many`, `oneOrNone`, and `one` result modes remain available. Native error fields use Postgres.js
51
+ names such as `table_name` and `constraint_name`.
52
+ - Value conversions follow Postgres.js defaults: undefined parameters reject, JSON inputs use native
53
+ JavaScript values, numeric arrays return strings, and timestamp infinities return invalid `Date`
54
+ objects. Pre-serialized JSON text becomes a JSON string.
55
+ - Removed the `/safeql` entry point, `safeqlPlugin`, `eslintPlugin`, and their dependencies and peer
56
+ requirements. Tenon no longer checks or fixes SQL result annotations automatically.
29
57
 
30
58
  ### Added
31
59
 
32
60
  - `describeQuery({ connection, query, session?, timeouts? })` and `tenon describe <query.sql>`
33
- report PostgreSQL parameter/result types, source columns, a conservative row declaration,
34
- and unresolved findings. Inspection accepts completed dynamic SQL, uses a separate session,
35
- and supports explicit role, search path, timezone, and timeouts. It does not execute the query
36
- or verify its TypeScript annotation. See [query inspection](docs/query-inspection.md).
37
- - `sql.insertValues(input, columns?)` builds inserts with database defaults for missing or
38
- undefined cells. `sql.assignments(input, columns?)` builds updates that omit undefined fields.
39
- Both preserve explicit null. See the [mutation cookbook](docs/mutations.md).
61
+ report PostgreSQL parameter/result types, source columns, a conservative row declaration, and
62
+ unresolved findings. Inspection accepts completed dynamic SQL, uses a separate session, and
63
+ supports explicit role, search path, timezone, and timeouts. It does not execute the query or
64
+ verify its TypeScript annotation. See [query inspection](docs/query-inspection.md).
65
+ - `sql.insertValues(input, columns?)` builds inserts with database defaults for missing or undefined
66
+ cells. `sql.assignments(input, columns?)` builds updates that omit undefined fields. Both preserve
67
+ explicit null. See the [mutation cookbook](docs/mutations.md).
40
68
  - Generated `Rows.Insert['table']` and `Rows.Update['table']` types account for defaults,
41
69
  nullability, domains, generated columns, and driver input representations.
42
70
 
@@ -44,21 +72,9 @@ Changes since 0.1.0, including improvements shipped in 0.1.1–0.1.4.
44
72
 
45
73
  - SQL NULL array elements remain JavaScript null; quoted `"NULL"`, escapes, nesting, and custom
46
74
  parsers retain their meanings.
47
- - Transactions and migration locks reject work after connection loss. Failed recovery discards
48
- the affected session, and accepted pending work finishes before transaction cleanup.
49
- - Transactions and standalone guarded writes issue one fewer bootstrap query by registering
50
- their connection during BEGIN. Cold-start regressions cover concurrent reservations and tooling locks.
51
- - Large fragment composition avoids argument-spread failures. Queries reject more than 65,533
52
- bound parameters before execution; batching remains application-controlled.
53
- - PostgreSQL `name` columns generate string types. `tenon generate --check` reports missing
54
- files and a short diff for stale definitions without modifying the saved file.
55
-
56
- ### Upgrading
57
-
58
- 1. Remove Tenon's SafeQL imports and plugin configuration, and unused checking dependencies.
59
- 2. Install Tenon and remove `pg`/`@types/pg` when no other code needs them. Replace pg-specific
60
- connection configuration and pools with Postgres.js equivalents.
61
- 3. Update native result/error access, pass JSON values directly, and regenerate table definitions.
62
- Review numeric arrays and date validity. Use generated insert/update types for write inputs.
63
- 4. Inspect meaningful query variants and keep database tests for complete rows, permissions,
64
- ordering, write effects, and rollback. Match inspection settings to the execution session.
75
+ - Transactions and migration locks reject work after connection loss. Failed recovery discards the
76
+ affected session, and accepted pending work finishes before transaction cleanup.
77
+ - Large fragment composition avoids argument-spread failures. Queries reject more than 65,533 bound
78
+ parameters before execution; batching remains application-controlled.
79
+ - PostgreSQL `name` columns generate string types. `tenon generate --check` reports missing files
80
+ and a short diff for stale definitions without modifying the saved file.
package/README.md CHANGED
@@ -2,21 +2,16 @@
2
2
 
3
3
  A PostgreSQL ORM designed for coding agents. Built for Node.js and TypeScript.
4
4
 
5
- Your coding agent already knows SQL. Tenon gives it a small API for parameterized queries,
6
- transactions, migrations, query inspection, and generated database types. Queries
7
- stay readable, reusable operations are ordinary functions, and humans can review exactly what will
8
- run against the database.
9
-
10
- Tenon takes the place of an ORM without hiding PostgreSQL behind a query builder. You or your agent
11
- write the SQL; Tenon handles binding values and returning results. Your application controls the
12
- connection configuration and validation. Tenon includes its pinned Postgres.js driver.
5
+ Write SQL directly, bind values with a template tag, and reuse queries through ordinary functions.
6
+ Tenon provides transactions, affected-row guards, SQL migrations, generated database types, and
7
+ query inspection. It includes its pinned pg driver.
13
8
 
14
9
  ```sh
15
10
  npm install @kequtech/tenon-orm
16
11
  ```
17
12
 
18
- Requires Node.js 24+, PostgreSQL 16+, and ESM. TypeScript consumers need TypeScript 6+ and
19
- Node development typings.
13
+ Requires Node.js 24+, PostgreSQL 16+, and ESM. TypeScript consumers need TypeScript 6+ and Node
14
+ development typings.
20
15
 
21
16
  ```ts
22
17
  import { createTenon, sql } from '@kequtech/tenon-orm';
@@ -39,70 +34,32 @@ try {
39
34
  }
40
35
  ```
41
36
 
42
- This standalone example assumes the table exists. In a server, reuse an instance per database and
43
- close it at shutdown, not after each request. Use `returns: 'many'` for an array, `'one'` when a row
44
- must exist, or omit the option for the driver's full result.
45
-
46
- Use [`db.native`](docs/reference.md#native-driver-access) to access the underlying Postgres.js client
47
- for LISTEN, cursors, COPY, and other native features without installing another driver. Native calls
48
- use the pool directly and bypass Tenon's query and transaction guards.
49
-
50
- Tenon also provides:
51
-
52
- - Transactions and affected-row limits that roll back rejected writes.
53
- - SQL migration files and a CLI to apply, inspect, and roll back migrations.
54
- - TypeScript row, insert, and update types generated from your database.
55
- - SQL fragments for sharing query parts or assembling variable conditions.
56
- - Query descriptions from PostgreSQL, including parameter/result types and session context.
57
-
58
- Joins, relationships, aggregates, and upserts use ordinary PostgreSQL SQL. Tenon does not introduce
59
- model classes or automatically load related records. Input validation, authorization, and database
60
- performance remain application concerns. You can keep an existing migration runner; Tenon's
61
- migrations and table type generation are independent conveniences.
62
-
63
- ## Here's what a coding agent says about it
37
+ The example assumes `public.users` exists. In a server, reuse the instance and close it at shutdown.
38
+ `oneOrNone` rejects multiple rows; use `one` for exactly one row, `many` for an array, or omit
39
+ `returns` for the driver's result object and metadata.
64
40
 
65
- > "The appeal is that I can read the query, see where the transaction starts and ends, and put
66
- > reusable logic in a function. There isn't much framework machinery to learn. The tradeoff is that
67
- > someone still has to write the SQL and check the result types. I'm happy to do that part. Please
68
- > still review my SQL."
69
- >
70
- > — Codex, after working on Tenon. An interested party, to put it mildly.
41
+ Joins, relationships, aggregates, and upserts use PostgreSQL SQL. Object-based writes can use
42
+ `sql.insertValues` and `sql.assignments` while keeping the surrounding statement explicit.
43
+ `db.native` exposes the underlying `pg.Pool` for LISTEN, cursors, COPY, and other driver features;
44
+ those calls bypass Tenon's query and transaction guards.
71
45
 
72
- ## Object-based writes
73
-
74
- For object-based writes, `sql.insertValues(rows)` preserves database defaults across heterogeneous
75
- rows and `sql.assignments(patch)` omits undefined fields. Keep the surrounding SQL explicit. Both
76
- support selected columns.
77
- Use [generated input types](docs/reference.md#insert-and-update-inputs) to check those objects
78
- against your schema.
79
-
80
- ## Checking query results
81
-
82
- The type in `sql<{ id: number; email: string }>` describes what you expect back. TypeScript alone
83
- cannot verify that your SQL actually selects those columns.
84
-
85
- Use `describeQuery({ connection, query })` to inspect the finished SQL, including dynamic fragments,
86
- or `npx tenon describe query.sql` for a SQL file. It reports PostgreSQL types, Postgres.js values,
87
- session context, and unresolved findings without executing the statement. No checking packages or
88
- compiler API are required. Keep database tests for actual values, nullability, authorization, and
89
- write effects; descriptions do not verify `sql<Row>`. See [query inspection](docs/query-inspection.md).
46
+ `sql<Row>` declares an expected result type. `describeQuery` inspects the finished SQL through
47
+ PostgreSQL without executing it; it does not verify that annotation. Keep database tests for actual
48
+ rows and write effects. Validation, authorization, and retry policy belong to the application.
49
+ Existing migration runners can be retained; Tenon's migrations and type generation are optional.
90
50
 
91
51
  ## Documentation
92
52
 
93
- - **[Changelog](CHANGELOG.md)** — release changes and upgrade notes.
94
- - **[Human guide](docs/human-guide.md)** — learn the workflow through practical examples.
95
- - **[Agent guide](docs/agent-guide.md)** — self-contained setup, API contracts, and implementation
96
- conventions.
97
- - **[Reference](docs/reference.md)** — complete options, query inspection, type mappings, and
98
- limitations.
99
- - **[Adoption guide](docs/adoption-guide.md)** — replace an existing ORM and, if needed, transfer
100
- migration ownership, update local write helpers, and validate converted queries.
101
- - **[Mutation cookbook](docs/mutations.md)** — defaults, patches, upserts, arrays, batching, and
102
- the boundaries between input typing and database validation.
103
-
104
- The complete `docs/` folder ships with the npm package. Maintainer instructions are in
105
- [CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md), which is not
106
- bundled.
53
+ - **[Human guide](docs/human-guide.md)** — a table, queries, transactions, and generated types.
54
+ - **[Agent guide](docs/agent-guide.md)** — implementation contracts and upgrade workflow.
55
+ - **[Reference](docs/reference.md)** — API options, value mappings, CLI, and limitations.
56
+ - **[Adoption guide](docs/adoption-guide.md)** — replace an ORM and transfer migration ownership.
57
+ - **[Mutation cookbook](docs/mutations.md)** — defaults, patches, upserts, arrays, and batching.
58
+ - **[Query inspection](docs/query-inspection.md)** — Describe reports and database tests.
59
+ - **[Changelog](CHANGELOG.md)** — versioned changes and upgrade instructions.
60
+
61
+ These documents all ship with the npm package, including the changelog so an agent upgrading a
62
+ project can read the relevant release entries locally. Repository maintenance instructions are in
63
+ [CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md).
107
64
 
108
65
  [ISC licensed](LICENSE). Maintained by [Kequtech](https://github.com/kequtech).
@@ -17,14 +17,14 @@ export function validateAffectedRows(value) {
17
17
  }
18
18
  export function checkAffectedRows(constraint, result) {
19
19
  if (!['INSERT', 'UPDATE', 'DELETE', 'MERGE'].includes(result.command) ||
20
- result.count === null ||
21
- !Number.isSafeInteger(result.count) ||
22
- result.count < 0) {
23
- throw new AffectedRowsError(constraint, result.count, result.command, false);
20
+ result.rowCount === null ||
21
+ !Number.isSafeInteger(result.rowCount) ||
22
+ result.rowCount < 0) {
23
+ throw new AffectedRowsError(constraint, result.rowCount, result.command, false);
24
24
  }
25
25
  const expected = constraint.exact ?? constraint.max;
26
- const matches = constraint.exact === undefined ? result.count <= expected : result.count === expected;
26
+ const matches = constraint.exact === undefined ? result.rowCount <= expected : result.rowCount === expected;
27
27
  if (!matches) {
28
- throw new AffectedRowsError(constraint, result.count, result.command, true);
28
+ throw new AffectedRowsError(constraint, result.rowCount, result.command, true);
29
29
  }
30
30
  }
package/dist/baseline.js CHANGED
@@ -82,16 +82,16 @@ export async function adoptBaseline(options) {
82
82
  if (applied.length) {
83
83
  return [];
84
84
  }
85
- const schema = await client.unsafe('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
85
+ const schema = await client.query('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
86
86
  namespace,
87
87
  ]);
88
- if (!schema.length) {
88
+ if (!schema.rows.length) {
89
89
  throw new Error('The baseline namespace does not exist. Use migrate to initialize a fresh database.');
90
90
  }
91
- await client.unsafe('BEGIN');
91
+ await client.query('BEGIN');
92
92
  await createHistoryTable(client, namespace);
93
93
  await recordMigration(client, namespace, baseline);
94
- const result = await client.unsafe('COMMIT');
94
+ const result = await client.query('COMMIT');
95
95
  if (result.command !== 'COMMIT') {
96
96
  throw new Error('Baseline adoption was rolled back.');
97
97
  }
package/dist/client.js CHANGED
@@ -16,7 +16,7 @@ export function createClient(query, onQuery) {
16
16
  try {
17
17
  const value = await query(statement.text, statement.values, (result) => {
18
18
  command = result.command;
19
- rowCount = result.count;
19
+ rowCount = result.rowCount;
20
20
  if (options.affectedRows)
21
21
  checkAffectedRows(options.affectedRows, result);
22
22
  return queryOutput(options, result);
@@ -1,20 +1,19 @@
1
- import postgres from 'postgres';
1
+ import { Pool, type PoolClient, type PoolConfig, type QueryResult as PgResult } from 'pg';
2
2
  import type { UnknownRow } from './query.js';
3
- export type ConnectionOptions = postgres.Options<{}>;
4
- export type Connection = string | ConnectionOptions | postgres.Sql;
5
- export type QueryResult<Row extends object = UnknownRow> = postgres.RowList<Row[]>;
6
- /** The driver operations shared by ordinary and reserved connections. */
7
- export interface SqlConnection {
8
- unsafe<Rows extends object[] = UnknownRow[]>(text: string, input?: postgres.ParameterOrJSON<never>[], options?: postgres.UnsafeQueryOptions & {
9
- simple?: boolean;
10
- }): Promise<postgres.RowList<Rows>>;
11
- array: postgres.Sql['array'];
12
- typed: postgres.Sql['typed'];
13
- }
3
+ export type ConnectionOptions = PoolConfig;
4
+ export type Connection = string | ConnectionOptions | Pool;
5
+ export type QueryResult<Row extends object = UnknownRow> = PgResult<Row>;
14
6
  export type QueryExecutor = <T>(text: string, input: readonly unknown[], readResult: (result: QueryResult) => T, guarded: boolean) => Promise<T>;
15
- /** Own clients created from a URI/config; borrow explicitly supplied Postgres.js clients. */
7
+ /** Own constructed pools; supplied pools retain their application's ownership and error policy. */
16
8
  export declare function createConnection(connection: Connection): {
17
- sql: postgres.Sql;
18
- owned: boolean;
9
+ pool: Pool;
10
+ run<T>(work: () => Promise<T>): Promise<T>;
11
+ close(timeoutMs: number): Promise<void>;
19
12
  };
20
- export declare function executeSql(sql: SqlConnection, text: string, input?: readonly unknown[]): Promise<QueryResult>;
13
+ /** A checked-out pg client remains tied to its session and is discarded after a failure. */
14
+ export declare function watchClient(client: PoolClient): {
15
+ check(): void;
16
+ release(error?: Error): void;
17
+ };
18
+ /** Unnamed extended protocol enforces a single statement, even without parameters. */
19
+ export declare function executeSql(connection: Pool | PoolClient, text: string, input?: readonly unknown[]): Promise<PgResult<UnknownRow>>;
@@ -1,22 +1,132 @@
1
- import postgres from 'postgres';
1
+ import { Pool, } from 'pg';
2
2
  import { plainObject } from './validation.js';
3
- /** Own clients created from a URI/config; borrow explicitly supplied Postgres.js clients. */
3
+ /** A supplied pool may come from another installed copy of pg. */
4
+ function isPool(connection) {
5
+ return (connection !== null &&
6
+ typeof connection === 'object' &&
7
+ 'connect' in connection &&
8
+ typeof connection.connect === 'function' &&
9
+ 'query' in connection &&
10
+ typeof connection.query === 'function' &&
11
+ 'totalCount' in connection &&
12
+ typeof connection.totalCount === 'number' &&
13
+ 'end' in connection &&
14
+ typeof connection.end === 'function');
15
+ }
16
+ /** Own constructed pools; supplied pools retain their application's ownership and error policy. */
4
17
  export function createConnection(connection) {
5
- if (typeof connection === 'function' && typeof connection.unsafe === 'function') {
6
- return { sql: connection, owned: false };
7
- }
18
+ if (isPool(connection))
19
+ return { pool: connection, run: (work) => work(), close: async () => { } };
20
+ let config;
8
21
  if (typeof connection === 'string') {
9
22
  if (!connection.trim())
10
23
  throw new Error('Connection string must not be empty.');
11
- return { sql: postgres(connection), owned: true };
24
+ config = { connectionString: connection };
12
25
  }
13
- plainObject(connection, 'Connection configuration');
14
- return { sql: postgres(connection), owned: true };
26
+ else {
27
+ plainObject(connection, 'Connection configuration');
28
+ if ('url' in connection) {
29
+ throw new Error('Use pg connectionString in the connection configuration instead of url/options.');
30
+ }
31
+ config = connection;
32
+ }
33
+ const pool = new Pool(config);
34
+ const clients = new Set();
35
+ const pending = new Map();
36
+ let terminating = false;
37
+ const terminate = (client) => {
38
+ void client.end().catch(() => { });
39
+ };
40
+ // pool.end handles idle clients; the deadline also ends native checkouts.
41
+ pool.on('acquire', (client) => {
42
+ clients.add(client);
43
+ if (terminating)
44
+ terminate(client);
45
+ });
46
+ pool.on('release', (_error, client) => clients.delete(client));
47
+ pool.on('error', (error) => process.emitWarning(error));
48
+ return {
49
+ pool,
50
+ run(work) {
51
+ let expire;
52
+ const task = new Promise((resolve, reject) => {
53
+ expire = reject;
54
+ void Promise.resolve().then(work).then(resolve, reject);
55
+ });
56
+ pending.set(task, expire);
57
+ const forget = () => {
58
+ pending.delete(task);
59
+ };
60
+ void task.then(forget, forget);
61
+ return task;
62
+ },
63
+ async close(timeoutMs) {
64
+ let timer;
65
+ let ending;
66
+ const end = () => (ending ??= pool.end());
67
+ try {
68
+ await Promise.race([
69
+ (async () => {
70
+ await Promise.allSettled([...pending.keys()]);
71
+ await end();
72
+ })(),
73
+ new Promise((resolve) => {
74
+ timer = setTimeout(resolve, timeoutMs);
75
+ }).then(async () => {
76
+ // Native callbacks must reject close on failure, not escape a timer callback.
77
+ terminating = true;
78
+ const error = new Error('The database shutdown deadline expired.');
79
+ for (const reject of pending.values())
80
+ reject(error);
81
+ try {
82
+ void end().catch(() => { });
83
+ }
84
+ finally {
85
+ // Attempt every checkout even when one native end call throws.
86
+ await Promise.all([...clients].map(async (client) => terminate(client)));
87
+ }
88
+ }),
89
+ ]);
90
+ }
91
+ finally {
92
+ clearTimeout(timer);
93
+ }
94
+ },
95
+ };
96
+ }
97
+ /** A checked-out pg client remains tied to its session and is discarded after a failure. */
98
+ export function watchClient(client) {
99
+ let failure;
100
+ const onError = (error) => {
101
+ failure ??= error;
102
+ };
103
+ const onEnd = () => {
104
+ failure ??= new Error('The PostgreSQL connection closed.');
105
+ };
106
+ client.on('error', onError);
107
+ client.on('end', onEnd);
108
+ return {
109
+ check() {
110
+ if (failure)
111
+ throw failure;
112
+ },
113
+ release(error) {
114
+ try {
115
+ client.release(error ?? failure);
116
+ }
117
+ finally {
118
+ client.removeListener('error', onError);
119
+ client.removeListener('end', onEnd);
120
+ }
121
+ },
122
+ };
15
123
  }
16
- export function executeSql(sql, text, input = []) {
17
- // Tenon compiles SQL separately from values; let PostgreSQL infer array/JSON types from the surrounding SQL.
18
- const values = input.map((value) => (Array.isArray(value) ? sql.typed(value, 0) : value));
19
- // Keep preparation off; CONSIDERATIONS.md records parser-cache and pipeline safety constraints.
20
- const options = { simple: false, prepare: false };
21
- return sql.unsafe(text, values, options);
124
+ /** Unnamed extended protocol enforces a single statement, even without parameters. */
125
+ export function executeSql(connection, text, input = []) {
126
+ const config = {
127
+ text,
128
+ values: [...input],
129
+ queryMode: 'extended',
130
+ };
131
+ return connection.query(config);
22
132
  }
package/dist/database.js CHANGED
@@ -1,8 +1,22 @@
1
1
  import { createClient } from './client.js';
2
2
  import { createConnection, executeSql } from './connection.js';
3
- import { prepareArrays } from './driver/array.js';
4
3
  import { withTransaction } from './transaction.js';
5
4
  import { plainObject } from './validation.js';
5
+ function closeTimeout(options) {
6
+ const settings = plainObject(options === undefined ? {} : options, 'Close options');
7
+ for (const key of Object.keys(settings)) {
8
+ if (key !== 'timeoutMs')
9
+ throw new Error(`Unknown close option: ${key}`);
10
+ }
11
+ const timeoutMs = settings.timeoutMs === undefined ? 5000 : settings.timeoutMs;
12
+ if (typeof timeoutMs !== 'number' ||
13
+ !Number.isInteger(timeoutMs) ||
14
+ timeoutMs < 0 ||
15
+ timeoutMs > 2147483647) {
16
+ throw new Error('Close timeoutMs must be an integer from 0 to 2147483647 milliseconds.');
17
+ }
18
+ return timeoutMs;
19
+ }
6
20
  export function createTenon(options) {
7
21
  for (const key of Object.keys(plainObject(options, 'Tenon options'))) {
8
22
  if (key !== 'connection' && key !== 'onQuery') {
@@ -12,35 +26,40 @@ export function createTenon(options) {
12
26
  if (options.onQuery !== undefined && typeof options.onQuery !== 'function') {
13
27
  throw new Error('onQuery must be a function.');
14
28
  }
15
- const { sql: pool, owned } = createConnection(options.connection);
16
- let closed = false;
29
+ const { pool, run, close } = createConnection(options.connection);
30
+ let closing;
17
31
  function checkOpen() {
18
- if (closed) {
32
+ if (closing) {
19
33
  throw new Error('This database instance is closed.');
20
34
  }
21
35
  }
22
- const query = async (text, input, readResult, guarded) => {
36
+ const query = (text, input, readResult, guarded) => {
23
37
  checkOpen();
24
- if (guarded) {
25
- return withTransaction(pool, (query) => query(text, input, readResult, false));
26
- }
27
- await prepareArrays(pool);
28
- return readResult(await executeSql(pool, text, input));
38
+ return run(async () => {
39
+ if (guarded) {
40
+ return withTransaction(pool, (query) => query(text, input, readResult, false));
41
+ }
42
+ return readResult(await executeSql(pool, text, input));
43
+ });
29
44
  };
30
45
  return {
31
46
  ...createClient(query, options.onQuery),
32
47
  native: pool,
33
48
  async transaction(callback) {
34
49
  checkOpen();
35
- return withTransaction(pool, (query) => callback(createClient(query, options.onQuery)));
50
+ return run(() => withTransaction(pool, (query) => callback(createClient(query, options.onQuery))));
36
51
  },
37
- async close() {
38
- if (closed) {
39
- return;
52
+ close(options) {
53
+ if (closing)
54
+ return closing;
55
+ try {
56
+ const timeoutMs = closeTimeout(options);
57
+ // Cache before invoking driver callbacks, so reentrant/concurrent callers share shutdown.
58
+ closing = Promise.resolve().then(() => close(timeoutMs));
59
+ return closing;
40
60
  }
41
- closed = true;
42
- if (owned) {
43
- await pool.end();
61
+ catch (error) {
62
+ return Promise.reject(error);
44
63
  }
45
64
  },
46
65
  };
@@ -1,4 +1,4 @@
1
- /** Postgres.js default representations (with SQL NULL array elements preserved), shared by generation and query inspection. */
1
+ /** pg default representations, shared by generation and query inspection. */
2
2
  export type ValueTypes = Readonly<{
3
3
  read: string;
4
4
  write: string;
@@ -8,18 +8,18 @@ export const arrayTypes = {
8
8
  '1022': 'number',
9
9
  '1028': 'number',
10
10
  '1016': { read: 'string', write: 'string | bigint' },
11
- '1231': { read: 'string', write: 'string | number' },
11
+ '1231': { read: 'number', write: 'string | number' },
12
12
  '1009': 'string',
13
13
  '1014': 'string',
14
14
  '1015': 'string',
15
15
  '1183': 'string',
16
16
  '1270': 'string',
17
17
  '2951': 'string',
18
- '1182': { read: 'globalThis.Date', write: 'globalThis.Date | string' },
19
- '1115': { read: 'globalThis.Date', write: 'globalThis.Date | string' },
20
- '1185': { read: 'globalThis.Date', write: 'globalThis.Date | string' },
21
- '199': { read: 'unknown', write: 'Exclude<import("@kequtech/tenon-orm").JSONValue, null>' },
22
- '3807': { read: 'unknown', write: 'Exclude<import("@kequtech/tenon-orm").JSONValue, null>' },
18
+ '1182': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
19
+ '1115': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
20
+ '1185': { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
21
+ '199': { read: 'unknown', write: 'string' },
22
+ '3807': { read: 'unknown', write: 'string' },
23
23
  };
24
24
  export const scalarTypes = {
25
25
  int2: 'number',
@@ -35,13 +35,13 @@ export const scalarTypes = {
35
35
  time: 'string',
36
36
  timetz: 'string',
37
37
  uuid: 'string',
38
- date: { read: 'globalThis.Date', write: 'globalThis.Date | string' },
39
- timestamp: { read: 'globalThis.Date', write: 'globalThis.Date | string' },
40
- timestamptz: { read: 'globalThis.Date', write: 'globalThis.Date | string' },
38
+ date: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
39
+ timestamp: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
40
+ timestamptz: { read: 'globalThis.Date | number', write: 'globalThis.Date | string' },
41
41
  int8: { read: 'string', write: 'string | bigint' },
42
42
  numeric: { read: 'string', write: 'string | number' },
43
- json: { read: 'unknown', write: 'Exclude<import("@kequtech/tenon-orm").JSONValue, null>' },
44
- jsonb: { read: 'unknown', write: 'Exclude<import("@kequtech/tenon-orm").JSONValue, null>' },
43
+ json: { read: 'unknown', write: 'string' },
44
+ jsonb: { read: 'unknown', write: 'string' },
45
45
  bytea: 'import("node:buffer").Buffer',
46
46
  };
47
47
  export function valueTypes(mapping) {
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  import type { AffectedRowsConstraint } from './affected-rows.js';
2
+ export declare class CardinalityError extends Error {
3
+ name: string;
4
+ readonly code = "TENON_CARDINALITY_MISMATCH";
5
+ readonly expected: 'one' | 'oneOrNone';
6
+ readonly actual: number;
7
+ readonly label?: string;
8
+ constructor(expected: 'one' | 'oneOrNone', actual: number, label?: string);
9
+ }
2
10
  export declare class AffectedRowsError extends Error {
3
11
  name: string;
4
12
  readonly code: 'TENON_AFFECTED_ROWS_MISMATCH' | 'TENON_AFFECTED_ROWS_UNSUPPORTED';
package/dist/errors.js CHANGED
@@ -1,3 +1,18 @@
1
+ export class CardinalityError extends Error {
2
+ name = 'CardinalityError';
3
+ code = 'TENON_CARDINALITY_MISMATCH';
4
+ expected;
5
+ actual;
6
+ label;
7
+ constructor(expected, actual, label) {
8
+ const count = expected === 'one' ? 'exactly one row' : 'at most one row';
9
+ super(`${label ?? 'Query'} expected ${count}, but returned ${actual} rows.`);
10
+ this.expected = expected;
11
+ this.actual = actual;
12
+ if (label !== undefined)
13
+ this.label = label;
14
+ }
15
+ }
1
16
  export class AffectedRowsError extends Error {
2
17
  name = 'AffectedRowsError';
3
18
  code;