@kequtech/tenon-orm 0.1.4 → 0.2.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.
- package/CHANGELOG.md +56 -0
- package/README.md +15 -19
- package/dist/affected-rows.d.ts +1 -1
- package/dist/affected-rows.js +6 -6
- package/dist/baseline.d.ts +1 -1
- package/dist/baseline.js +5 -5
- package/dist/cli.js +18 -39
- package/dist/client.js +1 -1
- package/dist/connection.d.ts +18 -13
- package/dist/connection.js +14 -43
- package/dist/database.js +4 -2
- package/dist/describe.d.ts +3 -0
- package/dist/describe.js +21 -0
- package/dist/driver/array.d.ts +5 -0
- package/dist/driver/array.js +70 -0
- package/dist/driver/lifecycle.d.ts +15 -0
- package/dist/driver/lifecycle.js +57 -0
- package/dist/driver/session.d.ts +8 -0
- package/dist/driver/session.js +92 -0
- package/dist/driver/types.d.ts +10 -0
- package/dist/driver/types.js +49 -0
- package/dist/generate.js +7 -7
- package/dist/generation/catalog.d.ts +2 -2
- package/dist/generation/catalog.js +4 -4
- package/dist/generation/output.d.ts +2 -0
- package/dist/generation/output.js +35 -0
- package/dist/generation/schema-summary.d.ts +3 -3
- package/dist/generation/schema-summary.js +11 -11
- package/dist/generation/table-types.js +2 -52
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/inspection/catalog.d.ts +25 -0
- package/dist/inspection/catalog.js +29 -0
- package/dist/inspection/cli.d.ts +22 -0
- package/dist/inspection/cli.js +34 -0
- package/dist/inspection/connection.d.ts +4 -0
- package/dist/inspection/connection.js +44 -0
- package/dist/inspection/options.d.ts +15 -0
- package/dist/inspection/options.js +49 -0
- package/dist/inspection/result.d.ts +4 -0
- package/dist/inspection/result.js +90 -0
- package/dist/inspection/types.d.ts +74 -0
- package/dist/inspection/types.js +1 -0
- package/dist/migrations/baseline-sql.d.ts +1 -1
- package/dist/migrations/baseline-sql.js +1 -1
- package/dist/migrations/connection.d.ts +2 -3
- package/dist/migrations/connection.js +15 -13
- package/dist/migrations/history.d.ts +4 -4
- package/dist/migrations/history.js +5 -5
- package/dist/migrations.js +7 -7
- package/dist/mutations.d.ts +0 -5
- package/dist/mutations.js +33 -41
- package/dist/query-options.d.ts +2 -2
- package/dist/query-options.js +4 -4
- package/dist/query.d.ts +3 -4
- package/dist/query.js +2 -2
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +1 -1
- package/dist/transaction-sql.js +1 -1
- package/dist/transaction.d.ts +2 -2
- package/dist/transaction.js +14 -13
- package/dist/types.d.ts +4 -4
- package/docs/adoption-guide.md +39 -76
- package/docs/agent-guide.md +76 -107
- package/docs/human-guide.md +20 -34
- package/docs/mutations.md +13 -13
- package/docs/query-inspection.md +128 -0
- package/docs/reference.md +66 -288
- package/package.json +10 -31
- package/dist/safeql/composition.d.ts +0 -9
- package/dist/safeql/composition.js +0 -168
- package/dist/safeql/eslint.d.ts +0 -2
- package/dist/safeql/eslint.js +0 -50
- package/dist/safeql/mutations.d.ts +0 -3
- package/dist/safeql/mutations.js +0 -74
- package/dist/safeql/plugin.d.ts +0 -3
- package/dist/safeql/plugin.js +0 -43
- package/dist/safeql/static.d.ts +0 -10
- package/dist/safeql/static.js +0 -59
- package/dist/safeql.d.ts +0 -6
- package/dist/safeql.js +0 -7
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
Changes since 0.1.0, including improvements shipped in 0.1.1–0.1.4.
|
|
6
|
+
|
|
7
|
+
### Breaking changes
|
|
8
|
+
|
|
9
|
+
- Tenon includes `postgres@3.4.9` as its only runtime dependency. Removed `pg`, `@types/pg`,
|
|
10
|
+
and driver peer requirements. Connections accept a PostgreSQL URI, Postgres.js options, or
|
|
11
|
+
an existing Postgres.js client.
|
|
12
|
+
- Native results are row arrays with `count`, `command`, and `columns` metadata. Replace
|
|
13
|
+
`result.rows` with the result itself, `rowCount` with `count`, and `fields` with `columns`.
|
|
14
|
+
The `many`, `oneOrNone`, and `one` result modes remain available. Native error fields use
|
|
15
|
+
Postgres.js names such as `table_name` and `constraint_name`.
|
|
16
|
+
- Value conversions follow Postgres.js defaults: undefined parameters reject, JSON inputs use
|
|
17
|
+
native JavaScript values, numeric arrays return strings, and timestamp infinities return
|
|
18
|
+
invalid `Date` objects. Pre-serialized JSON text becomes a JSON string.
|
|
19
|
+
- Removed the `/safeql` entry point, `safeqlPlugin`, `eslintPlugin`, and their dependencies and
|
|
20
|
+
peer requirements. Tenon no longer checks or fixes SQL result annotations automatically.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `describeQuery({ connection, query, session?, timeouts? })` and `tenon describe <query.sql>`
|
|
25
|
+
report PostgreSQL parameter/result types, source columns, a conservative row declaration,
|
|
26
|
+
and unresolved findings. Inspection accepts completed dynamic SQL, uses a separate session,
|
|
27
|
+
and supports explicit role, search path, timezone, and timeouts. It does not execute the query
|
|
28
|
+
or verify its TypeScript annotation. See [query inspection](docs/query-inspection.md).
|
|
29
|
+
- `sql.insertValues(input, columns?)` builds inserts with database defaults for missing or
|
|
30
|
+
undefined cells. `sql.assignments(input, columns?)` builds updates that omit undefined fields.
|
|
31
|
+
Both preserve explicit null. See the [mutation cookbook](docs/mutations.md).
|
|
32
|
+
- Generated `Rows.Insert['table']` and `Rows.Update['table']` types account for defaults,
|
|
33
|
+
nullability, domains, generated columns, and driver input representations.
|
|
34
|
+
|
|
35
|
+
### Fixes and improvements
|
|
36
|
+
|
|
37
|
+
- SQL NULL array elements remain JavaScript null; quoted `"NULL"`, escapes, nesting, and custom
|
|
38
|
+
parsers retain their meanings.
|
|
39
|
+
- Transactions and migration locks reject work after connection loss. Failed recovery discards
|
|
40
|
+
the affected session, and accepted pending work finishes before transaction cleanup.
|
|
41
|
+
- Transactions and standalone guarded writes issue one fewer bootstrap query by registering
|
|
42
|
+
their connection during BEGIN. Cold-start regressions cover concurrent reservations and tooling locks.
|
|
43
|
+
- Large fragment composition avoids argument-spread failures. Queries reject more than 65,533
|
|
44
|
+
bound parameters before execution; batching remains application-controlled.
|
|
45
|
+
- PostgreSQL `name` columns generate string types. `tenon generate --check` reports missing
|
|
46
|
+
files and a short diff for stale definitions without modifying the saved file.
|
|
47
|
+
|
|
48
|
+
### Upgrading
|
|
49
|
+
|
|
50
|
+
1. Remove Tenon's SafeQL imports and plugin configuration, and unused checking dependencies.
|
|
51
|
+
2. Install Tenon and remove `pg`/`@types/pg` when no other code needs them. Replace pg-specific
|
|
52
|
+
connection configuration and pools with Postgres.js equivalents.
|
|
53
|
+
3. Update native result/error access, pass JSON values directly, and regenerate table definitions.
|
|
54
|
+
Review numeric arrays and date validity. Use generated insert/update types for write inputs.
|
|
55
|
+
4. Inspect meaningful query variants and keep database tests for complete rows, permissions,
|
|
56
|
+
ordering, write effects, and rollback. Match inspection settings to the execution session.
|
package/README.md
CHANGED
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
A PostgreSQL ORM designed for coding agents. Built for Node.js and TypeScript.
|
|
4
4
|
|
|
5
5
|
Your coding agent already knows SQL. Tenon gives it a small API for parameterized queries,
|
|
6
|
-
transactions,
|
|
6
|
+
transactions, migrations, query inspection, and generated database types. Queries
|
|
7
7
|
stay readable, reusable operations are ordinary functions, and humans can review exactly what will
|
|
8
8
|
run against the database.
|
|
9
9
|
|
|
10
10
|
Tenon takes the place of an ORM without hiding PostgreSQL behind a query builder. You or your agent
|
|
11
11
|
write the SQL; Tenon handles binding values and returning results. Your application controls the
|
|
12
|
-
|
|
12
|
+
connection configuration and validation. Tenon includes its pinned Postgres.js driver.
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
|
-
npm install @kequtech/tenon-orm
|
|
15
|
+
npm install @kequtech/tenon-orm
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Requires Node.js 24+, PostgreSQL 16+, and ESM. TypeScript consumers need TypeScript 6+ and
|
|
19
|
-
development typings
|
|
19
|
+
Node development typings.
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
22
|
import { createTenon, sql } from '@kequtech/tenon-orm';
|
|
@@ -49,6 +49,7 @@ Tenon also provides:
|
|
|
49
49
|
- SQL migration files and a CLI to apply, inspect, and roll back migrations.
|
|
50
50
|
- TypeScript row, insert, and update types generated from your database.
|
|
51
51
|
- SQL fragments for sharing query parts or assembling variable conditions.
|
|
52
|
+
- Query descriptions from PostgreSQL, including parameter/result types and session context.
|
|
52
53
|
|
|
53
54
|
Joins, relationships, aggregates, and upserts use ordinary PostgreSQL SQL. Tenon does not introduce
|
|
54
55
|
model classes or automatically load related records. Input validation, authorization, and database
|
|
@@ -68,7 +69,7 @@ migrations and table type generation are independent conveniences.
|
|
|
68
69
|
|
|
69
70
|
For object-based writes, `sql.insertValues(rows)` preserves database defaults across heterogeneous
|
|
70
71
|
rows and `sql.assignments(patch)` omits undefined fields. Keep the surrounding SQL explicit. Both
|
|
71
|
-
support selected columns
|
|
72
|
+
support selected columns.
|
|
72
73
|
Use [generated input types](docs/reference.md#insert-and-update-inputs) to check those objects
|
|
73
74
|
against your schema.
|
|
74
75
|
|
|
@@ -77,29 +78,24 @@ against your schema.
|
|
|
77
78
|
The type in `sql<{ id: number; email: string }>` describes what you expect back. TypeScript alone
|
|
78
79
|
cannot verify that your SQL actually selects those columns.
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`eslintPlugin` companion rule. The adapter supports queries, constant fragments, and mutation
|
|
86
|
-
helpers; the rule reports queries it cannot inspect. See the
|
|
87
|
-
[SQL checking setup](docs/reference.md#sql-checking-with-safeql).
|
|
88
|
-
|
|
89
|
-
Checking complements typed inputs and database tests. It does not validate request values or
|
|
90
|
-
arbitrary JSON, and runtime-built query variants need their own tests.
|
|
81
|
+
Use `describeQuery({ connection, query })` to inspect the finished SQL, including dynamic fragments,
|
|
82
|
+
or `npx tenon describe query.sql` for a SQL file. It reports PostgreSQL types, Postgres.js values,
|
|
83
|
+
session context, and unresolved findings without executing the statement. No checking packages or
|
|
84
|
+
compiler API are required. Keep database tests for actual values, nullability, authorization, and
|
|
85
|
+
write effects; descriptions do not verify `sql<Row>`. See [query inspection](docs/query-inspection.md).
|
|
91
86
|
|
|
92
87
|
## Documentation
|
|
93
88
|
|
|
89
|
+
- **[Changelog](CHANGELOG.md)** — release changes and upgrade notes.
|
|
94
90
|
- **[Human guide](docs/human-guide.md)** — learn the workflow through practical examples.
|
|
95
91
|
- **[Agent guide](docs/agent-guide.md)** — self-contained setup, API contracts, and implementation
|
|
96
92
|
conventions.
|
|
97
|
-
- **[Reference](docs/reference.md)** — complete options,
|
|
93
|
+
- **[Reference](docs/reference.md)** — complete options, query inspection, type mappings, and
|
|
98
94
|
limitations.
|
|
99
95
|
- **[Adoption guide](docs/adoption-guide.md)** — replace an existing ORM and, if needed, transfer
|
|
100
|
-
migration ownership
|
|
96
|
+
migration ownership, update local write helpers, and validate converted queries.
|
|
101
97
|
- **[Mutation cookbook](docs/mutations.md)** — defaults, patches, upserts, arrays, batching, and
|
|
102
|
-
the boundaries between input typing and
|
|
98
|
+
the boundaries between input typing and database validation.
|
|
103
99
|
|
|
104
100
|
The complete `docs/` folder ships with the npm package. Maintainer instructions are in
|
|
105
101
|
[CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md), which is not
|
package/dist/affected-rows.d.ts
CHANGED
package/dist/affected-rows.js
CHANGED
|
@@ -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.
|
|
21
|
-
!Number.isSafeInteger(result.
|
|
22
|
-
result.
|
|
23
|
-
throw new AffectedRowsError(constraint, result.
|
|
20
|
+
result.count === null ||
|
|
21
|
+
!Number.isSafeInteger(result.count) ||
|
|
22
|
+
result.count < 0) {
|
|
23
|
+
throw new AffectedRowsError(constraint, result.count, result.command, false);
|
|
24
24
|
}
|
|
25
25
|
const expected = constraint.exact ?? constraint.max;
|
|
26
|
-
const matches = constraint.exact === undefined ? result.
|
|
26
|
+
const matches = constraint.exact === undefined ? result.count <= expected : result.count === expected;
|
|
27
27
|
if (!matches) {
|
|
28
|
-
throw new AffectedRowsError(constraint, result.
|
|
28
|
+
throw new AffectedRowsError(constraint, result.count, result.command, true);
|
|
29
29
|
}
|
|
30
30
|
}
|
package/dist/baseline.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface CapturedBaseline {
|
|
|
13
13
|
readonly id: string;
|
|
14
14
|
readonly upPath: string;
|
|
15
15
|
}
|
|
16
|
-
/** Capture
|
|
16
|
+
/** Capture a portable schema; deployment provisioning owns role-specific security. */
|
|
17
17
|
export declare function captureBaseline(options: CaptureBaselineOptions): Promise<CapturedBaseline>;
|
|
18
18
|
/** Acknowledge the saved baseline on an existing database; never execute its SQL. */
|
|
19
19
|
export declare function adoptBaseline(options: DatabaseToolOptions): Promise<readonly string[]>;
|
package/dist/baseline.js
CHANGED
|
@@ -8,7 +8,7 @@ import { readMigrations, withMigrationCreationLock } from './migrations/files.js
|
|
|
8
8
|
import { createHistoryTable, migrationState, migrationTable, recordMigration, } from './migrations/history.js';
|
|
9
9
|
import { qualified, quote } from './sql.js';
|
|
10
10
|
const run = promisify(execFile);
|
|
11
|
-
/** Capture
|
|
11
|
+
/** Capture a portable schema; deployment provisioning owns role-specific security. */
|
|
12
12
|
export async function captureBaseline(options) {
|
|
13
13
|
if (typeof options.connection !== 'string' || !options.connection.trim()) {
|
|
14
14
|
throw new Error('Baseline capture requires an explicit nonempty connection string.');
|
|
@@ -82,16 +82,16 @@ export async function adoptBaseline(options) {
|
|
|
82
82
|
if (applied.length) {
|
|
83
83
|
return [];
|
|
84
84
|
}
|
|
85
|
-
const schema = await client.
|
|
85
|
+
const schema = await client.unsafe('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
|
|
86
86
|
namespace,
|
|
87
87
|
]);
|
|
88
|
-
if (!schema.
|
|
88
|
+
if (!schema.length) {
|
|
89
89
|
throw new Error('The baseline namespace does not exist. Use migrate to initialize a fresh database.');
|
|
90
90
|
}
|
|
91
|
-
await client.
|
|
91
|
+
await client.unsafe('BEGIN');
|
|
92
92
|
await createHistoryTable(client, namespace);
|
|
93
93
|
await recordMigration(client, namespace, baseline);
|
|
94
|
-
const result = await client.
|
|
94
|
+
const result = await client.unsafe('COMMIT');
|
|
95
95
|
if (result.command !== 'COMMIT') {
|
|
96
96
|
throw new Error('Baseline adoption was rolled back.');
|
|
97
97
|
}
|
package/dist/cli.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { dirname, resolve } from 'node:path';
|
|
5
2
|
import { parseArgs } from 'node:util';
|
|
6
3
|
import { adoptBaseline, captureBaseline } from './baseline.js';
|
|
7
4
|
import { generateDefinitions } from './generate.js';
|
|
8
|
-
import {
|
|
5
|
+
import { writeDefinitions } from './generation/output.js';
|
|
6
|
+
import { describeFlags, runDescribe } from './inspection/cli.js';
|
|
9
7
|
import { migrate, migrationStatus, rollback } from './migrations.js';
|
|
10
8
|
import { createMigration } from './migrations/files.js';
|
|
11
9
|
const help = `tenon migrate|status|rollback [--directory migrations] [--namespace public]
|
|
@@ -13,43 +11,15 @@ tenon baseline capture|adopt [--directory migrations] [--namespace public]
|
|
|
13
11
|
baseline capture: [--schema name ...] (default: whole database schema)
|
|
14
12
|
tenon new <name> [--directory migrations] (no database required)
|
|
15
13
|
tenon generate [--namespace public] [--out src/generated-schema.ts] [--check]
|
|
14
|
+
tenon describe <query.sql> [--role name] [--search-path schemas] [--time-zone zone]
|
|
15
|
+
describe: [--connect-timeout-ms 3000] [--statement-timeout-ms 5000] [--lock-timeout-ms 1000]
|
|
16
|
+
describe emits JSON metadata; it does not execute the query or verify result annotations.
|
|
16
17
|
rollback: --steps 1
|
|
17
18
|
Connection: DATABASE_URL
|
|
18
19
|
`;
|
|
19
20
|
function warn(message) {
|
|
20
21
|
process.stderr.write(`Warning: ${message}\n`);
|
|
21
22
|
}
|
|
22
|
-
/** Check without writing, or replace the complete generated file atomically. */
|
|
23
|
-
async function writeDefinitions(source, filename, check) {
|
|
24
|
-
const output = resolve(filename);
|
|
25
|
-
if (check) {
|
|
26
|
-
let existing;
|
|
27
|
-
try {
|
|
28
|
-
existing = await readFile(output, 'utf8');
|
|
29
|
-
}
|
|
30
|
-
catch (error) {
|
|
31
|
-
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
|
|
32
|
-
throw error;
|
|
33
|
-
}
|
|
34
|
-
throw new Error(`Generated definitions file is missing: ${output}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
|
|
35
|
-
}
|
|
36
|
-
if (existing !== source) {
|
|
37
|
-
throw new Error(`Generated definitions differ: ${output}\n${definitionDiff(existing, source)}\nRun tenon generate with the same options, omitting --check, then commit the result.`);
|
|
38
|
-
}
|
|
39
|
-
process.stdout.write('Generated definitions are current.\n');
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
await mkdir(dirname(output), { recursive: true });
|
|
43
|
-
const temporary = `${output}.${randomUUID()}.tmp`;
|
|
44
|
-
try {
|
|
45
|
-
await writeFile(temporary, source, 'utf8');
|
|
46
|
-
await rename(temporary, output);
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
await rm(temporary, { force: true });
|
|
50
|
-
}
|
|
51
|
-
process.stdout.write(`Generated ${output}\n`);
|
|
52
|
-
}
|
|
53
23
|
async function runBaseline(action, options) {
|
|
54
24
|
warn("Capture one shared baseline at the old ORM's final schema checkpoint. Each existing database must be at that checkpoint before adoption. Tenon does not compare schemas or remove the old ORM.");
|
|
55
25
|
if (action === 'capture') {
|
|
@@ -74,6 +44,7 @@ async function main() {
|
|
|
74
44
|
check: { type: 'boolean', default: false },
|
|
75
45
|
steps: { type: 'string', default: '1' },
|
|
76
46
|
help: { type: 'boolean', default: false },
|
|
47
|
+
...describeFlags,
|
|
77
48
|
},
|
|
78
49
|
});
|
|
79
50
|
if (values.help) {
|
|
@@ -84,9 +55,9 @@ async function main() {
|
|
|
84
55
|
// Validate explicitly supplied flags, not defaults. Silently ignoring --check
|
|
85
56
|
// on migrate, for example, would turn an intended dry run into a schema change.
|
|
86
57
|
const allowed = new Set(['help']);
|
|
87
|
-
if (command !== 'generate')
|
|
58
|
+
if (command !== 'generate' && command !== 'describe')
|
|
88
59
|
allowed.add('directory');
|
|
89
|
-
if (command !== 'new') {
|
|
60
|
+
if (command !== 'new' && command !== 'describe') {
|
|
90
61
|
allowed.add('namespace');
|
|
91
62
|
}
|
|
92
63
|
if (command === 'generate') {
|
|
@@ -99,6 +70,10 @@ async function main() {
|
|
|
99
70
|
if (command === 'baseline' && argument === 'capture') {
|
|
100
71
|
allowed.add('schema');
|
|
101
72
|
}
|
|
73
|
+
if (command === 'describe') {
|
|
74
|
+
for (const name of Object.keys(describeFlags))
|
|
75
|
+
allowed.add(name);
|
|
76
|
+
}
|
|
102
77
|
for (const token of tokens) {
|
|
103
78
|
if (token.kind === 'option' && !allowed.has(token.name)) {
|
|
104
79
|
throw new Error(`--${token.name} is not supported by ${command ?? 'this command'}.`);
|
|
@@ -113,17 +88,21 @@ async function main() {
|
|
|
113
88
|
return;
|
|
114
89
|
}
|
|
115
90
|
const baseline = command === 'baseline' && (argument === 'capture' || argument === 'adopt');
|
|
116
|
-
const valid = baseline
|
|
91
|
+
const valid = baseline || command === 'describe'
|
|
117
92
|
? positionals.length === 2
|
|
118
93
|
: positionals.length === 1 &&
|
|
119
94
|
['migrate', 'generate', 'status', 'rollback'].includes(command ?? '');
|
|
120
95
|
if (!valid) {
|
|
121
|
-
throw new Error('Choose new, baseline capture|adopt, migrate, generate, status, or rollback. Use --help for options.');
|
|
96
|
+
throw new Error('Choose new, baseline capture|adopt, migrate, generate, describe <query.sql>, status, or rollback. Use --help for options.');
|
|
122
97
|
}
|
|
123
98
|
const connection = process.env.DATABASE_URL;
|
|
124
99
|
if (!connection) {
|
|
125
100
|
throw new Error('DATABASE_URL is required.');
|
|
126
101
|
}
|
|
102
|
+
if (command === 'describe') {
|
|
103
|
+
await runDescribe(argument, connection, values);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
127
106
|
const options = { connection, directory: values.directory, namespace: values.namespace };
|
|
128
107
|
if (baseline) {
|
|
129
108
|
await runBaseline(argument, {
|
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.
|
|
19
|
+
rowCount = result.count;
|
|
20
20
|
if (options.affectedRows)
|
|
21
21
|
checkAffectedRows(options.affectedRows, result);
|
|
22
22
|
return queryOutput(options, result);
|
package/dist/connection.d.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
export type
|
|
4
|
-
export type
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import postgres from 'postgres';
|
|
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
|
+
}
|
|
14
|
+
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. */
|
|
16
|
+
export declare function createConnection(connection: Connection): {
|
|
17
|
+
sql: postgres.Sql;
|
|
8
18
|
owned: boolean;
|
|
9
19
|
};
|
|
10
|
-
|
|
11
|
-
export declare function watchClient(client: PoolClient): {
|
|
12
|
-
check(): void;
|
|
13
|
-
release(error?: Error): void;
|
|
14
|
-
};
|
|
15
|
-
export declare function executeSql(connection: Pool | PoolClient, text: string, input: readonly unknown[]): Promise<QueryResult<Record<string, unknown>>>;
|
|
20
|
+
export declare function executeSql(sql: SqlConnection, text: string, input?: readonly unknown[]): Promise<QueryResult>;
|
package/dist/connection.js
CHANGED
|
@@ -1,51 +1,22 @@
|
|
|
1
|
-
import
|
|
1
|
+
import postgres from 'postgres';
|
|
2
2
|
import { plainObject } from './validation.js';
|
|
3
|
-
/**
|
|
4
|
-
export function
|
|
5
|
-
if (connection
|
|
6
|
-
return {
|
|
3
|
+
/** Own clients created from a URI/config; borrow explicitly supplied Postgres.js clients. */
|
|
4
|
+
export function createConnection(connection) {
|
|
5
|
+
if (typeof connection === 'function' && typeof connection.unsafe === 'function') {
|
|
6
|
+
return { sql: connection, owned: false };
|
|
7
7
|
}
|
|
8
|
-
// Missing configuration must not silently select pg's environment defaults.
|
|
9
|
-
// An explicit config object (including {}) may still use those defaults.
|
|
10
8
|
if (typeof connection === 'string') {
|
|
11
9
|
if (!connection.trim())
|
|
12
10
|
throw new Error('Connection string must not be empty.');
|
|
11
|
+
return { sql: postgres(connection), owned: true };
|
|
13
12
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
const config = typeof connection === 'string' ? { connectionString: connection } : connection;
|
|
18
|
-
const pool = new Pool(config);
|
|
19
|
-
// pg removes broken idle clients itself, but an unhandled pool error would
|
|
20
|
-
// still terminate Node. Borrowed pools keep their application's error policy.
|
|
21
|
-
pool.on('error', (error) => process.emitWarning(error));
|
|
22
|
-
return { pool, owned: true };
|
|
23
|
-
}
|
|
24
|
-
/** Observe failures between queries until ownership returns to the pool. */
|
|
25
|
-
export function watchClient(client) {
|
|
26
|
-
let failure;
|
|
27
|
-
const onError = (error) => {
|
|
28
|
-
failure ??= error;
|
|
29
|
-
};
|
|
30
|
-
client.on('error', onError);
|
|
31
|
-
return {
|
|
32
|
-
check() {
|
|
33
|
-
if (failure) {
|
|
34
|
-
throw failure;
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
release(error) {
|
|
38
|
-
try {
|
|
39
|
-
client.release(error ?? failure);
|
|
40
|
-
}
|
|
41
|
-
finally {
|
|
42
|
-
client.removeListener('error', onError);
|
|
43
|
-
}
|
|
44
|
-
},
|
|
45
|
-
};
|
|
13
|
+
plainObject(connection, 'Connection configuration');
|
|
14
|
+
return { sql: postgres(connection), owned: true };
|
|
46
15
|
}
|
|
47
|
-
export function executeSql(
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
|
|
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);
|
|
51
22
|
}
|
package/dist/database.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createClient } from './client.js';
|
|
2
|
-
import {
|
|
2
|
+
import { createConnection, executeSql } from './connection.js';
|
|
3
|
+
import { prepareArrays } from './driver/array.js';
|
|
3
4
|
import { withTransaction } from './transaction.js';
|
|
4
5
|
import { plainObject } from './validation.js';
|
|
5
6
|
export function createTenon(options) {
|
|
@@ -11,7 +12,7 @@ export function createTenon(options) {
|
|
|
11
12
|
if (options.onQuery !== undefined && typeof options.onQuery !== 'function') {
|
|
12
13
|
throw new Error('onQuery must be a function.');
|
|
13
14
|
}
|
|
14
|
-
const { pool, owned } =
|
|
15
|
+
const { sql: pool, owned } = createConnection(options.connection);
|
|
15
16
|
let closed = false;
|
|
16
17
|
function checkOpen() {
|
|
17
18
|
if (closed) {
|
|
@@ -23,6 +24,7 @@ export function createTenon(options) {
|
|
|
23
24
|
if (guarded) {
|
|
24
25
|
return withTransaction(pool, (query) => query(text, input, readResult, false));
|
|
25
26
|
}
|
|
27
|
+
await prepareArrays(pool);
|
|
26
28
|
return readResult(await executeSql(pool, text, input));
|
|
27
29
|
};
|
|
28
30
|
return {
|
package/dist/describe.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readStatementMetadata } from './inspection/catalog.js';
|
|
2
|
+
import { withInspectionConnection } from './inspection/connection.js';
|
|
3
|
+
import { queryDescription } from './inspection/result.js';
|
|
4
|
+
import { plainObject } from './validation.js';
|
|
5
|
+
/** Describe finished SQL without binding values or executing the inspected statement. */
|
|
6
|
+
export async function describeQuery(options) {
|
|
7
|
+
const input = plainObject(options, 'Describe options');
|
|
8
|
+
for (const key of Object.keys(input)) {
|
|
9
|
+
if (!['connection', 'query', 'session', 'timeouts'].includes(key)) {
|
|
10
|
+
throw new Error(`Unknown Describe option: ${key}`);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
let text = input.query;
|
|
14
|
+
if (text !== null && typeof text === 'object' && 'text' in text)
|
|
15
|
+
text = text.text;
|
|
16
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
17
|
+
throw new Error('Describe query requires nonempty SQL text or a query object with text.');
|
|
18
|
+
}
|
|
19
|
+
const query = text;
|
|
20
|
+
return withInspectionConnection(options, async (client, context) => queryDescription(await readStatementMetadata(client, query), context));
|
|
21
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type postgres from 'postgres';
|
|
2
|
+
/** Preserve driver scalar parsers and serializers; replace only lossy array parsing. */
|
|
3
|
+
export declare function prepareArrays(sql: postgres.Sql): Promise<void>;
|
|
4
|
+
/** PostgreSQL text arrays: only unquoted NULL is null; quoted values use the scalar parser. */
|
|
5
|
+
export declare function parseArray(text: string, parse: (value: string) => unknown, delimiter?: string): unknown[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const initialization = new WeakMap();
|
|
2
|
+
/** Preserve driver scalar parsers and serializers; replace only lossy array parsing. */
|
|
3
|
+
export function prepareArrays(sql) {
|
|
4
|
+
const existing = initialization.get(sql);
|
|
5
|
+
if (existing)
|
|
6
|
+
return existing;
|
|
7
|
+
const ready = (async () => {
|
|
8
|
+
const customArrays = new Set(Object.values(sql.options.types).flatMap((type) => type.from));
|
|
9
|
+
const arrays = await sql `
|
|
10
|
+
SELECT t.typarray AS oid, t.oid AS element, t.typdelim AS delimiter
|
|
11
|
+
FROM pg_catalog.pg_type t WHERE t.typarray <> 0`;
|
|
12
|
+
for (const type of arrays) {
|
|
13
|
+
if (customArrays.has(type.oid))
|
|
14
|
+
continue;
|
|
15
|
+
const scalar = sql.options.parsers[type.element] ?? ((value) => value);
|
|
16
|
+
sql.options.parsers[type.oid] = (value) => parseArray(value, scalar, type.delimiter);
|
|
17
|
+
}
|
|
18
|
+
})();
|
|
19
|
+
initialization.set(sql, ready);
|
|
20
|
+
void ready.catch(() => initialization.delete(sql));
|
|
21
|
+
return ready;
|
|
22
|
+
}
|
|
23
|
+
/** PostgreSQL text arrays: only unquoted NULL is null; quoted values use the scalar parser. */
|
|
24
|
+
export function parseArray(text, parse, delimiter = ',') {
|
|
25
|
+
let position = text.startsWith('[') ? text.indexOf('=') + 1 : 0;
|
|
26
|
+
function array() {
|
|
27
|
+
if (text[position++] !== '{')
|
|
28
|
+
throw new Error('Invalid PostgreSQL array.');
|
|
29
|
+
const values = [];
|
|
30
|
+
while (position < text.length && text[position] !== '}') {
|
|
31
|
+
if (text[position] === '{')
|
|
32
|
+
values.push(array());
|
|
33
|
+
else {
|
|
34
|
+
let value = '';
|
|
35
|
+
const quoted = text[position] === '"';
|
|
36
|
+
if (quoted)
|
|
37
|
+
position++;
|
|
38
|
+
while (position < text.length) {
|
|
39
|
+
const char = text[position];
|
|
40
|
+
if (char === '\\') {
|
|
41
|
+
position++;
|
|
42
|
+
value += text[position++] ?? '';
|
|
43
|
+
}
|
|
44
|
+
else if (quoted && char === '"') {
|
|
45
|
+
position++;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
else if (!quoted && (char === delimiter || char === '}'))
|
|
49
|
+
break;
|
|
50
|
+
else {
|
|
51
|
+
value += char;
|
|
52
|
+
position++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
values.push(!quoted && value === 'NULL' ? null : parse(value));
|
|
56
|
+
}
|
|
57
|
+
if (text[position] === delimiter)
|
|
58
|
+
position++;
|
|
59
|
+
else if (text[position] !== '}')
|
|
60
|
+
throw new Error('Invalid PostgreSQL array separator.');
|
|
61
|
+
}
|
|
62
|
+
if (text[position++] !== '}')
|
|
63
|
+
throw new Error('Unterminated PostgreSQL array.');
|
|
64
|
+
return values;
|
|
65
|
+
}
|
|
66
|
+
const result = array();
|
|
67
|
+
if (position !== text.length)
|
|
68
|
+
throw new Error('Unexpected PostgreSQL array suffix.');
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type postgres from 'postgres';
|
|
2
|
+
export interface Watch {
|
|
3
|
+
failure?: Error;
|
|
4
|
+
check(): void;
|
|
5
|
+
closed: Promise<never>;
|
|
6
|
+
reject(error: Error): void;
|
|
7
|
+
}
|
|
8
|
+
interface State {
|
|
9
|
+
acquiring: Set<(error: Error) => void>;
|
|
10
|
+
active: Map<number, Watch>;
|
|
11
|
+
capture(marker: string, watch: Watch): () => void;
|
|
12
|
+
}
|
|
13
|
+
/** Observe the driver's public connection callbacks; never reuse a reconnected transaction. */
|
|
14
|
+
export declare function observeConnections(sql: postgres.Sql): State;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const states = new WeakMap();
|
|
2
|
+
function disconnected() {
|
|
3
|
+
return Object.assign(new Error('The reserved PostgreSQL connection closed.'), {
|
|
4
|
+
code: 'CONNECTION_CLOSED',
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
/** Observe the driver's public connection callbacks; never reuse a reconnected transaction. */
|
|
8
|
+
export function observeConnections(sql) {
|
|
9
|
+
const previous = states.get(sql);
|
|
10
|
+
if (previous)
|
|
11
|
+
return previous;
|
|
12
|
+
const markers = new Map();
|
|
13
|
+
let debug = sql.options.debug;
|
|
14
|
+
// Capture the bootstrap connection ID, then restore debug before application work.
|
|
15
|
+
// Leaving it enabled would make the driver include SQL and values in enumerable errors.
|
|
16
|
+
const captureDebug = (id, text, values, types) => {
|
|
17
|
+
const watch = markers.get(text);
|
|
18
|
+
if (watch) {
|
|
19
|
+
state.active.set(id, watch);
|
|
20
|
+
markers.delete(text);
|
|
21
|
+
}
|
|
22
|
+
if (!markers.size)
|
|
23
|
+
sql.options.debug = debug;
|
|
24
|
+
if (typeof debug === 'function')
|
|
25
|
+
debug(id, text, values, types);
|
|
26
|
+
};
|
|
27
|
+
const state = {
|
|
28
|
+
acquiring: new Set(),
|
|
29
|
+
active: new Map(),
|
|
30
|
+
capture(marker, watch) {
|
|
31
|
+
if (!markers.size) {
|
|
32
|
+
debug = sql.options.debug;
|
|
33
|
+
sql.options.debug = captureDebug;
|
|
34
|
+
}
|
|
35
|
+
markers.set(marker, watch);
|
|
36
|
+
return () => {
|
|
37
|
+
markers.delete(marker);
|
|
38
|
+
if (!markers.size)
|
|
39
|
+
sql.options.debug = debug;
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
const onclose = sql.options.onclose;
|
|
44
|
+
sql.options.onclose = (id) => {
|
|
45
|
+
const error = disconnected();
|
|
46
|
+
const watch = state.active.get(id);
|
|
47
|
+
if (watch) {
|
|
48
|
+
watch.failure ??= error;
|
|
49
|
+
watch.reject(watch.failure);
|
|
50
|
+
}
|
|
51
|
+
for (const reject of state.acquiring)
|
|
52
|
+
reject(error);
|
|
53
|
+
onclose?.(id);
|
|
54
|
+
};
|
|
55
|
+
states.set(sql, state);
|
|
56
|
+
return state;
|
|
57
|
+
}
|