@ultimat3/db 13.0.0 → 15.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/CLAUDE.md +202 -0
- package/README.md +17 -2
- package/package.json +2 -2
- package/src/check-ddl.ts +224 -0
- package/src/column-default.ts +80 -0
- package/src/destructive.ts +2 -15
- package/src/drift-findings.ts +231 -0
- package/src/drift.ts +47 -184
- package/src/entity-shape.ts +46 -0
- package/src/foreign-key-plan.ts +10 -2
- package/src/foreign-key.ts +48 -4
- package/src/generate.ts +108 -158
- package/src/generated-column.ts +15 -6
- package/src/index-ddl.ts +137 -0
- package/src/index.ts +23 -0
- package/src/introspect.ts +69 -1
- package/src/invariant-ddl.ts +193 -0
- package/src/invariant-errors.ts +47 -0
- package/src/retype-dependents.ts +135 -0
- package/src/snapshot-parse.ts +31 -3
- package/src/statement-excerpt.ts +18 -0
- package/src/ungeneratable.ts +86 -0
- package/src/unrendered.ts +170 -0
package/src/index.ts
CHANGED
|
@@ -12,6 +12,14 @@ export {
|
|
|
12
12
|
listBranches,
|
|
13
13
|
reapBranches,
|
|
14
14
|
} from './branch';
|
|
15
|
+
export {
|
|
16
|
+
checkClauses,
|
|
17
|
+
checkPlan,
|
|
18
|
+
columnCheckName,
|
|
19
|
+
columnChecks,
|
|
20
|
+
columnNamesConstraint,
|
|
21
|
+
declaredChecks,
|
|
22
|
+
} from './check-ddl';
|
|
15
23
|
export type {
|
|
16
24
|
DbClient,
|
|
17
25
|
DbConnection,
|
|
@@ -32,6 +40,8 @@ export {
|
|
|
32
40
|
poolProfileFor,
|
|
33
41
|
setDbClient,
|
|
34
42
|
} from './client';
|
|
43
|
+
export type { ColumnDefaultLike } from './column-default';
|
|
44
|
+
export { defaultExpression } from './column-default';
|
|
35
45
|
export { defaultClient, REPLICA_URL_ENV } from './default-client';
|
|
36
46
|
export type { DestructiveKind, DestructiveStatement } from './destructive';
|
|
37
47
|
export {
|
|
@@ -56,6 +66,7 @@ export type {
|
|
|
56
66
|
ColumnDescriptionLike,
|
|
57
67
|
EntityDescriptionLike,
|
|
58
68
|
IndexDescriptionLike,
|
|
69
|
+
InvariantDescriptionLike,
|
|
59
70
|
} from './entity-shape';
|
|
60
71
|
export type { DbErrorCode, DbErrorInit } from './errors';
|
|
61
72
|
export {
|
|
@@ -96,6 +107,7 @@ export {
|
|
|
96
107
|
isIndexMethod,
|
|
97
108
|
} from './index-method';
|
|
98
109
|
export type {
|
|
110
|
+
CheckDescription,
|
|
99
111
|
ColumnDescription,
|
|
100
112
|
ForeignKeyDescription,
|
|
101
113
|
IndexDescription,
|
|
@@ -104,6 +116,13 @@ export type {
|
|
|
104
116
|
TableDescription,
|
|
105
117
|
} from './introspect';
|
|
106
118
|
export { buildSchema, findTable, introspect } from './introspect';
|
|
119
|
+
export {
|
|
120
|
+
constraintNameFor,
|
|
121
|
+
declaredIndexes,
|
|
122
|
+
invariantChecks,
|
|
123
|
+
uniqueColumns,
|
|
124
|
+
} from './invariant-ddl';
|
|
125
|
+
export { constraintExpressionUnsafe, constraintNameUnsafe } from './invariant-errors';
|
|
107
126
|
export type {
|
|
108
127
|
AppliedMigration,
|
|
109
128
|
LedgerRow,
|
|
@@ -172,3 +191,7 @@ export { STATEMENT_ATTRIBUTE } from './statement-span';
|
|
|
172
191
|
export { statementsOf } from './statement-split';
|
|
173
192
|
export type { DbTx, IsolationLevel, TransactionOptions } from './transaction';
|
|
174
193
|
export { beginStatement, currentTx, withTransaction } from './transaction';
|
|
194
|
+
export type { GeneratableForm } from './ungeneratable';
|
|
195
|
+
export { GENERATABLE_FORMS, ungeneratableStatements } from './ungeneratable';
|
|
196
|
+
export type { UnrenderedDeclaration } from './unrendered';
|
|
197
|
+
export { unrenderedComment, unrenderedOf } from './unrendered';
|
package/src/introspect.ts
CHANGED
|
@@ -57,6 +57,21 @@ export interface ForeignKeyDescription {
|
|
|
57
57
|
readonly onDelete: string | null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A named CHECK constraint, as the SNAPSHOT spells it — an entity invariant of kind `check`.
|
|
62
|
+
*
|
|
63
|
+
* Absent from every row this module reads out of the live catalog, deliberately and for the reason
|
|
64
|
+
* `ColumnDescription.generated` gives one field up: `pg_get_constraintdef` answers Postgres' own
|
|
65
|
+
* rewriting of the expression, so a catalog value could never compare equal to a generated one and
|
|
66
|
+
* drift would report a correct database forever. The diff that DOES read it is `x db gen`'s, where
|
|
67
|
+
* both sides are this generator's own spellings.
|
|
68
|
+
*/
|
|
69
|
+
export interface CheckDescription {
|
|
70
|
+
readonly name: string;
|
|
71
|
+
/** The predicate, exactly as the entity's invariant spells it. */
|
|
72
|
+
readonly expression: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
export interface TableDescription {
|
|
61
76
|
readonly schema: string;
|
|
62
77
|
readonly name: string;
|
|
@@ -64,6 +79,32 @@ export interface TableDescription {
|
|
|
64
79
|
readonly primaryKey: readonly string[];
|
|
65
80
|
readonly indexes: readonly IndexDescription[];
|
|
66
81
|
readonly foreignKeys: readonly ForeignKeyDescription[];
|
|
82
|
+
/**
|
|
83
|
+
* The CHECK constraints migrations declare. Absent — never `[]` — on a table that declares none
|
|
84
|
+
* and in every sidecar written before this field existed, matching `IndexDescription.using`: a
|
|
85
|
+
* snapshot that predates it must read as "nothing recorded" so the next `x db gen` emits the
|
|
86
|
+
* `add constraint` the database is genuinely missing, rather than as "recorded none", which
|
|
87
|
+
* would leave every already-generated app's invariants unenforced forever.
|
|
88
|
+
*/
|
|
89
|
+
readonly checks?: readonly CheckDescription[] | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* The catalog's half of `checks`, and a **separate field rather than the same one** — names
|
|
92
|
+
* only, `conname` for `contype = 'c'`, never a definition.
|
|
93
|
+
*
|
|
94
|
+
* The two readings cannot share `checks` because they are not the same value. A catalog read
|
|
95
|
+
* carries Postgres' own rewriting of the predicate and a declaration carries this generator's
|
|
96
|
+
* spelling, so a `checks` filled from `pg_constraint` would put a rewritten expression on the
|
|
97
|
+
* field `checkPlan` diffs against a generated one — every regenerated migration would then drop
|
|
98
|
+
* and re-add every constraint in the app, forever, because the two strings can never be equal.
|
|
99
|
+
* Splitting them means the type says which reading a value came from, and `checkPlan` cannot be
|
|
100
|
+
* handed a catalog value by accident.
|
|
101
|
+
*
|
|
102
|
+
* `snapshotOf` never writes it and `parseSnapshot` never reads it, so a sidecar carries `checks`
|
|
103
|
+
* alone. `introspect()` always answers with it, `[]` included: absent therefore means "nobody
|
|
104
|
+
* asked the catalog", which is what keeps `compareChecks` silent on a description that never
|
|
105
|
+
* read one instead of reporting every declared constraint as missing.
|
|
106
|
+
*/
|
|
107
|
+
readonly checkNames?: readonly string[] | undefined;
|
|
67
108
|
}
|
|
68
109
|
|
|
69
110
|
export interface SchemaDescription {
|
|
@@ -112,6 +153,12 @@ interface ForeignKeyRow {
|
|
|
112
153
|
readonly on_delete: string | null;
|
|
113
154
|
}
|
|
114
155
|
|
|
156
|
+
/** A CHECK constraint's NAME. There is deliberately no column for its definition — see `checkNames`. */
|
|
157
|
+
interface CheckRow {
|
|
158
|
+
readonly table_name: string;
|
|
159
|
+
readonly constraint_name: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
115
162
|
const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : 1);
|
|
116
163
|
|
|
117
164
|
export async function introspect(options: IntrospectOptions = {}): Promise<SchemaDescription> {
|
|
@@ -183,7 +230,21 @@ export async function introspect(options: IntrospectOptions = {}): Promise<Schem
|
|
|
183
230
|
order by src.relname, c.conname
|
|
184
231
|
`);
|
|
185
232
|
|
|
186
|
-
|
|
233
|
+
// `conname` and nothing else. `pg_get_constraintdef(c.oid)` is one word further along this line
|
|
234
|
+
// and is the reason this query did not exist: it answers Postgres' rewriting of the predicate,
|
|
235
|
+
// which no generated spelling can ever equal, so reading it would make every drift check report
|
|
236
|
+
// a correct database as wrong. `contype = 'c'` is the CHECK constraints alone — Postgres 17
|
|
237
|
+
// onwards records a NOT NULL as `'n'`, and a domain's as `'c'` on the domain rather than here.
|
|
238
|
+
const checks = await client.query<CheckRow>(sql`
|
|
239
|
+
select src.relname as table_name, c.conname as constraint_name
|
|
240
|
+
from pg_constraint c
|
|
241
|
+
join pg_class src on src.oid = c.conrelid
|
|
242
|
+
join pg_namespace n on n.oid = src.relnamespace
|
|
243
|
+
where c.contype = 'c' and n.nspname = ${schema} and src.relkind = 'r'
|
|
244
|
+
order by src.relname, c.conname
|
|
245
|
+
`);
|
|
246
|
+
|
|
247
|
+
return buildSchema(schema, excluded, columns, indexes, foreignKeys, checks);
|
|
187
248
|
}
|
|
188
249
|
|
|
189
250
|
/** Pure, so the row -> description mapping is testable without a database. */
|
|
@@ -193,6 +254,7 @@ export function buildSchema(
|
|
|
193
254
|
columns: readonly ColumnRow[],
|
|
194
255
|
indexes: readonly IndexRow[],
|
|
195
256
|
foreignKeys: readonly ForeignKeyRow[],
|
|
257
|
+
checks: readonly CheckRow[] = [],
|
|
196
258
|
): SchemaDescription {
|
|
197
259
|
const names = [...new Set(columns.map((row) => row.table_name))]
|
|
198
260
|
.filter((name) => !excluded.includes(name))
|
|
@@ -238,6 +300,12 @@ export function buildSchema(
|
|
|
238
300
|
onDelete: row.on_delete,
|
|
239
301
|
}))
|
|
240
302
|
.sort(byName),
|
|
303
|
+
// Always written, `[]` included: this reading of a table HAS asked the catalog, and absence
|
|
304
|
+
// is reserved for a description that has not (`compareChecks` is silent on that one).
|
|
305
|
+
checkNames: checks
|
|
306
|
+
.filter((row) => row.table_name === name)
|
|
307
|
+
.map((row) => row.constraint_name)
|
|
308
|
+
.sort(),
|
|
241
309
|
};
|
|
242
310
|
});
|
|
243
311
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Single responsibility: the DDL an entity's INVARIANTS become — what a rule is called, the index a
|
|
2
|
+
// `unique` becomes, and the CHECK list a caller merges. `check-ddl.ts` owns the plan those checks
|
|
3
|
+
// join, because a column declares one of its own and the two are one list on the server.
|
|
4
|
+
//
|
|
5
|
+
// A `check` becomes a named CONSTRAINT, inline on a created table and `alter table … add
|
|
6
|
+
// constraint` on an existing one. A `unique` becomes a unique INDEX — never a UNIQUE constraint —
|
|
7
|
+
// because a soft-deleting entity stamps `deleted_at is null` onto it and Postgres has no partial
|
|
8
|
+
// unique constraint, only a partial unique index. An `assert` becomes nothing: it is declared as a
|
|
9
|
+
// rule only the app can judge (`sql: null`), which is what `hasJsOnlyInvariant` reads it as.
|
|
10
|
+
|
|
11
|
+
import { assert } from '@ultimat3/core';
|
|
12
|
+
import type {
|
|
13
|
+
EntityDescriptionLike,
|
|
14
|
+
IndexDescriptionLike,
|
|
15
|
+
InvariantDescriptionLike,
|
|
16
|
+
} from './entity-shape';
|
|
17
|
+
import { indexMethodOf } from './index-method';
|
|
18
|
+
import type { CheckDescription } from './introspect';
|
|
19
|
+
import { constraintNameUnsafe } from './invariant-errors';
|
|
20
|
+
import { identifier } from './sql';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `NAMEDATALEN - 1`. Postgres truncates a longer identifier and says nothing, so two constraints
|
|
24
|
+
* sharing their first 63 bytes are ONE constraint on the server while both names still differ in
|
|
25
|
+
* the snapshot — invisible to a drift check comparing declared names. `@ultimat3/entity` bounds
|
|
26
|
+
* the index names it mints for the same reason; this bound is Postgres', not a convention, so
|
|
27
|
+
* stating it on both sides of the tier seam is one fact written twice rather than two rules.
|
|
28
|
+
*
|
|
29
|
+
* Exported so `check-ddl.ts` bounds a column's constraint name against the same number: two copies
|
|
30
|
+
* of NAMEDATALEN in one package is two rules that can drift, which is the thing it exists against.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_IDENTIFIER_BYTES = 63;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The convention alone, with nothing validated and nothing refused. One copy, so `constraintNameFor`
|
|
36
|
+
* and `namesConstraint` can never disagree about what a rule's constraint is called — the two
|
|
37
|
+
* questions "what do I emit" and "is this recorded constraint that rule's" are the same string.
|
|
38
|
+
*/
|
|
39
|
+
function spellConstraintName(table: string, invariant: InvariantDescriptionLike): string {
|
|
40
|
+
return `${table}_${invariant.name}_${invariant.kind === 'unique' ? 'key' : 'check'}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether a CHECK a migration RECORDED is this rule's own enforcement in the database. Two
|
|
45
|
+
* spellings, because two writers name it: this generator's `<table>_<name>_check`, and a
|
|
46
|
+
* hand-written migration that used the rule's own name — which is what `examples/dummy`'s
|
|
47
|
+
* `0001_init.sql` did for every one of its app-judged rules.
|
|
48
|
+
*
|
|
49
|
+
* Never throws, unlike `constraintNameFor`: its caller is a REPORTER (`unrendered.ts`), reached by
|
|
50
|
+
* `x verify`'s drift step, where a throw replaces a finding with a crash. The RECORDED name is
|
|
51
|
+
* required to be an identifier and the invariant's is not, because only the recorded one is written
|
|
52
|
+
* back out — into a `--` comment and into a `fix:` — and a sidecar is a hand-editable file.
|
|
53
|
+
*/
|
|
54
|
+
export function namesConstraint(
|
|
55
|
+
table: string,
|
|
56
|
+
invariant: InvariantDescriptionLike,
|
|
57
|
+
recorded: string,
|
|
58
|
+
): boolean {
|
|
59
|
+
if (!isIdentifier(recorded)) return false;
|
|
60
|
+
return recorded === invariant.name || recorded === spellConstraintName(table, invariant);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The constraint an invariant becomes: `<table>_<name>_check` / `<table>_<name>_key`.
|
|
65
|
+
*
|
|
66
|
+
* The same string `@ultimat3/entity`'s `constraintName` builds, and it has to be re-derived here
|
|
67
|
+
* rather than read off the description because the projection carries the rule's own name and not
|
|
68
|
+
* the constraint's. Both spellings are pinned — entity's by `invariants.test.ts`, this one by
|
|
69
|
+
* `generate-invariant.test.ts` — and a divergence would show up as a constraint this generator
|
|
70
|
+
* adds twice under two names.
|
|
71
|
+
*/
|
|
72
|
+
export function constraintNameFor(table: string, invariant: InvariantDescriptionLike): string {
|
|
73
|
+
const name = spellConstraintName(table, invariant);
|
|
74
|
+
// Through the package's one identifier rule, never a second regex: an invariant name is
|
|
75
|
+
// validated by nobody at declaration, so this is where a name that closes the quote is stopped.
|
|
76
|
+
if (!isIdentifier(invariant.name) || !isIdentifier(table)) {
|
|
77
|
+
throw constraintNameUnsafe(table, invariant.name);
|
|
78
|
+
}
|
|
79
|
+
// Bytes and not characters: 63 is what the server counts, and `.length` stops seeing the
|
|
80
|
+
// truncation the moment a name is not ASCII.
|
|
81
|
+
const bytes = new TextEncoder().encode(name).length;
|
|
82
|
+
assert(
|
|
83
|
+
bytes <= MAX_IDENTIFIER_BYTES,
|
|
84
|
+
`constraint name "${name}" is ${bytes} bytes; Postgres truncates at ${MAX_IDENTIFIER_BYTES} and says nothing`,
|
|
85
|
+
`invariant('${invariant.name.slice(0, 20)}…', …) # shorten the invariant name, then x db gen`,
|
|
86
|
+
);
|
|
87
|
+
return name;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether `identifier()` would accept this name — the package's one rule, asked rather than run. */
|
|
91
|
+
export function isIdentifier(value: string): boolean {
|
|
92
|
+
try {
|
|
93
|
+
identifier(value);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
// `identifier` throws `X_SQL_UNSAFE` for exactly one reason and the caller re-throws its own,
|
|
97
|
+
// naming the invariant rather than the raw name — so nothing is swallowed here.
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The physical columns a `unique` invariant names. `columns` when the description carries it;
|
|
104
|
+
* otherwise the `sql` field, which for a `unique` IS the comma-joined column list.
|
|
105
|
+
*
|
|
106
|
+
* The fallback is a re-read, not a name parsed back out of a convention: every part is validated
|
|
107
|
+
* as an identifier and a part that is not one is REFUSED, so the failure mode `parseIndexName` had
|
|
108
|
+
* — `posts_org_id_created_at_idx` silently becoming the column `"org_id_created_at"` — cannot
|
|
109
|
+
* happen, because a physical column name cannot contain a comma. It exists so this package can
|
|
110
|
+
* emit the constraint before `@ultimat3/entity` (tier 2, which this one may not import) projects
|
|
111
|
+
* `Invariant.columns`; the field it already holds is what makes the fallback deletable later.
|
|
112
|
+
*/
|
|
113
|
+
export function uniqueColumns(
|
|
114
|
+
table: string,
|
|
115
|
+
invariant: InvariantDescriptionLike,
|
|
116
|
+
): readonly string[] {
|
|
117
|
+
const declared = invariant.columns ?? (invariant.sql ?? '').split(',').map((part) => part.trim());
|
|
118
|
+
assert(
|
|
119
|
+
declared.length > 0 && declared.every((column) => column.length > 0),
|
|
120
|
+
`unique invariant "${invariant.name}" on "${table}" names no columns`,
|
|
121
|
+
`invariant('${invariant.name}', c.unique(['<column>'])) # name the columns, then x db gen`,
|
|
122
|
+
);
|
|
123
|
+
for (const column of declared) {
|
|
124
|
+
if (!isIdentifier(column)) throw constraintNameUnsafe(table, column);
|
|
125
|
+
}
|
|
126
|
+
return declared;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A `unique` invariant as the index it is — so one list of indexes is created, diffed and recorded. */
|
|
130
|
+
function uniqueIndexOf(
|
|
131
|
+
entity: EntityDescriptionLike,
|
|
132
|
+
invariant: InvariantDescriptionLike,
|
|
133
|
+
): IndexDescriptionLike {
|
|
134
|
+
return {
|
|
135
|
+
name: constraintNameFor(entity.table, invariant),
|
|
136
|
+
columns: uniqueColumns(entity.table, invariant),
|
|
137
|
+
unique: true,
|
|
138
|
+
where: invariant.where,
|
|
139
|
+
order: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every part of an index Postgres fixes at creation — the dedup key, and `redefineIndex`'s. */
|
|
144
|
+
const shapeOf = (index: IndexDescriptionLike): string =>
|
|
145
|
+
JSON.stringify([
|
|
146
|
+
[...index.columns],
|
|
147
|
+
index.unique,
|
|
148
|
+
index.where,
|
|
149
|
+
index.order,
|
|
150
|
+
indexMethodOf(index),
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The indexes this entity declares: its own, plus one per `unique` invariant. ONE list, because
|
|
155
|
+
* `createTable`, `diffTable` and `snapshotOf` must agree about what exists — a unique index emitted
|
|
156
|
+
* but not recorded is `42P07` on the next `x db gen`, which is a worse failure than the silent drop
|
|
157
|
+
* this whole change is against.
|
|
158
|
+
*
|
|
159
|
+
* Deduped on the whole definition and never on the name, the rule `@ultimat3/entity` already
|
|
160
|
+
* applies. The case that bites: `invariant('slug', c.unique(['slug']))` on `members` derives
|
|
161
|
+
* `members_slug_key`, byte for byte what Postgres calls the index a `unique` column clause creates
|
|
162
|
+
* — so an entity declaring both pushes two `create unique index` statements under one name, which
|
|
163
|
+
* is `42P07` and a migration that cannot be applied at all. The entity's own index wins, because
|
|
164
|
+
* `impliedByColumnClause` is written against that name.
|
|
165
|
+
*/
|
|
166
|
+
export function declaredIndexes(entity: EntityDescriptionLike): readonly IndexDescriptionLike[] {
|
|
167
|
+
const invariants = entity.invariants ?? [];
|
|
168
|
+
if (invariants.length === 0) return entity.indexes;
|
|
169
|
+
const seen = new Set(entity.indexes.map(shapeOf));
|
|
170
|
+
const extra: IndexDescriptionLike[] = [];
|
|
171
|
+
for (const invariant of invariants) {
|
|
172
|
+
if (invariant.kind !== 'unique') continue;
|
|
173
|
+
const index = uniqueIndexOf(entity, invariant);
|
|
174
|
+
if (seen.has(shapeOf(index))) continue;
|
|
175
|
+
seen.add(shapeOf(index));
|
|
176
|
+
extra.push(index);
|
|
177
|
+
}
|
|
178
|
+
return [...entity.indexes, ...extra];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The CHECK constraints this entity's INVARIANTS declare, in declaration order. The predicate is
|
|
183
|
+
* handed on unvalidated: `check-ddl.ts` refuses a second command over the MERGED list, so one rule
|
|
184
|
+
* covers a rule's expression and a column's alike rather than one guard per producer.
|
|
185
|
+
*/
|
|
186
|
+
export function invariantChecks(entity: EntityDescriptionLike): readonly CheckDescription[] {
|
|
187
|
+
return (entity.invariants ?? [])
|
|
188
|
+
.filter((invariant) => invariant.kind === 'check' && invariant.sql !== null)
|
|
189
|
+
.map((invariant) => ({
|
|
190
|
+
name: constraintNameFor(entity.table, invariant),
|
|
191
|
+
expression: invariant.sql ?? '',
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Single responsibility: the two refusals an entity INVARIANT earns before its DDL exists — a name
|
|
2
|
+
// that cannot be an identifier, and a predicate holding a second command. Split out of `errors.ts`
|
|
3
|
+
// only because that file reached the 500-line ceiling; both carry `X_SQL_UNSAFE`, which
|
|
4
|
+
// `DB_OWNED_ERROR_CODES` there still declares and registers. No new code, and none is needed: an
|
|
5
|
+
// invariant name reaching a statement text is the same hazard a branch name or an isolation level
|
|
6
|
+
// is, and axiom 1 says one situation gets one code.
|
|
7
|
+
|
|
8
|
+
import { describeValue } from '@ultimat3/core';
|
|
9
|
+
import { DbError } from './errors';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A name an invariant contributes to a statement — its own, or a column its `unique` list names —
|
|
13
|
+
* that cannot be an identifier. `X_SQL_UNSAFE` for the reason `branchNameInvalid` uses it:
|
|
14
|
+
* `create table` and `add constraint` take no parameters, so the name is SPLICED into the
|
|
15
|
+
* statement text, and NOTHING validates an invariant name at declaration, so
|
|
16
|
+
* `invariant('x" ); drop table t; --', …)` type-checks all the way to the generator. The identical
|
|
17
|
+
* hole `columnName` carried when it was `meta.name ?? snake(property)` with only the first branch
|
|
18
|
+
* checked, measured through `generateMigration` as a real `drop table`.
|
|
19
|
+
*
|
|
20
|
+
* Its own factory rather than `identifierUnsafe`, and that is the whole of its value — `identifier`
|
|
21
|
+
* refuses the same string one call later, at every site that emits it. What only this one carries
|
|
22
|
+
* is the REPAIR: `identifierUnsafe` says "pass a plain table/column name" to a caller holding a
|
|
23
|
+
* name, and an author holding a schema module needs the `invariant()` call named instead. Pinned
|
|
24
|
+
* on the `fix:` line, because a guard whose only value is its message is proven by nothing else.
|
|
25
|
+
*/
|
|
26
|
+
export const constraintNameUnsafe = (table: string, received: unknown): DbError =>
|
|
27
|
+
new DbError({
|
|
28
|
+
code: 'X_SQL_UNSAFE',
|
|
29
|
+
cause: `an invariant on "${table}" contributes ${describeValue(received)} to a statement, which cannot be a Postgres identifier`,
|
|
30
|
+
fix: "invariant('post_slug_unique', c.unique(['slug'])) # every name is [A-Za-z_][A-Za-z0-9_$]*, then x db gen",
|
|
31
|
+
meta: { table },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A constraint predicate holding more than one command. Read through `statementsOf` — this
|
|
36
|
+
* package's one lexer, so a `;` inside a string literal is data and not a second statement — and
|
|
37
|
+
* refused before it is spliced into `check (…)`. The predicate arrives from `Expr.toSql()` at tier
|
|
38
|
+
* 2 or from a hand-built description, and an operand TypeScript never saw closing the parenthesis
|
|
39
|
+
* is an injection rather than a typo, which is what `X_SQL_UNSAFE` is for.
|
|
40
|
+
*/
|
|
41
|
+
export const constraintExpressionUnsafe = (constraint: string, count: number): DbError =>
|
|
42
|
+
new DbError({
|
|
43
|
+
code: 'X_SQL_UNSAFE',
|
|
44
|
+
cause: `the predicate of constraint "${constraint}" holds ${count} commands; a CHECK is one expression`,
|
|
45
|
+
fix: `invariant('${constraint}', c.column.atLeast(0)) # build the predicate with the column DSL, never as text`,
|
|
46
|
+
meta: { constraint, count },
|
|
47
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Single responsibility: which RECORDED objects a retype of one column breaks, and the statements
|
|
2
|
+
// that take them out of the way before the ALTER and put them back in the `down`.
|
|
3
|
+
//
|
|
4
|
+
// Postgres compiles a partial index's predicate and a CHECK's expression against the column's type
|
|
5
|
+
// at creation and cannot recompile either: `alter table "posts" alter column "status" type text
|
|
6
|
+
// using "status"::text` answers `42883 operator does not exist: text = post_status` and the
|
|
7
|
+
// migration aborts mid-run — inside `ROLE=migrate`, with the ledger recording nothing. Measured on
|
|
8
|
+
// Postgres 18.4 (`generate-retype.live.test.ts`), one dependent shape at a time:
|
|
9
|
+
//
|
|
10
|
+
// | recorded object | survives the ALTER |
|
|
11
|
+
// |------------------------------------------|--------------------|
|
|
12
|
+
// | btree on the column, plain or unique | yes — Postgres rebuilds it itself |
|
|
13
|
+
// | composite btree including the column | yes |
|
|
14
|
+
// | partial index whose predicate names it | **no — 42883** |
|
|
15
|
+
// | partial index naming another column | yes |
|
|
16
|
+
// | CHECK whose expression names it | **no — 42883** |
|
|
17
|
+
//
|
|
18
|
+
// So only an expression that MENTIONS the column is dependent, and dropping the rest would be a
|
|
19
|
+
// table scan per index for nothing.
|
|
20
|
+
|
|
21
|
+
import { addCheck, dropCheck } from './check-ddl';
|
|
22
|
+
import type { Plan } from './foreign-key-plan';
|
|
23
|
+
import { asDeclared, createIndex, dropIndex } from './index-ddl';
|
|
24
|
+
import type { CheckDescription, IndexDescription, TableDescription } from './introspect';
|
|
25
|
+
import { IDENTIFIER_PART, noiseAt } from './sql-scan';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether `expression` reads `column`, over-approximating on purpose.
|
|
29
|
+
*
|
|
30
|
+
* The two errors are not symmetrical. A dependent object missed is `42883` in the release phase;
|
|
31
|
+
* one reported that was not is a rebuild nobody asked for — so every ambiguous case answers `true`,
|
|
32
|
+
* and the folding is case-insensitive because Postgres folds an unquoted identifier to lower case
|
|
33
|
+
* and `"Status"` naming a different column is a rarity beside a predicate this must not miss.
|
|
34
|
+
*
|
|
35
|
+
* What it does NOT count is noise, through this package's one lexer (`sql-scan.ts`): the `status`
|
|
36
|
+
* in `where kind = 'status'` is data, not a reference, and the one in `-- status` is prose. A
|
|
37
|
+
* QUOTED identifier is counted — `"status"` is the reference the catalog stores for an author who
|
|
38
|
+
* quoted it, and skipping it as noise is exactly the miss that ends in `42883`.
|
|
39
|
+
*/
|
|
40
|
+
export function referencesColumn(expression: string, column: string): boolean {
|
|
41
|
+
const wanted = column.toLowerCase();
|
|
42
|
+
let at = 0;
|
|
43
|
+
while (at < expression.length) {
|
|
44
|
+
const noise = noiseAt(expression, at);
|
|
45
|
+
if (noise !== null) {
|
|
46
|
+
if (
|
|
47
|
+
noise.kind === 'identifier' &&
|
|
48
|
+
expression.slice(at + 1, noise.end - 1).toLowerCase() === wanted
|
|
49
|
+
) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
at = noise.end;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!IDENTIFIER_PART.test(expression[at] ?? '')) {
|
|
56
|
+
at += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
let end = at;
|
|
60
|
+
while (end < expression.length && IDENTIFIER_PART.test(expression[end] ?? '')) end += 1;
|
|
61
|
+
if (expression.slice(at, end).toLowerCase() === wanted) return true;
|
|
62
|
+
at = end;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The recorded objects a retype of `column` cannot leave in place. */
|
|
68
|
+
export interface RetypeDependents {
|
|
69
|
+
/**
|
|
70
|
+
* Partial indexes whose predicate reads the column. A `primary` one is structurally impossible —
|
|
71
|
+
* a primary key index has no predicate — which is what keeps `drop index` off the two indexes
|
|
72
|
+
* Postgres refuses it on: a primary key's and a unique constraint's.
|
|
73
|
+
*/
|
|
74
|
+
readonly indexes: readonly IndexDescription[];
|
|
75
|
+
/** CHECK constraints whose expression reads the column. */
|
|
76
|
+
readonly checks: readonly CheckDescription[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What a retype of `table`.`column` breaks, read off the RECORDED schema — never the catalog.
|
|
81
|
+
* `x db gen` runs with no database open, so a hand-added expression index over the same column is
|
|
82
|
+
* invisible here and still `42883`; what this can see is every object a migration wrote down.
|
|
83
|
+
*/
|
|
84
|
+
export function retypeDependents(column: string, live: TableDescription): RetypeDependents {
|
|
85
|
+
return {
|
|
86
|
+
indexes: live.indexes.filter(
|
|
87
|
+
(index) => !index.primary && index.where !== null && referencesColumn(index.where, column),
|
|
88
|
+
),
|
|
89
|
+
checks: (live.checks ?? []).filter((check) => referencesColumn(check.expression, column)),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What this plan has already dropped ahead of a retype — names only, because that is all the two
|
|
95
|
+
* readers need. The ordinary diff runs AFTER the ALTER and must not act on an object that is no
|
|
96
|
+
* longer there: the index loop CREATES a name in `indexes` instead of comparing it (a `drop index`
|
|
97
|
+
* on a name already dropped is `42704`, and a definition that never moved would emit nothing at
|
|
98
|
+
* all, leaving the table with no index), and `checkPlan` neither drops nor re-adds a name in
|
|
99
|
+
* `checks` — the declared side is added back by its own arm, and a recorded constraint the entity
|
|
100
|
+
* no longer declares is simply gone, which is what `checkPlan` would have done to it anyway.
|
|
101
|
+
*/
|
|
102
|
+
export interface MovedAside {
|
|
103
|
+
readonly indexes: Set<string>;
|
|
104
|
+
readonly checks: Set<string>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Drop every dependent in `up`, restore it in `down`, and record what was moved.
|
|
109
|
+
*
|
|
110
|
+
* `down` is reversed at assembly, so the restores are pushed FORWARDS here and the retype's own
|
|
111
|
+
* reversal is pushed after them — the reversed script therefore reads: retype back to the old
|
|
112
|
+
* type, then recreate the objects that were compiled against it. Restoring first would recreate a
|
|
113
|
+
* predicate against a type the column no longer has, which is `42883` in the other direction.
|
|
114
|
+
*
|
|
115
|
+
* What is restored is what the snapshot RECORDED, never what the entity declares: an object still
|
|
116
|
+
* declared is re-created by the ordinary diff, one statement later, in its current shape.
|
|
117
|
+
*/
|
|
118
|
+
export function moveDependentsAside(
|
|
119
|
+
live: TableDescription,
|
|
120
|
+
column: string,
|
|
121
|
+
plan: Plan,
|
|
122
|
+
moved: MovedAside,
|
|
123
|
+
): void {
|
|
124
|
+
const dependents = retypeDependents(column, live);
|
|
125
|
+
for (const index of dependents.indexes) {
|
|
126
|
+
plan.up.push(dropIndex(index.name));
|
|
127
|
+
plan.down.push(createIndex(live.name, asDeclared(index)));
|
|
128
|
+
moved.indexes.add(index.name);
|
|
129
|
+
}
|
|
130
|
+
for (const check of dependents.checks) {
|
|
131
|
+
plan.up.push(dropCheck(live.name, check.name));
|
|
132
|
+
plan.down.push(addCheck(live.name, check));
|
|
133
|
+
moved.checks.add(check.name);
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/snapshot-parse.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// is a sidecar one of them accepts and the other cannot use.
|
|
5
5
|
|
|
6
6
|
import type {
|
|
7
|
+
CheckDescription,
|
|
7
8
|
ColumnDescription,
|
|
8
9
|
ForeignKeyDescription,
|
|
9
10
|
IndexDescription,
|
|
@@ -28,10 +29,30 @@ const order = (value: unknown): value is 'asc' | 'desc' | null =>
|
|
|
28
29
|
|
|
29
30
|
function column(value: unknown): ColumnDescription | undefined {
|
|
30
31
|
if (!isRow(value)) return undefined;
|
|
31
|
-
const { name, dataType, nullable, default: fallback, position } = value;
|
|
32
|
+
const { name, dataType, nullable, default: fallback, position, generated } = value;
|
|
32
33
|
if (!str(name) || !str(dataType) || !bool(nullable) || !nullableStr(fallback)) return undefined;
|
|
33
34
|
if (typeof position !== 'number') return undefined;
|
|
34
|
-
|
|
35
|
+
// `generated` was recorded by `snapshotOf` and dropped HERE, silently, for as long as the field
|
|
36
|
+
// has existed: the sidecar carried the expression and the parse handed back a column without it,
|
|
37
|
+
// so `retypeColumn` read every generated column as newly generated and rebuilt it on every
|
|
38
|
+
// `x db gen`. Absent stays absent — an ordinary column must gain no key.
|
|
39
|
+
if (!(generated === undefined || str(generated))) return undefined;
|
|
40
|
+
return {
|
|
41
|
+
name,
|
|
42
|
+
dataType,
|
|
43
|
+
nullable,
|
|
44
|
+
default: fallback,
|
|
45
|
+
position,
|
|
46
|
+
...(generated === undefined ? {} : { generated }),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The predicate is the snapshot's own spelling, so both halves are plain strings or nothing. */
|
|
51
|
+
function check(value: unknown): CheckDescription | undefined {
|
|
52
|
+
if (!isRow(value)) return undefined;
|
|
53
|
+
const { name, expression } = value;
|
|
54
|
+
if (!str(name) || !str(expression)) return undefined;
|
|
55
|
+
return { name, expression };
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
function index(value: unknown): IndexDescription | undefined {
|
|
@@ -88,7 +109,14 @@ function tableOf(value: unknown): TableDescription | undefined {
|
|
|
88
109
|
const indexes = all(value['indexes'], index);
|
|
89
110
|
const foreignKeys = all(value['foreignKeys'], foreignKey);
|
|
90
111
|
if (columns === undefined || indexes === undefined || foreignKeys === undefined) return undefined;
|
|
91
|
-
|
|
112
|
+
// Absent, never `[]`. A sidecar written before constraints were recorded says nothing about
|
|
113
|
+
// them, and reading that as "this table declares none" would drop every invariant an app has
|
|
114
|
+
// already generated instead of adding the ones its database is missing.
|
|
115
|
+
const raw = value['checks'];
|
|
116
|
+
if (raw === undefined) return { schema, name, columns, primaryKey, indexes, foreignKeys };
|
|
117
|
+
const checks = all(raw, check);
|
|
118
|
+
if (checks === undefined) return undefined;
|
|
119
|
+
return { schema, name, columns, primaryKey, indexes, foreignKeys, checks };
|
|
92
120
|
}
|
|
93
121
|
|
|
94
122
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Single responsibility: one SQL statement as one capped line, for an error to print. Its own
|
|
2
|
+
// module because two rails now report statements — `destructive.ts` and `ungeneratable.ts` — and a
|
|
3
|
+
// second copy of "what does a reported statement look like" is two answers to one question.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Only the comments *preceding* the statement come off, the ones `statementsOf` carries in from the
|
|
7
|
+
* file header or from the `-- backfill …` note above it; the SQL itself stays verbatim.
|
|
8
|
+
*
|
|
9
|
+
* Blanking is for **deciding**, never for reporting: `stripSqlNoise` empties quoted identifiers, so
|
|
10
|
+
* a report built from it says `drop table ""`, which names nothing an author can act on.
|
|
11
|
+
*/
|
|
12
|
+
export function statementExcerpt(statement: string): string {
|
|
13
|
+
const line = statement
|
|
14
|
+
.replace(/^(?:\s*(?:--[^\n]*|\/\*[\s\S]*?\*\/)\s*)+/, '')
|
|
15
|
+
.replace(/\s+/g, ' ')
|
|
16
|
+
.trim();
|
|
17
|
+
return line.length > 120 ? `${line.slice(0, 117)}...` : line;
|
|
18
|
+
}
|