@ultimat3/cli 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,146 @@
1
+ // Single responsibility: the drift a hash cannot see — what the app's entities declare NOW against
2
+ // the schema the newest migration wrote down. No database, so the gate runs it in a CI with nothing
3
+ // listening, exactly as the source-hash half does.
4
+ //
5
+ // It exists because the hash half compares a schema-source hash to a `.hash` sidecar and never
6
+ // reads what the migration RECORDED: `dummy/social-media-clone` sat green on `drift` while nine
7
+ // declared CHECK constraints had never reached any database, and a squash produced a migration
8
+ // missing ten invariants and nine defaults with the gate green over it. Nothing that hashes source
9
+ // can see either, because the source did not move.
10
+
11
+ // why: Bun exposes no synchronous "does this path exist" primitive, and this is a probe rather
12
+ // than a read — `Bun.file().exists()` answers about a file where the question is about a directory.
13
+ import { existsSync } from 'node:fs';
14
+ // why: Bun ships no path joiner of its own, and this path is built to be probed, never opened.
15
+ import { join } from 'node:path';
16
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
17
+ import type { EntityDescriptionLike, UnrenderedDeclaration } from '@ultimat3/db';
18
+ import { declaredSchema, snapshotOf, unrenderedOf } from '@ultimat3/db';
19
+ import { describeEntities } from '@ultimat3/entity';
20
+ import { loadApp } from './app-load';
21
+ import { checkSourceDrift, DB_PACKAGE } from './drift';
22
+ import { MIGRATIONS_DIR, readMigrations } from './migrations';
23
+ import type { Finding } from './output';
24
+ import { diffDeclaredSchema, type SchemaDifference } from './schema-diff';
25
+
26
+ /**
27
+ * What the app declares, or `undefined` when the app would not load. Injected so this module's
28
+ * rules are testable against hand-built descriptions with no app on disk, and so the ONE thing a
29
+ * caller must never do — read a short registry as the whole schema — has a single seam.
30
+ */
31
+ export type DeclaredEntities = (
32
+ root: string,
33
+ ) => Promise<readonly EntityDescriptionLike[] | undefined>;
34
+
35
+ /**
36
+ * `undefined`, never a short list. A module that will not import leaves the registry missing the
37
+ * entities it would have registered, and a short registry against a whole snapshot reads as "every
38
+ * one of these tables was dropped" — a DROP per table, handed out as the fix, for a syntax error in
39
+ * one file. `loadApp`'s findings are reported by the steps that own them.
40
+ */
41
+ const appEntities: DeclaredEntities = async (root) => {
42
+ const app = await loadApp(root);
43
+ if (app.findings.length > 0) return undefined;
44
+ return describeEntities();
45
+ };
46
+
47
+ /**
48
+ * `x db gen` is the repair for both directions — and it is the WRONG instruction while the
49
+ * declaration holds something this generator cannot write down, because regenerating drops it
50
+ * silently and the result is green. In that state the unrendered entry's own `fix:` is the
51
+ * instruction: `@ultimat3/db` owns that wording, and a second one here would drift from it.
52
+ */
53
+ function repairFix(
54
+ unrendered: readonly UnrenderedDeclaration[],
55
+ difference: SchemaDifference,
56
+ ): string {
57
+ // The entry ABOUT this difference first, then any entry at all. Reading `unrendered[0]`
58
+ // unconditionally meant that in any app carrying an unrendered DEFAULT, every difference —
59
+ // a dropped CHECK included — was answered with the default's edit, which is an instruction for
60
+ // a different problem in a different file.
61
+ const named = unrendered.find(
62
+ (entry) => entry.table === difference.table && entry.name === difference.name,
63
+ );
64
+ // The entry ABOUT this difference carries the edit that repairs it, so it is the whole answer.
65
+ if (named !== undefined) return named.fix;
66
+ // Otherwise `x db gen` is still unsafe — it would drop what the other entries name — but the
67
+ // reader must NOT be handed a different declaration's edit as the instruction for this one.
68
+ // Reading `unrendered[0]` did exactly that: a column default and an index each got
69
+ // `invariant('org_slug_shape', …)`, an edit in another file about another rule.
70
+ // No `x db gen` in this branch, and that is the point: while ANY declaration reaches no SQL,
71
+ // regenerating drops it, so the command is the wrong instruction for every difference in the
72
+ // app — not only for the one the entry names.
73
+ const blocker = unrendered[0];
74
+ if (blocker !== undefined) {
75
+ return `${blocker.fix} # ${blocker.table}.${blocker.name} reaches no SQL, so regenerating would drop it; repair that before recording ${difference.name}`;
76
+ }
77
+ // "record", not "add": one finding covers a declaration the migrations never carried AND one they
78
+ // carry differently, so `add` would be a wrong migration name for half of them. `undeclared` gets
79
+ // both branches, in the order they are safe — regenerating emits the DROP, and the declaration
80
+ // may have been LOST rather than removed, which is the whole reason this direction is its own
81
+ // finding.
82
+ return difference.direction === 'unmigrated'
83
+ ? `x db gen "record ${difference.name}"`
84
+ : `x db gen "drop ${difference.name}" # or re-declare ${difference.name} on the entity`;
85
+ }
86
+
87
+ /**
88
+ * Two directions, two codes, because they are two repairs. `unmigrated` means the database will
89
+ * never get what the app declares; `undeclared` means the database holds what nothing declares.
90
+ * One "drift" verdict over both teaches a reader neither.
91
+ */
92
+ function findingFor(difference: SchemaDifference, fix: string): Finding {
93
+ const cause = `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
94
+ return difference.direction === 'unmigrated'
95
+ ? { code: 'X_DB_SCHEMA_UNMIGRATED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR }
96
+ : { code: 'X_DB_SCHEMA_UNDECLARED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
97
+ }
98
+
99
+ /**
100
+ * One finding per difference, never one per table: each names one declaration and one edit, and a
101
+ * grouped finding would hand a reader nine constraint names under one instruction.
102
+ *
103
+ * Three conditions are deliberately left to their own reporters, because each already has a code
104
+ * and a remedy: an app with no `packages/db`, an app whose first migration has not been generated
105
+ * (`checkSourceDrift`, `x db gen "initial"`), and a newest migration carrying no sidecar
106
+ * (`X_MIGRATION_SNAPSHOT_MISSING`, whose two-branch fix is `@ultimat3/db`'s).
107
+ */
108
+ export async function checkSnapshotDrift(
109
+ root: string,
110
+ declared: DeclaredEntities = appEntities,
111
+ ): Promise<readonly Finding[]> {
112
+ if (!existsSync(join(root, DB_PACKAGE))) return [];
113
+ const entities = await declared(root);
114
+ if (entities === undefined) return [];
115
+ const migrations = await readMigrations(root);
116
+ if (migrations.length === 0) return [];
117
+ const recorded = declaredSchema(migrations);
118
+ if (recorded === undefined) return [];
119
+ const differences = diffDeclaredSchema(snapshotOf(entities), recorded);
120
+ if (differences.length === 0) return [];
121
+ // `recorded` and not the entities alone: whether an `assert` is a LOSS depends on whether a
122
+ // migration recorded a CHECK for it, which only the sidecar knows.
123
+ const unrendered = unrenderedOf(entities, recorded);
124
+ return differences.map((difference) => findingFor(difference, repairFix(unrendered, difference)));
125
+ }
126
+
127
+ /**
128
+ * The whole no-database drift answer, for the gate step and for `x doctor` — one composition, so
129
+ * the two can never disagree about an app.
130
+ *
131
+ * The source hash STAYS, and it runs second. It catches a class this comparison cannot see at all:
132
+ * a seed, a repo helper or a TS-only invariant moving under `packages/db/src` with no physical
133
+ * schema behind it, which is what `reconcileSchemaHash` exists to re-record. It is suppressed when
134
+ * the snapshot half found something, because both then answer one condition and only one of them
135
+ * is an instruction — `schema hashes to 3f2a, newest migration recorded 91bc` names no constraint,
136
+ * no column and no table.
137
+ */
138
+ export async function checkMigrationDrift(
139
+ root: string,
140
+ declared: DeclaredEntities = appEntities,
141
+ hashDrift: (root: string) => Promise<readonly Finding[]> = checkSourceDrift,
142
+ ): Promise<readonly Finding[]> {
143
+ const snapshot = await checkSnapshotDrift(root, declared);
144
+ if (snapshot.length > 0) return snapshot;
145
+ return hashDrift(root);
146
+ }
@@ -23,8 +23,8 @@ import { policyFindings } from './app-permissions';
23
23
  import { APP_CONFIG_FILE } from './app-root';
24
24
  import { checkBudgets, readBuildStats } from './budgets';
25
25
  import { checkDestructiveMigrations } from './db-destructive';
26
+ import { checkUngeneratableMigrations } from './db-ungeneratable';
26
27
  import { checkDocumentStyles, documentSurfaces } from './document-styles';
27
- import { checkSourceDrift } from './drift';
28
28
  import { checkErrorCodeResolution, checkErrorFixReport } from './error-contract';
29
29
  import { guardFindings } from './guards';
30
30
  import { catalogFindings } from './i18n-registration';
@@ -32,6 +32,7 @@ import { liveRouteFindings } from './live-routes';
32
32
  import { msg } from './messages';
33
33
  import type { Finding } from './output';
34
34
  import { findingFrom } from './output';
35
+ import { checkMigrationDrift } from './schema-drift';
35
36
  import { scanSiteMeta } from './seo-meta';
36
37
  import { floorProblemFindings, readVerifyFloor } from './verify-floor';
37
38
  import type { VerifyStep } from './verify-step';
@@ -147,7 +148,8 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
147
148
  ...TEST_STEPS,
148
149
  {
149
150
  name: 'drift',
150
- summary: 'schema source vs migrations, and every destructive statement declared',
151
+ summary:
152
+ 'entity declarations vs migrations, every destructive statement declared, and every statement no declaration carries',
151
153
  // Only an app owns migrations; a package monorepo's `packages/db` is the driver, not a schema.
152
154
  // Source, not database: the gate runs in CI with nothing listening, and the database half is
153
155
  // the post-migrate verification `runMigrations` performs where a connection is already open.
@@ -158,8 +160,13 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
158
160
  applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
159
161
  run: async (ctx) =>
160
162
  fromFindings([
161
- ...(await checkSourceDrift(ctx.root)),
163
+ ...(await checkMigrationDrift(ctx.root)),
162
164
  ...(await checkDestructiveMigrations(ctx.root)),
165
+ // The third rail, and the one the other two cannot see: a hand-written statement is
166
+ // recorded by no snapshot and hashed by no source, so both halves above are green over SQL
167
+ // a squash silently drops. Same directory, same reader, no database — this step's own
168
+ // question, which is why it is not an eighteenth step.
169
+ ...(await checkUngeneratableMigrations(ctx.root)),
163
170
  ]),
164
171
  },
165
172
  {