@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.
- package/CLAUDE.md +121 -1
- package/package.json +29 -28
- package/src/cmd-db.ts +11 -3
- package/src/cmd-doctor.ts +2 -2
- package/src/db-generate.ts +53 -3
- package/src/db-ungeneratable.ts +108 -0
- package/src/dev-queue.ts +10 -36
- package/src/e2e-dom-fixture.ts +117 -0
- package/src/e2e-driver.ts +78 -0
- package/src/e2e-errors.ts +103 -0
- package/src/e2e-evaluate.ts +156 -0
- package/src/e2e-locator.ts +86 -0
- package/src/e2e-page.ts +124 -0
- package/src/e2e-selection.ts +182 -0
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +40 -0
- package/src/framework-schema.ts +152 -0
- package/src/index.ts +42 -0
- package/src/mcp-errors.ts +29 -0
- package/src/schema-diff.ts +275 -0
- package/src/schema-drift.ts +135 -0
- package/src/schema-errors.ts +28 -0
- package/src/verify-checks.ts +10 -3
|
@@ -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); })()`;
|
package/src/error-catalog.ts
CHANGED
package/src/error-codes.ts
CHANGED
|
@@ -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',
|