@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.
@@ -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
+ }