@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.
- package/LICENSE +15 -0
- package/README.md +105 -2
- package/dist/affected-rows.d.ts +11 -0
- package/dist/affected-rows.js +30 -0
- package/dist/baseline.d.ts +19 -0
- package/dist/baseline.js +100 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +158 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +54 -0
- package/dist/connection.d.ts +15 -0
- package/dist/connection.js +51 -0
- package/dist/database.d.ts +7 -0
- package/dist/database.js +44 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +18 -0
- package/dist/generate.d.ts +9 -0
- package/dist/generate.js +34 -0
- package/dist/generation/catalog.d.ts +19 -0
- package/dist/generation/catalog.js +35 -0
- package/dist/generation/row-types.d.ts +2 -0
- package/dist/generation/row-types.js +115 -0
- package/dist/generation/schema-summary.d.ts +6 -0
- package/dist/generation/schema-summary.js +114 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +7 -0
- package/dist/migrations/baseline-sql.d.ts +3 -0
- package/dist/migrations/baseline-sql.js +38 -0
- package/dist/migrations/connection.d.ts +9 -0
- package/dist/migrations/connection.js +65 -0
- package/dist/migrations/files.d.ts +21 -0
- package/dist/migrations/files.js +131 -0
- package/dist/migrations/history.d.ts +12 -0
- package/dist/migrations/history.js +30 -0
- package/dist/migrations/sql.d.ts +2 -0
- package/dist/migrations/sql.js +94 -0
- package/dist/migrations.d.ts +10 -0
- package/dist/migrations.js +68 -0
- package/dist/mutations.d.ts +11 -0
- package/dist/mutations.js +87 -0
- package/dist/query-options.d.ts +15 -0
- package/dist/query-options.js +35 -0
- package/dist/query.d.ts +38 -0
- package/dist/query.js +129 -0
- package/dist/safeql/composition.d.ts +9 -0
- package/dist/safeql/composition.js +159 -0
- package/dist/safeql/eslint.d.ts +2 -0
- package/dist/safeql/eslint.js +45 -0
- package/dist/safeql/mutations.d.ts +3 -0
- package/dist/safeql/mutations.js +74 -0
- package/dist/safeql/plugin.d.ts +3 -0
- package/dist/safeql/plugin.js +43 -0
- package/dist/safeql/static.d.ts +10 -0
- package/dist/safeql/static.js +59 -0
- package/dist/safeql.d.ts +6 -0
- package/dist/safeql.js +7 -0
- package/dist/sql.d.ts +5 -0
- package/dist/sql.js +28 -0
- package/dist/transaction-sql.d.ts +2 -0
- package/dist/transaction-sql.js +67 -0
- package/dist/transaction.d.ts +4 -0
- package/dist/transaction.js +75 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +1 -0
- package/dist/validation.d.ts +1 -0
- package/dist/validation.js +10 -0
- package/docs/adoption-guide.md +91 -0
- package/docs/agent-guide.md +310 -0
- package/docs/human-guide.md +271 -0
- package/docs/mutations.md +115 -0
- package/docs/reference.md +568 -0
- package/package.json +92 -4
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Connection } from './connection.js';
|
|
2
|
+
export interface GenerateOptions {
|
|
3
|
+
readonly connection: Connection;
|
|
4
|
+
readonly namespace?: string;
|
|
5
|
+
/** Receives generation warnings; defaults to console.warn. */
|
|
6
|
+
readonly onWarning?: (message: string) => void;
|
|
7
|
+
}
|
|
8
|
+
/** Read-only introspection. Output needs no runtime imports or schema registration. */
|
|
9
|
+
export declare function generateDefinitions(options: GenerateOptions): Promise<string>;
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readTables } from './generation/catalog.js';
|
|
2
|
+
import { renderRowTypes } from './generation/row-types.js';
|
|
3
|
+
import { domainSummaries, schemaSummary, tableSummary } from './generation/schema-summary.js';
|
|
4
|
+
import { withMigrationLock } from './migrations/connection.js';
|
|
5
|
+
import { quote } from './sql.js';
|
|
6
|
+
/** Read-only introspection. Output needs no runtime imports or schema registration. */
|
|
7
|
+
export async function generateDefinitions(options) {
|
|
8
|
+
const onWarning = options.onWarning ?? ((message) => console.warn(`Warning: ${message}`));
|
|
9
|
+
return withMigrationLock(options, 'shared', async (client, namespace) => {
|
|
10
|
+
await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
11
|
+
const schema = await client.query('SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname = $1', [
|
|
12
|
+
namespace,
|
|
13
|
+
]);
|
|
14
|
+
if (!schema.rows.length)
|
|
15
|
+
throw new Error(`Schema ${quote(namespace)} does not exist.`);
|
|
16
|
+
// Keep catalog deparsing stable across connection settings and replay schemas.
|
|
17
|
+
// Transaction-local settings are restored before returning a borrowed client.
|
|
18
|
+
await client.query(`SET LOCAL search_path TO pg_catalog, ${quote(namespace)}`);
|
|
19
|
+
await client.query('SET LOCAL quote_all_identifiers TO off');
|
|
20
|
+
await client.query('SET LOCAL standard_conforming_strings TO on');
|
|
21
|
+
const definitions = await readTables(client, namespace);
|
|
22
|
+
const summaries = [];
|
|
23
|
+
for (const table of definitions) {
|
|
24
|
+
summaries.push(await tableSummary(client, table.oid, table.name));
|
|
25
|
+
}
|
|
26
|
+
const domains = await domainSummaries(client, definitions.flatMap((table) => table.columns.map((column) => column.declaredOid)));
|
|
27
|
+
const types = renderRowTypes(definitions, namespace, onWarning);
|
|
28
|
+
await client.query('COMMIT');
|
|
29
|
+
return `// Generated by Tenon from the PostgreSQL schema. Do not edit.
|
|
30
|
+
// Commit this file and regenerate it after changing the schema.
|
|
31
|
+
|
|
32
|
+
${schemaSummary([...domains, ...summaries])}${types}`;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PoolClient } from 'pg';
|
|
2
|
+
export interface ColumnRow {
|
|
3
|
+
name: string;
|
|
4
|
+
oid: string;
|
|
5
|
+
type: string;
|
|
6
|
+
typeSchema: string;
|
|
7
|
+
typeKind: string;
|
|
8
|
+
declaredOid: string;
|
|
9
|
+
declaredType: string;
|
|
10
|
+
declaredSchema: string;
|
|
11
|
+
nullable: boolean;
|
|
12
|
+
choices: string[] | null;
|
|
13
|
+
}
|
|
14
|
+
export interface TableRow {
|
|
15
|
+
oid: string;
|
|
16
|
+
name: string;
|
|
17
|
+
columns: ColumnRow[];
|
|
18
|
+
}
|
|
19
|
+
export declare function readTables(client: PoolClient, namespace: string): Promise<TableRow[]>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { migrationTable } from '../migrations/history.js';
|
|
2
|
+
export async function readTables(client, namespace) {
|
|
3
|
+
const tables = await client.query(`
|
|
4
|
+
SELECT c.oid::text AS oid, c.relname AS name
|
|
5
|
+
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
6
|
+
WHERE n.nspname = $1 AND c.relname <> $2 AND c.relkind IN ('r', 'p')
|
|
7
|
+
AND NOT c.relispartition
|
|
8
|
+
ORDER BY c.relname COLLATE "C"`, [namespace, migrationTable]);
|
|
9
|
+
const definitions = [];
|
|
10
|
+
for (const table of tables.rows) {
|
|
11
|
+
// Attached descendants are storage details, not separate generated models.
|
|
12
|
+
const columns = await client.query(`
|
|
13
|
+
WITH RECURSIVE resolved AS (
|
|
14
|
+
SELECT a.attnum, t.oid, t.typbasetype
|
|
15
|
+
FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
|
16
|
+
WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
17
|
+
UNION ALL
|
|
18
|
+
SELECT r.attnum, t.oid, t.typbasetype
|
|
19
|
+
FROM resolved r JOIN pg_catalog.pg_type t ON t.oid = r.typbasetype
|
|
20
|
+
)
|
|
21
|
+
SELECT a.attname AS name, t.oid::text AS oid, t.typname AS type, tn.nspname AS "typeSchema", t.typtype AS "typeKind",
|
|
22
|
+
declared.oid::text AS "declaredOid", declared.typname AS "declaredType", dn.nspname AS "declaredSchema",
|
|
23
|
+
NOT a.attnotnull AS nullable,
|
|
24
|
+
(SELECT array_agg(e.enumlabel::text ORDER BY e.enumsortorder) FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS choices
|
|
25
|
+
FROM pg_catalog.pg_attribute a
|
|
26
|
+
JOIN resolved r ON r.attnum = a.attnum AND r.typbasetype = 0
|
|
27
|
+
JOIN pg_catalog.pg_type t ON t.oid = r.oid
|
|
28
|
+
JOIN pg_catalog.pg_namespace tn ON tn.oid = t.typnamespace
|
|
29
|
+
JOIN pg_catalog.pg_type declared ON declared.oid = a.atttypid
|
|
30
|
+
JOIN pg_catalog.pg_namespace dn ON dn.oid = declared.typnamespace
|
|
31
|
+
WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, [table.oid]);
|
|
32
|
+
definitions.push({ ...table, columns: columns.rows });
|
|
33
|
+
}
|
|
34
|
+
return definitions;
|
|
35
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Every generated table remains accessible through Rows["exact SQL name"].
|
|
2
|
+
const reservedTypeNames = new Set(`
|
|
3
|
+
as await break case catch class const continue debugger default delete do else enum
|
|
4
|
+
export extends false finally for function if implements import in instanceof
|
|
5
|
+
interface let new null package private protected public return static super
|
|
6
|
+
switch this throw true try typeof var void while with yield any bigint boolean
|
|
7
|
+
intrinsic never number object string symbol undefined unknown
|
|
8
|
+
Rows globalThis PgArray
|
|
9
|
+
`
|
|
10
|
+
.trim()
|
|
11
|
+
.split(/\s+/));
|
|
12
|
+
function canExportRowName(name) {
|
|
13
|
+
return (/^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*$/u.test(name) && !reservedTypeNames.has(name));
|
|
14
|
+
}
|
|
15
|
+
// Exact built-in OIDs with verified default pg text parsers. Array parsers do
|
|
16
|
+
// not necessarily use their scalar type's representation (notably numeric[]).
|
|
17
|
+
const arrayTypes = {
|
|
18
|
+
'1000': 'boolean',
|
|
19
|
+
'1001': 'import("node:buffer").Buffer',
|
|
20
|
+
'1005': 'number',
|
|
21
|
+
'1007': 'number',
|
|
22
|
+
'1021': 'number',
|
|
23
|
+
'1022': 'number',
|
|
24
|
+
'1028': 'number',
|
|
25
|
+
'1016': 'string',
|
|
26
|
+
'1231': 'number',
|
|
27
|
+
'1009': 'string',
|
|
28
|
+
'1014': 'string',
|
|
29
|
+
'1015': 'string',
|
|
30
|
+
'1183': 'string',
|
|
31
|
+
'1270': 'string',
|
|
32
|
+
'2951': 'string',
|
|
33
|
+
'1182': 'globalThis.Date | number',
|
|
34
|
+
'1115': 'globalThis.Date | number',
|
|
35
|
+
'1185': 'globalThis.Date | number',
|
|
36
|
+
'199': 'unknown',
|
|
37
|
+
'3807': 'unknown',
|
|
38
|
+
};
|
|
39
|
+
const scalarTypes = {
|
|
40
|
+
int2: 'number',
|
|
41
|
+
int4: 'number',
|
|
42
|
+
oid: 'number',
|
|
43
|
+
float4: 'number',
|
|
44
|
+
float8: 'number',
|
|
45
|
+
bool: 'boolean',
|
|
46
|
+
text: 'string',
|
|
47
|
+
varchar: 'string',
|
|
48
|
+
bpchar: 'string',
|
|
49
|
+
time: 'string',
|
|
50
|
+
timetz: 'string',
|
|
51
|
+
uuid: 'string',
|
|
52
|
+
// pg returns +/-Infinity as numbers. TypeScript has no infinity literal type.
|
|
53
|
+
date: 'globalThis.Date | number',
|
|
54
|
+
timestamp: 'globalThis.Date | number',
|
|
55
|
+
timestamptz: 'globalThis.Date | number',
|
|
56
|
+
int8: 'string',
|
|
57
|
+
numeric: 'string',
|
|
58
|
+
json: 'unknown',
|
|
59
|
+
jsonb: 'unknown',
|
|
60
|
+
bytea: 'import("node:buffer").Buffer',
|
|
61
|
+
};
|
|
62
|
+
function isSupportedArray(value) {
|
|
63
|
+
return Object.hasOwn(arrayTypes, value.oid);
|
|
64
|
+
}
|
|
65
|
+
function columnType(value, column, onWarning) {
|
|
66
|
+
let scalar;
|
|
67
|
+
if (isSupportedArray(value)) {
|
|
68
|
+
scalar = `PgArray<${arrayTypes[value.oid]}>`;
|
|
69
|
+
}
|
|
70
|
+
else if (value.typeKind === 'e') {
|
|
71
|
+
scalar = value.choices?.map((choice) => JSON.stringify(choice)).join(' | ') || 'never';
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const type = value.typeSchema === 'pg_catalog' && Object.hasOwn(scalarTypes, value.type)
|
|
75
|
+
? scalarTypes[value.type]
|
|
76
|
+
: undefined;
|
|
77
|
+
if (type) {
|
|
78
|
+
scalar = type;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const underlying = value.declaredOid === value.oid
|
|
82
|
+
? ''
|
|
83
|
+
: ` with underlying type ${JSON.stringify(value.typeSchema)}.${JSON.stringify(value.type)} (OID ${value.oid})`;
|
|
84
|
+
onWarning(`Column ${column} uses PostgreSQL type ${JSON.stringify(value.declaredSchema)}.${JSON.stringify(value.declaredType)} (OID ${value.declaredOid})${underlying} without a verified pg mapping; generated as unknown.`);
|
|
85
|
+
scalar = 'unknown';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// unknown already includes null, including for nullable JSON and unmapped types.
|
|
89
|
+
return `${scalar}${value.nullable && scalar !== 'unknown' ? ' | null' : ''}`;
|
|
90
|
+
}
|
|
91
|
+
export function renderRowTypes(tables, namespace, onWarning) {
|
|
92
|
+
const rows = tables.map((table) => {
|
|
93
|
+
const tableName = JSON.stringify(table.name);
|
|
94
|
+
const columns = table.columns.map((column) => {
|
|
95
|
+
const columnName = JSON.stringify(column.name);
|
|
96
|
+
const path = `${JSON.stringify(namespace)}.${tableName}.${columnName}`;
|
|
97
|
+
return ` ${columnName}: ${columnType(column, path, onWarning)};`;
|
|
98
|
+
});
|
|
99
|
+
return ` ${tableName}: {
|
|
100
|
+
${columns.join('\n')}
|
|
101
|
+
};`;
|
|
102
|
+
});
|
|
103
|
+
const aliases = tables
|
|
104
|
+
.filter((table) => canExportRowName(table.name))
|
|
105
|
+
.map((table) => `export type ${table.name} = Rows[${JSON.stringify(table.name)}];`);
|
|
106
|
+
const arrays = tables.some((table) => table.columns.some(isSupportedArray))
|
|
107
|
+
? 'export type PgArray<T> = (T | null | PgArray<T>)[];\n\n'
|
|
108
|
+
: '';
|
|
109
|
+
return `${arrays}// Full database rows. Compose query types with Pick, indexed access, and intersections.
|
|
110
|
+
export type Rows = {
|
|
111
|
+
${rows.join('\n')}
|
|
112
|
+
};
|
|
113
|
+
${aliases.join('\n')}
|
|
114
|
+
`;
|
|
115
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PoolClient } from 'pg';
|
|
2
|
+
/** Include only domains reached through selected columns, base domains or arrays. */
|
|
3
|
+
export declare function domainSummaries(client: PoolClient, typeOids: readonly string[]): Promise<string[]>;
|
|
4
|
+
/** Called inside generation's read-only snapshot, with a fixed local search_path. */
|
|
5
|
+
export declare function tableSummary(client: PoolClient, oid: string, name: string): Promise<string>;
|
|
6
|
+
export declare function schemaSummary(summaries: readonly string[]): string;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { quote } from '../sql.js';
|
|
2
|
+
/** Include only domains reached through selected columns, base domains or arrays. */
|
|
3
|
+
export async function domainSummaries(client, typeOids) {
|
|
4
|
+
const domains = await client.query(`
|
|
5
|
+
WITH RECURSIVE referenced(oid) AS (
|
|
6
|
+
SELECT unnest($1::oid[])
|
|
7
|
+
UNION
|
|
8
|
+
SELECT child.oid FROM referenced r JOIN pg_catalog.pg_type t ON t.oid = r.oid
|
|
9
|
+
CROSS JOIN LATERAL (VALUES (t.typbasetype), (t.typelem)) child(oid)
|
|
10
|
+
WHERE child.oid <> 0
|
|
11
|
+
)
|
|
12
|
+
SELECT t.oid::text AS oid, pg_catalog.format_type(t.oid, NULL) AS name,
|
|
13
|
+
pg_catalog.format_type(t.typbasetype, t.typtypmod) AS base,
|
|
14
|
+
NOT t.typnotnull AS nullable, pg_catalog.pg_get_expr(t.typdefaultbin, 0, false) AS expression,
|
|
15
|
+
CASE WHEN t.typcollation <> base.typcollation THEN t.typcollation::pg_catalog.regcollation::text END AS collation
|
|
16
|
+
FROM referenced r JOIN pg_catalog.pg_type t ON t.oid = r.oid
|
|
17
|
+
JOIN pg_catalog.pg_type base ON base.oid = t.typbasetype
|
|
18
|
+
WHERE t.typtype = 'd'
|
|
19
|
+
ORDER BY pg_catalog.format_type(t.oid, NULL) COLLATE "C"`, [typeOids]);
|
|
20
|
+
const summaries = [];
|
|
21
|
+
for (const domain of domains.rows) {
|
|
22
|
+
const constraints = await client.query(`
|
|
23
|
+
SELECT conname AS name, pg_catalog.pg_get_constraintdef(oid, false) AS definition
|
|
24
|
+
FROM pg_catalog.pg_constraint WHERE contypid = $1::oid
|
|
25
|
+
ORDER BY conname COLLATE "C"`, [domain.oid]);
|
|
26
|
+
summaries.push([
|
|
27
|
+
`DOMAIN ${domain.name} AS ${domain.base}` +
|
|
28
|
+
(domain.collation === null ? '' : ` COLLATE ${domain.collation}`) +
|
|
29
|
+
(domain.expression === null ? '' : ` DEFAULT ${domain.expression}`) +
|
|
30
|
+
(domain.nullable ? '' : ' NOT NULL'),
|
|
31
|
+
...constraints.rows.map((constraint) => ` CONSTRAINT ${quote(constraint.name)} ${constraint.definition}`),
|
|
32
|
+
].join('\n'));
|
|
33
|
+
}
|
|
34
|
+
return summaries;
|
|
35
|
+
}
|
|
36
|
+
/** Called inside generation's read-only snapshot, with a fixed local search_path. */
|
|
37
|
+
export async function tableSummary(client, oid, name) {
|
|
38
|
+
const columns = await client.query(`
|
|
39
|
+
SELECT a.attname AS name,
|
|
40
|
+
pg_catalog.format_type(a.atttypid, a.atttypmod)
|
|
41
|
+
|| CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END
|
|
42
|
+
|| CASE
|
|
43
|
+
WHEN a.attidentity <> '' THEN
|
|
44
|
+
' GENERATED ' || CASE a.attidentity WHEN 'a' THEN 'ALWAYS' ELSE 'BY DEFAULT' END
|
|
45
|
+
|| ' AS IDENTITY' || pg_catalog.format(
|
|
46
|
+
' (START WITH %s INCREMENT BY %s MINVALUE %s MAXVALUE %s CACHE %s %s)',
|
|
47
|
+
s.seqstart, s.seqincrement, s.seqmin, s.seqmax, s.seqcache,
|
|
48
|
+
CASE WHEN s.seqcycle THEN 'CYCLE' ELSE 'NO CYCLE' END)
|
|
49
|
+
WHEN a.attgenerated <> '' THEN
|
|
50
|
+
' GENERATED ALWAYS AS (' || pg_catalog.pg_get_expr(d.adbin, d.adrelid, false)
|
|
51
|
+
|| ')' || CASE a.attgenerated WHEN 's' THEN ' STORED' WHEN 'v' THEN ' VIRTUAL' ELSE '' END
|
|
52
|
+
WHEN d.oid IS NOT NULL THEN ' DEFAULT ' || pg_catalog.pg_get_expr(d.adbin, d.adrelid, false)
|
|
53
|
+
ELSE ''
|
|
54
|
+
END AS definition
|
|
55
|
+
FROM pg_catalog.pg_attribute a
|
|
56
|
+
LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
|
57
|
+
LEFT JOIN pg_catalog.pg_depend dep ON a.attidentity <> ''
|
|
58
|
+
AND dep.refclassid = 'pg_catalog.pg_class'::pg_catalog.regclass
|
|
59
|
+
AND dep.refobjid = a.attrelid AND dep.refobjsubid = a.attnum
|
|
60
|
+
AND dep.classid = 'pg_catalog.pg_class'::pg_catalog.regclass AND dep.deptype = 'i'
|
|
61
|
+
LEFT JOIN pg_catalog.pg_sequence s ON s.seqrelid = dep.objid
|
|
62
|
+
WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
63
|
+
ORDER BY a.attnum`, [oid]);
|
|
64
|
+
// Column definitions already describe NOT NULL; PostgreSQL 18 also stores it
|
|
65
|
+
// as a table constraint, which would duplicate every required column here.
|
|
66
|
+
const constraints = await client.query(`
|
|
67
|
+
SELECT conname AS name, pg_catalog.pg_get_constraintdef(oid, false) AS definition
|
|
68
|
+
FROM pg_catalog.pg_constraint
|
|
69
|
+
WHERE conrelid = $1::oid AND conparentid = 0 AND contype IN ('p', 'u', 'f', 'c', 'x')
|
|
70
|
+
ORDER BY conname COLLATE "C"`, [oid]);
|
|
71
|
+
const indexes = await client.query(`
|
|
72
|
+
SELECT pg_catalog.pg_get_indexdef(i.indexrelid, 0, false) AS definition,
|
|
73
|
+
pg_catalog.format('CREATE %sINDEX %I ON %s',
|
|
74
|
+
CASE WHEN i.indisunique THEN 'UNIQUE ' ELSE '' END, ic.relname,
|
|
75
|
+
CASE WHEN t.relkind = 'p' THEN 'ONLY ' ELSE '' END) AS prefix,
|
|
76
|
+
pg_catalog.quote_ident(n.nspname) || '.' AS qualification,
|
|
77
|
+
i.indisvalid AS valid, i.indisready AS ready
|
|
78
|
+
FROM pg_catalog.pg_index i
|
|
79
|
+
JOIN pg_catalog.pg_class ic ON ic.oid = i.indexrelid
|
|
80
|
+
JOIN pg_catalog.pg_class t ON t.oid = i.indrelid
|
|
81
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
|
|
82
|
+
WHERE i.indrelid = $1::oid
|
|
83
|
+
ORDER BY ic.relname COLLATE "C"`, [oid]);
|
|
84
|
+
const lines = [
|
|
85
|
+
`TABLE ${quote(name)}`,
|
|
86
|
+
...columns.rows.map((column) => ` ${quote(column.name)} ${column.definition}`),
|
|
87
|
+
...constraints.rows.map((constraint) => ` CONSTRAINT ${quote(constraint.name)} ${constraint.definition}`),
|
|
88
|
+
...indexes.rows.map((index) => {
|
|
89
|
+
// pg_get_indexdef always qualifies its target table. Remove only that
|
|
90
|
+
// known prefix, never matching/replacing namespace text inside SQL literals.
|
|
91
|
+
const qualifiedPrefix = index.prefix + index.qualification;
|
|
92
|
+
if (!index.definition.startsWith(qualifiedPrefix)) {
|
|
93
|
+
throw new Error(`Unexpected PostgreSQL index definition for ${quote(name)}.`);
|
|
94
|
+
}
|
|
95
|
+
const definition = index.prefix + index.definition.slice(qualifiedPrefix.length);
|
|
96
|
+
return ` ${definition}${index.valid ? '' : ' [INVALID]'}${index.ready ? '' : ' [NOT READY]'}`;
|
|
97
|
+
}),
|
|
98
|
+
];
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
export function schemaSummary(summaries) {
|
|
102
|
+
const text = [
|
|
103
|
+
'Schema summary — same database checkpoint as the row types below.',
|
|
104
|
+
'Unqualified application names use the selected namespace; pg_catalog is also in the search path.',
|
|
105
|
+
'Descriptive catalog output, not a migration script. Includes constraint-backed indexes.',
|
|
106
|
+
...summaries.map((summary) => `\n${summary}`),
|
|
107
|
+
].join('\n');
|
|
108
|
+
// Identifiers and SQL literals can contain any JavaScript line terminator.
|
|
109
|
+
// Prefix every physical line so catalog text always remains a comment.
|
|
110
|
+
return (text
|
|
111
|
+
.split(/\r\n|[\n\r\u2028\u2029]/u)
|
|
112
|
+
.map((line) => `//${line ? ` ${line}` : ''}`)
|
|
113
|
+
.join('\n') + '\n\n');
|
|
114
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type { QueryResult } from 'pg';
|
|
2
|
+
export type { AffectedRowsConstraint } from './affected-rows.js';
|
|
3
|
+
export { createTenon } from './database.js';
|
|
4
|
+
export type { TenonOptions } from './database.js';
|
|
5
|
+
export { AffectedRowsError } from './errors.js';
|
|
6
|
+
export type { QueryOptions, QueryOutput, QueryReturnMode } from './query-options.js';
|
|
7
|
+
export { identifier, sql, unsafeSql } from './query.js';
|
|
8
|
+
export type { SqlFragment, SqlQuery, UnknownRow } from './query.js';
|
|
9
|
+
export type { Database, QueryClient, QueryEvent, Transaction } from './types.js';
|
|
10
|
+
export { adoptBaseline, captureBaseline } from './baseline.js';
|
|
11
|
+
export type { CaptureBaselineOptions, CapturedBaseline } from './baseline.js';
|
|
12
|
+
export { generateDefinitions } from './generate.js';
|
|
13
|
+
export type { GenerateOptions } from './generate.js';
|
|
14
|
+
export { migrate, migrationStatus, rollback } from './migrations.js';
|
|
15
|
+
export type { MigrationStatus } from './migrations.js';
|
|
16
|
+
export type { DatabaseToolOptions } from './migrations/connection.js';
|
|
17
|
+
export { createMigration, readMigrations } from './migrations/files.js';
|
|
18
|
+
export type { CreateMigrationOptions, CreatedMigration, SqlMigration } from './migrations/files.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createTenon } from './database.js';
|
|
2
|
+
export { AffectedRowsError } from './errors.js';
|
|
3
|
+
export { identifier, sql, unsafeSql } from './query.js';
|
|
4
|
+
export { adoptBaseline, captureBaseline } from './baseline.js';
|
|
5
|
+
export { generateDefinitions } from './generate.js';
|
|
6
|
+
export { migrate, migrationStatus, rollback } from './migrations.js';
|
|
7
|
+
export { createMigration, readMigrations } from './migrations/files.js';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { quote } from '../sql.js';
|
|
2
|
+
export const baselineHeader = '-- Tenon baseline captured by pg_dump.\n';
|
|
3
|
+
/** Bridge pg_dump's plain-text script to pg's transactional SQL execution. */
|
|
4
|
+
export function prepareBaselineSql(dump, namespace) {
|
|
5
|
+
// Recent pg_dump versions bracket psql scripts with a random restrict key.
|
|
6
|
+
// Remove only the outer pair; SQL bodies containing similar text stay intact.
|
|
7
|
+
const opening = /^\\restrict ([A-Za-z0-9]+)\r?$/m.exec(dump);
|
|
8
|
+
if (opening) {
|
|
9
|
+
const closing = dump.lastIndexOf(`\\unrestrict ${opening[1]}`);
|
|
10
|
+
if (closing < opening.index) {
|
|
11
|
+
throw new Error('pg_dump output has an incomplete psql wrapper.');
|
|
12
|
+
}
|
|
13
|
+
dump =
|
|
14
|
+
dump.slice(0, closing) + dump.slice(closing).replace(/^\\unrestrict [A-Za-z0-9]+\r?\n?/, '');
|
|
15
|
+
dump = dump.slice(0, opening.index) + dump.slice(opening.index + opening[0].length);
|
|
16
|
+
}
|
|
17
|
+
// Only the native preamble and bookkeeping schema declaration need adapting. Never
|
|
18
|
+
// rewrite SET/CREATE text inside functions or other captured object bodies.
|
|
19
|
+
const start = dump.indexOf('\n-- Name:');
|
|
20
|
+
const preamble = start === -1 ? dump : dump.slice(0, start);
|
|
21
|
+
let objects = start === -1 ? '' : dump.slice(start);
|
|
22
|
+
const declaration = `\nCREATE SCHEMA ${quote(namespace)};`;
|
|
23
|
+
const position = objects.indexOf(declaration);
|
|
24
|
+
const section = objects.lastIndexOf('\n-- Name:', position);
|
|
25
|
+
const isSchemaDeclaration = position !== -1 &&
|
|
26
|
+
objects
|
|
27
|
+
.slice(Math.max(section, 0), position)
|
|
28
|
+
.split('\n')
|
|
29
|
+
.every((line) => !line.trim() || line.startsWith('--'));
|
|
30
|
+
if (isSchemaDeclaration) {
|
|
31
|
+
objects = objects.replace(declaration, `\nCREATE SCHEMA IF NOT EXISTS ${quote(namespace)};`);
|
|
32
|
+
}
|
|
33
|
+
return (baselineHeader +
|
|
34
|
+
preamble
|
|
35
|
+
.replace(/^SET /gm, 'SET LOCAL ')
|
|
36
|
+
.replace("SELECT pg_catalog.set_config('search_path', '', false);", "SELECT pg_catalog.set_config('search_path', '', true);") +
|
|
37
|
+
objects);
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PoolClient } from 'pg';
|
|
2
|
+
import type { Connection } from '../connection.js';
|
|
3
|
+
export interface DatabaseToolOptions {
|
|
4
|
+
readonly connection: Connection;
|
|
5
|
+
readonly namespace?: string;
|
|
6
|
+
readonly directory?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Coordinate tools independently of application sessions; PostgreSQL owns the wait. */
|
|
9
|
+
export declare function withMigrationLock<T>(options: DatabaseToolOptions, mode: 'shared' | 'exclusive', work: (client: PoolClient, namespace: string) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createPool, watchClient } from '../connection.js';
|
|
2
|
+
import { validateName } from '../sql.js';
|
|
3
|
+
/** Coordinate tools independently of application sessions; PostgreSQL owns the wait. */
|
|
4
|
+
export async function withMigrationLock(options, mode, work) {
|
|
5
|
+
const namespace = options.namespace ?? 'public';
|
|
6
|
+
validateName(namespace);
|
|
7
|
+
const { pool, owned } = createPool(options.connection);
|
|
8
|
+
const suffix = mode === 'shared' ? '_shared' : '';
|
|
9
|
+
const lockKey = `tenon:migrations:${namespace}`;
|
|
10
|
+
const errors = [];
|
|
11
|
+
let client;
|
|
12
|
+
let connection;
|
|
13
|
+
let locked = false;
|
|
14
|
+
let result;
|
|
15
|
+
try {
|
|
16
|
+
client = await pool.connect();
|
|
17
|
+
connection = watchClient(client);
|
|
18
|
+
await client.query(`SELECT pg_advisory_lock${suffix}(hashtextextended($1, 0))`, [lockKey]);
|
|
19
|
+
locked = true;
|
|
20
|
+
result = await work(client, namespace);
|
|
21
|
+
connection.check();
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
errors.push(error);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
if (client) {
|
|
28
|
+
let releaseError;
|
|
29
|
+
try {
|
|
30
|
+
await client.query('ROLLBACK');
|
|
31
|
+
if (locked) {
|
|
32
|
+
await client.query(`SELECT pg_advisory_unlock${suffix}(hashtextextended($1, 0))`, [
|
|
33
|
+
lockKey,
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
errors.push(error);
|
|
39
|
+
releaseError =
|
|
40
|
+
error instanceof Error ? error : new Error('Tool connection cleanup failed.');
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
connection.release(releaseError);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
errors.push(error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (owned) {
|
|
50
|
+
try {
|
|
51
|
+
await pool.end();
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
errors.push(error);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (errors.length === 1) {
|
|
59
|
+
throw errors[0];
|
|
60
|
+
}
|
|
61
|
+
if (errors.length > 1) {
|
|
62
|
+
throw new AggregateError(errors, 'Database operation and cleanup failed.');
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface SqlMigration {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly name: string;
|
|
4
|
+
readonly up: string;
|
|
5
|
+
readonly down?: string;
|
|
6
|
+
readonly checksum: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CreateMigrationOptions {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly directory?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface CreatedMigration {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly upPath: string;
|
|
15
|
+
readonly downPath: string;
|
|
16
|
+
}
|
|
17
|
+
/** Serialize offline scaffolding and baseline capture in one directory. */
|
|
18
|
+
export declare function withMigrationCreationLock<T>(directory: string, work: (directory: string) => Promise<T>): Promise<T>;
|
|
19
|
+
/** Scaffold empty SQL files offline; authors supply both migration bodies. */
|
|
20
|
+
export declare function createMigration(options: CreateMigrationOptions): Promise<CreatedMigration>;
|
|
21
|
+
export declare function readMigrations(directory?: string): Promise<readonly SqlMigration[]>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { baselineHeader } from './baseline-sql.js';
|
|
5
|
+
import { validateMigrationSql } from './sql.js';
|
|
6
|
+
const migrationFilename = /^(\d+)_([A-Za-z0-9_-]+)\.(up|down)\.sql$/;
|
|
7
|
+
/** Serialize offline scaffolding and baseline capture in one directory. */
|
|
8
|
+
export async function withMigrationCreationLock(directory, work) {
|
|
9
|
+
directory = resolve(directory);
|
|
10
|
+
await mkdir(directory, { recursive: true });
|
|
11
|
+
const lockPath = join(directory, '.tenon-create.lock');
|
|
12
|
+
// Serialize local numbering even when two processes choose different names.
|
|
13
|
+
const lock = await open(lockPath, 'wx').catch((error) => {
|
|
14
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
|
|
15
|
+
throw new Error(`Migration creation lock exists at ${lockPath}; another creator may be running. Remove a stale lock only after that process has stopped.`);
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
return await work(directory);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
try {
|
|
24
|
+
await lock.close();
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await unlink(lockPath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Scaffold empty SQL files offline; authors supply both migration bodies. */
|
|
32
|
+
export async function createMigration(options) {
|
|
33
|
+
if (typeof options.name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(options.name)) {
|
|
34
|
+
throw new Error('Migration names must contain only letters, digits, underscores or hyphens.');
|
|
35
|
+
}
|
|
36
|
+
return withMigrationCreationLock(options.directory ?? 'migrations', async (directory) => {
|
|
37
|
+
let latest = 0n;
|
|
38
|
+
let width = 4;
|
|
39
|
+
for (const file of await readdir(directory, { withFileTypes: true })) {
|
|
40
|
+
if (!file.isFile() || !file.name.endsWith('.sql')) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const match = migrationFilename.exec(file.name);
|
|
44
|
+
if (!match) {
|
|
45
|
+
throw new Error(`Invalid migration filename: ${file.name}`);
|
|
46
|
+
}
|
|
47
|
+
const id = BigInt(match[1]);
|
|
48
|
+
if (id > latest) {
|
|
49
|
+
latest = id;
|
|
50
|
+
}
|
|
51
|
+
width = Math.max(width, match[1].length);
|
|
52
|
+
}
|
|
53
|
+
const id = (latest + 1n).toString().padStart(width, '0');
|
|
54
|
+
const upPath = join(directory, `${id}_${options.name}.up.sql`);
|
|
55
|
+
const downPath = join(directory, `${id}_${options.name}.down.sql`);
|
|
56
|
+
await writeFile(upPath, '', { flag: 'wx' });
|
|
57
|
+
try {
|
|
58
|
+
await writeFile(downPath, '', { flag: 'wx' });
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
try {
|
|
62
|
+
await unlink(upPath);
|
|
63
|
+
}
|
|
64
|
+
catch (cleanupError) {
|
|
65
|
+
throw new AggregateError([error, cleanupError], 'Migration creation and cleanup failed.');
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return { id, upPath, downPath };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function readMigrations(directory = 'migrations') {
|
|
73
|
+
const files = await readdir(directory, { withFileTypes: true });
|
|
74
|
+
const groups = new Map();
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
if (!file.isFile() || !file.name.endsWith('.sql')) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const match = migrationFilename.exec(file.name);
|
|
80
|
+
if (!match) {
|
|
81
|
+
throw new Error(`Invalid migration filename: ${file.name}; use 0001_description.up.sql and optional .down.sql.`);
|
|
82
|
+
}
|
|
83
|
+
const id = match[1];
|
|
84
|
+
const name = match[2];
|
|
85
|
+
const direction = match[3];
|
|
86
|
+
const group = groups.get(id) ?? { name };
|
|
87
|
+
if (group.name !== name || group[direction] !== undefined) {
|
|
88
|
+
throw new Error(`Duplicate migration version: ${id}`);
|
|
89
|
+
}
|
|
90
|
+
const sql = await readFile(join(directory, file.name), 'utf8');
|
|
91
|
+
if (!sql.trim()) {
|
|
92
|
+
throw new Error(`Empty migration: ${file.name}`);
|
|
93
|
+
}
|
|
94
|
+
// The filename/header opts into native pg_dump SQL, including SQL-standard
|
|
95
|
+
// function bodies our guard cannot parse. This is an explicit trust convention,
|
|
96
|
+
// not proof of provenance: baseline SQL must be reviewed before execution.
|
|
97
|
+
if (!(file.name === '0001_baseline.up.sql' && sql.startsWith(baselineHeader))) {
|
|
98
|
+
validateMigrationSql(sql, file.name);
|
|
99
|
+
}
|
|
100
|
+
group[direction] = sql;
|
|
101
|
+
groups.set(id, group);
|
|
102
|
+
}
|
|
103
|
+
const sorted = [...groups].sort(([left], [right]) => {
|
|
104
|
+
const a = BigInt(left);
|
|
105
|
+
const b = BigInt(right);
|
|
106
|
+
if (a < b) {
|
|
107
|
+
return -1;
|
|
108
|
+
}
|
|
109
|
+
if (a > b) {
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
return 0;
|
|
113
|
+
});
|
|
114
|
+
return sorted.map(([id, group], i) => {
|
|
115
|
+
if (i && BigInt(sorted[i - 1][0]) === BigInt(id)) {
|
|
116
|
+
throw new Error(`Duplicate migration version: ${id}`);
|
|
117
|
+
}
|
|
118
|
+
if (group.up === undefined) {
|
|
119
|
+
throw new Error(`Migration ${id} has a down file without an up file.`);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
id,
|
|
123
|
+
name: group.name,
|
|
124
|
+
up: group.up,
|
|
125
|
+
...(group.down === undefined ? {} : { down: group.down }),
|
|
126
|
+
checksum: createHash('sha256')
|
|
127
|
+
.update(JSON.stringify([group.up, group.down ?? null]))
|
|
128
|
+
.digest('hex'),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PoolClient } from 'pg';
|
|
2
|
+
import type { SqlMigration } from './files.js';
|
|
3
|
+
export declare const migrationTable = "__tenon_migrations";
|
|
4
|
+
interface AppliedMigration {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
checksum: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function migrationState(client: PoolClient, namespace: string, migrations: readonly SqlMigration[]): Promise<readonly AppliedMigration[]>;
|
|
10
|
+
export declare function createHistoryTable(client: PoolClient, namespace: string): Promise<void>;
|
|
11
|
+
export declare function recordMigration(client: PoolClient, namespace: string, migration: SqlMigration): Promise<void>;
|
|
12
|
+
export {};
|