@kequtech/tenon-orm 0.3.0 → 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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,9 @@
6
6
  or `pg.Pool`; removed `{ url, options }` and `ConnectionUri`. `db.native` is the actual pool;
7
7
  results use `rows`, `rowCount`, and `fields`, and error fields use pg names (`table`,
8
8
  `constraint`). Native LISTEN uses checked-out clients with application-owned resubscription.
9
+ Default results are no longer iterable: use `result.rows`, replace metadata `count` with
10
+ `rowCount`, and update recording helpers. Selected SQL columns named `count` are unchanged. See
11
+ [transaction adapters](docs/reference.md#adapting-transactional-results) for `{ rows }` consumers.
9
12
  - Driver defaults apply, including no acquisition timeout and SQL null for bound `undefined`. The
10
13
  parameter ceiling now matches PostgreSQL's 65,535 limit.
11
14
  - Regenerate types: temporal reads include numeric infinities, date-only values use Node's local
@@ -103,6 +103,16 @@ reference definitions rather than comparing against types regenerated after late
103
103
  `generateDefinitions` covers relation metadata, not functions or privileges; include reviewed
104
104
  catalog checks for other objects the application needs. Tenon does not perform these comparisons.
105
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
+
106
116
  ```sh
107
117
  npx tenon baseline adopt --directory migrations --namespace app
108
118
  npx tenon migrate --directory migrations --namespace app
@@ -103,7 +103,9 @@ Use `JSON.stringify(value)` for JSON write inputs; arrays otherwise bind as Post
103
103
  Bigint and numeric scalars read as strings, while numeric arrays read as numbers and can lose
104
104
  precision. Temporal reads use `Date | number` for finite dates and infinities. SQL NULL array
105
105
  elements remain JavaScript null. Keep domain validation at the persistence boundary. See
106
- [value mappings](reference.md#generated-types-and-checking).
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.
107
109
 
108
110
  ## Transactions and native access
109
111
 
@@ -114,6 +114,10 @@ needs a type for what that particular query selects. An annotation is a declarat
114
114
  the SQL matches. Without one, result fields are `unknown`. Use the inspection and database-test
115
115
  workflow below to check the declared contract against the query's behavior.
116
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
+
117
121
  ## Reuse a query with a function
118
122
 
119
123
  Use an ordinary function when several callers need the same query. An object input makes the
package/docs/reference.md CHANGED
@@ -25,7 +25,9 @@ and `close`. The required `connection` accepts:
25
25
  const db = createTenon({
26
26
  connection: {
27
27
  connectionString: connection,
28
- connectionTimeoutMillis: 5000,
28
+ max: 10,
29
+ idleTimeoutMillis: 30_000,
30
+ connectionTimeoutMillis: 30_000,
29
31
  options: '-c search_path=app,public',
30
32
  },
31
33
  });
@@ -36,6 +38,9 @@ Strings become pg's `connectionString`; configuration objects pass to `new Pool(
36
38
  including no acquisition timeout unless `connectionTimeoutMillis` is configured. Pool timeouts use
37
39
  milliseconds. pg parses the URI for connection fields; matching URI values override client
38
40
  configuration, including an `options` URI parameter overriding the configuration's `options` string.
41
+ The pool sizes and timeouts above are application choices. Configure pool policy in `PoolConfig`;
42
+ old driver-specific URI settings such as `idle_timeout` do not configure pg's idle timeout. Preserve
43
+ existing URI startup options when appending settings such as UTC and `search_path`.
39
44
  Preserve deployment credentials and TLS requirements; see
40
45
  [pg connection configuration](https://node-postgres.com/features/connecting).
41
46
 
@@ -64,15 +69,91 @@ modes, affected-row guards, SQL checks, and `onQuery`. They follow pg's query co
64
69
  not join a Tenon transaction; callback clients expose only `tx.query`.
65
70
 
66
71
  Use pg's [native APIs](https://node-postgres.com/apis/pool) and compatible cursor/COPY packages when
67
- needed. For LISTEN, check out a client with `await db.native.connect()`, attach `notification` and
68
- `error` listeners, and issue `LISTEN` on that client. Keep it checked out while listening; release
69
- it after `UNLISTEN`. Native queued acquisitions follow pg's shutdown behavior and must be settled by
70
- their caller before closing. A disconnected client does not reconnect. The application owns
72
+ needed. For LISTEN, check out a client with `await db.native.connect()`, attach `notification`,
73
+ `error`, and `end` listeners, and issue `LISTEN` on that client. Keep it checked out while listening;
74
+ release it after `UNLISTEN`. Native queued acquisitions follow pg's shutdown behavior and must be
75
+ settled by their caller before closing. A disconnected client does not reconnect. The application owns
71
76
  resubscription and catch-up; notifications are not replayed.
72
77
 
73
78
  Close owned pools through `db.close()` for bounded shutdown, including checked-out native clients.
74
79
  Borrowed pools and their listeners remain usable after wrapper close until their owner ends them.
75
80
 
81
+ ### LISTEN lifecycle example
82
+
83
+ This helper owns a dedicated single-client pool. It returns an intentional-close function only
84
+ after LISTEN is ready. Callbacks are synchronous; notification exceptions are isolated, and an
85
+ unexpected disconnect invalidates this generation once before notifying its consumer. Failed
86
+ connection acquisition or LISTEN setup closes the owned resources and rejects the setup promise.
87
+
88
+ ```ts
89
+ import { createTenon } from '@kequtech/tenon-orm';
90
+
91
+ async function listenForChanges(
92
+ connectionString: string,
93
+ onChange: (payload: string | undefined) => void,
94
+ onDisconnect: (error: Error) => void,
95
+ ) {
96
+ const db = createTenon({
97
+ connection: { connectionString, max: 1, idleTimeoutMillis: 0, connectionTimeoutMillis: 30_000 },
98
+ });
99
+ const client = await db.native.connect().catch(async (error: unknown) => {
100
+ await db.close();
101
+ throw error;
102
+ });
103
+ let active = true;
104
+ let closing: Promise<void> | undefined;
105
+
106
+ function stop(error?: Error): Promise<void> {
107
+ if (closing) return closing;
108
+ active = false;
109
+ closing = Promise.resolve().then(async () => {
110
+ try {
111
+ if (error) onDisconnect(error);
112
+ } finally {
113
+ client.release(true);
114
+ await db.close();
115
+ }
116
+ });
117
+ return closing;
118
+ }
119
+
120
+ const failed = (error: Error) => {
121
+ if (active) void stop(error).catch(console.error);
122
+ };
123
+ client.on('error', failed);
124
+ client.on('end', () => failed(new Error('The notification connection ended.')));
125
+ client.on('notification', (event) => {
126
+ if (!active || event.channel !== 'app_changes') return;
127
+ try {
128
+ onChange(event.payload);
129
+ } catch (error) {
130
+ console.error(error);
131
+ }
132
+ });
133
+ try {
134
+ await client.query('LISTEN "app_changes"');
135
+ if (!active) throw new Error('The notification connection closed during setup.');
136
+ } catch (error) {
137
+ await stop();
138
+ throw error;
139
+ }
140
+ return () => stop();
141
+ }
142
+ ```
143
+
144
+ Both failure and intentional close discard the dedicated checkout with `release(true)` before
145
+ closing its pool, so no UNLISTEN is needed and normal shutdown does not wait for Tenon's deadline.
146
+ The active flag suppresses duplicate error/end callbacks and intentional-close notifications.
147
+ Create a new helper instance to recover, and reconcile missed events through persistent data.
148
+
149
+ This helper's close function is available after readiness. A manager that also supports replacement
150
+ or shutdown while setup is pending must assign a generation token before awaiting setup. Only the
151
+ current generation may change shared readiness/subscription state or notify its consumers. If stale
152
+ setup later succeeds, close that setup's own listener; if it fails or finishes cleanup, leave the
153
+ replacement alone. Invalidate the current token before intentional shutdown. Keep those guards in
154
+ the application's subscription manager. The fixed channel above is trusted; validate and quote
155
+ application-selected channel names before placing them in native SQL.
156
+
76
157
  ## Queries and result modes
77
158
 
78
159
  `sql<Row>` creates a detached `SqlQuery<Row>` with SQL `text` and ordered `values`; it does not
@@ -104,6 +185,25 @@ alone does not undo an autocommitted write; use `affectedRows` for write bounds
104
185
  Functions accepting `QueryClient` can use either `Database` or `Transaction`. Public types also
105
186
  include `QueryOptions`, `QueryReturnMode`, and `QueryOutput<Row, Mode>`.
106
187
 
188
+ ### Adapting transactional results
189
+
190
+ For a job library or other adapter expecting `{ rows }`, extract the native result's row array:
191
+
192
+ ```ts
193
+ import { unsafeSql, type QueryClient } from '@kequtech/tenon-orm';
194
+
195
+ async function queryRows(tx: QueryClient, text: string, values: readonly unknown[]) {
196
+ const result = await tx.query(unsafeSql(text, values));
197
+ return { rows: result.rows };
198
+ }
199
+ ```
200
+
201
+ Alternatively, request `{ returns: 'many' }` and wrap the returned array in `{ rows }`. Supply the
202
+ callback's `tx` when the adapter's work must commit with application writes. A native pool query
203
+ uses a separate checkout and does not join that transaction. Await adapter calls before returning
204
+ from the callback, retain commit/rollback tests, and make recording helpers return pg-shaped native
205
+ results. The adapter's SQL text must be trusted and values must remain separately bound.
206
+
107
207
  ## SQL parameters and identifiers
108
208
 
109
209
  Ordinary `sql` interpolations bind values. Do not quote them or write manual `$n` placeholders
@@ -250,6 +350,13 @@ changes. It compares the generated representation, not pending migrations or eve
250
350
  Output includes relation/constraint/index summaries and referenced domains, not function bodies,
251
351
  grants, or a complete schema fingerprint.
252
352
 
353
+ **Generated declarations are not a version-stable schema fingerprint.** TypeScript mappings can
354
+ change without schema drift. The schema-summary comments describe SQL column types, defaults,
355
+ constraints, indexes, and referenced domains independently of those mappings, but their formatting
356
+ and PostgreSQL's rendering of definitions are not a versioned comparison contract either. Preserve
357
+ reviewed generator-specific references if an existing guard hashes this output; see
358
+ [baseline verification](adoption-guide.md#check-and-adopt-an-existing-database).
359
+
253
360
  Generation covers ordinary tables and partitioned roots. Views, foreign tables, and attached
254
361
  partitions need query-specific types. A missing namespace fails; an existing empty schema is valid.
255
362
  Exact table names are available through `Rows['users']`; valid nonreserved identifiers also receive
@@ -284,6 +391,74 @@ needed.
284
391
  Generated types describe storage representations. Text `CHECK` constraints do not become unions, and
285
392
  JSON reads remain unknown. Validate values before narrowing to application domain types.
286
393
 
394
+ For example, assume this additional table and regenerate `Rows` from the resulting schema:
395
+
396
+ ```sql
397
+ CREATE TABLE public.sessions (
398
+ id text PRIMARY KEY,
399
+ data jsonb NOT NULL,
400
+ expires_at timestamptz NOT NULL
401
+ );
402
+ ```
403
+
404
+ Use the same application validator before encoding a write and when restoring a domain value:
405
+
406
+ ```ts
407
+ import { sql } from '@kequtech/tenon-orm';
408
+ import type { Rows } from './generated-schema.js';
409
+
410
+ function sessionData(value: unknown): { userId: string } {
411
+ if (
412
+ typeof value !== 'object' ||
413
+ value === null ||
414
+ Array.isArray(value) ||
415
+ !('userId' in value) ||
416
+ typeof value.userId !== 'string' ||
417
+ value.userId.length === 0
418
+ ) {
419
+ throw new Error('Expected session data with a nonempty userId');
420
+ }
421
+ return { userId: value.userId };
422
+ }
423
+
424
+ function finiteDate(value: Date | number): Date {
425
+ if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
426
+ throw new Error('Expected a finite database timestamp');
427
+ }
428
+ return value;
429
+ }
430
+
431
+ const input = {
432
+ id: 'example-session',
433
+ data: JSON.stringify(sessionData({ userId: 'user-42' })),
434
+ expires_at: new Date(Date.now() + 60_000),
435
+ } satisfies Rows.Insert['sessions'];
436
+ await db.query(sql`INSERT INTO public.sessions ${sql.insertValues(input)}`);
437
+
438
+ const row = await db.query(
439
+ sql<Rows['sessions']>`SELECT id, data, expires_at FROM public.sessions
440
+ WHERE id = ${input.id} AND isfinite(expires_at) AND expires_at > now()`,
441
+ { returns: 'oneOrNone' },
442
+ );
443
+ const session =
444
+ row === undefined
445
+ ? undefined
446
+ : {
447
+ id: row.id,
448
+ data: sessionData(row.data),
449
+ expiresAt: finiteDate(row.expires_at),
450
+ };
451
+ ```
452
+
453
+ The generated JSON read remains `unknown` and the timestamp remains `Date | number`. The projection
454
+ validates both without assertions or edits to generated types. Preserve null separately for nullable
455
+ columns. For an authentication policy requiring finite expiry, `isfinite(expires_at)` excludes both
456
+ infinities before the expiry comparison; `expires_at > now()` alone would accept positive infinity.
457
+ Include such predicates in expiry-sensitive updates/deletes too, and test infinite and expired
458
+ values. This example illustrates representation validation; applications must also retain their
459
+ token verification, revocation, and authorization checks. See
460
+ [JSON and PostgreSQL arrays](mutations.md#json-and-postgresql-arrays) for array write boundaries.
461
+
287
462
  Bigint and `count(*)` reads are decimal strings. Use SQL or BigInt for exact arithmetic and decimal
288
463
  strings at JSON boundaries; convert to number only when bounded or approximate values are intended.
289
464
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kequtech/tenon-orm",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",