@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 +3 -0
- package/docs/adoption-guide.md +10 -0
- package/docs/agent-guide.md +3 -1
- package/docs/human-guide.md +4 -0
- package/docs/reference.md +180 -5
- package/package.json +1 -1
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
|
package/docs/adoption-guide.md
CHANGED
|
@@ -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
|
package/docs/agent-guide.md
CHANGED
|
@@ -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
|
|
package/docs/human-guide.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
68
|
-
`error` listeners, and issue `LISTEN` on that client. Keep it checked out while listening;
|
|
69
|
-
it after `UNLISTEN`. Native queued acquisitions follow pg's shutdown behavior and must be
|
|
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
|
|