@ultimat3/db 1.0.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/LICENSE +21 -0
- package/README.md +191 -0
- package/package.json +43 -0
- package/src/branch.ts +136 -0
- package/src/client.ts +237 -0
- package/src/drift.ts +148 -0
- package/src/errors.ts +150 -0
- package/src/fake.ts +79 -0
- package/src/generate.ts +310 -0
- package/src/index.ts +129 -0
- package/src/introspect.ts +187 -0
- package/src/migrate.ts +228 -0
- package/src/pglite-branch.ts +84 -0
- package/src/pglite-turns.ts +51 -0
- package/src/pglite.ts +200 -0
- package/src/readonly-query.ts +155 -0
- package/src/readonly-role.ts +116 -0
- package/src/readonly.ts +111 -0
- package/src/sql.ts +152 -0
- package/src/transaction.ts +124 -0
package/src/drift.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Single responsibility: prove the live schema and the migration ledger agree. Drift is the
|
|
2
|
+
// failure an agent creates most often — it edits a table by hand, the next deploy diverges, and
|
|
3
|
+
// nothing complains until production. The rendered `X_DB_DRIFT` output is byte-for-byte pinned
|
|
4
|
+
// by the framework contract; `x verify` fails on it and `--json` carries every difference.
|
|
5
|
+
|
|
6
|
+
import { baseClient, type DbClient } from './client';
|
|
7
|
+
import { DbError } from './errors';
|
|
8
|
+
import { findTable, introspect, type SchemaDescription, type TableDescription } from './introspect';
|
|
9
|
+
import { type LedgerRow, type Migration, readLedger } from './migrate';
|
|
10
|
+
|
|
11
|
+
export type DriftKind =
|
|
12
|
+
| 'unexpected-column'
|
|
13
|
+
| 'missing-column'
|
|
14
|
+
| 'unexpected-table'
|
|
15
|
+
| 'missing-table';
|
|
16
|
+
|
|
17
|
+
export interface DriftDifference {
|
|
18
|
+
readonly kind: DriftKind;
|
|
19
|
+
readonly table: string;
|
|
20
|
+
readonly column: string | null;
|
|
21
|
+
readonly cause: string;
|
|
22
|
+
readonly fix: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DriftReport {
|
|
26
|
+
readonly ok: boolean;
|
|
27
|
+
readonly differences: readonly DriftDifference[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function unexpectedColumn(table: string, column: string): DriftDifference {
|
|
31
|
+
return {
|
|
32
|
+
kind: 'unexpected-column',
|
|
33
|
+
table,
|
|
34
|
+
column,
|
|
35
|
+
// Pinned by the contract. Do not reword without changing docs/errors/X_DB_DRIFT.
|
|
36
|
+
cause: `table "${table}" has column "${column}" not present in any migration`,
|
|
37
|
+
fix: `x db gen "add ${column}"`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function missingColumn(table: string, column: string): DriftDifference {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'missing-column',
|
|
44
|
+
table,
|
|
45
|
+
column,
|
|
46
|
+
cause: `table "${table}" is missing column "${column}" that migrations declare`,
|
|
47
|
+
fix: 'x db migrate',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function unexpectedTable(table: string): DriftDifference {
|
|
52
|
+
return {
|
|
53
|
+
kind: 'unexpected-table',
|
|
54
|
+
table,
|
|
55
|
+
column: null,
|
|
56
|
+
cause: `table "${table}" is not present in any migration`,
|
|
57
|
+
fix: `x db gen "add ${table}"`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function missingTable(table: string): DriftDifference {
|
|
62
|
+
return {
|
|
63
|
+
kind: 'missing-table',
|
|
64
|
+
table,
|
|
65
|
+
column: null,
|
|
66
|
+
cause: `table "${table}" is declared by migrations but does not exist`,
|
|
67
|
+
fix: 'x db migrate',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function compareTable(live: TableDescription, expected: TableDescription): DriftDifference[] {
|
|
72
|
+
const differences: DriftDifference[] = [];
|
|
73
|
+
const expectedColumns = new Set(expected.columns.map((column) => column.name));
|
|
74
|
+
const liveColumns = new Set(live.columns.map((column) => column.name));
|
|
75
|
+
for (const column of live.columns) {
|
|
76
|
+
if (expectedColumns.has(column.name)) continue;
|
|
77
|
+
differences.push(unexpectedColumn(live.name, column.name));
|
|
78
|
+
}
|
|
79
|
+
for (const column of expected.columns) {
|
|
80
|
+
if (!liveColumns.has(column.name)) differences.push(missingColumn(live.name, column.name));
|
|
81
|
+
}
|
|
82
|
+
return differences;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Pure and total: the same inputs always produce the same ordered report. */
|
|
86
|
+
export function diffSchema(live: SchemaDescription, expected: SchemaDescription): DriftReport {
|
|
87
|
+
const differences: DriftDifference[] = [];
|
|
88
|
+
for (const table of live.tables) {
|
|
89
|
+
const counterpart = findTable(expected, table.name);
|
|
90
|
+
if (counterpart === undefined) differences.push(unexpectedTable(table.name));
|
|
91
|
+
else differences.push(...compareTable(table, counterpart));
|
|
92
|
+
}
|
|
93
|
+
for (const table of expected.tables) {
|
|
94
|
+
if (findTable(live, table.name) === undefined) differences.push(missingTable(table.name));
|
|
95
|
+
}
|
|
96
|
+
differences.sort((a, b) => {
|
|
97
|
+
if (a.table !== b.table) return a.table < b.table ? -1 : 1;
|
|
98
|
+
if (a.kind !== b.kind) return a.kind < b.kind ? -1 : 1;
|
|
99
|
+
return (a.column ?? '') < (b.column ?? '') ? -1 : 1;
|
|
100
|
+
});
|
|
101
|
+
return { ok: differences.length === 0, differences };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function driftError(difference: DriftDifference): DbError {
|
|
105
|
+
return new DbError({
|
|
106
|
+
code: 'X_DB_DRIFT',
|
|
107
|
+
cause: difference.cause,
|
|
108
|
+
fix: difference.fix,
|
|
109
|
+
meta: { kind: difference.kind, table: difference.table, column: difference.column },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Throws the first difference. `x verify` calls this; `x db drift --json` reads the report. */
|
|
114
|
+
export function assertNoDrift(report: DriftReport): void {
|
|
115
|
+
const first = report.differences[0];
|
|
116
|
+
if (first !== undefined) throw driftError(first);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The schema migrations claim. Each generated migration carries the snapshot it leaves behind,
|
|
121
|
+
* so the newest applied one with a snapshot is the expectation — no SQL is re-parsed.
|
|
122
|
+
*/
|
|
123
|
+
export function expectedSchema(
|
|
124
|
+
migrations: readonly Migration[],
|
|
125
|
+
ledger: readonly LedgerRow[],
|
|
126
|
+
): SchemaDescription {
|
|
127
|
+
const applied = new Set(ledger.map((row) => row.id));
|
|
128
|
+
const snapshots = [...migrations]
|
|
129
|
+
.filter((migration) => applied.has(migration.id) && migration.snapshot !== undefined)
|
|
130
|
+
.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
131
|
+
return snapshots[snapshots.length - 1]?.snapshot ?? { tables: [] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface DriftOptions {
|
|
135
|
+
readonly migrations: readonly Migration[];
|
|
136
|
+
readonly client?: DbClient | undefined;
|
|
137
|
+
readonly schema?: string | undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function checkDrift(options: DriftOptions): Promise<DriftReport> {
|
|
141
|
+
const client = options.client ?? baseClient();
|
|
142
|
+
const ledger = await readLedger(client);
|
|
143
|
+
const live = await introspect({
|
|
144
|
+
client,
|
|
145
|
+
...(options.schema === undefined ? {} : { schema: options.schema }),
|
|
146
|
+
});
|
|
147
|
+
return diffSchema(live, expectedSchema(options.migrations, ledger));
|
|
148
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// The database layer's stable error codes. Every factory produces the exact command that
|
|
2
|
+
// fixes the situation — `X_DB_DRIFT` is the flagship and its rendering is byte-for-byte
|
|
3
|
+
// pinned by the framework contract, so change its strings only with the contract.
|
|
4
|
+
|
|
5
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Codes this package declares and owns. `X_DB_DRIFT` is db's: it is a statement about migrations
|
|
9
|
+
* against a schema, and `@ultimat3/entity` — which imports db — only throws it.
|
|
10
|
+
*/
|
|
11
|
+
export const DB_OWNED_ERROR_CODES = [
|
|
12
|
+
'X_DB_UNAVAILABLE',
|
|
13
|
+
'X_DB_DRIFT',
|
|
14
|
+
'X_MIGRATION_CONFLICT',
|
|
15
|
+
'X_MIGRATION_IRREVERSIBLE',
|
|
16
|
+
'X_SQL_UNSAFE',
|
|
17
|
+
'X_BRANCH_EXISTS',
|
|
18
|
+
'X_READONLY_VIOLATION',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/** `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. Never titled here, never registered here. */
|
|
22
|
+
export const DB_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
|
|
23
|
+
|
|
24
|
+
/** Every code db can throw: the ones it owns plus the ones it borrows. */
|
|
25
|
+
export const DB_ERROR_CODES = [...DB_OWNED_ERROR_CODES, ...DB_BORROWED_ERROR_CODES] as const;
|
|
26
|
+
|
|
27
|
+
export type DbOwnedErrorCode = (typeof DB_OWNED_ERROR_CODES)[number];
|
|
28
|
+
export type DbErrorCode = (typeof DB_ERROR_CODES)[number];
|
|
29
|
+
|
|
30
|
+
export const DB_ERROR_TITLES: Readonly<Record<DbOwnedErrorCode, string>> = {
|
|
31
|
+
X_DB_UNAVAILABLE: 'cannot reach the database',
|
|
32
|
+
X_DB_DRIFT: 'schema differs from migrations',
|
|
33
|
+
X_MIGRATION_CONFLICT: 'the migration ledger disagrees with this build',
|
|
34
|
+
X_MIGRATION_IRREVERSIBLE: 'this migration cannot be reversed without data loss',
|
|
35
|
+
X_SQL_UNSAFE: 'SQL was built by string interpolation',
|
|
36
|
+
X_BRANCH_EXISTS: 'that branch database already exists',
|
|
37
|
+
X_READONLY_VIOLATION: 'a mutating statement reached a read-only client',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Registered unconditionally, in one call, so a second package claiming one of db's codes fails
|
|
41
|
+
// loudly as X_ERROR_CODE_DUPLICATE at import instead of silently losing to whoever loaded first.
|
|
42
|
+
registerErrorCodes(
|
|
43
|
+
Object.fromEntries(Object.entries(DB_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export interface DbErrorInit {
|
|
47
|
+
readonly code: DbErrorCode;
|
|
48
|
+
readonly cause: string;
|
|
49
|
+
readonly fix: string;
|
|
50
|
+
readonly meta?: Readonly<Record<string, unknown>> | undefined;
|
|
51
|
+
readonly sourceError?: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class DbError extends UltimateError {
|
|
55
|
+
override readonly name = 'DbError';
|
|
56
|
+
/**
|
|
57
|
+
* `code` is deliberately NOT re-declared here. `declare` cannot combine with `override`,
|
|
58
|
+
* and a plain re-declaration would shadow the property `UltimateError`'s constructor
|
|
59
|
+
* already assigned — under `useDefineForClassFields` that resets it to `undefined` at
|
|
60
|
+
* runtime. Callers get the narrow type from `DbErrorInit` at the construction site,
|
|
61
|
+
* which is where it matters.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
constructor(init: DbErrorInit) {
|
|
65
|
+
super({
|
|
66
|
+
code: init.code,
|
|
67
|
+
cause: init.cause,
|
|
68
|
+
fix: init.fix,
|
|
69
|
+
docs: `https://ultimate.dev/errors/${init.code}`,
|
|
70
|
+
meta: init.meta,
|
|
71
|
+
sourceError: init.sourceError,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const dbUnavailable = (detail: string, sourceError?: unknown): DbError =>
|
|
77
|
+
new DbError({
|
|
78
|
+
code: 'X_DB_UNAVAILABLE',
|
|
79
|
+
cause: detail,
|
|
80
|
+
fix: 'set DATABASE_URL to a reachable Postgres url, or run `x dev` to use the embedded PGlite',
|
|
81
|
+
sourceError,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** The contract's pinned wording. Mirror of `@ultimat3/entity`'s `dbDrift()` — keep in sync. */
|
|
85
|
+
export const dbDrift = (tableName: string, columnName: string): DbError =>
|
|
86
|
+
new DbError({
|
|
87
|
+
code: 'X_DB_DRIFT',
|
|
88
|
+
cause: `table "${tableName}" has column "${columnName}" not present in any migration`,
|
|
89
|
+
fix: `x db gen "add ${columnName}"`,
|
|
90
|
+
meta: { table: tableName, column: columnName },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export const migrationConflict = (cause: string, fix: string): DbError =>
|
|
94
|
+
new DbError({ code: 'X_MIGRATION_CONFLICT', cause, fix });
|
|
95
|
+
|
|
96
|
+
export const migrationIrreversible = (cause: string, fix: string): DbError =>
|
|
97
|
+
new DbError({ code: 'X_MIGRATION_IRREVERSIBLE', cause, fix });
|
|
98
|
+
|
|
99
|
+
export const sqlUnsafe = (received: string, position: number): DbError =>
|
|
100
|
+
new DbError({
|
|
101
|
+
code: 'X_SQL_UNSAFE',
|
|
102
|
+
cause:
|
|
103
|
+
`interpolation #${position} in a sql\`\` template is ${received}, ` +
|
|
104
|
+
'which cannot be bound as a parameter',
|
|
105
|
+
fix: 'pass a scalar (it becomes $n), a nested sql`` fragment, or wrap audited SQL in raw()',
|
|
106
|
+
meta: { position, received },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const identifierUnsafe = (name: string): DbError =>
|
|
110
|
+
new DbError({
|
|
111
|
+
code: 'X_SQL_UNSAFE',
|
|
112
|
+
cause: `${JSON.stringify(name)} is not usable as a Postgres identifier`,
|
|
113
|
+
fix: 'pass a plain table/column name — identifiers cannot be bound as parameters',
|
|
114
|
+
meta: { name },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export const branchExists = (branch: string): DbError =>
|
|
118
|
+
new DbError({
|
|
119
|
+
code: 'X_BRANCH_EXISTS',
|
|
120
|
+
cause: `database "${branch}" already exists`,
|
|
121
|
+
fix: `x db branch drop ${branch} # then re-create, or pick another name`,
|
|
122
|
+
meta: { branch },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* An unvalidated branch name is spliced into `CREATE DATABASE "<name>"`, so a bad name is an
|
|
127
|
+
* injection vector, not a typo — hence `X_SQL_UNSAFE` rather than a validation code.
|
|
128
|
+
*/
|
|
129
|
+
export const branchNameInvalid = (branch: string): DbError =>
|
|
130
|
+
new DbError({
|
|
131
|
+
code: 'X_SQL_UNSAFE',
|
|
132
|
+
cause: `branch name "${branch}" is not [a-z0-9_-]+`,
|
|
133
|
+
fix: 'x db branch create <name> # lowercase letters, digits, underscore and dash only',
|
|
134
|
+
meta: { branch },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export const readonlyViolation = (statement: string, keyword: string): DbError =>
|
|
138
|
+
new DbError({
|
|
139
|
+
code: 'X_READONLY_VIOLATION',
|
|
140
|
+
cause: `a read-only client received a ${keyword.toUpperCase()} statement: ${statement}`,
|
|
141
|
+
fix: 'use db() instead of readOnly(db()), or rewrite the statement as a SELECT',
|
|
142
|
+
meta: { keyword },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export const dbNotImplemented = (feature: string, fix: string): DbError =>
|
|
146
|
+
new DbError({
|
|
147
|
+
code: 'X_NOT_IMPLEMENTED',
|
|
148
|
+
cause: `${feature} is not implemented by this driver`,
|
|
149
|
+
fix,
|
|
150
|
+
});
|
package/src/fake.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Single responsibility: a `DbClient` that records instead of connecting. Every test in this
|
|
2
|
+
// package — and every app test that wants to assert "the action wrote one row" — runs against it
|
|
3
|
+
// via `setDbClient()`. Recording the exact text and values is what makes migration, transaction
|
|
4
|
+
// and drift behaviour assertable with no database and no Docker.
|
|
5
|
+
|
|
6
|
+
import type { DbClient } from './client';
|
|
7
|
+
import type { SqlFragment } from './sql';
|
|
8
|
+
|
|
9
|
+
export interface RecordedStatement {
|
|
10
|
+
readonly text: string;
|
|
11
|
+
readonly values: readonly unknown[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StubResponse {
|
|
15
|
+
readonly rows?: readonly unknown[] | undefined;
|
|
16
|
+
readonly affected?: number | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RecordingClient extends DbClient {
|
|
20
|
+
readonly statements: readonly RecordedStatement[];
|
|
21
|
+
/** Statement texts with runs of whitespace collapsed — what assertions actually match on. */
|
|
22
|
+
readonly texts: readonly string[];
|
|
23
|
+
/** Later registrations win, so a test can narrow a general stub. */
|
|
24
|
+
on(match: string | RegExp, response: StubResponse): RecordingClient;
|
|
25
|
+
reset(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Stub {
|
|
29
|
+
readonly match: string | RegExp;
|
|
30
|
+
readonly response: StubResponse;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const squash = (text: string): string => text.replace(/\s+/g, ' ').trim();
|
|
34
|
+
|
|
35
|
+
function matches(stub: Stub, text: string): boolean {
|
|
36
|
+
return typeof stub.match === 'string' ? text.includes(stub.match) : stub.match.test(text);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createRecordingClient(): RecordingClient {
|
|
40
|
+
const statements: RecordedStatement[] = [];
|
|
41
|
+
const stubs: Stub[] = [];
|
|
42
|
+
|
|
43
|
+
function respond(fragment: SqlFragment): StubResponse {
|
|
44
|
+
statements.push({ text: fragment.text, values: [...fragment.values] });
|
|
45
|
+
const text = squash(fragment.text);
|
|
46
|
+
for (let index = stubs.length - 1; index >= 0; index -= 1) {
|
|
47
|
+
const stub = stubs[index];
|
|
48
|
+
if (stub !== undefined && matches(stub, text)) return stub.response;
|
|
49
|
+
}
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const client: RecordingClient = {
|
|
54
|
+
statements,
|
|
55
|
+
get texts(): readonly string[] {
|
|
56
|
+
return statements.map((statement) => squash(statement.text));
|
|
57
|
+
},
|
|
58
|
+
on(match: string | RegExp, response: StubResponse): RecordingClient {
|
|
59
|
+
stubs.push({ match, response });
|
|
60
|
+
return client;
|
|
61
|
+
},
|
|
62
|
+
reset(): void {
|
|
63
|
+
statements.length = 0;
|
|
64
|
+
stubs.length = 0;
|
|
65
|
+
},
|
|
66
|
+
async query<T>(fragment: SqlFragment): Promise<readonly T[]> {
|
|
67
|
+
return (respond(fragment).rows ?? []) as readonly T[];
|
|
68
|
+
},
|
|
69
|
+
async one<T>(fragment: SqlFragment): Promise<T | null> {
|
|
70
|
+
const rows = respond(fragment).rows ?? [];
|
|
71
|
+
return (rows[0] as T | undefined) ?? null;
|
|
72
|
+
},
|
|
73
|
+
async execute(fragment: SqlFragment): Promise<number> {
|
|
74
|
+
const response = respond(fragment);
|
|
75
|
+
return response.affected ?? response.rows?.length ?? 0;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
return client;
|
|
79
|
+
}
|
package/src/generate.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Single responsibility: turn an entity snapshot into a timestamped, reversible migration.
|
|
2
|
+
// `db` is tier 2 and cannot import `@ultimat3/entity`, so the snapshot arrives as a parameter —
|
|
3
|
+
// the CLI passes `describeEntities()` and the types below mirror `EntityDescription` field for
|
|
4
|
+
// field. Every generated migration must be reversible; a drop that loses data refuses instead.
|
|
5
|
+
|
|
6
|
+
import { migrationIrreversible } from './errors';
|
|
7
|
+
import {
|
|
8
|
+
type ColumnDescription,
|
|
9
|
+
findTable,
|
|
10
|
+
type SchemaDescription,
|
|
11
|
+
type TableDescription,
|
|
12
|
+
} from './introspect';
|
|
13
|
+
|
|
14
|
+
/** Structurally assignment-compatible with `@ultimat3/entity`'s `ColumnDescription`. */
|
|
15
|
+
export interface ColumnDescriptionLike {
|
|
16
|
+
readonly property: string;
|
|
17
|
+
readonly column: string;
|
|
18
|
+
readonly kind: string;
|
|
19
|
+
readonly notNull: boolean;
|
|
20
|
+
readonly primaryKey: boolean;
|
|
21
|
+
readonly unique: boolean;
|
|
22
|
+
readonly hasDefault: boolean;
|
|
23
|
+
readonly check: string | null;
|
|
24
|
+
readonly references: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Structurally assignment-compatible with `@ultimat3/entity`'s `EntityDescription`. */
|
|
28
|
+
export interface EntityDescriptionLike {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly table: string;
|
|
31
|
+
readonly primaryKey: readonly string[];
|
|
32
|
+
readonly columns: readonly ColumnDescriptionLike[];
|
|
33
|
+
/** Index names only, following entity's `<table>_<column>_idx` / `_key` convention. */
|
|
34
|
+
readonly indexes: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SQL_TYPES: Readonly<Record<string, string>> = {
|
|
38
|
+
uuid: 'uuid',
|
|
39
|
+
text: 'text',
|
|
40
|
+
// Bare `char` is `char(1)` in Postgres, and the only column carrying this kind is money's
|
|
41
|
+
// currency — a three-letter ISO 4217 code whose CHECK the entity emits on the same line.
|
|
42
|
+
// Without the length no currency ever fits the constraint the same statement demands.
|
|
43
|
+
char: 'char(3)',
|
|
44
|
+
boolean: 'boolean',
|
|
45
|
+
integer: 'integer',
|
|
46
|
+
bigint: 'bigint',
|
|
47
|
+
numeric: 'numeric',
|
|
48
|
+
timestamptz: 'timestamptz',
|
|
49
|
+
date: 'date',
|
|
50
|
+
jsonb: 'jsonb',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function sqlType(kind: string): string {
|
|
54
|
+
return SQL_TYPES[kind] ?? kind;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Entity descriptions carry `hasDefault` but not the expression, so the two generated defaults
|
|
59
|
+
* are inferred from the blessed column helpers. Anything else is left to a follow-up migration.
|
|
60
|
+
*/
|
|
61
|
+
function defaultExpression(column: ColumnDescriptionLike): string | null {
|
|
62
|
+
if (!column.hasDefault) return null;
|
|
63
|
+
if (column.kind === 'uuid' && column.primaryKey) return 'gen_random_uuid()';
|
|
64
|
+
if (column.kind === 'timestamptz') return 'now()';
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function columnClause(column: ColumnDescriptionLike): string {
|
|
69
|
+
const parts = [`"${column.column}"`, sqlType(column.kind)];
|
|
70
|
+
const expression = defaultExpression(column);
|
|
71
|
+
if (expression !== null) parts.push(`default ${expression}`);
|
|
72
|
+
if (column.notNull) parts.push('not null');
|
|
73
|
+
if (column.unique && !column.primaryKey) parts.push('unique');
|
|
74
|
+
if (column.check !== null) parts.push(`check (${column.check})`);
|
|
75
|
+
if (column.references !== null) {
|
|
76
|
+
const [refTable = column.references, refColumn = 'id'] = column.references.split('.');
|
|
77
|
+
parts.push(`references "${refTable}" ("${refColumn}")`);
|
|
78
|
+
}
|
|
79
|
+
return parts.join(' ');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ParsedIndex {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly columns: readonly string[];
|
|
85
|
+
readonly unique: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A `unique` column clause already creates an index, and Postgres names it exactly what the
|
|
90
|
+
* entity's own convention names it — `<table>_<column>_key`. Emitting `create unique index` for
|
|
91
|
+
* it too is the same index twice: `42P07`, and a migration that cannot be applied at all.
|
|
92
|
+
* Mirrors the rule `entity()` already applies to a foreign key indexing its own column.
|
|
93
|
+
*/
|
|
94
|
+
function impliedByColumnClause(
|
|
95
|
+
entity: EntityDescriptionLike,
|
|
96
|
+
index: ParsedIndex,
|
|
97
|
+
added: ReadonlySet<string>,
|
|
98
|
+
): boolean {
|
|
99
|
+
const [only] = index.columns;
|
|
100
|
+
if (!index.unique || index.columns.length !== 1 || only === undefined) return false;
|
|
101
|
+
const column = entity.columns.find((each) => each.column === only);
|
|
102
|
+
// `columnClause` writes `unique` under exactly this condition — keep the two in step.
|
|
103
|
+
return column !== undefined && column.unique && !column.primaryKey && added.has(only);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Entity only records index names; the convention is what makes the columns recoverable. */
|
|
107
|
+
export function parseIndexName(table: string, name: string): ParsedIndex {
|
|
108
|
+
const unique = name.endsWith('_key');
|
|
109
|
+
const prefix = `${table}_`;
|
|
110
|
+
const withoutTable = name.startsWith(prefix) ? name.slice(prefix.length) : name;
|
|
111
|
+
const middle = withoutTable.replace(/_(idx|key)$/, '');
|
|
112
|
+
return { name, columns: middle === '' ? [] : [middle], unique };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function snapshotOf(entities: readonly EntityDescriptionLike[]): SchemaDescription {
|
|
116
|
+
const tables = [...entities]
|
|
117
|
+
.sort((a, b) => (a.table < b.table ? -1 : 1))
|
|
118
|
+
.map((entity): TableDescription => {
|
|
119
|
+
const columns: ColumnDescription[] = [...entity.columns]
|
|
120
|
+
.sort((a, b) => (a.column < b.column ? -1 : 1))
|
|
121
|
+
.map((column, index) => ({
|
|
122
|
+
name: column.column,
|
|
123
|
+
dataType: sqlType(column.kind),
|
|
124
|
+
nullable: !column.notNull,
|
|
125
|
+
default: defaultExpression(column),
|
|
126
|
+
position: index + 1,
|
|
127
|
+
}));
|
|
128
|
+
return {
|
|
129
|
+
schema: 'public',
|
|
130
|
+
name: entity.table,
|
|
131
|
+
columns,
|
|
132
|
+
primaryKey: [...entity.primaryKey],
|
|
133
|
+
indexes: entity.indexes.map((name) => ({
|
|
134
|
+
...parseIndexName(entity.table, name),
|
|
135
|
+
primary: false,
|
|
136
|
+
})),
|
|
137
|
+
foreignKeys: [],
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
return { tables };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function createTable(entity: EntityDescriptionLike): readonly string[] {
|
|
144
|
+
const clauses = entity.columns.map(columnClause);
|
|
145
|
+
if (entity.primaryKey.length > 0) {
|
|
146
|
+
clauses.push(`primary key (${entity.primaryKey.map((key) => `"${key}"`).join(', ')})`);
|
|
147
|
+
}
|
|
148
|
+
const statements = [`create table "${entity.table}" (\n ${clauses.join(',\n ')}\n);`];
|
|
149
|
+
// Every column of a new table carries its own clause, so every `unique` one brings its index.
|
|
150
|
+
const added = new Set(entity.columns.map((column) => column.column));
|
|
151
|
+
for (const name of entity.indexes) {
|
|
152
|
+
const index = parseIndexName(entity.table, name);
|
|
153
|
+
if (index.columns.length === 0 || impliedByColumnClause(entity, index, added)) continue;
|
|
154
|
+
statements.push(createIndex(entity.table, index));
|
|
155
|
+
}
|
|
156
|
+
return statements;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function createIndex(table: string, index: ParsedIndex): string {
|
|
160
|
+
const kind = index.unique ? 'create unique index' : 'create index';
|
|
161
|
+
const columns = index.columns.map((column) => `"${column}"`).join(', ');
|
|
162
|
+
return `${kind} "${index.name}" on "${table}" (${columns});`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface Plan {
|
|
166
|
+
readonly up: string[];
|
|
167
|
+
readonly down: string[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Skipping an existing column by name alone missed the type moving under it: a table created
|
|
172
|
+
* while money's currency was bare `char` keeps `char(1)` and rejects every ISO 4217 code, yet the
|
|
173
|
+
* snapshot this run records claims `char(3)` — two claims with no statement between them. Both
|
|
174
|
+
* sides are generated spellings (`current` is a previous migration's own snapshot), so any
|
|
175
|
+
* difference is a real kind change, not a catalog alias.
|
|
176
|
+
*/
|
|
177
|
+
function retypeColumn(
|
|
178
|
+
table: string,
|
|
179
|
+
column: ColumnDescriptionLike,
|
|
180
|
+
recorded: ColumnDescription,
|
|
181
|
+
plan: Plan,
|
|
182
|
+
): void {
|
|
183
|
+
const wanted = sqlType(column.kind);
|
|
184
|
+
if (recorded.dataType === wanted) return;
|
|
185
|
+
const alter = (type: string): string =>
|
|
186
|
+
`alter table "${table}" alter column "${column.column}" type ${type} ` +
|
|
187
|
+
`using "${column.column}"::${type};`;
|
|
188
|
+
plan.up.push(alter(wanted));
|
|
189
|
+
plan.down.push(alter(recorded.dataType));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function diffTable(entity: EntityDescriptionLike, live: TableDescription, plan: Plan): void {
|
|
193
|
+
const existing = new Map(live.columns.map((column) => [column.name, column]));
|
|
194
|
+
const added = new Set<string>();
|
|
195
|
+
for (const column of entity.columns) {
|
|
196
|
+
const recorded = existing.get(column.column);
|
|
197
|
+
if (recorded !== undefined) {
|
|
198
|
+
retypeColumn(entity.table, column, recorded, plan);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
added.add(column.column);
|
|
202
|
+
// A NOT NULL add with no default cannot succeed on a populated table; emit it nullable and
|
|
203
|
+
// leave the agent the exact follow-up rather than a migration that fails at 3am.
|
|
204
|
+
const nullable = column.notNull && defaultExpression(column) === null;
|
|
205
|
+
const clause = nullable ? columnClause({ ...column, notNull: false }) : columnClause(column);
|
|
206
|
+
plan.up.push(`alter table "${entity.table}" add column ${clause};`);
|
|
207
|
+
if (nullable) {
|
|
208
|
+
plan.up.push(
|
|
209
|
+
`-- backfill "${column.column}", then: ` +
|
|
210
|
+
`alter table "${entity.table}" alter column "${column.column}" set not null;`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
plan.down.push(`alter table "${entity.table}" drop column "${column.column}";`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const indexed = new Set(live.indexes.map((index) => index.name));
|
|
217
|
+
for (const name of entity.indexes) {
|
|
218
|
+
if (indexed.has(name)) continue;
|
|
219
|
+
const index = parseIndexName(entity.table, name);
|
|
220
|
+
// `added` only: an index over a column that was already there is implied by no clause this
|
|
221
|
+
// migration emits, so it still needs a statement of its own.
|
|
222
|
+
if (index.columns.length === 0 || impliedByColumnClause(entity, index, added)) continue;
|
|
223
|
+
plan.up.push(createIndex(entity.table, index));
|
|
224
|
+
plan.down.push(`drop index "${name}";`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface GenerateOptions {
|
|
229
|
+
readonly entities: readonly EntityDescriptionLike[];
|
|
230
|
+
/** The schema migrations already declare — `expectedSchema(migrations, ledger)`. */
|
|
231
|
+
readonly current?: SchemaDescription | undefined;
|
|
232
|
+
readonly name: string;
|
|
233
|
+
readonly now?: Date | undefined;
|
|
234
|
+
/** Allow a DROP COLUMN whose down cannot restore the data. `x db gen --allow-destructive`. */
|
|
235
|
+
readonly allowDestructive?: boolean | undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface GeneratedMigration {
|
|
239
|
+
readonly id: string;
|
|
240
|
+
readonly name: string;
|
|
241
|
+
readonly fileName: string;
|
|
242
|
+
readonly up: string;
|
|
243
|
+
readonly down: string;
|
|
244
|
+
readonly snapshot: SchemaDescription;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function migrationStamp(now: Date): string {
|
|
248
|
+
return now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function slugify(name: string): string {
|
|
252
|
+
return name
|
|
253
|
+
.toLowerCase()
|
|
254
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
255
|
+
.replace(/^_+|_+$/g, '');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function generateMigration(options: GenerateOptions): GeneratedMigration {
|
|
259
|
+
const current = options.current ?? { tables: [] };
|
|
260
|
+
const plan: Plan = { up: [], down: [] };
|
|
261
|
+
const wanted = new Set(options.entities.map((entity) => entity.table));
|
|
262
|
+
|
|
263
|
+
for (const entity of options.entities) {
|
|
264
|
+
const live = findTable(current, entity.table);
|
|
265
|
+
if (live === undefined) {
|
|
266
|
+
plan.up.push(...createTable(entity));
|
|
267
|
+
plan.down.push(`drop table "${entity.table}";`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
diffTable(entity, live, plan);
|
|
271
|
+
const kept = new Set(entity.columns.map((column) => column.column));
|
|
272
|
+
for (const column of live.columns) {
|
|
273
|
+
if (kept.has(column.name)) continue;
|
|
274
|
+
if (options.allowDestructive !== true) {
|
|
275
|
+
throw migrationIrreversible(
|
|
276
|
+
`dropping "${entity.table}"."${column.name}" discards its rows and cannot be undone`,
|
|
277
|
+
`x db gen "${options.name}" --allow-destructive # or keep the column and deprecate it`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
plan.up.push(`alter table "${entity.table}" drop column "${column.name}";`);
|
|
281
|
+
plan.down.push(
|
|
282
|
+
`alter table "${entity.table}" add column "${column.name}" ${column.dataType};` +
|
|
283
|
+
' -- data is not restored',
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const table of current.tables) {
|
|
289
|
+
if (wanted.has(table.name)) continue;
|
|
290
|
+
if (options.allowDestructive !== true) {
|
|
291
|
+
throw migrationIrreversible(
|
|
292
|
+
`dropping table "${table.name}" discards every row and cannot be undone`,
|
|
293
|
+
`x db gen "${options.name}" --allow-destructive # or delete the entity in two releases`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
plan.up.push(`drop table "${table.name}";`);
|
|
297
|
+
plan.down.push(`-- "${table.name}" cannot be restored; recover it from a backup`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const id = `${migrationStamp(options.now ?? new Date())}_${slugify(options.name)}`;
|
|
301
|
+
return {
|
|
302
|
+
id,
|
|
303
|
+
name: options.name,
|
|
304
|
+
fileName: `migrations/${id}.sql`,
|
|
305
|
+
up: plan.up.join('\n'),
|
|
306
|
+
// Reverse order: the last thing created is the first thing dropped.
|
|
307
|
+
down: [...plan.down].reverse().join('\n'),
|
|
308
|
+
snapshot: snapshotOf(options.entities),
|
|
309
|
+
};
|
|
310
|
+
}
|