@kequtech/tenon-orm 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +99 -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/query-options.d.ts +15 -0
  40. package/dist/query-options.js +35 -0
  41. package/dist/query.d.ts +31 -0
  42. package/dist/query.js +110 -0
  43. package/dist/safeql/composition.d.ts +12 -0
  44. package/dist/safeql/composition.js +194 -0
  45. package/dist/safeql/eslint.d.ts +2 -0
  46. package/dist/safeql/eslint.js +45 -0
  47. package/dist/safeql/plugin.d.ts +3 -0
  48. package/dist/safeql/plugin.js +43 -0
  49. package/dist/safeql.d.ts +6 -0
  50. package/dist/safeql.js +7 -0
  51. package/dist/sql.d.ts +5 -0
  52. package/dist/sql.js +28 -0
  53. package/dist/transaction-sql.d.ts +2 -0
  54. package/dist/transaction-sql.js +67 -0
  55. package/dist/transaction.d.ts +4 -0
  56. package/dist/transaction.js +75 -0
  57. package/dist/types.d.ts +28 -0
  58. package/dist/types.js +1 -0
  59. package/dist/validation.d.ts +1 -0
  60. package/dist/validation.js +10 -0
  61. package/docs/adoption-guide.md +91 -0
  62. package/docs/agent-guide.md +289 -0
  63. package/docs/human-guide.md +253 -0
  64. package/docs/reference.md +456 -0
  65. package/package.json +92 -4
