@kequtech/tenon-orm 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +105 -2
  3. package/dist/affected-rows.d.ts +11 -0
  4. package/dist/affected-rows.js +30 -0
  5. package/dist/baseline.d.ts +19 -0
  6. package/dist/baseline.js +100 -0
  7. package/dist/cli.d.ts +2 -0
  8. package/dist/cli.js +158 -0
  9. package/dist/client.d.ts +4 -0
  10. package/dist/client.js +54 -0
  11. package/dist/connection.d.ts +15 -0
  12. package/dist/connection.js +51 -0
  13. package/dist/database.d.ts +7 -0
  14. package/dist/database.js +44 -0
  15. package/dist/errors.d.ts +9 -0
  16. package/dist/errors.js +18 -0
  17. package/dist/generate.d.ts +9 -0
  18. package/dist/generate.js +34 -0
  19. package/dist/generation/catalog.d.ts +19 -0
  20. package/dist/generation/catalog.js +35 -0
  21. package/dist/generation/row-types.d.ts +2 -0
  22. package/dist/generation/row-types.js +115 -0
  23. package/dist/generation/schema-summary.d.ts +6 -0
  24. package/dist/generation/schema-summary.js +114 -0
  25. package/dist/index.d.ts +18 -0
  26. package/dist/index.js +7 -0
  27. package/dist/migrations/baseline-sql.d.ts +3 -0
  28. package/dist/migrations/baseline-sql.js +38 -0
  29. package/dist/migrations/connection.d.ts +9 -0
  30. package/dist/migrations/connection.js +65 -0
  31. package/dist/migrations/files.d.ts +21 -0
  32. package/dist/migrations/files.js +131 -0
  33. package/dist/migrations/history.d.ts +12 -0
  34. package/dist/migrations/history.js +30 -0
  35. package/dist/migrations/sql.d.ts +2 -0
  36. package/dist/migrations/sql.js +94 -0
  37. package/dist/migrations.d.ts +10 -0
  38. package/dist/migrations.js +68 -0
  39. package/dist/mutations.d.ts +11 -0
  40. package/dist/mutations.js +87 -0
  41. package/dist/query-options.d.ts +15 -0
  42. package/dist/query-options.js +35 -0
  43. package/dist/query.d.ts +38 -0
  44. package/dist/query.js +129 -0
  45. package/dist/safeql/composition.d.ts +9 -0
  46. package/dist/safeql/composition.js +159 -0
  47. package/dist/safeql/eslint.d.ts +2 -0
  48. package/dist/safeql/eslint.js +45 -0
  49. package/dist/safeql/mutations.d.ts +3 -0
  50. package/dist/safeql/mutations.js +74 -0
  51. package/dist/safeql/plugin.d.ts +3 -0
  52. package/dist/safeql/plugin.js +43 -0
  53. package/dist/safeql/static.d.ts +10 -0
  54. package/dist/safeql/static.js +59 -0
  55. package/dist/safeql.d.ts +6 -0
  56. package/dist/safeql.js +7 -0
  57. package/dist/sql.d.ts +5 -0
  58. package/dist/sql.js +28 -0
  59. package/dist/transaction-sql.d.ts +2 -0
  60. package/dist/transaction-sql.js +67 -0
  61. package/dist/transaction.d.ts +4 -0
  62. package/dist/transaction.js +75 -0
  63. package/dist/types.d.ts +28 -0
  64. package/dist/types.js +1 -0
  65. package/dist/validation.d.ts +1 -0
  66. package/dist/validation.js +10 -0
  67. package/docs/adoption-guide.md +91 -0
  68. package/docs/agent-guide.md +310 -0
  69. package/docs/human-guide.md +271 -0
  70. package/docs/mutations.md +115 -0
  71. package/docs/reference.md +568 -0
  72. package/package.json +92 -4
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Kequtech
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,106 @@
1
- # Temporary Holding Version
1
+ # Tenon ORM
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A PostgreSQL ORM designed for coding agents. Built for Node.js and TypeScript.
4
+
5
+ Your coding agent already knows SQL. Tenon gives it a small API for parameterized queries,
6
+ transactions, and migrations, with optional tooling to check SQL and generate result 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
+ `pg` driver, connection configuration, and validation.
13
+
14
+ ```sh
15
+ npm install @kequtech/tenon-orm pg
16
+ ```
17
+
18
+ Requires Node.js 24+, PostgreSQL 16+, and ESM. TypeScript consumers need TypeScript 6+ and
19
+ development typings for Node and `pg`.
20
+
21
+ ```ts
22
+ import { createTenon, sql } from '@kequtech/tenon-orm';
23
+
24
+ const connection = process.env.DATABASE_URL;
25
+ if (!connection) throw new Error('DATABASE_URL is required');
26
+ const db = createTenon({ connection });
27
+
28
+ try {
29
+ const email = 'reader@example.test';
30
+ const user = await db.query(
31
+ sql<{ id: number; email: string }>`
32
+ SELECT id, email FROM public."User" WHERE email = ${email}
33
+ `,
34
+ { returns: 'oneOrNone' },
35
+ );
36
+ // user is { id: number; email: string } | undefined
37
+ } finally {
38
+ await db.close();
39
+ }
40
+ ```
41
+
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
+ Tenon also provides:
47
+
48
+ - Transactions and affected-row limits that roll back rejected writes.
49
+ - SQL migration files and a CLI to apply, inspect, and roll back migrations.
50
+ - TypeScript table row types generated from your database.
51
+ - SQL fragments for sharing query parts or assembling variable conditions.
52
+
53
+ Joins, relationships, aggregates, and upserts use ordinary PostgreSQL SQL. Tenon does not introduce
54
+ model classes or automatically load related records. Input validation, authorization, and database
55
+ performance remain application concerns. You can keep an existing migration runner; Tenon's
56
+ migrations and table type generation are independent conveniences.
57
+
58
+ ## Here's what a coding agent says about it
59
+
60
+ > "The appeal is that I can read the query, see where the transaction starts and ends, and put
61
+ > reusable logic in a function. There isn't much framework machinery to learn. The tradeoff is that
62
+ > someone still has to write the SQL and check the result types. I'm happy to do that part. Please
63
+ > still review my SQL."
64
+ >
65
+ > — Codex, after working on Tenon. An interested party, to put it mildly.
66
+
67
+ ## Object-based writes
68
+
69
+ For object-based writes, `sql.insertValues(rows)` preserves database defaults across heterogeneous
70
+ rows and `sql.assignments(patch)` omits undefined fields. Keep the surrounding SQL explicit. Both
71
+ support selected columns and [SafeQL checking](docs/reference.md#object-based-mutation-fragments).
72
+
73
+ ## Checking query results
74
+
75
+ The type in `sql<{ id: number; email: string }>` describes what you expect back. TypeScript alone
76
+ cannot verify that your SQL actually selects those columns.
77
+
78
+ For that, Tenon provides an optional integration with [SafeQL](https://safeql.dev/). SafeQL checks
79
+ SQL against your development database and can write or repair the result annotation. ESLint runs the
80
+ check during development and CI; it is not part of query execution.
81
+
82
+ Configure SafeQL in your application's ESLint setup. Tenon supplies a `safeqlPlugin` adapter for its
83
+ queries, constant fragments, and official mutation helpers, plus an `eslintPlugin` rule that flags
84
+ queries the adapter cannot check. SafeQL handles database analysis and result annotations; Tenon keeps the SQL and pg type
85
+ mappings consistent with execution. Both integrations are optional development tooling. See
86
+ [SQL checking setup](docs/reference.md#sql-checking-with-safeql) for installation and a complete
87
+ configuration, including multiple databases.
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.
91
+
92
+ ## Documentation
93
+
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, SQL checking setup, type mappings, and
98
+ limitations.
99
+ - **[Adoption guide](docs/adoption-guide.md)** — replace an existing ORM and, if needed, transfer
100
+ migration ownership.
101
+
102
+ The complete `docs/` folder ships with the npm package. Maintainer instructions are in
103
+ [CONTRIBUTING.md](https://github.com/kequtech/tenon-orm/blob/main/CONTRIBUTING.md), which is not
104
+ bundled.
105
+
106
+ [ISC licensed](LICENSE). Maintained by [Kequtech](https://github.com/kequtech).
@@ -0,0 +1,11 @@
1
+ import type { QueryResult } from 'pg';
2
+ /** PostgreSQL's final command count, independent of RETURNING. */
3
+ export type AffectedRowsConstraint = {
4
+ readonly max: number;
5
+ readonly exact?: never;
6
+ } | {
7
+ readonly exact: number;
8
+ readonly max?: never;
9
+ };
10
+ export declare function validateAffectedRows(value: unknown): AffectedRowsConstraint | undefined;
11
+ export declare function checkAffectedRows(constraint: AffectedRowsConstraint, result: QueryResult<Record<string, unknown>>): void;
@@ -0,0 +1,30 @@
1
+ import { AffectedRowsError } from './errors.js';
2
+ import { plainObject } from './validation.js';
3
+ export function validateAffectedRows(value) {
4
+ if (value === undefined)
5
+ return undefined;
6
+ const config = plainObject(value, 'affectedRows');
7
+ const keys = Object.keys(config);
8
+ const key = keys[0];
9
+ if (keys.length !== 1 || (key !== 'max' && key !== 'exact')) {
10
+ throw new Error('affectedRows must specify exactly one of max or exact.');
11
+ }
12
+ const count = config[key];
13
+ if (typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0) {
14
+ throw new Error('affectedRows must be a nonnegative safe integer.');
15
+ }
16
+ return Object.freeze(key === 'max' ? { max: count } : { exact: count });
17
+ }
18
+ export function checkAffectedRows(constraint, result) {
19
+ if (!['INSERT', 'UPDATE', 'DELETE', 'MERGE'].includes(result.command) ||
20
+ result.rowCount === null ||
21
+ !Number.isSafeInteger(result.rowCount) ||
22
+ result.rowCount < 0) {
23
+ throw new AffectedRowsError(constraint, result.rowCount, result.command, false);
24
+ }
25
+ const expected = constraint.exact ?? constraint.max;
26
+ const matches = constraint.exact === undefined ? result.rowCount <= expected : result.rowCount === expected;
27
+ if (!matches) {
28
+ throw new AffectedRowsError(constraint, result.rowCount, result.command, true);
29
+ }
30
+ }
@@ -0,0 +1,19 @@
1
+ import type { DatabaseToolOptions } from './migrations/connection.js';
2
+ export interface CaptureBaselineOptions {
3
+ /** Native libpq connection string, passed directly to pg_dump. */
4
+ readonly connection: string;
5
+ readonly namespace?: string;
6
+ /** Optional native pg_dump schema selection; omitted captures the whole database schema. */
7
+ readonly schemas?: readonly string[];
8
+ readonly directory?: string;
9
+ /** Receives native pg_dump warnings; defaults to console.warn. */
10
+ readonly onWarning?: (message: string) => void;
11
+ }
12
+ export interface CapturedBaseline {
13
+ readonly id: string;
14
+ readonly upPath: string;
15
+ }
16
+ /** Capture schema only. No source history writes, comparisons or ORM cleanup. */
17
+ export declare function captureBaseline(options: CaptureBaselineOptions): Promise<CapturedBaseline>;
18
+ /** Acknowledge the saved baseline on an existing database; never execute its SQL. */
19
+ export declare function adoptBaseline(options: DatabaseToolOptions): Promise<readonly string[]>;
@@ -0,0 +1,100 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { link, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { prepareBaselineSql } from './migrations/baseline-sql.js';
6
+ import { withMigrationLock } from './migrations/connection.js';
7
+ import { readMigrations, withMigrationCreationLock } from './migrations/files.js';
8
+ import { createHistoryTable, migrationState, migrationTable, recordMigration, } from './migrations/history.js';
9
+ import { qualified, quote } from './sql.js';
10
+ const run = promisify(execFile);
11
+ /** Capture schema only. No source history writes, comparisons or ORM cleanup. */
12
+ export async function captureBaseline(options) {
13
+ if (typeof options.connection !== 'string' || !options.connection.trim()) {
14
+ throw new Error('Baseline capture requires an explicit nonempty connection string.');
15
+ }
16
+ if (options.schemas !== undefined && options.schemas.length === 0) {
17
+ throw new Error('Baseline schemas must not be empty; omit schemas to capture the whole database schema.');
18
+ }
19
+ const namespace = options.namespace ?? 'public';
20
+ quote(namespace);
21
+ const schemas = options.schemas?.map((schema) => `--schema=${quote(schema)}`) ?? [];
22
+ return withMigrationCreationLock(options.directory ?? 'migrations', async (directory) => {
23
+ if ((await readdir(directory)).some((name) => name.endsWith('.sql'))) {
24
+ throw new Error('Baseline capture requires an empty Tenon migration directory. Existing files were not changed.');
25
+ }
26
+ const temporary = await mkdtemp(join(directory, '.tenon-baseline-'));
27
+ try {
28
+ const file = join(temporary, 'dump.sql');
29
+ let warnings;
30
+ try {
31
+ const result = await run('pg_dump', [
32
+ '--dbname',
33
+ options.connection,
34
+ '--no-password',
35
+ '--schema-only',
36
+ '--no-owner',
37
+ '--no-privileges',
38
+ '--quote-all-identifiers',
39
+ '--strict-names',
40
+ ...schemas,
41
+ `--exclude-table=${qualified(namespace, migrationTable)}`,
42
+ '--file',
43
+ file,
44
+ ]);
45
+ warnings = result.stderr.trim();
46
+ }
47
+ catch (error) {
48
+ // execFile's Error includes argv, potentially containing credentials.
49
+ // Do not propagate that Error, its command line, or a credential-bearing cause.
50
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
51
+ throw new Error('Baseline capture requires pg_dump on PATH. Install PostgreSQL client tools.');
52
+ }
53
+ throw new Error('pg_dump failed. Check database access, permissions and PostgreSQL client/server compatibility. No baseline was written.');
54
+ }
55
+ if (warnings) {
56
+ (options.onWarning ?? console.warn)(warnings);
57
+ }
58
+ const upPath = join(directory, '0001_baseline.up.sql');
59
+ const prepared = join(temporary, 'migration.sql');
60
+ await writeFile(prepared, prepareBaselineSql(await readFile(file, 'utf8'), namespace));
61
+ // Publish a complete file atomically without replacing an existing path.
62
+ await link(prepared, upPath);
63
+ return { id: '0001', upPath };
64
+ }
65
+ finally {
66
+ await rm(temporary, { recursive: true, force: true });
67
+ }
68
+ });
69
+ }
70
+ /** Acknowledge the saved baseline on an existing database; never execute its SQL. */
71
+ export async function adoptBaseline(options) {
72
+ const files = await readMigrations(options.directory);
73
+ const baseline = files[0];
74
+ if (!baseline ||
75
+ baseline.id !== '0001' ||
76
+ baseline.name !== 'baseline' ||
77
+ baseline.down !== undefined) {
78
+ throw new Error('Adoption requires 0001_baseline.up.sql with no down file. Capture and commit a baseline first.');
79
+ }
80
+ return withMigrationLock(options, 'exclusive', async (client, namespace) => {
81
+ const applied = await migrationState(client, namespace, files);
82
+ if (applied.length) {
83
+ return [];
84
+ }
85
+ const schema = await client.query('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
86
+ namespace,
87
+ ]);
88
+ if (!schema.rows.length) {
89
+ throw new Error('The baseline namespace does not exist. Use migrate to initialize a fresh database.');
90
+ }
91
+ await client.query('BEGIN');
92
+ await createHistoryTable(client, namespace);
93
+ await recordMigration(client, namespace, baseline);
94
+ const result = await client.query('COMMIT');
95
+ if (result.command !== 'COMMIT') {
96
+ throw new Error('Baseline adoption was rolled back.');
97
+ }
98
+ return [baseline.id];
99
+ });
100
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,158 @@
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
+ import { parseArgs } from 'node:util';
6
+ import { adoptBaseline, captureBaseline } from './baseline.js';
7
+ import { generateDefinitions } from './generate.js';
8
+ import { migrate, migrationStatus, rollback } from './migrations.js';
9
+ import { createMigration } from './migrations/files.js';
10
+ const help = `tenon migrate|status|rollback [--directory migrations] [--namespace public]
11
+ tenon baseline capture|adopt [--directory migrations] [--namespace public]
12
+ baseline capture: [--schema name ...] (default: whole database schema)
13
+ tenon new <name> [--directory migrations] (no database required)
14
+ tenon generate [--namespace public] [--out src/generated-schema.ts] [--check]
15
+ rollback: --steps 1
16
+ Connection: DATABASE_URL
17
+ `;
18
+ function warn(message) {
19
+ process.stderr.write(`Warning: ${message}\n`);
20
+ }
21
+ /** Check without writing, or replace the complete generated file atomically. */
22
+ async function writeDefinitions(source, filename, check) {
23
+ const output = resolve(filename);
24
+ if (check) {
25
+ let existing;
26
+ try {
27
+ existing = await readFile(output, 'utf8');
28
+ }
29
+ catch (error) {
30
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
31
+ throw error;
32
+ }
33
+ }
34
+ if (existing !== source) {
35
+ throw new Error('Generated definitions are stale or missing; run tenon generate and commit the result.');
36
+ }
37
+ process.stdout.write('Generated definitions are current.\n');
38
+ return;
39
+ }
40
+ await mkdir(dirname(output), { recursive: true });
41
+ const temporary = `${output}.${randomUUID()}.tmp`;
42
+ try {
43
+ await writeFile(temporary, source, 'utf8');
44
+ await rename(temporary, output);
45
+ }
46
+ finally {
47
+ await rm(temporary, { force: true });
48
+ }
49
+ process.stdout.write(`Generated ${output}\n`);
50
+ }
51
+ async function runBaseline(action, options) {
52
+ 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.");
53
+ if (action === 'capture') {
54
+ const result = await captureBaseline({ ...options, onWarning: warn });
55
+ process.stdout.write(`Captured ${result.upPath}. Source history and data were not changed. Commit this file and generated definitions.\n`);
56
+ }
57
+ else {
58
+ warn('Adoption records 0001_baseline without executing its SQL. Use migrate for a fresh database. Disable the old migration runner when switching to Tenon.');
59
+ const completed = await adoptBaseline(options);
60
+ process.stdout.write(`baseline adopt: ${completed.length ? completed.join(', ') : 'already recorded'}\n`);
61
+ }
62
+ }
63
+ async function main() {
64
+ const { positionals, values, tokens } = parseArgs({
65
+ allowPositionals: true,
66
+ tokens: true,
67
+ options: {
68
+ directory: { type: 'string', default: 'migrations' },
69
+ namespace: { type: 'string', default: 'public' },
70
+ out: { type: 'string', default: 'src/generated-schema.ts' },
71
+ schema: { type: 'string', multiple: true },
72
+ check: { type: 'boolean', default: false },
73
+ steps: { type: 'string', default: '1' },
74
+ help: { type: 'boolean', default: false },
75
+ },
76
+ });
77
+ if (values.help) {
78
+ process.stdout.write(help);
79
+ return;
80
+ }
81
+ const [command, argument] = positionals;
82
+ // Validate explicitly supplied flags, not defaults. Silently ignoring --check
83
+ // on migrate, for example, would turn an intended dry run into a schema change.
84
+ const allowed = new Set(['help']);
85
+ if (command !== 'generate')
86
+ allowed.add('directory');
87
+ if (command !== 'new') {
88
+ allowed.add('namespace');
89
+ }
90
+ if (command === 'generate') {
91
+ allowed.add('out');
92
+ allowed.add('check');
93
+ }
94
+ if (command === 'rollback') {
95
+ allowed.add('steps');
96
+ }
97
+ if (command === 'baseline' && argument === 'capture') {
98
+ allowed.add('schema');
99
+ }
100
+ for (const token of tokens) {
101
+ if (token.kind === 'option' && !allowed.has(token.name)) {
102
+ throw new Error(`--${token.name} is not supported by ${command ?? 'this command'}.`);
103
+ }
104
+ }
105
+ if (command === 'new') {
106
+ if (positionals.length !== 2) {
107
+ throw new Error('Use new <name> to create a migration pair.');
108
+ }
109
+ const files = await createMigration({ name: argument, directory: values.directory });
110
+ process.stdout.write(`Created ${files.upPath}\nCreated ${files.downPath}\n`);
111
+ return;
112
+ }
113
+ const baseline = command === 'baseline' && (argument === 'capture' || argument === 'adopt');
114
+ const valid = baseline
115
+ ? positionals.length === 2
116
+ : positionals.length === 1 &&
117
+ ['migrate', 'generate', 'status', 'rollback'].includes(command ?? '');
118
+ if (!valid) {
119
+ throw new Error('Choose new, baseline capture|adopt, migrate, generate, status, or rollback. Use --help for options.');
120
+ }
121
+ const connection = process.env.DATABASE_URL;
122
+ if (!connection) {
123
+ throw new Error('DATABASE_URL is required.');
124
+ }
125
+ const options = { connection, directory: values.directory, namespace: values.namespace };
126
+ if (baseline) {
127
+ await runBaseline(argument, {
128
+ ...options,
129
+ ...(values.schema ? { schemas: values.schema } : {}),
130
+ });
131
+ return;
132
+ }
133
+ switch (command) {
134
+ case 'generate': {
135
+ const source = await generateDefinitions({
136
+ connection,
137
+ namespace: values.namespace,
138
+ onWarning: warn,
139
+ });
140
+ await writeDefinitions(source, values.out, values.check);
141
+ break;
142
+ }
143
+ case 'status':
144
+ process.stdout.write(`${JSON.stringify(await migrationStatus(options), null, 2)}\n`);
145
+ break;
146
+ default: {
147
+ const completed = command === 'migrate'
148
+ ? await migrate(options)
149
+ : await rollback({ ...options, steps: Number(values.steps) });
150
+ process.stdout.write(`${command}: ${completed.length ? completed.join(', ') : 'nothing to do'}\n`);
151
+ }
152
+ }
153
+ }
154
+ main().catch((error) => {
155
+ // Do not print connection configuration or raw SQL/parameter payloads.
156
+ process.stderr.write(`${error instanceof Error ? error.message : 'Database command failed.'}\n`);
157
+ process.exitCode = 1;
158
+ });
@@ -0,0 +1,4 @@
1
+ import type { QueryExecutor } from './connection.js';
2
+ import type { QueryEvent, Transaction } from './types.js';
3
+ /** All public execution shares validation, atomic checks, and result handling. */
4
+ export declare function createClient(query: QueryExecutor, onQuery?: (event: QueryEvent) => void | Promise<void>): Transaction;
package/dist/client.js ADDED
@@ -0,0 +1,54 @@
1
+ import { checkAffectedRows } from './affected-rows.js';
2
+ import { queryOutput, validateOptions } from './query-options.js';
3
+ import { assertQuery } from './query.js';
4
+ import { validateTransactionSql } from './transaction-sql.js';
5
+ /** All public execution shares validation, atomic checks, and result handling. */
6
+ export function createClient(query, onQuery) {
7
+ async function execute(statement, input) {
8
+ const options = validateOptions(input);
9
+ assertQuery(statement);
10
+ validateTransactionSql(statement.text, options.label ?? 'Query');
11
+ const started = performance.now();
12
+ let command;
13
+ let rowCount;
14
+ let failure;
15
+ let succeeded = false;
16
+ try {
17
+ const value = await query(statement.text, statement.values, (result) => {
18
+ command = result.command;
19
+ rowCount = result.rowCount;
20
+ if (options.affectedRows)
21
+ checkAffectedRows(options.affectedRows, result);
22
+ return queryOutput(options, result);
23
+ }, options.affectedRows !== undefined);
24
+ succeeded = true;
25
+ return value;
26
+ }
27
+ catch (error) {
28
+ failure = error;
29
+ throw error;
30
+ }
31
+ finally {
32
+ if (onQuery) {
33
+ const code = failure && typeof failure === 'object' && 'code' in failure ? failure.code : undefined;
34
+ const event = Object.freeze({
35
+ ...(options.label && { label: options.label }),
36
+ durationMs: performance.now() - started,
37
+ outcome: succeeded ? 'success' : 'error',
38
+ ...(command !== undefined && { command }),
39
+ ...(rowCount !== undefined && { rowCount }),
40
+ ...(typeof code === 'string' && { errorCode: code }),
41
+ });
42
+ // Diagnostics must not turn a committed write into an apparent failure.
43
+ const report = () => process.emitWarning('Tenon onQuery callback failed; query outcome was preserved.');
44
+ try {
45
+ void Promise.resolve(onQuery(event)).catch(report);
46
+ }
47
+ catch {
48
+ report();
49
+ }
50
+ }
51
+ }
52
+ }
53
+ return { query: execute };
54
+ }
@@ -0,0 +1,15 @@
1
+ import type { PoolClient, PoolConfig, QueryResult } from 'pg';
2
+ import { Pool } from 'pg';
3
+ export type Connection = string | PoolConfig | Pool;
4
+ export type QueryExecutor = <T>(text: string, input: readonly unknown[], readResult: (result: QueryResult<Record<string, unknown>>) => T, guarded: boolean) => Promise<T>;
5
+ /** Borrow application pools; own pools created from a connection string or config. */
6
+ export declare function createPool(connection: Connection): {
7
+ pool: Pool;
8
+ owned: boolean;
9
+ };
10
+ /** Observe failures between queries until ownership returns to the pool. */
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>>>;
@@ -0,0 +1,51 @@
1
+ import { Pool } from 'pg';
2
+ import { plainObject } from './validation.js';
3
+ /** Borrow application pools; own pools created from a connection string or config. */
4
+ export function createPool(connection) {
5
+ if (connection instanceof Pool) {
6
+ return { pool: connection, owned: false };
7
+ }
8
+ // Missing configuration must not silently select pg's environment defaults.
9
+ // An explicit config object (including {}) may still use those defaults.
10
+ if (typeof connection === 'string') {
11
+ if (!connection.trim())
12
+ throw new Error('Connection string must not be empty.');
13
+ }
14
+ else {
15
+ plainObject(connection, 'Connection configuration');
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
+ };
46
+ }
47
+ export function executeSql(connection, text, input) {
48
+ // Extended protocol makes even a zero-parameter call one SQL statement.
49
+ const config = { text, values: [...input], queryMode: 'extended' };
50
+ return connection.query(config);
51
+ }
@@ -0,0 +1,7 @@
1
+ import type { Connection } from './connection.js';
2
+ import type { Database, QueryEvent } from './types.js';
3
+ export interface TenonOptions {
4
+ readonly connection: Connection;
5
+ readonly onQuery?: (event: QueryEvent) => void | Promise<void>;
6
+ }
7
+ export declare function createTenon(options: TenonOptions): Database;
@@ -0,0 +1,44 @@
1
+ import { createClient } from './client.js';
2
+ import { createPool, executeSql } from './connection.js';
3
+ import { withTransaction } from './transaction.js';
4
+ import { plainObject } from './validation.js';
5
+ export function createTenon(options) {
6
+ for (const key of Object.keys(plainObject(options, 'Tenon options'))) {
7
+ if (key !== 'connection' && key !== 'onQuery') {
8
+ throw new Error(`Unknown Tenon option: ${key}`);
9
+ }
10
+ }
11
+ if (options.onQuery !== undefined && typeof options.onQuery !== 'function') {
12
+ throw new Error('onQuery must be a function.');
13
+ }
14
+ const { pool, owned } = createPool(options.connection);
15
+ let closed = false;
16
+ function checkOpen() {
17
+ if (closed) {
18
+ throw new Error('This database instance is closed.');
19
+ }
20
+ }
21
+ const query = async (text, input, readResult, guarded) => {
22
+ checkOpen();
23
+ if (guarded) {
24
+ return withTransaction(pool, (query) => query(text, input, readResult, false));
25
+ }
26
+ return readResult(await executeSql(pool, text, input));
27
+ };
28
+ return {
29
+ ...createClient(query, options.onQuery),
30
+ async transaction(callback) {
31
+ checkOpen();
32
+ return withTransaction(pool, (query) => callback(createClient(query, options.onQuery)));
33
+ },
34
+ async close() {
35
+ if (closed) {
36
+ return;
37
+ }
38
+ closed = true;
39
+ if (owned) {
40
+ await pool.end();
41
+ }
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,9 @@
1
+ import type { AffectedRowsConstraint } from './affected-rows.js';
2
+ export declare class AffectedRowsError extends Error {
3
+ name: string;
4
+ readonly code: 'TENON_AFFECTED_ROWS_MISMATCH' | 'TENON_AFFECTED_ROWS_UNSUPPORTED';
5
+ readonly expected: AffectedRowsConstraint;
6
+ readonly actual: number | null;
7
+ readonly command: string;
8
+ constructor(expected: AffectedRowsConstraint, actual: number | null, command: string, supported: boolean);
9
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,18 @@
1
+ export class AffectedRowsError extends Error {
2
+ name = 'AffectedRowsError';
3
+ code;
4
+ expected;
5
+ actual;
6
+ command;
7
+ constructor(expected, actual, command, supported) {
8
+ const limit = expected.exact ?? expected.max;
9
+ const comparison = expected.exact === undefined ? 'at most' : 'exactly';
10
+ super(supported
11
+ ? `Expected ${comparison} ${limit} affected rows, but PostgreSQL reported ${actual}.`
12
+ : 'affectedRows requires an INSERT, UPDATE, DELETE, or MERGE command count.');
13
+ this.code = supported ? 'TENON_AFFECTED_ROWS_MISMATCH' : 'TENON_AFFECTED_ROWS_UNSUPPORTED';
14
+ this.expected = Object.freeze({ ...expected });
15
+ this.actual = actual;
16
+ this.command = command;
17
+ }
18
+ }