@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,182 @@
1
+ // What an e2e locator SELECTS, as a value, plus the one in-page expression that resolves it.
2
+ // `locator`/`getByRole`/`getByText` are lazy handles, so the selection has to survive as data
3
+ // until something asks a question about it — and only then does it become a string the browser
4
+ // can run.
5
+
6
+ /** Where the driver parks its click target. Removed again as soon as the click has been made. */
7
+ export const MARK_ATTRIBUTE = 'data-x-e2e';
8
+
9
+ /** One selection, exactly as the test spelled it. `first` is `.first()`, applied at resolve time. */
10
+ export type E2eSelection =
11
+ | { readonly kind: 'css'; readonly selector: string; readonly first: boolean }
12
+ | {
13
+ readonly kind: 'role';
14
+ readonly role: string;
15
+ readonly name?: string | undefined;
16
+ readonly level?: number | undefined;
17
+ readonly first: boolean;
18
+ }
19
+ | { readonly kind: 'text'; readonly text: string; readonly first: boolean };
20
+
21
+ /**
22
+ * What the page answers for one selection. `count` is how many elements matched AFTER `first`
23
+ * narrowed it, `visible` is about the first match alone, and `marked` says the click target now
24
+ * carries `MARK_ATTRIBUTE` — three facts in one round trip, because a locator that asked twice
25
+ * could be answered about two different renders.
26
+ */
27
+ export interface E2eResolution {
28
+ readonly count: number;
29
+ readonly visible: boolean;
30
+ readonly marked: boolean;
31
+ }
32
+
33
+ /**
34
+ * The elements that carry a role IMPLICITLY, so `getByRole('button')` finds a `<button>` that
35
+ * never wrote the attribute. A `Map` and not an object literal: the key is a role a test typed,
36
+ * and a computed read of a `Record` answers `Object.prototype` members — the defect
37
+ * `bun run proto-index` exists for.
38
+ *
39
+ * Deliberately not the whole of WAI-ARIA. A role absent from this table still resolves through
40
+ * its explicit `[role="…"]` attribute, which is why an unknown role is not refused: refusing
41
+ * `role="feed"` because a table in the framework is short would be the framework deciding an app's
42
+ * markup is wrong.
43
+ */
44
+ const IMPLICIT_ROLE_ELEMENTS = new Map<string, readonly string[]>([
45
+ ['banner', ['header']],
46
+ ['button', ['button', 'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]']],
47
+ ['checkbox', ['input[type="checkbox"]']],
48
+ ['combobox', ['select']],
49
+ ['contentinfo', ['footer']],
50
+ ['dialog', ['dialog']],
51
+ ['form', ['form']],
52
+ ['heading', ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']],
53
+ ['img', ['img']],
54
+ ['link', ['a[href]']],
55
+ ['list', ['ul', 'ol']],
56
+ ['listitem', ['li']],
57
+ ['main', ['main']],
58
+ ['navigation', ['nav']],
59
+ ['option', ['option']],
60
+ ['radio', ['input[type="radio"]']],
61
+ ['table', ['table']],
62
+ ['textbox', ['input[type="text"]', 'input[type="email"]', 'input[type="search"]', 'textarea']],
63
+ ]);
64
+
65
+ /** Elements whose text is markup rather than page copy — `getByText` must never land on one. */
66
+ const TEXT_SKIP_TAGS = ['SCRIPT', 'STYLE', 'HEAD', 'TITLE', 'META', 'LINK', 'NOSCRIPT'];
67
+
68
+ /** A CSS attribute selector takes a double-quoted string, which is what `JSON.stringify` writes. */
69
+ const roleSelector = (role: string): string =>
70
+ [`[role=${JSON.stringify(role)}]`, ...(IMPLICIT_ROLE_ELEMENTS.get(role) ?? [])].join(',');
71
+
72
+ /**
73
+ * The call a test wrote, rebuilt from the selection. Every refusal below quotes it, because
74
+ * "an e2e locator matched nothing" names no line of the test file and this does.
75
+ */
76
+ export function selectionCall(selection: E2eSelection): string {
77
+ const tail = selection.first ? '.first()' : '';
78
+ if (selection.kind === 'css') return `page.locator(${JSON.stringify(selection.selector)})${tail}`;
79
+ if (selection.kind === 'text') return `page.getByText(${JSON.stringify(selection.text)})${tail}`;
80
+ const options = [
81
+ ...(selection.name === undefined ? [] : [`name: ${JSON.stringify(selection.name)}`]),
82
+ ...(selection.level === undefined ? [] : [`level: ${String(selection.level)}`]),
83
+ ];
84
+ const role = JSON.stringify(selection.role);
85
+ const suffix = options.length === 0 ? '' : `, { ${options.join(', ')} }`;
86
+ return `page.getByRole(${role}${suffix})${tail}`;
87
+ }
88
+
89
+ /**
90
+ * The candidate list, as a JS expression evaluating to an array of elements.
91
+ *
92
+ * `getByRole` and `getByText` are not CSS and cannot be: a role is implicit in a tag name, an
93
+ * accessible name comes off four different attributes before it comes off the text, and
94
+ * `getByText` has to pick the INNERMOST element that contains the string. So the union selector
95
+ * is only the cheap first pass and every rule after it runs in JS, in the page.
96
+ */
97
+ function candidateSource(selection: E2eSelection): string {
98
+ if (selection.kind === 'css') return `all(${JSON.stringify(selection.selector)})`;
99
+ if (selection.kind === 'text') {
100
+ const needle = JSON.stringify(selection.text.replace(/\s+/g, ' ').trim().toLowerCase());
101
+ // Innermost FIRST and the skip list second, in that order. Reversed, `<html>` becomes the
102
+ // innermost survivor of a page whose only copy of the string is inside a `<script>` — the
103
+ // ancestor inherits the match its own excluded child made, which is worse than not filtering
104
+ // at all: it reports one match, at the document root, for text no reader can see.
105
+ return `innermost(all('*').filter((el) => norm(el.textContent).toLowerCase().indexOf(${needle}) !== -1)).filter((el) => ${JSON.stringify(TEXT_SKIP_TAGS)}.indexOf(el.tagName) === -1)`;
106
+ }
107
+ const byRole = `all(${JSON.stringify(roleSelector(selection.role))}).filter((el) => { const own = el.getAttribute('role'); return own === null || norm(own).toLowerCase() === ${JSON.stringify(selection.role.toLowerCase())}; })`;
108
+ const byLevel =
109
+ selection.level === undefined
110
+ ? byRole
111
+ : `${byRole}.filter((el) => level(el) === ${String(selection.level)})`;
112
+ return selection.name === undefined
113
+ ? byLevel
114
+ : `${byLevel}.filter((el) => accName(el).toLowerCase() === ${JSON.stringify(selection.name.replace(/\s+/g, ' ').trim().toLowerCase())})`;
115
+ }
116
+
117
+ /**
118
+ * The helpers the candidate source above calls, defined once inside the IIFE.
119
+ *
120
+ * `visible` is `display`/`visibility`/`opacity`, which is character for character what
121
+ * `@ultimat3/scraping`'s `snapshotExpression` computes for `ElementSnapshot.visible`. That is a
122
+ * COPY and it is the wrong shape: `ScrapeTarget.query()` already answers a snapshot per element
123
+ * and `ScrapeFrame` does not expose it, so this driver cannot reach the framework's one definition
124
+ * of visible without an edit to `packages/scraping/src/page.ts`. Mirrored deliberately rather than
125
+ * invented, so the two cannot disagree about a page until that edit lands.
126
+ */
127
+ const HELPERS = `
128
+ const norm = (s) => (s || '').replace(/\\s+/g, ' ').trim();
129
+ const all = (sel) => Array.prototype.slice.call(document.querySelectorAll(sel));
130
+ const innermost = (found) => found.filter((el) => !found.some((other) => other !== el && el.contains(other)));
131
+ const level = (el) => {
132
+ const aria = el.getAttribute('aria-level');
133
+ if (aria !== null) return Number(aria);
134
+ const tag = el.tagName.toLowerCase();
135
+ return /^h[1-6]$/.test(tag) ? Number(tag.slice(1)) : undefined;
136
+ };
137
+ const accName = (el) => {
138
+ const label = el.getAttribute('aria-label');
139
+ if (label !== null && norm(label) !== '') return norm(label);
140
+ const by = el.getAttribute('aria-labelledby');
141
+ if (by !== null) {
142
+ const parts = norm(by).split(' ').map((id) => document.getElementById(id)).filter((n) => n).map((n) => norm(n.textContent));
143
+ if (norm(parts.join(' ')) !== '') return norm(parts.join(' '));
144
+ }
145
+ const tag = el.tagName.toLowerCase();
146
+ if (tag === 'input') { const v = el.getAttribute('value'); if (v !== null && norm(v) !== '') return norm(v); }
147
+ if (tag === 'img') { const a = el.getAttribute('alt'); if (a !== null) return norm(a); }
148
+ const text = norm(el.textContent);
149
+ if (text !== '') return text;
150
+ const title = el.getAttribute('title');
151
+ return title !== null ? norm(title) : '';
152
+ };`;
153
+
154
+ /**
155
+ * Returns JSON TEXT rather than an object, the same bargain `cdp-snapshot.ts` makes: a CDP round
156
+ * trip serialises the answer anyway, and a string has ONE deserialiser — a schema parse — instead
157
+ * of an implicit one inside the browser library plus a cast on this side.
158
+ */
159
+ export function selectionExpression(selection: E2eSelection, mark?: string): string {
160
+ const marker = mark === undefined ? 'null' : JSON.stringify(mark);
161
+ return `(() => {${HELPERS}
162
+ const found = ${candidateSource(selection)};
163
+ const matches = ${selection.first ? 'found.slice(0, 1)' : 'found'};
164
+ const el = matches[0];
165
+ let visible = false;
166
+ if (el) {
167
+ const style = getComputedStyle(el);
168
+ visible = style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
169
+ }
170
+ const mark = ${marker};
171
+ let marked = false;
172
+ if (mark !== null && el) { el.setAttribute(${JSON.stringify(MARK_ATTRIBUTE)}, mark); marked = true; }
173
+ return JSON.stringify({ count: matches.length, visible: visible, marked: marked });
174
+ })()`;
175
+ }
176
+
177
+ /** The CSS the marked element answers to — what `ScrapePage.click` is handed once one is marked. */
178
+ export const markSelector = (mark: string): string => `[${MARK_ATTRIBUTE}=${JSON.stringify(mark)}]`;
179
+
180
+ /** Undo. Best effort by design: a click that navigated took the whole document with it. */
181
+ export const unmarkExpression = (mark: string): string =>
182
+ `(() => { const el = document.querySelector(${JSON.stringify(markSelector(mark))}); if (el) el.removeAttribute(${JSON.stringify(MARK_ATTRIBUTE)}); return JSON.stringify(true); })()`;
@@ -92,6 +92,22 @@ export const CLI_OWNED_ERROR_CODES = [
92
92
  'X_GENERATE_CONFLICT',
93
93
  'X_PORT_IN_USE',
94
94
  'X_DB_GEN_FAILED',
95
+ // The two directions of the drift a hash cannot see, and they are two repairs. The `drift` step
96
+ // compared a schema-source HASH to a `.hash` sidecar and never read what the migration recorded,
97
+ // so nine declared CHECK constraints that had never reached any database were green for as long
98
+ // as nobody edited the schema. `UNMIGRATED` means the entities declare it and no migration
99
+ // carries it — the database will never get it. `UNDECLARED` means a migration recorded it and
100
+ // nothing in source declares it any more. One code over both would hand two readers one wrong
101
+ // edit, which is the reason `X_ERROR_FIX_PATH_MISSING` is not `X_ERROR_FIX_INVALID` either.
102
+ 'X_DB_SCHEMA_UNMIGRATED',
103
+ 'X_DB_SCHEMA_UNDECLARED',
104
+ // The third thing the `drift` step asks, and the one no declaration-based check can answer: SQL
105
+ // in a committed migration that `x db gen` could never have written, which a squash discards in
106
+ // silence because a regenerated sidecar equals the declaration by construction. CLI-owned rather
107
+ // than `@ultimat3/db`'s — that package classifies the statements and deliberately declares no
108
+ // code, because the only remedy available for all of them is a line in the migration file, and
109
+ // where that file lives is this package's fact.
110
+ 'X_MIGRATION_UNGENERATABLE',
95
111
  'X_DB_MIGRATE_FAILED',
96
112
  'X_DB_BRANCH_FAILED',
97
113
  'X_DB_STUDIO_FAILED',
@@ -132,6 +148,16 @@ export const CLI_OWNED_ERROR_CODES = [
132
148
  'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
133
149
  'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
134
150
  'X_SHOT_ISLAND_MISSING',
151
+ // The browser-backed e2e driver — `e2e-driver.ts` and the three modules under it. Owned by the
152
+ // CLI because the ADAPTER is: `@ultimat3/testing` declares `PageLike` and may not import a
153
+ // browser, `@ultimat3/scraping` owns the browser and may not import the harness, and neither
154
+ // package can name a failure that only exists where the two meet.
155
+ 'X_E2E_EVALUATE_UNSUPPORTED',
156
+ 'X_E2E_EVALUATE_CAPTURED',
157
+ 'X_E2E_EVALUATE_THREW',
158
+ 'X_E2E_LOCATOR_EMPTY',
159
+ 'X_E2E_LOCATOR_AMBIGUOUS',
160
+ 'X_E2E_SERVICE_WORKER_ABSENT',
135
161
  'X_GH_UNAVAILABLE',
136
162
  'X_GH_NOT_AUTHENTICATED',
137
163
  'X_GH_COMMAND_FAILED',
@@ -229,6 +255,9 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
229
255
  X_GENERATE_CONFLICT: 'a generator would overwrite a file',
230
256
  X_PORT_IN_USE: 'the dev port is taken',
231
257
  X_DB_GEN_FAILED: 'x db gen failed',
258
+ X_DB_SCHEMA_UNMIGRATED: 'an entity declaration no migration recorded',
259
+ X_DB_SCHEMA_UNDECLARED: 'a migration records schema no entity declares',
260
+ X_MIGRATION_UNGENERATABLE: 'this migration holds SQL no declaration carries and does not say so',
232
261
  X_DB_MIGRATE_FAILED: 'x db migrate failed',
233
262
  X_DB_BRANCH_FAILED: 'an x db branch step failed',
234
263
  X_DB_STUDIO_FAILED: 'x db studio failed',
@@ -250,6 +279,12 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
250
279
  X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
251
280
  X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
252
281
  X_SHOT_ISLAND_MISSING: 'a declared island picture is not on disk',
282
+ X_E2E_EVALUATE_UNSUPPORTED: 'a page.evaluate() closure cannot be sent into the browser',
283
+ X_E2E_EVALUATE_CAPTURED: 'a page.evaluate() closure named a binding the page does not have',
284
+ X_E2E_EVALUATE_THREW: 'an expression an e2e page ran threw inside the browser',
285
+ X_E2E_LOCATOR_EMPTY: 'an e2e locator matched no element',
286
+ X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
287
+ X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
253
288
  X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
254
289
  X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
255
290
  X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
package/src/index.ts CHANGED
@@ -113,6 +113,33 @@ export {
113
113
  schemaHash,
114
114
  writeSchemaHash,
115
115
  } from './drift';
116
+ // The browser-backed e2e driver. `installE2eDriver` is the ONE entry point an app's test preload
117
+ // calls; everything below it is exported because the adapter's own pieces are what a driver author
118
+ // re-uses, and a deep import into `src/` would make each of them a compatibility promise anyway.
119
+ export type { E2eDriverOptions } from './e2e-driver';
120
+ export { e2eFixtures, installE2eDriver } from './e2e-driver';
121
+ export {
122
+ E2eEvaluateCapturedError,
123
+ E2eEvaluateThrewError,
124
+ E2eEvaluateUnsupportedError,
125
+ E2eLocatorAmbiguousError,
126
+ E2eLocatorEmptyError,
127
+ E2eServiceWorkerAbsentError,
128
+ } from './e2e-errors';
129
+ export type { EvaluablePage } from './e2e-evaluate';
130
+ export { closureSource, evaluateClosure, evaluateExpression } from './e2e-evaluate';
131
+ export type { LocatablePage } from './e2e-locator';
132
+ export { e2eLocator, resetLocatorMarks } from './e2e-locator';
133
+ export type { E2eBrowserPage, E2ePageOptions } from './e2e-page';
134
+ export { DEFAULT_E2E_TIMEOUT_MS, DEFAULT_SERVICE_WORKER_TIMEOUT_MS, e2ePage } from './e2e-page';
135
+ export type { E2eResolution, E2eSelection } from './e2e-selection';
136
+ export {
137
+ MARK_ATTRIBUTE,
138
+ markSelector,
139
+ selectionCall,
140
+ selectionExpression,
141
+ unmarkExpression,
142
+ } from './e2e-selection';
116
143
  export type { ErrorCatalog } from './error-catalog';
117
144
  export {
118
145
  buildErrorCatalog,
@@ -248,6 +275,11 @@ export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from
248
275
  export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
249
276
  export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
250
277
  export { COMMANDS, cliVersion, commandFor, SPECS } from './registry';
278
+ export type { SchemaDifference, SchemaDirection, SchemaPart } from './schema-diff';
279
+ export { diffDeclaredSchema } from './schema-diff';
280
+ // The drift a hash cannot see, and the composition both the gate step and `x doctor` read.
281
+ export type { DeclaredEntities } from './schema-drift';
282
+ export { checkMigrationDrift, checkSnapshotDrift } from './schema-drift';
251
283
  export { FrameworkSchemaFailedError } from './schema-errors';
252
284
  export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
253
285
  export {
package/src/mcp-errors.ts CHANGED
@@ -64,6 +64,20 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
64
64
  'x help shot --json # the cause lists every request the state must answer under routes',
65
65
  X_SHOT_ISLAND_MISSING:
66
66
  'x help shot --json # every absent picture carries its own named refusal in the run above',
67
+ // The six e2e-driver codes. Every one of them is raised inside a running suite, so the runnable
68
+ // half is the command that re-runs that suite — the cause already names the closure, the locator
69
+ // call or the budget, and no `x` command can edit a test for its author.
70
+ X_E2E_EVALUATE_UNSUPPORTED:
71
+ 'x test e2e --json # the cause quotes the closure; page.evaluate takes a zero-parameter arrow',
72
+ X_E2E_EVALUATE_CAPTURED:
73
+ 'x test e2e --json # the fix line names the binding to inline into the closure',
74
+ X_E2E_EVALUATE_THREW:
75
+ 'x dev --json # then run the expression the cause quotes in the browser console; the throw is the page\u2019s',
76
+ X_E2E_LOCATOR_EMPTY:
77
+ 'x test e2e --json # the fix line carries the toBeVisible() assertion to await first',
78
+ X_E2E_LOCATOR_AMBIGUOUS:
79
+ 'x test e2e --json # the fix line carries the same call with .first() on it',
80
+ X_E2E_SERVICE_WORKER_ABSENT: 'x build --target static --json',
67
81
  X_GH_UNAVAILABLE: 'gh auth login # install first from https://cli.github.com',
68
82
  X_GH_NOT_AUTHENTICATED: 'gh auth login',
69
83
  X_GH_COMMAND_FAILED: 'x ci --json # the finding carries the gh invocation that failed',
@@ -142,6 +156,18 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
142
156
  // so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
143
157
  // reachability and drift, and is already this table's answer for X_DB_STUDIO_FAILED.
144
158
  X_DB_GEN_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
159
+ // Both directions resolve through the same command, and the CAUSE is what differs: one names a
160
+ // declaration the migrations never carried, the other one they carry and nothing declares. The
161
+ // narrowing is deliberately not a second command — there is only one generator.
162
+ X_DB_SCHEMA_UNMIGRATED:
163
+ 'x db gen "add the declaration the cause names" --json # then x db migrate --json',
164
+ X_DB_SCHEMA_UNDECLARED:
165
+ 'x db gen "drop the declaration the cause names" --json # or re-declare it on the entity',
166
+ // Not `x db gen`: regenerating is exactly what DISCARDS these statements, so the one command
167
+ // that must not be offered here is the one every other db code answers with. The gate is what
168
+ // reproduces the finding, and the finding names the file and the header line to add.
169
+ X_MIGRATION_UNGENERATABLE:
170
+ 'x verify --only drift --json # then add the `-- ungeneratable: <n>` header line the finding names',
145
171
  X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
146
172
  X_DB_BRANCH_FAILED: 'x db branch ls --json',
147
173
  X_DB_STUDIO_FAILED: 'x doctor --json',
@@ -0,0 +1,275 @@
1
+ // Single responsibility: what two GENERATED schema snapshots disagree about, as data. One side is
2
+ // `snapshotOf(describeEntities())` — what the app declares right now — and the other is the
3
+ // `.snapshot.json` the newest migration wrote down.
4
+ //
5
+ // Not `@ultimat3/db`'s `diffSchema`, and it never can be: that one compares a LIVE catalog to a
6
+ // snapshot, so a predicate, a type name and a default are the server's own rewriting and are
7
+ // deliberately left uncompared. Here both sides come out of `snapshotOf`, which is what makes a
8
+ // check, a default and a column type comparable at all — and those are exactly the three the
9
+ // `drift` gate step could not see.
10
+
11
+ import type {
12
+ CheckDescription,
13
+ ColumnDescription,
14
+ ForeignKeyDescription,
15
+ IndexDescription,
16
+ SchemaDescription,
17
+ TableDescription,
18
+ } from '@ultimat3/db';
19
+ import { indexMethodOf } from '@ultimat3/db';
20
+
21
+ /**
22
+ * Which side holds a declaration the other does not, and they are two different repairs.
23
+ * `unmigrated` means the app declares it and no migration carries it, so no database will ever
24
+ * get it. `undeclared` means a migration recorded it and nothing in source declares it any more.
25
+ */
26
+ export type SchemaDirection = 'unmigrated' | 'undeclared';
27
+
28
+ /** The part of a table a difference is about. Rendered into the cause verbatim. */
29
+ export type SchemaPart = 'table' | 'column' | 'index' | 'foreign key' | 'check';
30
+
31
+ export interface SchemaDifference {
32
+ readonly direction: SchemaDirection;
33
+ readonly part: SchemaPart;
34
+ readonly table: string;
35
+ /** The column, index, constraint or table name. */
36
+ readonly name: string;
37
+ /** One line naming what differs — it reaches a `cause:`, so it never spans lines. */
38
+ readonly detail: string;
39
+ }
40
+
41
+ const DECLARED_ONLY = 'is declared by the entities and no migration recorded it';
42
+ const RECORDED_ONLY = 'was recorded by the newest migration and no entity declares it';
43
+
44
+ const columns = (names: readonly string[]): string => names.join(', ');
45
+
46
+ /** A default is SQL or nothing at all, and "nothing" has to read as a value in the sentence. */
47
+ const defaultSql = (value: string | null): string => (value === null ? 'no default' : value);
48
+
49
+ function columnDetail(
50
+ declared: ColumnDescription,
51
+ recorded: ColumnDescription,
52
+ ): string | undefined {
53
+ if (declared.dataType !== recorded.dataType) {
54
+ return `is declared ${declared.dataType} and was recorded ${recorded.dataType}`;
55
+ }
56
+ if (declared.nullable !== recorded.nullable) {
57
+ return declared.nullable
58
+ ? 'is declared nullable and was recorded not null'
59
+ : 'is declared not null and was recorded nullable';
60
+ }
61
+ if (declared.default !== recorded.default) {
62
+ return `is declared ${defaultSql(declared.default)} and was recorded ${defaultSql(recorded.default)}`;
63
+ }
64
+ // Absent and absent agree; absent against an expression is a column the database computes on one
65
+ // side and a writer supplies on the other, which is a `23502` on the first insert.
66
+ if ((declared.generated ?? null) !== (recorded.generated ?? null)) {
67
+ return `is declared generated as ${declared.generated ?? 'nothing'} and was recorded ${
68
+ recorded.generated ?? 'not generated'
69
+ }`;
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ /**
75
+ * Absent `using` is `btree` and absent `order` is `asc` — both are Postgres' own defaults and what
76
+ * every index recorded before those fields existed is. Read literally, every app with a sidecar
77
+ * from before them would report a difference on every index it has.
78
+ */
79
+ function indexDetail(declared: IndexDescription, recorded: IndexDescription): string | undefined {
80
+ if (columns(declared.columns) !== columns(recorded.columns)) {
81
+ return `is declared over (${columns(declared.columns)}) and was recorded over (${columns(recorded.columns)})`;
82
+ }
83
+ if (declared.unique !== recorded.unique) {
84
+ return declared.unique
85
+ ? 'is declared unique and was recorded non-unique'
86
+ : 'is declared non-unique and was recorded unique';
87
+ }
88
+ if (indexMethodOf(declared) !== indexMethodOf(recorded)) {
89
+ return `is declared ${indexMethodOf(declared)} and was recorded ${indexMethodOf(recorded)}`;
90
+ }
91
+ if ((declared.order ?? 'asc') !== (recorded.order ?? 'asc')) {
92
+ return `is declared ${declared.order ?? 'asc'} and was recorded ${recorded.order ?? 'asc'}`;
93
+ }
94
+ if (declared.where !== recorded.where) {
95
+ return `is declared ${declared.where === null ? 'over every row' : `where ${declared.where}`} and was recorded ${
96
+ recorded.where === null ? 'over every row' : `where ${recorded.where}`
97
+ }`;
98
+ }
99
+ return undefined;
100
+ }
101
+
102
+ function keyDetail(
103
+ declared: ForeignKeyDescription,
104
+ recorded: ForeignKeyDescription,
105
+ ): string | undefined {
106
+ const target = (key: ForeignKeyDescription): string =>
107
+ `(${columns(key.columns)}) -> "${key.referencedTable}" (${columns(key.referencedColumns)})`;
108
+ if (target(declared) !== target(recorded)) {
109
+ return `is declared ${target(declared)} and was recorded ${target(recorded)}`;
110
+ }
111
+ // `null` is "no rule declared", which Postgres records as `no action` — a real answer, and the
112
+ // difference between an orphan row and a cascading delete.
113
+ if (declared.onDelete !== recorded.onDelete) {
114
+ return `is declared on delete ${declared.onDelete ?? 'unset'} and was recorded on delete ${
115
+ recorded.onDelete ?? 'unset'
116
+ }`;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ const checkDetail = (declared: CheckDescription, recorded: CheckDescription): string | undefined =>
122
+ declared.expression === recorded.expression
123
+ ? undefined
124
+ : `is declared "${declared.expression}" and was recorded "${recorded.expression}"`;
125
+
126
+ interface ListComparison<T> {
127
+ readonly part: SchemaPart;
128
+ readonly table: string;
129
+ readonly declared: readonly T[];
130
+ readonly recorded: readonly T[];
131
+ readonly key: (item: T) => string;
132
+ /** What differs between two items of the same name, or `undefined` when they agree. */
133
+ readonly detail: (declared: T, recorded: T) => string | undefined;
134
+ }
135
+
136
+ /**
137
+ * Both directions of one named list, in one pass. A `Map` and not an object literal: a constraint
138
+ * name is data, and a computed read of a `Record<…>` answers an `Object.prototype` member for a
139
+ * table someone called `constructor`.
140
+ *
141
+ * A CHANGED item is reported as `unmigrated`, never as its own direction: the declaration is the
142
+ * intent and a migration that recorded something else is the one that is behind.
143
+ */
144
+ function compareList<T>(comparison: ListComparison<T>): SchemaDifference[] {
145
+ const { part, table } = comparison;
146
+ const out: SchemaDifference[] = [];
147
+ const recorded = new Map(comparison.recorded.map((item) => [comparison.key(item), item]));
148
+ for (const item of comparison.declared) {
149
+ const name = comparison.key(item);
150
+ const counterpart = recorded.get(name);
151
+ if (counterpart === undefined) {
152
+ out.push({ direction: 'unmigrated', part, table, name, detail: DECLARED_ONLY });
153
+ continue;
154
+ }
155
+ const detail = comparison.detail(item, counterpart);
156
+ if (detail !== undefined) out.push({ direction: 'unmigrated', part, table, name, detail });
157
+ }
158
+ const declared = new Set(comparison.declared.map(comparison.key));
159
+ for (const item of comparison.recorded) {
160
+ const name = comparison.key(item);
161
+ if (declared.has(name)) continue;
162
+ out.push({ direction: 'undeclared', part, table, name, detail: RECORDED_ONLY });
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * `checks` is absent — never `[]` — on a table that declares none, deliberately, so a sidecar
169
+ * written before the field existed reads as "nothing recorded" rather than "recorded none". Both
170
+ * sides normalise to empty here for the same reason: a table that never had a constraint must not
171
+ * report one, and a table that has nine of them must.
172
+ */
173
+ function compareTable(declared: TableDescription, recorded: TableDescription): SchemaDifference[] {
174
+ const table = declared.name;
175
+ const out: SchemaDifference[] = [];
176
+ // Read into locals first, and NOT named `…Key`: `bun run secret-compare` matches an operand
177
+ // whose NAME says it holds a credential, and a row's identity is the opposite of a secret — it
178
+ // is in every URL the app serves.
179
+ const declaredIdentity = columns(declared.primaryKey);
180
+ const recordedIdentity = columns(recorded.primaryKey);
181
+ if (declaredIdentity !== recordedIdentity) {
182
+ out.push({
183
+ direction: 'unmigrated',
184
+ part: 'table',
185
+ table,
186
+ name: table,
187
+ detail: `declares primary key (${columns(declared.primaryKey)}) and the migration recorded (${columns(recorded.primaryKey)})`,
188
+ });
189
+ }
190
+ out.push(
191
+ ...compareList({
192
+ part: 'column',
193
+ table,
194
+ declared: declared.columns,
195
+ recorded: recorded.columns,
196
+ key: (column) => column.name,
197
+ detail: columnDetail,
198
+ }),
199
+ ...compareList({
200
+ part: 'index',
201
+ table,
202
+ declared: declared.indexes,
203
+ recorded: recorded.indexes,
204
+ key: (index) => index.name,
205
+ detail: indexDetail,
206
+ }),
207
+ ...compareList({
208
+ part: 'foreign key',
209
+ table,
210
+ declared: declared.foreignKeys,
211
+ recorded: recorded.foreignKeys,
212
+ key: (key) => key.name,
213
+ detail: keyDetail,
214
+ }),
215
+ ...compareList({
216
+ part: 'check',
217
+ table,
218
+ declared: declared.checks ?? [],
219
+ recorded: recorded.checks ?? [],
220
+ key: (check) => check.name,
221
+ detail: checkDetail,
222
+ }),
223
+ );
224
+ return out;
225
+ }
226
+
227
+ const PART_ORDER: readonly SchemaPart[] = ['table', 'column', 'index', 'foreign key', 'check'];
228
+
229
+ /**
230
+ * Pure and total: the same two snapshots always produce the same ordered report, so a diff of two
231
+ * gate runs on two machines is readable.
232
+ *
233
+ * A table present on only one side is ONE difference and the columns under it are not descended
234
+ * into — the repair is a single statement, and a finding per column would be twelve instructions
235
+ * for it.
236
+ */
237
+ export function diffDeclaredSchema(
238
+ declared: SchemaDescription,
239
+ recorded: SchemaDescription,
240
+ ): readonly SchemaDifference[] {
241
+ const out: SchemaDifference[] = [];
242
+ const byName = new Map(recorded.tables.map((table) => [table.name, table]));
243
+ for (const table of declared.tables) {
244
+ const counterpart = byName.get(table.name);
245
+ if (counterpart === undefined) {
246
+ out.push({
247
+ direction: 'unmigrated',
248
+ part: 'table',
249
+ table: table.name,
250
+ name: table.name,
251
+ detail: DECLARED_ONLY,
252
+ });
253
+ continue;
254
+ }
255
+ out.push(...compareTable(table, counterpart));
256
+ }
257
+ const declaredTables = new Set(declared.tables.map((table) => table.name));
258
+ for (const table of recorded.tables) {
259
+ if (declaredTables.has(table.name)) continue;
260
+ out.push({
261
+ direction: 'undeclared',
262
+ part: 'table',
263
+ table: table.name,
264
+ name: table.name,
265
+ detail: RECORDED_ONLY,
266
+ });
267
+ }
268
+ out.sort((a, b) => {
269
+ if (a.table !== b.table) return a.table < b.table ? -1 : 1;
270
+ if (a.part !== b.part) return PART_ORDER.indexOf(a.part) - PART_ORDER.indexOf(b.part);
271
+ if (a.direction !== b.direction) return a.direction < b.direction ? -1 : 1;
272
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
273
+ });
274
+ return out;
275
+ }