@@ -0,0 +1,43 @@
1
+ import ts from 'typescript';
2
+ import { CompositionError, isExport, renderValue } from './composition.js';
3
+ /** Loaded through the exported SafeQL plugin descriptor. */
4
+ export function factory() {
5
+ return {
6
+ name: 'tenon',
7
+ connectionDefaults: {
8
+ inferLiterals: false,
9
+ overrides: {
10
+ types: {
11
+ int8: 'string',
12
+ numeric: 'string',
13
+ json: 'unknown',
14
+ jsonb: 'unknown',
15
+ date: 'Date | number',
16
+ timestamp: 'Date | number',
17
+ timestamptz: 'Date | number',
18
+ },
19
+ },
20
+ },
21
+ onTarget({ node, context }) {
22
+ const template = context.parser.esTreeNodeToTSNodeMap.get(node);
23
+ if (!ts.isTaggedTemplateExpression(template) ||
24
+ !isExport(template.tag, context.checker, 'sql'))
25
+ return false;
26
+ // The companion ESLint rule reports unsupported forms. Skip their SQL analysis.
27
+ if (node.quasi.quasis.some(({ value }) => value.raw !== value.cooked))
28
+ return false;
29
+ // Defer to SafeQL's configured targets, including per-connection tag selection.
30
+ return undefined;
31
+ },
32
+ onExpression({ context }) {
33
+ try {
34
+ return renderValue(context.tsNode, context.checker);
35
+ }
36
+ catch (error) {
37
+ if (!(error instanceof CompositionError))
38
+ throw error;
39
+ return false;
40
+ }
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,6 @@
1
+ /** Add to a connection's plugins in normal SafeQL configuration. */
2
+ export declare const safeqlPlugin: {
3
+ package: string;
4
+ };
5
+ /** Register as `tenon` in ESLint and enable `tenon/check-composition`. */
6
+ export { eslintPlugin } from './safeql/eslint.js';
package/dist/safeql.js ADDED
@@ -0,0 +1,7 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ /** Add to a connection's plugins in normal SafeQL configuration. */
3
+ export const safeqlPlugin = {
4
+ package: fileURLToPath(new URL('./safeql/plugin.js', import.meta.url)),
5
+ };
6
+ /** Register as `tenon` in ESLint and enable `tenon/check-composition`. */
7
+ export { eslintPlugin } from './safeql/eslint.js';
package/dist/sql.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare function validateName(value: string): void;
2
+ export declare function quote(name: string): string;
3
+ export declare function qualified(namespace: string, table: string): string;
4
+ /** Shared by query assembly and static checking so separators produce identical SQL. */
5
+ export declare function joinSeparator(value: unknown): string;
package/dist/sql.js ADDED
@@ -0,0 +1,28 @@
1
+ export function validateName(value) {
2
+ if (typeof value !== 'string' ||
3
+ !value ||
4
+ value.includes('\0') ||
5
+ Buffer.byteLength(value) > 63) {
6
+ throw new Error('Identifiers must contain 1–63 UTF-8 bytes and no NUL character.');
7
+ }
8
+ }
9
+ export function quote(name) {
10
+ validateName(name);
11
+ return `"${name.replaceAll('"', '""')}"`;
12
+ }
13
+ export function qualified(namespace, table) {
14
+ return `${quote(namespace)}.${quote(table)}`;
15
+ }
16
+ /** Shared by query assembly and static checking so separators produce identical SQL. */
17
+ export function joinSeparator(value) {
18
+ switch (value) {
19
+ case 'AND':
20
+ return ' AND ';
21
+ case 'OR':
22
+ return ' OR ';
23
+ case ',':
24
+ return ', ';
25
+ default:
26
+ throw new Error('sql.join separator must be "AND", "OR", or ",".');
27
+ }
28
+ }
@@ -0,0 +1,2 @@
1
+ /** Public SQL cannot take ownership away from Tenon's callback transaction. */
2
+ export declare function validateTransactionSql(sql: string, name: string): void;
@@ -0,0 +1,67 @@
1
+ /** Read only the command prefix, leaving strings, bodies and SQL syntax to pg. */
2
+ function commandPrefix(sql) {
3
+ const words = [];
4
+ let i = 0;
5
+ while (i < sql.length && words.length < 3) {
6
+ if (/\s/.test(sql[i])) {
7
+ i++;
8
+ continue;
9
+ }
10
+ if (!words.length && sql[i] === ';') {
11
+ i++;
12
+ continue;
13
+ }
14
+ if (sql.startsWith('--', i)) {
15
+ i += 2;
16
+ while (i < sql.length && sql[i] !== '\n' && sql[i] !== '\r') {
17
+ i++;
18
+ }
19
+ continue;
20
+ }
21
+ if (sql.startsWith('/*', i)) {
22
+ let depth = 1;
23
+ i += 2;
24
+ while (i < sql.length && depth) {
25
+ if (sql.startsWith('/*', i)) {
26
+ depth++;
27
+ i += 2;
28
+ }
29
+ else if (sql.startsWith('*/', i)) {
30
+ depth--;
31
+ i += 2;
32
+ }
33
+ else {
34
+ i++;
35
+ }
36
+ }
37
+ continue;
38
+ }
39
+ if (sql[i] === '(') {
40
+ words.push('(');
41
+ break;
42
+ }
43
+ const word = /^[A-Za-z_\u0080-\uFFFF][A-Za-z_0-9$\u0080-\uFFFF]*/.exec(sql.slice(i));
44
+ if (!word) {
45
+ break;
46
+ }
47
+ words.push(word[0].toUpperCase());
48
+ i += word[0].length;
49
+ }
50
+ return words;
51
+ }
52
+ /** Public SQL cannot take ownership away from Tenon's callback transaction. */
53
+ export function validateTransactionSql(sql, name) {
54
+ const [first, second, third] = commandPrefix(sql);
55
+ const rollbackTo = first === 'ROLLBACK' &&
56
+ (second === 'TO' || ((second === 'WORK' || second === 'TRANSACTION') && third === 'TO'));
57
+ if (first === 'BEGIN' ||
58
+ first === 'COMMIT' ||
59
+ first === 'END' ||
60
+ first === 'ABORT' ||
61
+ (first === 'ROLLBACK' && !rollbackTo) ||
62
+ (first === 'START' && second === 'TRANSACTION') ||
63
+ // A SQL prepared statement named transaction is not a prepared transaction.
64
+ (first === 'PREPARE' && second === 'TRANSACTION' && third !== 'AS' && third !== '(')) {
65
+ throw new Error(`${name} cannot start or end a transaction; use db.transaction() to manage transaction boundaries.`);
66
+ }
67
+ }
@@ -0,0 +1,4 @@
1
+ import type { Pool } from 'pg';
2
+ import type { QueryExecutor } from './connection.js';
3
+ /** The callback owns one connection and expires before commit or rollback begins. */
4
+ export declare function withTransaction<T>(pool: Pool, work: (query: QueryExecutor) => Promise<T>): Promise<T>;
@@ -0,0 +1,75 @@
1
+ import { executeSql, watchClient } from './connection.js';
2
+ /** The callback owns one connection and expires before commit or rollback begins. */
3
+ export async function withTransaction(pool, work) {
4
+ const client = await pool.connect();
5
+ const connection = watchClient(client);
6
+ let active = true;
7
+ let releaseError;
8
+ let savepointId = 0;
9
+ let pending = Promise.resolve();
10
+ let recoveryFailure;
11
+ const query = async (text, input, readResult, guarded) => {
12
+ if (!active)
13
+ throw new Error('The transaction has finished.');
14
+ // Queue whole operations, including savepoint cleanup. Otherwise concurrent
15
+ // calls could place another write inside a savepoint that gets rolled back.
16
+ const operation = pending.then(async () => {
17
+ connection.check();
18
+ if (recoveryFailure)
19
+ throw recoveryFailure;
20
+ if (!guarded)
21
+ return readResult(await executeSql(client, text, input));
22
+ const savepoint = `tenon_affected_rows_${++savepointId}`;
23
+ await client.query(`SAVEPOINT ${savepoint}`);
24
+ try {
25
+ const result = readResult(await executeSql(client, text, input));
26
+ await client.query(`RELEASE SAVEPOINT ${savepoint}`);
27
+ return result;
28
+ }
29
+ catch (error) {
30
+ try {
31
+ await client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
32
+ await client.query(`RELEASE SAVEPOINT ${savepoint}`);
33
+ }
34
+ catch (rollbackError) {
35
+ // Even if application code catches this error, the outer transaction
36
+ // must not commit when the rejected write could still be present.
37
+ recoveryFailure = new AggregateError([error, rollbackError], 'Operation and savepoint recovery failed.');
38
+ throw recoveryFailure;
39
+ }
40
+ throw error;
41
+ }
42
+ });
43
+ pending = operation.then(() => { }, () => { });
44
+ return operation;
45
+ };
46
+ try {
47
+ await client.query('BEGIN');
48
+ const result = await work(query);
49
+ active = false;
50
+ await pending;
51
+ connection.check();
52
+ if (recoveryFailure)
53
+ throw recoveryFailure;
54
+ const committed = await client.query('COMMIT');
55
+ if (committed.command !== 'COMMIT') {
56
+ throw new Error('PostgreSQL rolled back the aborted transaction.');
57
+ }
58
+ return result;
59
+ }
60
+ catch (error) {
61
+ active = false;
62
+ await pending;
63
+ try {
64
+ await client.query('ROLLBACK');
65
+ }
66
+ catch (rollbackError) {
67
+ releaseError = rollbackError instanceof Error ? rollbackError : new Error('Rollback failed.');
68
+ throw new AggregateError([error, rollbackError], 'Transaction and rollback failed.');
69
+ }
70
+ throw error;
71
+ }
72
+ finally {
73
+ connection.release(releaseError);
74
+ }
75
+ }
@@ -0,0 +1,28 @@
1
+ import type { QueryResult, QueryResultRow } from 'pg';
2
+ import type { QueryOptions, QueryOutput, QueryReturnMode } from './query-options.js';
3
+ import type { SqlQuery } from './query.js';
4
+ export interface QueryClient {
5
+ query<Row extends QueryResultRow>(query: SqlQuery<Row>, options?: QueryOptions & {
6
+ readonly returns?: 'native';
7
+ }): NoInfer<Promise<QueryResult<Row>>>;
8
+ query<Row extends QueryResultRow, Mode extends QueryReturnMode>(query: SqlQuery<Row>, options: QueryOptions & {
9
+ readonly returns: Mode;
10
+ }): NoInfer<Promise<QueryOutput<Row, Mode>>>;
11
+ query<Row extends QueryResultRow>(query: SqlQuery<Row>, options: QueryOptions | undefined): NoInfer<Promise<QueryOutput<Row, QueryReturnMode>>>;
12
+ }
13
+ export type Transaction = QueryClient;
14
+ export type Database = Transaction & {
15
+ /** Await all work inside the callback. Commit on success; rollback on an escaping error. */
16
+ transaction<T>(callback: (tx: Transaction) => Promise<T>): Promise<T>;
17
+ close(): Promise<void>;
18
+ };
19
+ /** One completed public query call, including rollback/commit time for guarded calls. */
20
+ export interface QueryEvent {
21
+ readonly label?: string;
22
+ readonly durationMs: number;
23
+ readonly outcome: 'success' | 'error';
24
+ readonly command?: string;
25
+ readonly rowCount?: number | null;
26
+ /** Stable Tenon or PostgreSQL error code, when available. */
27
+ readonly errorCode?: string;
28
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare function plainObject(value: unknown, name: string): Record<string, unknown>;
@@ -0,0 +1,10 @@
1
+ export function plainObject(value, name) {
2
+ if (value === null || typeof value !== 'object') {
3
+ throw new Error(`${name} must be a plain object.`);
4
+ }
5
+ const prototype = Object.getPrototypeOf(value);
6
+ if (prototype !== Object.prototype && prototype !== null) {
7
+ throw new Error(`${name} must be a plain object.`);
8
+ }
9
+ return value;
10
+ }
@@ -0,0 +1,91 @@
1
+ # Adopting Tenon from another ORM
2
+
3
+ This guide covers replacing an existing ORM and, separately, transferring migration ownership. For a
4
+ query tutorial use the [human guide](human-guide.md); for API details use the
5
+ [reference](reference.md). Preserve the existing schema, connection configuration, data, and
6
+ application behavior while replacing the old ORM. Tenon does not need to own migrations to execute
7
+ queries or generate types; keep the existing migration runner if that is the intended scope.
8
+
9
+ ## Replace application queries
10
+
11
+ 1. Locate the old ORM's connection setup, schema, migrations, and call sites. Identify behavior
12
+ supplied by the ORM: validation, IDs, timestamps, hooks, defaults, relation loading,
13
+ serialization, and transaction boundaries.
14
+ 2. If table row types will help your application, generate them from the existing database:
15
+
16
+ ```sh
17
+ npx tenon generate --out src/generated-schema.ts
18
+ ```
19
+
20
+ Use `--namespace name` for a non-public schema. Generation reads the actual database and requires
21
+ no Tenon migration history.
22
+
23
+ 3. Replace queries with parameterized SQL in ordinary functions with named object inputs where
24
+ useful. Preserve each caller's projection, null handling, ordering, and transaction behavior.
25
+ Generated types describe pg values: check bigint/numeric strings, dates, JSON, and arrays when
26
+ the old ORM transformed them.
27
+ 4. If using optional SQL checking, configure the
28
+ [SafeQL integration](reference.md#sql-checking-with-safeql) to generate query annotations and
29
+ check them against the intended schema. Run tests against a dedicated test database. Verify
30
+ writes and rollback, relationships, validation, and any hooks you moved into application code or
31
+ SQL. A schema-preserving first release makes application rollback easier.
32
+ 5. Remove the old runtime dependency and unused generated code after its callers are gone. Keep the
33
+ migration tooling until the migration transition below is complete, if that transition is part of
34
+ the work.
35
+
36
+ ## Move migration ownership to Tenon
37
+
38
+ Choose one final schema checkpoint for the old runner and bring existing target databases to it.
39
+ Disable its migration/schema synchronization before handing ownership to Tenon. The tools do not
40
+ share locks.
41
+
42
+ Capture one baseline from the reference database into an empty migration directory:
43
+
44
+ ```sh
45
+ npx tenon baseline capture --directory migrations --namespace public
46
+ ```
47
+
48
+ This uses native `pg_dump --schema-only --no-owner --no-privileges` and writes
49
+ `0001_baseline.up.sql` without a down file. It requires a compatible `pg_dump` on PATH; prefer the
50
+ server's major. Default scope is the whole database schema. Repeat `--schema name` to select schemas
51
+ explicitly, accounting for dependencies, extensions, and required roles. Tenon's selected history
52
+ table is excluded; old ORM metadata is preserved for deliberate cleanup later.
53
+
54
+ Capture changes no source data, migration history, or sequence positions. Review and commit the SQL.
55
+ Keep the capture header: it identifies native dump SQL that bypasses the authored-migration scanner.
56
+ It is a convention, not proof of provenance; do not use it on handwritten migrations. Normal
57
+ transactional execution and checksums still apply.
58
+
59
+ On each existing database already at that checkpoint:
60
+
61
+ ```sh
62
+ npx tenon baseline adopt
63
+ ```
64
+
65
+ Adoption records the baseline checksum without executing the SQL or comparing schemas. It requires
66
+ the selected namespace to exist. Repeating matching adoption is harmless, including after later
67
+ migrations; conflicting history fails.
68
+
69
+ On a fresh database, run migrations instead:
70
+
71
+ ```sh
72
+ npx tenon migrate
73
+ ```
74
+
75
+ That executes the baseline and subsequent migrations. Use the same committed baseline for every
76
+ environment. Do not run adoption unconditionally on fresh databases. Existing databases adopt once
77
+ and then use ordinary `migrate`.
78
+
79
+ Use the development [`db:migrate` script](human-guide.md#create-a-table) to apply subsequent
80
+ migrations and update table types together. Production continues to run only `npx tenon migrate`.
81
+ Verify both an existing database's upgrade and a fresh replay. Baselines contain schema only; they
82
+ do not back up rows or sequence positions. The missing down file prevents normal rollback through
83
+ the baseline. Keep the application's backup and recovery procedure.
84
+
85
+ Programmatic equivalents:
86
+
87
+ - `captureBaseline({ connection, directory?, namespace?, schemas?, onWarning? })` requires a
88
+ nonempty native libpq connection string and returns `{ id, upPath }`. Omit `schemas` for the whole
89
+ database schema; an explicitly empty list is rejected.
90
+ - `adoptBaseline({ connection, directory?, namespace? })` accepts the usual Tenon connection options
91
+ and returns newly recorded migration IDs.