@ultimat3/db 12.0.0 → 14.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 +138 -1
- package/README.md +4 -0
- package/package.json +2 -2
- package/src/check-ddl.ts +212 -0
- package/src/column-default.ts +80 -0
- package/src/destructive.ts +2 -15
- package/src/drift.ts +2 -4
- package/src/entity-shape.ts +57 -0
- package/src/foreign-key-plan.ts +10 -2
- package/src/foreign-key.ts +48 -4
- package/src/generate.ts +125 -42
- package/src/generated-column.ts +119 -0
- package/src/index.ts +23 -0
- package/src/introspect.ts +32 -0
- package/src/invariant-ddl.ts +193 -0
- package/src/invariant-errors.ts +47 -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/introspect.ts
CHANGED
|
@@ -14,6 +14,15 @@ export interface ColumnDescription {
|
|
|
14
14
|
readonly nullable: boolean;
|
|
15
15
|
readonly default: string | null;
|
|
16
16
|
readonly position: number;
|
|
17
|
+
/**
|
|
18
|
+
* The generation expression, as the SNAPSHOT spells it. Absent for an ordinary column and absent
|
|
19
|
+
* for every row this module reads out of the live catalog — deliberately: Postgres stores its own
|
|
20
|
+
* rewriting of the expression (`COALESCE(title, ''::text)` for `coalesce("title", '')`), so a
|
|
21
|
+
* catalog value could never compare equal to a generated one, and drift would report a correct
|
|
22
|
+
* database forever. Both sides of the diff that DOES read it — `x db gen`'s — are generated
|
|
23
|
+
* spellings, which is the same rule `IndexDescription.where` states one field down.
|
|
24
|
+
*/
|
|
25
|
+
readonly generated?: string | undefined;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
export interface IndexDescription {
|
|
@@ -48,6 +57,21 @@ export interface ForeignKeyDescription {
|
|
|
48
57
|
readonly onDelete: string | null;
|
|
49
58
|
}
|
|
50
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
|
+
|
|
51
75
|
export interface TableDescription {
|
|
52
76
|
readonly schema: string;
|
|
53
77
|
readonly name: string;
|
|
@@ -55,6 +79,14 @@ export interface TableDescription {
|
|
|
55
79
|
readonly primaryKey: readonly string[];
|
|
56
80
|
readonly indexes: readonly IndexDescription[];
|
|
57
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;
|
|
58
90
|
}
|
|
59
91
|
|
|
60
92
|
export interface SchemaDescription {
|
|
@@ -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
|
+
});
|
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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Single responsibility: which statements in a migration's `up` half `x db gen` could never have
|
|
2
|
+
// written — the SQL a squash discards in silence. `REPLICA IDENTITY FULL`, `CREATE EXTENSION`, a
|
|
3
|
+
// `GRANT`, a data backfill: none of them is a declaration, none reaches a `.snapshot.json`, and no
|
|
4
|
+
// declaration-based drift check can see them, because a regenerated sidecar equals the declaration
|
|
5
|
+
// by construction. The rail is the same shape `destructive.ts` is — statements in, statements out.
|
|
6
|
+
|
|
7
|
+
import { stripSqlNoise } from './sql-noise';
|
|
8
|
+
import { statementExcerpt } from './statement-excerpt';
|
|
9
|
+
import { statementsOf } from './statement-split';
|
|
10
|
+
|
|
11
|
+
/** One statement form `generateMigration` emits, matched on its leading verb phrase. */
|
|
12
|
+
export interface GeneratableForm {
|
|
13
|
+
/** What the form is called in a failing test's output. */
|
|
14
|
+
readonly name: string;
|
|
15
|
+
/** Anchored at the statement's start, against blanked and lowercased text. */
|
|
16
|
+
readonly pattern: RegExp;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Everything this package's generator can emit, and nothing else.
|
|
21
|
+
*
|
|
22
|
+
* **The list is not a hand-typed opinion, and `ungeneratable.test.ts` is what keeps it from
|
|
23
|
+
* becoming one.** Two assertions, in both directions, over a corpus that is the real output of
|
|
24
|
+
* `generateMigration`: no statement in that corpus may be reported (or the check fires on the
|
|
25
|
+
* framework's own migrations), and every entry here must match a statement in it (or the list has
|
|
26
|
+
* grown an entry excusing SQL the generator never writes — which is the exact thing this rail
|
|
27
|
+
* exists to report). A statement form added to `generate.ts` and not to this list fails the first;
|
|
28
|
+
* an entry added here to silence a finding fails the second.
|
|
29
|
+
*
|
|
30
|
+
* Matched on the leading **verb phrase**, never on the whole statement: `alter table` is four
|
|
31
|
+
* different operations and only some of them are generated, so the sub-clause is part of the
|
|
32
|
+
* phrase — `alter table … replica identity full` shares its first two words with `add column` and
|
|
33
|
+
* is the statement that started this.
|
|
34
|
+
*
|
|
35
|
+
* What it deliberately does not do is judge a statement's *body*. A hand-written `create table …
|
|
36
|
+
* partition by range (…)` reads as generatable, because its verb phrase is one the generator
|
|
37
|
+
* writes. Reporting that needs a schema comparison, which is `schema-drift`'s question and already
|
|
38
|
+
* has an answer; this one is only ever about a statement with no declaration behind it at all.
|
|
39
|
+
*/
|
|
40
|
+
export const GENERATABLE_FORMS: readonly GeneratableForm[] = [
|
|
41
|
+
{ name: 'create table', pattern: /^create\s+table\b/ },
|
|
42
|
+
{ name: 'drop table', pattern: /^drop\s+table\b/ },
|
|
43
|
+
{ name: 'create index', pattern: /^create\s+index\b/ },
|
|
44
|
+
{ name: 'create unique index', pattern: /^create\s+unique\s+index\b/ },
|
|
45
|
+
{ name: 'drop index', pattern: /^drop\s+index\b/ },
|
|
46
|
+
{ name: 'add column', pattern: /^alter\s+table\s[\s\S]*?\badd\s+column\b/ },
|
|
47
|
+
{ name: 'drop column', pattern: /^alter\s+table\s[\s\S]*?\bdrop\s+column\b/ },
|
|
48
|
+
{ name: 'add constraint', pattern: /^alter\s+table\s[\s\S]*?\badd\s+constraint\b/ },
|
|
49
|
+
{ name: 'drop constraint', pattern: /^alter\s+table\s[\s\S]*?\bdrop\s+constraint\b/ },
|
|
50
|
+
{
|
|
51
|
+
name: 'alter column type',
|
|
52
|
+
pattern: /^alter\s+table\s[\s\S]*?\balter\s+column\s[\s\S]*?\btype\b/,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'alter column set expression',
|
|
56
|
+
pattern: /^alter\s+table\s[\s\S]*?\balter\s+column\s[\s\S]*?\bset\s+expression\b/,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'alter column drop expression',
|
|
60
|
+
pattern: /^alter\s+table\s[\s\S]*?\balter\s+column\s[\s\S]*?\bdrop\s+expression\b/,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Every statement in `up` that a regenerated migration would not carry, in apply order, as the one
|
|
66
|
+
* capped line an error prints — the statement, never a count, because the whole value is telling an
|
|
67
|
+
* author *which* line a squash discards.
|
|
68
|
+
*
|
|
69
|
+
* Decided on blanked text and reported from the original, the rule `destructiveStatements` states:
|
|
70
|
+
* `statementsOf` cuts on a `;` that is not inside a literal, an identifier, a dollar-quoted body or
|
|
71
|
+
* a comment, and `stripSqlNoise` blanks all four before a verb is looked for — so
|
|
72
|
+
* `-- create extension pg_trgm` is prose and `values ('grant select on posts')` is data. The
|
|
73
|
+
* excerpt keeps its identifiers, because `create extension ""` names nothing an author can act on.
|
|
74
|
+
*
|
|
75
|
+
* Only `up`, exactly as the destructive rail: `down` is full of statements the generator does emit
|
|
76
|
+
* and reversing it teaches nobody anything about what the committed file uniquely holds.
|
|
77
|
+
*/
|
|
78
|
+
export function ungeneratableStatements(up: string): readonly string[] {
|
|
79
|
+
const found: string[] = [];
|
|
80
|
+
for (const statement of statementsOf(up)) {
|
|
81
|
+
const bare = stripSqlNoise(statement).trim().toLowerCase();
|
|
82
|
+
if (GENERATABLE_FORMS.some((form) => form.pattern.test(bare))) continue;
|
|
83
|
+
found.push(statementExcerpt(statement));
|
|
84
|
+
}
|
|
85
|
+
return found;
|
|
86
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Single responsibility: what a generated migration declares it could NOT write, and how that
|
|
2
|
+
// reaches the file. A generator that silently emits less than the declaration is the defect this
|
|
3
|
+
// module exists against — ten invariants and nine defaults went missing between an entity and its
|
|
4
|
+
// own regenerated migration, and the `drift` gate step compares a source hash to a sidecar and
|
|
5
|
+
// never reads the SQL, so the loss was GREEN. A comment in the emitted `up` cannot be green.
|
|
6
|
+
|
|
7
|
+
import { assert } from '@ultimat3/core';
|
|
8
|
+
import { columnNamesConstraint } from './check-ddl';
|
|
9
|
+
import { hasUnrenderedDefault } from './column-default';
|
|
10
|
+
import type { EntityDescriptionLike, InvariantDescriptionLike } from './entity-shape';
|
|
11
|
+
import { findTable, type SchemaDescription, type TableDescription } from './introspect';
|
|
12
|
+
import { namesConstraint } from './invariant-ddl';
|
|
13
|
+
|
|
14
|
+
export interface UnrenderedDeclaration {
|
|
15
|
+
/** Which half of the declaration reached no SQL. */
|
|
16
|
+
readonly kind: 'default' | 'invariant';
|
|
17
|
+
/** The physical table it was declared on. */
|
|
18
|
+
readonly table: string;
|
|
19
|
+
/** The column or the invariant it was declared on. */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** What was declared and why nothing was written. One line. */
|
|
22
|
+
readonly cause: string;
|
|
23
|
+
/** The edit or the command that makes the next generation carry it. One line. */
|
|
24
|
+
readonly fix: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Refused, not sanitised: a `\n` inside a `--` line comment ENDS the comment, so a cause carrying
|
|
29
|
+
* one would put the rest of itself into the migration as real SQL. Every value that reaches here
|
|
30
|
+
* is built from identifiers this generator already validated, so this is the assertion that keeps
|
|
31
|
+
* that true rather than a filter that quietly rewrites text an author has to act on.
|
|
32
|
+
*/
|
|
33
|
+
function commentLine(text: string): string {
|
|
34
|
+
assert(
|
|
35
|
+
!/[\r\n]/.test(text),
|
|
36
|
+
`a migration comment may not span lines: ${JSON.stringify(text)}`,
|
|
37
|
+
'report this — a validated identifier reached the comment renderer carrying a newline',
|
|
38
|
+
);
|
|
39
|
+
return text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The block that goes at the TOP of `up`, or nothing at all. Nothing at all is the point: a marker
|
|
44
|
+
* on every migration marks none, which is the rule `destructive.ts` already states for its own.
|
|
45
|
+
*
|
|
46
|
+
* Comments, never a refusal. `x db gen` refusing here would be a generator no app with a
|
|
47
|
+
* `.default('draft')` could run at all — the whole tree is in that state until `@ultimat3/entity`
|
|
48
|
+
* projects the expression — and a migration nobody can generate repairs nothing. The comment
|
|
49
|
+
* survives into the committed file, where a reviewer and the next agent both read it, and
|
|
50
|
+
* `GeneratedMigration.unrendered` carries the same list for a caller that would rather refuse.
|
|
51
|
+
*/
|
|
52
|
+
export function unrenderedComment(entries: readonly UnrenderedDeclaration[]): string {
|
|
53
|
+
if (entries.length === 0) return '';
|
|
54
|
+
const header =
|
|
55
|
+
`-- UNRENDERED: ${entries.length} declaration${entries.length === 1 ? '' : 's'} reached no SQL. ` +
|
|
56
|
+
'This migration is SMALLER than the entities declare.';
|
|
57
|
+
const lines = entries.flatMap((entry) => [
|
|
58
|
+
commentLine(`-- ${entry.kind} on "${entry.table}"."${entry.name}": ${entry.cause}`),
|
|
59
|
+
commentLine(`-- fix: ${entry.fix}`),
|
|
60
|
+
]);
|
|
61
|
+
return [commentLine(header), ...lines, ''].join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A rule the app still declares and this migration TAKES AWAY. An `assert` reaches no SQL by
|
|
66
|
+
* design — `sql: null` says only the app can judge it — so on its own it is not a loss, and
|
|
67
|
+
* reporting every one would put a marker on nearly every app's every migration, which marks none.
|
|
68
|
+
*
|
|
69
|
+
* It becomes a loss the moment a migration RECORDED the rule as a real CHECK, because `checkPlan`
|
|
70
|
+
* drops a recorded check nothing declares: regenerating then deletes the database's half of a rule
|
|
71
|
+
* the entity still states — and it earns no `-- destructive:` marker of its own, because
|
|
72
|
+
* `destructive.ts` excludes `drop constraint` by name on the argument that the database rebuilds
|
|
73
|
+
* it, which here nothing does. Measured on `examples/dummy`: five constraints out of
|
|
74
|
+
* `0001_init.sql` dropped in one run, three of them declared as asserts and reported by this, and
|
|
75
|
+
* `unrendered` was empty — so `@ultimat3/cli`'s `repairFix` handed out
|
|
76
|
+
* `x db gen "drop post_slug_shape"` — the command that performs the loss — as the repair for it.
|
|
77
|
+
*
|
|
78
|
+
* Self-clearing, which is what keeps it off every later file: once the drop is applied and the new
|
|
79
|
+
* sidecar written, nothing records the check and the next generation reports nothing.
|
|
80
|
+
*/
|
|
81
|
+
function unrenderedInvariant(
|
|
82
|
+
entity: EntityDescriptionLike,
|
|
83
|
+
invariant: InvariantDescriptionLike,
|
|
84
|
+
recorded: string,
|
|
85
|
+
): UnrenderedDeclaration {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'invariant',
|
|
88
|
+
table: entity.table,
|
|
89
|
+
// The RECORDED name, never the rule's: it is the string in this migration's own `drop
|
|
90
|
+
// constraint`, in the sidecar, and in the drift finding a caller matches this entry against.
|
|
91
|
+
name: recorded,
|
|
92
|
+
cause:
|
|
93
|
+
`the entity declares "${invariant.name}" as an assert — a rule only the app can judge — ` +
|
|
94
|
+
'and this migration drops the CHECK a migration recorded for it',
|
|
95
|
+
fix:
|
|
96
|
+
`invariant('${invariant.name}', …) # express it in SQL to keep the CHECK — ` +
|
|
97
|
+
'an assert has none, so the next x db gen drops it',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every recorded CHECK on this table that an `assert` still declares and this run would drop. */
|
|
102
|
+
function droppedAsserts(
|
|
103
|
+
entity: EntityDescriptionLike,
|
|
104
|
+
live: TableDescription | undefined,
|
|
105
|
+
): UnrenderedDeclaration[] {
|
|
106
|
+
const recorded = live?.checks ?? [];
|
|
107
|
+
if (recorded.length === 0) return [];
|
|
108
|
+
const entries: UnrenderedDeclaration[] = [];
|
|
109
|
+
for (const invariant of entity.invariants ?? []) {
|
|
110
|
+
// `sql !== null` beside the kind, the pair `hasJsOnlyInvariant` already reads: a description
|
|
111
|
+
// carrying an expression is rendered by `declaredChecks` whatever its kind claims.
|
|
112
|
+
if (invariant.kind !== 'assert' || invariant.sql !== null) continue;
|
|
113
|
+
for (const check of recorded) {
|
|
114
|
+
if (!namesConstraint(entity.table, invariant, check.name)) continue;
|
|
115
|
+
// A recorded check one of this entity's COLUMNS still declares is not being dropped — the two
|
|
116
|
+
// naming conventions collide on `<table>_<column>_check` when an assert is named after a
|
|
117
|
+
// column, and only what this run DECLARES can tell the two apart.
|
|
118
|
+
if (columnNamesConstraint(entity, check.name)) continue;
|
|
119
|
+
entries.push(unrenderedInvariant(entity, invariant, check.name));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return entries;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What the entities declare and this migration does not carry. Two producers, and they are one
|
|
127
|
+
* question — "is this migration smaller than the declaration?" — never two:
|
|
128
|
+
*
|
|
129
|
+
* - a column whose description says `hasDefault` with no expression beside it, which is every
|
|
130
|
+
* non-`now()`, non-`gen_random_uuid()` default until `@ultimat3/entity` projects
|
|
131
|
+
* `ColumnMeta.default`;
|
|
132
|
+
* - an `assert` invariant whose CHECK a previous migration recorded, which this run drops.
|
|
133
|
+
*
|
|
134
|
+
* The defaults half is read off the ENTITIES and not off the plan, deliberately: a diff that
|
|
135
|
+
* emitted nothing for a table because nothing about it moved still has to report a default the
|
|
136
|
+
* create statement never carried, or the loss becomes invisible again on the second run. The
|
|
137
|
+
* invariants half needs `current` for the opposite reason — an assert with nothing recorded behind
|
|
138
|
+
* it is not a loss at all, and the recorded schema is the only thing that can tell the two apart.
|
|
139
|
+
*
|
|
140
|
+
* `current` is REQUIRED and may be `undefined`: a caller with no recorded schema (the first
|
|
141
|
+
* migration) has to say so, because the alternative is an argument nobody passes and a blind
|
|
142
|
+
* answer nobody notices — which is exactly how five drops shipped under an empty list.
|
|
143
|
+
*/
|
|
144
|
+
export function unrenderedOf(
|
|
145
|
+
entities: readonly EntityDescriptionLike[],
|
|
146
|
+
current: SchemaDescription | undefined,
|
|
147
|
+
): UnrenderedDeclaration[] {
|
|
148
|
+
const entries: UnrenderedDeclaration[] = [];
|
|
149
|
+
for (const entity of entities) {
|
|
150
|
+
for (const column of entity.columns) {
|
|
151
|
+
if (!hasUnrenderedDefault(column)) continue;
|
|
152
|
+
entries.push({
|
|
153
|
+
kind: 'default',
|
|
154
|
+
table: entity.table,
|
|
155
|
+
name: column.column,
|
|
156
|
+
cause: 'the entity description carries hasDefault with no expression beside it',
|
|
157
|
+
fix:
|
|
158
|
+
'project ColumnMeta.default onto ColumnDescription in ' +
|
|
159
|
+
'packages/entity/src/describe.ts, then re-run x db gen',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
entries.push(
|
|
163
|
+
...droppedAsserts(
|
|
164
|
+
entity,
|
|
165
|
+
current === undefined ? undefined : findTable(current, entity.table),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return entries;
|
|
170
|
+
}
|