@ultimat3/cli 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.
@@ -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); })()`;
@@ -30,6 +30,7 @@ export const CATALOG_PACKAGES = [
30
30
  '@ultimat3/manifest',
31
31
  '@ultimat3/mcp',
32
32
  '@ultimat3/money',
33
+ '@ultimat3/notify',
33
34
  '@ultimat3/policy',
34
35
  '@ultimat3/pwa',
35
36
  '@ultimat3/query',
@@ -49,6 +49,10 @@ export const CLI_OWNED_ERROR_CODES = [
49
49
  // reading — the hole that let `scripts/` hold seven type errors under a green gate.
50
50
  'X_PACKAGE_UNREFERENCED',
51
51
  'X_RELEASE_VERSION_SKEW',
52
+ // The framework's own tables, refused by name rather than by the driver's rejection: a raw
53
+ // `permission denied for schema public` says which statement failed and neither which framework
54
+ // table it was creating nor which package wants it.
55
+ 'X_FRAMEWORK_SCHEMA_FAILED',
52
56
  'X_STORAGE_UNWRITABLE',
53
57
  'X_STORAGE_SECRET_DEV',
54
58
  'X_MANIFEST_STALE',
@@ -88,6 +92,22 @@ export const CLI_OWNED_ERROR_CODES = [
88
92
  'X_GENERATE_CONFLICT',
89
93
  'X_PORT_IN_USE',
90
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',
91
111
  'X_DB_MIGRATE_FAILED',
92
112
  'X_DB_BRANCH_FAILED',
93
113
  'X_DB_STUDIO_FAILED',
@@ -128,6 +148,16 @@ export const CLI_OWNED_ERROR_CODES = [
128
148
  'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
129
149
  'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
130
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',
131
161
  'X_GH_UNAVAILABLE',
132
162
  'X_GH_NOT_AUTHENTICATED',
133
163
  'X_GH_COMMAND_FAILED',
@@ -198,6 +228,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
198
228
  X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
199
229
  X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
200
230
  X_ERROR_CODE_UNRESOLVED: 'an error code is written as a name this repository cannot resolve',
231
+ X_FRAMEWORK_SCHEMA_FAILED: 'a framework table could not be created at boot',
201
232
  X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
202
233
  X_STORAGE_SECRET_DEV: 'upload grants would be signed with the shipped development key',
203
234
  X_CLI_UNEXPECTED: 'the CLI itself failed',
@@ -224,6 +255,9 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
224
255
  X_GENERATE_CONFLICT: 'a generator would overwrite a file',
225
256
  X_PORT_IN_USE: 'the dev port is taken',
226
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',
227
261
  X_DB_MIGRATE_FAILED: 'x db migrate failed',
228
262
  X_DB_BRANCH_FAILED: 'an x db branch step failed',
229
263
  X_DB_STUDIO_FAILED: 'x db studio failed',
@@ -245,6 +279,12 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
245
279
  X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
246
280
  X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
247
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',
248
288
  X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
249
289
  X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
250
290
  X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
@@ -0,0 +1,152 @@
1
+ // The framework's OWN tables, as one table: which package declares each, which relations it
2
+ // creates, and the DDL. One list, read by the one applier, so "installed in dev and not in
3
+ // production" is not a state this framework can be in — `startQueue` is on every boot path there
4
+ // is, `ROLE=migrate` included.
5
+ //
6
+ // A framework table an app has to install by hand is a table that will be missing in production on
7
+ // the one code path that needs it, and it surfaces as a Postgres `42P01` from inside a worker.
8
+
9
+ import { SQL_AUDIT_TABLE, SQL_IDEMPOTENCY_TABLE } from '@ultimat3/action';
10
+ import { AUTH_TABLE_NAMES, AUTH_TABLES, SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
11
+ import { SQL_RATE_LIMIT_TABLE } from '@ultimat3/http';
12
+ import { SQL_JOBS_TABLE } from '@ultimat3/jobs';
13
+ import { SQL_NOTIFY_DELIVERIES_TABLE, SQL_NOTIFY_INBOX_TABLE } from '@ultimat3/notify';
14
+ import { FrameworkSchemaFailedError } from './schema-errors';
15
+
16
+ export interface FrameworkSchema {
17
+ /** The package whose source declares the DDL — where to look when a column is wrong. */
18
+ readonly pkg: string;
19
+ /**
20
+ * Every relation this entry creates. Read by the refusal below, so an operator learns which
21
+ * tables were being installed rather than only which statement failed — and pinned against the
22
+ * DDL text by `framework-schema.test.ts`, so a row cannot claim a table its SQL never creates.
23
+ */
24
+ readonly tables: readonly string[];
25
+ /** One or more statements each; `;` separates. */
26
+ readonly ddl: readonly string[];
27
+ }
28
+
29
+ /**
30
+ * Applied unconditionally, whether or not this boot installs a store behind it.
31
+ *
32
+ * `create table if not exists` on an unused table costs one round trip at boot. The alternative
33
+ * costs a request: a store installed later must never be the thing that discovers the schema was
34
+ * never applied, and several of these are installed AFTER this runs — `defineAuth` builds its
35
+ * limiter when the app's modules import, and `setNotifyStores` is an app's boot line.
36
+ *
37
+ * Ordered so a foreign key never precedes its target. Only `AUTH_TABLES` has any, and they are
38
+ * internal to that entry, which is why it ships as an ordered list rather than one string.
39
+ */
40
+ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
41
+ Object.freeze({
42
+ pkg: '@ultimat3/jobs',
43
+ tables: Object.freeze([
44
+ 'x_jobs',
45
+ 'x_job_steps',
46
+ 'x_backfills',
47
+ 'x_outbox',
48
+ 'x_scheduler_state',
49
+ 'x_scheduler_leader',
50
+ 'x_job_leases',
51
+ 'x_job_events',
52
+ ]),
53
+ ddl: Object.freeze([SQL_JOBS_TABLE]),
54
+ }),
55
+ Object.freeze({
56
+ pkg: '@ultimat3/action',
57
+ tables: Object.freeze(['x_idempotency']),
58
+ ddl: Object.freeze([SQL_IDEMPOTENCY_TABLE]),
59
+ }),
60
+ // The DDL only, and deliberately NO `setAuditSink`: there is no default audit sink on purpose,
61
+ // so `X_AUDIT_SINK_MISSING` keeps firing at boot for an app that declares `audit: true` and
62
+ // installs none.
63
+ Object.freeze({
64
+ pkg: '@ultimat3/action',
65
+ tables: Object.freeze(['x_audit']),
66
+ ddl: Object.freeze([SQL_AUDIT_TABLE]),
67
+ }),
68
+ Object.freeze({
69
+ pkg: '@ultimat3/http',
70
+ tables: Object.freeze(['x_rate_limit']),
71
+ ddl: Object.freeze([SQL_RATE_LIMIT_TABLE]),
72
+ }),
73
+ Object.freeze({
74
+ pkg: '@ultimat3/auth',
75
+ tables: Object.freeze(['x_auth_failures', 'x_auth_lockouts']),
76
+ ddl: Object.freeze([SQL_AUTH_LIMIT_TABLES]),
77
+ }),
78
+ /**
79
+ * The five tables `BuiltinAdapter` reads, and the oldest hole in this list.
80
+ *
81
+ * `packages/auth/src/tables.ts` exports them "so an app can paste them into a migration", and
82
+ * nothing in the framework has ever applied them — while `x db gen` diffs `describeEntities()`
83
+ * and these are not `entity()` declarations, so neither half was a file anybody could
84
+ * hand-write. `examples/dummy/CLAUDE.md` records the consequence in its own words: nobody can
85
+ * hold a session in the reference app. Applied here on exactly the rule the rate-limit and audit
86
+ * rows already follow.
87
+ *
88
+ * `AUTH_TABLE_NAMES` rather than five literals: @ultimat3/auth already publishes the list, and a
89
+ * second copy is a second thing to keep right when a table is added.
90
+ */
91
+ Object.freeze({
92
+ pkg: '@ultimat3/auth',
93
+ tables: AUTH_TABLE_NAMES,
94
+ ddl: AUTH_TABLES,
95
+ }),
96
+ /**
97
+ * The delivery ledger is what stops a replayed notifier job sending twice, and it is the entry
98
+ * whose absence is least visible: without the table the ledger's first `claim` raises `42P01`
99
+ * from inside a worker, which reads as a dead-lettered notification rather than as a missing
100
+ * schema. Installed whether or not this boot calls `setNotifyStores`, for the same reason as
101
+ * every row above it — that call is an APP's boot line and runs after this one.
102
+ */
103
+ Object.freeze({
104
+ pkg: '@ultimat3/notify',
105
+ tables: Object.freeze(['x_notify_deliveries']),
106
+ ddl: Object.freeze([SQL_NOTIFY_DELIVERIES_TABLE]),
107
+ }),
108
+ Object.freeze({
109
+ pkg: '@ultimat3/notify',
110
+ tables: Object.freeze(['x_notify_inbox']),
111
+ ddl: Object.freeze([SQL_NOTIFY_INBOX_TABLE]),
112
+ }),
113
+ ]);
114
+
115
+ /** Every relation this boot creates, flattened. */
116
+ export const frameworkTableNames = (): readonly string[] =>
117
+ FRAMEWORK_SCHEMA.flatMap((entry) => [...entry.tables]);
118
+
119
+ /**
120
+ * PGlite speaks the extended protocol, which carries one statement per round trip, so the DDL is
121
+ * applied statement by statement. Safe to split on `;`: every constant is fixed, with no semicolon
122
+ * inside a literal, and each package's own SQL test is where that stays true.
123
+ */
124
+ export const schemaStatements = (ddl: readonly string[]): readonly string[] =>
125
+ ddl.flatMap((text) => text.split(';')).filter((statement) => statement.trim().length > 0);
126
+
127
+ /** One statement, executed. The caller owns the connection; this file owns no database import. */
128
+ export type SchemaExecutor = (statement: string) => Promise<unknown>;
129
+
130
+ /**
131
+ * Apply every entry, in order, and answer what was created.
132
+ *
133
+ * The refusal is the point of the `pkg`/`tables` columns: a raw `permission denied for schema
134
+ * public` names neither the framework table it was creating nor the package that wants it, and a
135
+ * boot failure is read by an operator who has no source tree open.
136
+ */
137
+ export async function applyFrameworkSchema(execute: SchemaExecutor): Promise<readonly string[]> {
138
+ for (const entry of FRAMEWORK_SCHEMA) {
139
+ for (const statement of schemaStatements(entry.ddl)) {
140
+ try {
141
+ await execute(statement);
142
+ } catch (error) {
143
+ throw new FrameworkSchemaFailedError({
144
+ pkg: entry.pkg,
145
+ tables: entry.tables,
146
+ cause: error,
147
+ });
148
+ }
149
+ }
150
+ }
151
+ return frameworkTableNames();
152
+ }
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,
@@ -196,6 +223,15 @@ export type { FixHelper, FixScan } from './fix-scan';
196
223
  export { scanFixes, scanFixHelpers, scanFixSites } from './fix-scan';
197
224
  export type { DeclaredFlag } from './flag-reads';
198
225
  export { checkFlagReads, declaredFlags, readsFlag } from './flag-reads';
226
+ export type { FrameworkSchema, SchemaExecutor } from './framework-schema';
227
+ // The framework's own tables, as data. Exported so `scripts/` can read the applier's list without
228
+ // re-deriving it — the shape a ratchet over declared-but-never-applied DDL needs.
229
+ export {
230
+ applyFrameworkSchema,
231
+ FRAMEWORK_SCHEMA,
232
+ frameworkTableNames,
233
+ schemaStatements,
234
+ } from './framework-schema';
199
235
  export type { Guard } from './guards';
200
236
  export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
201
237
  // The island bundler, and only its entry point. An island is the one module Ultimate ships to a
@@ -239,6 +275,12 @@ export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from
239
275
  export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
240
276
  export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
241
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';
283
+ export { FrameworkSchemaFailedError } from './schema-errors';
242
284
  export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
243
285
  export {
244
286
  CONTAINER_BINDING,
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',
@@ -96,6 +110,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
96
110
  X_RELEASE_VERSION_SKEW: 'bun run scripts/release.ts --bump patch --dry-run --json',
97
111
  // Two real remedies and the command cannot know which one this deployment wants, so it names
98
112
  // the one that inspects the binding rather than guessing between a volume and a bucket.
113
+ // `x db migrate --json` and not `x doctor`: this fires from inside `startQueue`, so the command
114
+ // that re-runs exactly the failing step is the migrate role, and it reports what it applied.
115
+ X_FRAMEWORK_SCHEMA_FAILED: 'x db migrate --json',
99
116
  X_STORAGE_UNWRITABLE: 'x doctor --json',
100
117
  X_STORAGE_SECRET_DEV: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
101
118
  X_MANIFEST_STALE: 'x manifest --json',
@@ -139,6 +156,18 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
139
156
  // so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
140
157
  // reachability and drift, and is already this table's answer for X_DB_STUDIO_FAILED.
141
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',
142
171
  X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
143
172
  X_DB_BRANCH_FAILED: 'x db branch ls --json',
144
173
  X_DB_STUDIO_FAILED: 'x doctor --json',