@ultimat3/cli 17.0.0 → 19.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 +194 -17
- package/package.json +30 -30
- package/src/app-root.ts +22 -1
- package/src/cdp-browser.ts +100 -0
- package/src/cdp-connection.ts +211 -0
- package/src/cdp-e2e-page.ts +209 -0
- package/src/cdp-errors.ts +56 -0
- package/src/cdp-launch.ts +130 -0
- package/src/cmd-dev.ts +28 -2
- package/src/cmd-doctor.ts +1 -1
- package/src/cmd-test.ts +20 -9
- package/src/compile-externals.ts +11 -4
- package/src/db-accept-created.ts +207 -0
- package/src/db-generate.ts +18 -1
- package/src/db-subscribes.ts +81 -0
- package/src/db-ungeneratable.ts +14 -2
- package/src/dev-assets.ts +19 -54
- package/src/dev-notify-retention.ts +69 -0
- package/src/dev-purge.ts +47 -2
- package/src/dev-render.ts +20 -4
- package/src/dev-replicator.ts +19 -1
- package/src/dev-runtime.ts +12 -3
- package/src/dev-services.ts +8 -0
- package/src/e2e-driver.ts +35 -17
- package/src/e2e-page.ts +15 -3
- package/src/error-codes.ts +20 -0
- package/src/icon-assets.ts +74 -0
- package/src/index.ts +46 -4
- package/src/island-harness-script.ts +8 -1
- package/src/island-shot.ts +37 -4
- package/src/island-verdict.ts +16 -4
- package/src/mcp-errors.ts +15 -0
- package/src/messages.ts +5 -1
- package/src/prerender.ts +41 -1
- package/src/pwa-artifacts.ts +230 -0
- package/src/serve.ts +24 -1
- package/src/static-report.ts +46 -3
- package/src/sw-artifacts.ts +162 -0
- package/src/sw-routes.ts +53 -0
- package/src/templates/naming.ts +11 -0
- package/src/templates/scaffold-app.ts +58 -7
- package/src/templates/scaffold-repo.ts +17 -1
- package/src/test-shards.ts +150 -116
- package/src/ts-scan.ts +6 -1
- package/src/verify-test-run.ts +31 -46
package/src/cmd-doctor.ts
CHANGED
|
@@ -10,8 +10,8 @@ import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/stor
|
|
|
10
10
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
11
11
|
import type { CliCommand, CommandContext } from './command';
|
|
12
12
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
13
|
-
import { ICON_SOURCE } from './dev-assets';
|
|
14
13
|
import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
|
|
14
|
+
import { ICON_SOURCE } from './icon-assets';
|
|
15
15
|
import { msg } from './messages';
|
|
16
16
|
import type { CommandResult, Finding } from './output';
|
|
17
17
|
import type { ParsedArgs } from './parse';
|
package/src/cmd-test.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { discoverTests, missingSelection, readSample, readType, sampleFiles } fr
|
|
|
20
20
|
import { runShards } from './test-shards';
|
|
21
21
|
import { defaultWorkers, WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
|
|
22
22
|
import type { TestType } from './verify-tests';
|
|
23
|
-
import { TEST_TYPES } from './verify-tests';
|
|
23
|
+
import { SERIAL_TYPES, TEST_TYPES } from './verify-tests';
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* `--workers` and `--shard`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while
|
|
@@ -33,10 +33,10 @@ const readIndex = (args: ParsedArgs, name: string, min: number): number | undefi
|
|
|
33
33
|
command: 'test',
|
|
34
34
|
min,
|
|
35
35
|
// The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
|
|
36
|
-
// accepted
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
36
|
+
// accepted and the run clamps only to the file count, which is one Bun worker per test FILE,
|
|
37
|
+
// each with the framework module graph and a cloned database. `--worker` is an index into an
|
|
38
|
+
// N-way split, so the same bound holds it (the exact upper index is `workers - 1`, refused a
|
|
39
|
+
// line below by the check that knows the real width).
|
|
40
40
|
max: WORKER_CEILING,
|
|
41
41
|
example: `x test --${name} ${Math.max(min, 1)}`,
|
|
42
42
|
});
|
|
@@ -103,19 +103,20 @@ export const testCommand: CliCommand = {
|
|
|
103
103
|
spec: {
|
|
104
104
|
name: 'test',
|
|
105
105
|
summary:
|
|
106
|
-
'run one test type — or the whole suite — across N
|
|
106
|
+
'run one test type — or the whole suite — across N workers, one isolated database per worker',
|
|
107
107
|
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json]`,
|
|
108
108
|
positionalChoices: TEST_TYPES,
|
|
109
109
|
flags: [
|
|
110
110
|
{
|
|
111
111
|
name: 'workers',
|
|
112
112
|
type: 'string',
|
|
113
|
-
summary: `
|
|
113
|
+
summary: `bun worker count (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING}); clamped to the file count, and to 1 for ${SERIAL_TYPES.join(' and ')}`,
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
name: 'worker',
|
|
117
117
|
type: 'string',
|
|
118
|
-
summary:
|
|
118
|
+
summary:
|
|
119
|
+
'run only shard I of an N-way split of the selection, serially — one CI job\u2019s share',
|
|
119
120
|
},
|
|
120
121
|
{ name: 'filter', type: 'string', summary: 'only files whose path contains this substring' },
|
|
121
122
|
{
|
|
@@ -172,7 +173,17 @@ export const testCommand: CliCommand = {
|
|
|
172
173
|
}
|
|
173
174
|
const files = sample === undefined ? selected : sampleFiles(selected, sample);
|
|
174
175
|
const requested = readIndex(ctx.args, 'workers', 1) ?? defaultWorkers();
|
|
175
|
-
|
|
176
|
+
// A serial type is serial HERE TOO, `As of 2026-08-27`. `verify-tests.ts` routes `live` and
|
|
177
|
+
// `e2e` through `runSerial` and this command never read the same list, so `x verify` ran one
|
|
178
|
+
// process over the very files `x test live --workers 8` ran eight over — two answers to one
|
|
179
|
+
// question, which is axiom 1, and the dangerous one is the command a human types while
|
|
180
|
+
// debugging. What makes them serial is not a preference: a logical replication slot is named
|
|
181
|
+
// at the Postgres CLUSTER level, so a per-worker database does not isolate it and two workers
|
|
182
|
+
// race `pg_create_logical_replication_slot`; `e2e` shares one built `dist/` and one browser
|
|
183
|
+
// profile. Neither is visible without a real `TEST_DATABASE_URL`, which is why the split
|
|
184
|
+
// measured green for as long as it did.
|
|
185
|
+
const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
|
|
186
|
+
const workers = Math.max(1, Math.min(requested, ceiling));
|
|
176
187
|
const only = readIndex(ctx.args, 'worker', 0);
|
|
177
188
|
if (only !== undefined && only >= workers) {
|
|
178
189
|
throw new BadFlagError({
|
package/src/compile-externals.ts
CHANGED
|
@@ -11,10 +11,17 @@
|
|
|
11
11
|
* anyway, and that is the whole failure: Bun 1.3 refuses the build with
|
|
12
12
|
* `Could not resolve: "@babel/preset-typescript/package.json"`, while Bun 1.4 bundles the
|
|
13
13
|
* unresolvable `require` as a runtime throw — so one tree compiled on a laptop and did not in CI.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
*
|
|
15
|
+
* **This list is what closed that, and the version pin never was.** The paragraph here said the
|
|
16
|
+
* skew was closed `As of 2026-08-20` by CI moving to `1.4.x`, "so every builder now takes the
|
|
17
|
+
* second branch", and added that the external stays regardless. Read together those are two fixes
|
|
18
|
+
* for one bug, and only the second is a fix: pinning the whole repository to the Bun that TOLERATES
|
|
19
|
+
* an unresolvable `require` leaves the `--compile` graph still reaching one, so the next Bun that
|
|
20
|
+
* tightens resolution breaks the build again. Marking the specifier external means the graph never
|
|
21
|
+
* reaches it, on either Bun — measured on 2026-08-27, when the 1.4 pin was trialled in reverse:
|
|
22
|
+
* `docker build -f docker/Dockerfile` is green on `oven/bun:1.3-slim` and the image answers
|
|
23
|
+
* `--version`. So this file does not depend on the series above it, and a future move of that pin
|
|
24
|
+
* costs it nothing.
|
|
18
25
|
*
|
|
19
26
|
* Marking the dead specifier external rather than the two live ones: `serve.ts` calls
|
|
20
27
|
* `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Single responsibility: accept a table an applied migration's own SQL created. A snapshot records
|
|
2
|
+
// only what ENTITIES declare, so a hand-written `create table` is `unexpected-table` on every
|
|
3
|
+
// deploy forever (issue #345) — and the migration list `x db migrate` has just applied is the one
|
|
4
|
+
// piece of evidence that the app owns the relation. Named in `@ultimat3/db`'s `unexpectedTable`.
|
|
5
|
+
|
|
6
|
+
import type { DriftReport, Migration } from '@ultimat3/db';
|
|
7
|
+
import { statementsOf } from '@ultimat3/db';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `create table`, `create unlogged table`, either with `if not exists`. Anchored, so only the verb
|
|
11
|
+
* phrase a statement OPENS with counts.
|
|
12
|
+
*
|
|
13
|
+
* `temp`/`temporary` is deliberately absent: a temporary table lives in `pg_temp` and can never be
|
|
14
|
+
* the relation `checkDrift` introspected, so reading one as evidence would accept a `drafts`
|
|
15
|
+
* somebody created by hand on the strength of SQL that never touched it.
|
|
16
|
+
*/
|
|
17
|
+
const CREATE_TABLE = /^create\s+(?:unlogged\s+)?table\s+(?:if\s+not\s+exists\s+)?/i;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `drop table`, with `if exists`. A migration that creates a relation and later drops it OWNS
|
|
21
|
+
* neither: re-creating `legacy_audit` by hand afterwards is drift, and a set that only ever grew
|
|
22
|
+
* accepted it forever. `cascade`/`restrict` and a comma list are handled by the caller.
|
|
23
|
+
*/
|
|
24
|
+
const DROP_TABLE = /^drop\s+table\s+(?:if\s+exists\s+)?/i;
|
|
25
|
+
|
|
26
|
+
/** `alter table … rename to …` — the old name stops existing and the new one starts. */
|
|
27
|
+
const ALTER_TABLE = /^alter\s+table\s+(?:if\s+exists\s+)?(?:only\s+)?/i;
|
|
28
|
+
const RENAME_TO = /^\s*rename\s+to\s+/i;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One identifier: quoted (with `""` for a literal quote) or bare. The bare form is Postgres' own
|
|
32
|
+
* charset — a letter or `_` to open, then letters, digits, `_` and `$` — with everything above
|
|
33
|
+
* ASCII admitted, since the server accepts any multibyte letter and a name it accepts must be
|
|
34
|
+
* readable here or the relation behind it is drift nobody can clear.
|
|
35
|
+
*/
|
|
36
|
+
const NAME = /^(?:("(?:[^"]|"")+")|([A-Za-z_\u0080-\uFFFF][A-Za-z0-9_$\u0080-\uFFFF]*))/;
|
|
37
|
+
|
|
38
|
+
/** A qualifier's dot, with the whitespace Postgres allows on either side of it. */
|
|
39
|
+
const DOT = /^\s*\.\s*/;
|
|
40
|
+
|
|
41
|
+
/** The schema `checkDrift` compares — `introspect()`'s default, and what `runMigrations` asks for. */
|
|
42
|
+
const COMPARED_SCHEMA = 'public';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Postgres folds an unquoted identifier to lower case and stores a quoted one verbatim, so
|
|
46
|
+
* `CREATE TABLE LegacyAudit` and `create table legacyaudit` are the same relation and
|
|
47
|
+
* `"LegacyAudit"` is a different one. The catalog name is what a drift difference carries, so the
|
|
48
|
+
* fold has to happen here or the comparison misses on every unquoted name that was not typed flat.
|
|
49
|
+
*/
|
|
50
|
+
function unquote(match: RegExpExecArray): string {
|
|
51
|
+
const quoted = match[1];
|
|
52
|
+
if (quoted === undefined) return (match[2] ?? '').toLowerCase();
|
|
53
|
+
return quoted.slice(1, -1).replaceAll('""', '"');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The relation one statement creates, or `null`.
|
|
58
|
+
*
|
|
59
|
+
* **The anchor is the whole protection**, and it is read off the raw text. A statement can only
|
|
60
|
+
* open with `create table` by BEING one: `values ('create table ghost')` opens with `insert`, and
|
|
61
|
+
* a chunk that is nothing but a comment is not a statement at all (`statementsOf` drops it). A
|
|
62
|
+
* `stripSqlNoise` pass was written here first and then deleted — it could not change one answer,
|
|
63
|
+
* because position 0 is the one position no literal, comment or dollar body can cover, and a
|
|
64
|
+
* defence that cannot fail is a defence nobody can test.
|
|
65
|
+
*
|
|
66
|
+
* Anything the anchor admits but the name grammar does not — a comment between the keywords,
|
|
67
|
+
* `create table (` — contributes nothing. Fail-closed: drift that could have been accepted is
|
|
68
|
+
* reported, never the reverse.
|
|
69
|
+
*/
|
|
70
|
+
function createdBy(statement: string): string | null {
|
|
71
|
+
const text = statement.trimStart();
|
|
72
|
+
const head = CREATE_TABLE.exec(text);
|
|
73
|
+
if (head === null) return null;
|
|
74
|
+
const rest = text.slice(head[0].length);
|
|
75
|
+
const first = NAME.exec(rest);
|
|
76
|
+
if (first === null) return null;
|
|
77
|
+
|
|
78
|
+
const tail = rest.slice(first[0].length);
|
|
79
|
+
const dot = DOT.exec(tail);
|
|
80
|
+
if (dot === null) return unquote(first);
|
|
81
|
+
const second = NAME.exec(tail.slice(dot[0].length));
|
|
82
|
+
if (second === null) return null;
|
|
83
|
+
// A qualifier naming another schema is evidence about a relation this report never mentions:
|
|
84
|
+
// `checkDrift` introspects one schema, and `audit.drafts` is not the `drafts` it compared.
|
|
85
|
+
return unquote(first) === COMPARED_SCHEMA ? unquote(second) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every relation a migration script creates, in statement order.
|
|
90
|
+
*
|
|
91
|
+
* `statementsOf` is `@ultimat3/db`'s own splitter — the one `x db migrate` sends by — so a `;`
|
|
92
|
+
* inside a literal, an identifier or a dollar-quoted body is not a statement boundary here either.
|
|
93
|
+
*/
|
|
94
|
+
export function createdTables(up: string): readonly string[] {
|
|
95
|
+
const owned = new Set<string>();
|
|
96
|
+
applyOwnership(up, owned);
|
|
97
|
+
return [...owned];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fold one migration's statements over the owned set, IN ORDER.
|
|
102
|
+
*
|
|
103
|
+
* A set that only ever grew was the defect: `create table legacy_audit` followed by
|
|
104
|
+
* `drop table legacy_audit` left the name accepted, so a `legacy_audit` somebody re-created by
|
|
105
|
+
* hand afterwards lost its `unexpected-table` finding — real drift, silenced, which is the one
|
|
106
|
+
* thing this module may not do.
|
|
107
|
+
*
|
|
108
|
+
* Fail-closed on anything the grammar cannot read: a `drop`/`rename` this cannot parse REMOVES
|
|
109
|
+
* nothing it is unsure about only when it could not name a relation at all; when it can name one,
|
|
110
|
+
* dropping it from the set is always the safe direction, because the cost of being wrong is a
|
|
111
|
+
* difference reported that could have been accepted.
|
|
112
|
+
*/
|
|
113
|
+
function applyOwnership(up: string, owned: Set<string>): void {
|
|
114
|
+
for (const statement of statementsOf(up)) {
|
|
115
|
+
const created = createdBy(statement);
|
|
116
|
+
if (created !== null) {
|
|
117
|
+
owned.add(created);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const dropped of droppedBy(statement)) owned.delete(dropped);
|
|
121
|
+
const renamed = renamedBy(statement);
|
|
122
|
+
if (renamed === null) continue;
|
|
123
|
+
// Only inherit ownership when the OLD name was owned: renaming a hand-made table into a name
|
|
124
|
+
// a migration once created must not launder it into an accepted one.
|
|
125
|
+
owned.delete(renamed.from);
|
|
126
|
+
if (renamed.owned) owned.add(renamed.to);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Every relation one `drop table` names — the form takes a comma list. */
|
|
131
|
+
function droppedBy(statement: string): readonly string[] {
|
|
132
|
+
const text = statement.trimStart();
|
|
133
|
+
const head = DROP_TABLE.exec(text);
|
|
134
|
+
if (head === null) return [];
|
|
135
|
+
const dropped: string[] = [];
|
|
136
|
+
let rest = text.slice(head[0].length);
|
|
137
|
+
for (;;) {
|
|
138
|
+
const name = qualifiedName(rest);
|
|
139
|
+
if (name === null) break;
|
|
140
|
+
if (name.table !== null) dropped.push(name.table);
|
|
141
|
+
if (!/^\s*,/.test(name.rest)) break;
|
|
142
|
+
rest = name.rest.replace(/^\s*,\s*/, '');
|
|
143
|
+
}
|
|
144
|
+
return dropped;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `alter table <old> rename to <new>`, or `null`. `owned` is filled in by the caller's set. */
|
|
148
|
+
function renamedBy(statement: string): { from: string; to: string; owned: boolean } | null {
|
|
149
|
+
const text = statement.trimStart();
|
|
150
|
+
const head = ALTER_TABLE.exec(text);
|
|
151
|
+
if (head === null) return null;
|
|
152
|
+
const source = qualifiedName(text.slice(head[0].length));
|
|
153
|
+
if (source === null || source.table === null) return null;
|
|
154
|
+
const verb = RENAME_TO.exec(source.rest);
|
|
155
|
+
if (verb === null) return null;
|
|
156
|
+
const target = qualifiedName(source.rest.slice(verb[0].length));
|
|
157
|
+
if (target === null || target.table === null) return null;
|
|
158
|
+
return { from: source.table, to: target.table, owned: true };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One optionally schema-qualified relation name and what follows it. `table` is `null` when the
|
|
163
|
+
* qualifier names a schema `checkDrift` never introspected — the same rule `createdBy` applies.
|
|
164
|
+
*/
|
|
165
|
+
function qualifiedName(input: string): { table: string | null; rest: string } | null {
|
|
166
|
+
const first = NAME.exec(input);
|
|
167
|
+
if (first === null) return null;
|
|
168
|
+
const tail = input.slice(first[0].length);
|
|
169
|
+
const dot = DOT.exec(tail);
|
|
170
|
+
if (dot === null) return { table: unquote(first), rest: tail };
|
|
171
|
+
const second = NAME.exec(tail.slice(dot[0].length));
|
|
172
|
+
if (second === null) return null;
|
|
173
|
+
const rest = tail.slice(dot[0].length + second[0].length);
|
|
174
|
+
return { table: unquote(first) === COMPARED_SCHEMA ? unquote(second) : null, rest };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The drift report minus the tables these migrations demonstrably create.
|
|
179
|
+
*
|
|
180
|
+
* **Only `unexpected-table`, and only for a name a migration's SQL creates.** That is what makes
|
|
181
|
+
* this an acceptance and not the check switched off: a table absent from the snapshot produces
|
|
182
|
+
* exactly ONE difference (`diffSchema` reports it and never compares its columns), so nothing else
|
|
183
|
+
* about the relation was being said, and every difference about a table that IS declared — a
|
|
184
|
+
* missing column, a changed index, a dropped constraint — passes through untouched.
|
|
185
|
+
*
|
|
186
|
+
* The migrations are the list `x db migrate` applied immediately before the check, which is why
|
|
187
|
+
* "created" is provable rather than assumed: `runMigrations` applies every pending file and then
|
|
188
|
+
* asks, so a `create table` still on disk and unapplied is not one this path can be handed.
|
|
189
|
+
*
|
|
190
|
+
* Identity in, identity out when nothing matched — a report this has nothing to say about is not
|
|
191
|
+
* its to rebuild.
|
|
192
|
+
*/
|
|
193
|
+
export function acceptCreatedTables(
|
|
194
|
+
report: DriftReport,
|
|
195
|
+
migrations: readonly Migration[],
|
|
196
|
+
): DriftReport {
|
|
197
|
+
// In migration ORDER, across the whole list: ownership is a running state, not a union. A
|
|
198
|
+
// relation created by 0003 and dropped by 0007 is owned by neither.
|
|
199
|
+
const created = new Set<string>();
|
|
200
|
+
for (const migration of migrations) applyOwnership(migration.up, created);
|
|
201
|
+
if (created.size === 0) return report;
|
|
202
|
+
const differences = report.differences.filter(
|
|
203
|
+
(difference) => !(difference.kind === 'unexpected-table' && created.has(difference.table)),
|
|
204
|
+
);
|
|
205
|
+
if (differences.length === report.differences.length) return report;
|
|
206
|
+
return { ok: differences.length === 0, differences };
|
|
207
|
+
}
|
package/src/db-generate.ts
CHANGED
|
@@ -13,7 +13,9 @@ import {
|
|
|
13
13
|
snapshotJson,
|
|
14
14
|
} from '@ultimat3/db';
|
|
15
15
|
import { describeEntities } from '@ultimat3/entity';
|
|
16
|
+
import { describeQueries } from '@ultimat3/query';
|
|
16
17
|
import { loadApp } from './app-load';
|
|
18
|
+
import { replicaIdentityTables } from './db-subscribes';
|
|
17
19
|
import { reconcileSchemaHash, writeSchemaHash } from './drift';
|
|
18
20
|
import { hashFileName, MIGRATIONS_DIR, readMigrations, snapshotFileName } from './migrations';
|
|
19
21
|
import type { Finding } from './output';
|
|
@@ -103,10 +105,25 @@ export async function generateAppMigration(
|
|
|
103
105
|
throw migrationSnapshotMissing(id, join(MIGRATIONS_DIR, snapshotFileName(id)));
|
|
104
106
|
}
|
|
105
107
|
|
|
108
|
+
const entities = describeEntities();
|
|
109
|
+
// Read AFTER the findings check above, never before: a module that would not import leaves the
|
|
110
|
+
// registry short, and every `subscribes:` name whose entity lives in that module would then look
|
|
111
|
+
// like a typo. The refusal below is only sound over a whole registry.
|
|
112
|
+
//
|
|
113
|
+
// `describeQueries()` is the manifest's own source — `frameworkSources` copies this exact field
|
|
114
|
+
// onto `QueryFact.subscribes` and changes nothing — so this is the manifest fact one hop earlier.
|
|
115
|
+
// Building the manifest here would re-load the app and demand a `package.json`, which `x db gen`
|
|
116
|
+
// has never needed: generation reads declarations and writes files.
|
|
117
|
+
const replicaIdentityFull = replicaIdentityTables(
|
|
118
|
+
describeQueries(),
|
|
119
|
+
new Set(entities.map((entity) => entity.table)),
|
|
120
|
+
);
|
|
121
|
+
|
|
106
122
|
const migration = generateMigration({
|
|
107
|
-
entities
|
|
123
|
+
entities,
|
|
108
124
|
current,
|
|
109
125
|
name: options.name,
|
|
126
|
+
replicaIdentityFull,
|
|
110
127
|
...(options.allowDestructive === true ? { allowDestructive: true } : {}),
|
|
111
128
|
});
|
|
112
129
|
// An empty diff writes no MIGRATION — one with no statement still takes a ledger row, a checksum
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL`, read off the
|
|
2
|
+
// live queries' DECLARED `subscribes:` — and the refusal for a declared name no entity's table
|
|
3
|
+
// matches. This tier is the only one holding both registries, so it is the only one that can ask.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The two fields this reads, and nothing else. Structurally satisfied by `QueryDescriptor` (whose
|
|
9
|
+
* `subscribes` is `null` when the read declared none) and by `@ultimat3/manifest`'s `QueryFact`
|
|
10
|
+
* (where it is absent instead) — one function over both spellings of one fact, rather than a
|
|
11
|
+
* projection per caller that could disagree about which reads are subscribed.
|
|
12
|
+
*/
|
|
13
|
+
export interface SubscribingQuery {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly subscribes?: readonly string[] | null | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Enough of the app's own tables to act on, without printing a hundred of them into one line. */
|
|
19
|
+
const NAMED_IN_FIX = 10;
|
|
20
|
+
|
|
21
|
+
function offer(tables: ReadonlySet<string>): string {
|
|
22
|
+
const known = [...tables].sort();
|
|
23
|
+
if (known.length === 0) return 'this app declares no entity at all — x g entity Note body:text';
|
|
24
|
+
const shown = known.slice(0, NAMED_IN_FIX).join(', ');
|
|
25
|
+
return known.length > NAMED_IN_FIX ? `${shown} and ${known.length - NAMED_IN_FIX} more` : shown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A live read declares a relation this app has no entity for.
|
|
30
|
+
*
|
|
31
|
+
* Refused rather than dropped, and that is the whole point of the check: `@ultimat3/db` keeps only
|
|
32
|
+
* the declared names an entity's table matches (`replica-identity.ts`), so an EXTRA name is
|
|
33
|
+
* discarded in silence — and `@ultimat3/query` has no table catalog, so its own `subscribes:`
|
|
34
|
+
* assertions cannot see one either. A typo therefore granted `REPLICA IDENTITY FULL` to nothing
|
|
35
|
+
* while its author read the declaration as granted, which is the failure #357 exists to end.
|
|
36
|
+
*
|
|
37
|
+
* The name goes in the `cause` and never into a command: it is a string from the app's own source,
|
|
38
|
+
* and the remedy is an edit to a field rather than anything to paste at a shell.
|
|
39
|
+
*/
|
|
40
|
+
export class QuerySubscribesUnknownError extends UltimateError {
|
|
41
|
+
constructor(input: { query: string; table: string; tables: ReadonlySet<string> }) {
|
|
42
|
+
super({
|
|
43
|
+
code: 'X_QUERY_SUBSCRIBES_UNKNOWN',
|
|
44
|
+
cause:
|
|
45
|
+
`the query "${input.query}" declares subscribes: ["${input.table}"] and no entity ` +
|
|
46
|
+
'declares a table with that name',
|
|
47
|
+
fix:
|
|
48
|
+
`edit subscribes: on the query "${input.query}" to name a table this app declares ` +
|
|
49
|
+
`(${offer(input.tables)}), or drop the name — x db gen grants REPLICA IDENTITY FULL to ` +
|
|
50
|
+
'exactly the tables it lists, and would have granted it to nothing',
|
|
51
|
+
meta: { query: input.query, table: input.table },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The tables to hand `GenerateOptions.replicaIdentityFull`, deduped and sorted.
|
|
58
|
+
*
|
|
59
|
+
* Sorted so the same app generates the same bytes whatever order its modules registered in — the
|
|
60
|
+
* rule `@ultimat3/db`'s own `pending()` states one layer down, kept here too because a caller that
|
|
61
|
+
* ordered by registration would put a diff in a file for nothing.
|
|
62
|
+
*
|
|
63
|
+
* Every name is checked against `tables` BEFORE any of them is returned: a run that emitted the
|
|
64
|
+
* good half and refused afterwards would leave an author with a migration that is right for one
|
|
65
|
+
* table and silently absent for the other.
|
|
66
|
+
*/
|
|
67
|
+
export function replicaIdentityTables(
|
|
68
|
+
queries: readonly SubscribingQuery[],
|
|
69
|
+
tables: ReadonlySet<string>,
|
|
70
|
+
): readonly string[] {
|
|
71
|
+
const wanted = new Set<string>();
|
|
72
|
+
for (const query of queries) {
|
|
73
|
+
for (const table of query.subscribes ?? []) {
|
|
74
|
+
if (!tables.has(table)) {
|
|
75
|
+
throw new QuerySubscribesUnknownError({ query: query.name, table, tables });
|
|
76
|
+
}
|
|
77
|
+
wanted.add(table);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...wanted].sort();
|
|
81
|
+
}
|
package/src/db-ungeneratable.ts
CHANGED
|
@@ -58,8 +58,20 @@ export function declaredUngeneratable(up: string): number {
|
|
|
58
58
|
* the statement stays, and the next author to squash these migrations is told by the file itself
|
|
59
59
|
* that regenerating it loses something. Re-declaring is available only for the statements an
|
|
60
60
|
* entity can express — an enum is a text column plus a check invariant, which `x db gen` writes —
|
|
61
|
-
*
|
|
62
|
-
*
|
|
61
|
+
* so a `fix:` naming only the second branch would be an instruction some of its readers cannot
|
|
62
|
+
* carry out.
|
|
63
|
+
*
|
|
64
|
+
* `REPLICA IDENTITY FULL` used to be the example of a statement with no second branch at all, and
|
|
65
|
+
* it stopped being one on 2026-08-26 (#357): a live query DECLARES the relations it is patched
|
|
66
|
+
* from (`subscribes:`), `db-subscribes.ts` reads them off the registry the manifest is built from,
|
|
67
|
+
* and `x db gen` emits the ALTER and records it on the snapshot. So the re-declare branch now
|
|
68
|
+
* covers it too: declare `subscribes:` on the live query and regenerate.
|
|
69
|
+
*
|
|
70
|
+
* A committed `replica identity full`/`default` ALTER is NOT counted, `As of 2026-08-26`:
|
|
71
|
+
* `GENERATABLE_FORMS` carries the verb phrase, so a squash no longer loses it and no marker is
|
|
72
|
+
* owed for one. Measured on `examples/dummy/packages/db/migrations/0001_init.sql`: 7 found and 7
|
|
73
|
+
* declared before that entry, 5 and 5 after. `using index` and `nothing` are NOT on the list —
|
|
74
|
+
* the generator emits neither, so those stay hand-written and stay counted.
|
|
63
75
|
*/
|
|
64
76
|
export class MigrationUngeneratableError extends UltimateError {
|
|
65
77
|
constructor(input: { file: string; declared: number; statements: readonly string[] }) {
|
package/src/dev-assets.ts
CHANGED
|
@@ -5,14 +5,9 @@
|
|
|
5
5
|
// thing it does NOT decide is who may read a stored object: `/media` borrows that whole answer
|
|
6
6
|
// from `dev-storage.ts`, because the same bytes are reachable through both.
|
|
7
7
|
|
|
8
|
-
// `join` is `node:`-only by necessity: Bun exposes no path-join primitive, and `ICON_SOURCE` is
|
|
9
|
-
// app-root-relative, so resolving it against the root is string work no `Bun.file` overload does.
|
|
10
|
-
import { join } from 'node:path';
|
|
11
8
|
import { probeImage } from '@ultimat3/core';
|
|
12
9
|
import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
|
|
13
10
|
import { applyCacheHeaders } from '@ultimat3/http';
|
|
14
|
-
import type { IconPlan } from '@ultimat3/pwa';
|
|
15
|
-
import { BuiltinImagePipeline, PwaIconMissingError, planIcons } from '@ultimat3/pwa';
|
|
16
11
|
import type { ImageQuery, ImageTransformDriver } from '@ultimat3/seo';
|
|
17
12
|
import { builtinImageDriver, DEFAULT_WIDTHS, parseImageQuery } from '@ultimat3/seo';
|
|
18
13
|
import type { ImageTransform, Storage, VariantFormat } from '@ultimat3/storage';
|
|
@@ -24,16 +19,13 @@ import {
|
|
|
24
19
|
STORAGE_READ_PERMISSION,
|
|
25
20
|
} from './dev-storage';
|
|
26
21
|
import { faviconRoute } from './favicon';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
/** Where `planIcons` writes, and therefore the paths the generated web manifest names. */
|
|
36
|
-
export const ICON_BASE_PATH = '/icons';
|
|
22
|
+
// The icon matrix's source, its base path and its renderer live in their own module so that
|
|
23
|
+
// `pwa-artifacts.ts` — which this file imports for the manifest route — can reach them without
|
|
24
|
+
// importing this one back. A cycle between the two would be the manifest and the icons it names
|
|
25
|
+
// resolving through each other.
|
|
26
|
+
import { iconPlan, iconRenderer } from './icon-assets';
|
|
27
|
+
import type { PwaArtifacts } from './pwa-artifacts';
|
|
28
|
+
import { pwaManifestRoute } from './pwa-artifacts';
|
|
37
29
|
|
|
38
30
|
/**
|
|
39
31
|
* Storage-backed images. `responsiveImage({ src: '/media/<key>' })` mints its variants under it.
|
|
@@ -171,50 +163,18 @@ async function mediaResponse(
|
|
|
171
163
|
return imageResponse(read.bytes, read.object.contentType, mediaCache(key));
|
|
172
164
|
}
|
|
173
165
|
|
|
174
|
-
/**
|
|
175
|
-
* Rendered once per process, not per request: the fourteen matrix entries are pure functions of
|
|
176
|
-
* one source file, and re-encoding a 512px PNG on every hit would be work no caller can observe.
|
|
177
|
-
*/
|
|
178
|
-
function iconRenderer(root: string): (plan: IconPlan, path: string) => Promise<Uint8Array> {
|
|
179
|
-
const pipeline = new BuiltinImagePipeline();
|
|
180
|
-
const rendered = new Map<string, Promise<Uint8Array>>();
|
|
181
|
-
const sourceBytes = async (): Promise<Uint8Array> => {
|
|
182
|
-
const file = Bun.file(join(root, ICON_SOURCE));
|
|
183
|
-
if (!(await file.exists())) {
|
|
184
|
-
throw new PwaIconMissingError(
|
|
185
|
-
`${ICON_SOURCE} does not exist, so every icon the web manifest declares is unbacked and ` +
|
|
186
|
-
'the app is not installable',
|
|
187
|
-
// The same edit `x doctor` reports for the same condition, in `@ultimat3/pwa`'s own words.
|
|
188
|
-
// `x new` was here and takes an app name, so it could never run inside the broken app.
|
|
189
|
-
`add a 1024x1024 square PNG at ${ICON_SOURCE}`,
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
return file.bytes();
|
|
193
|
-
};
|
|
194
|
-
return async (plan, path) => {
|
|
195
|
-
const entry = plan.entries.find((candidate) => candidate.outputPath === path);
|
|
196
|
-
if (entry === undefined) {
|
|
197
|
-
throw new PwaIconMissingError(
|
|
198
|
-
`${path} is not in the icon matrix, so no transform describes it`,
|
|
199
|
-
`request one of ${plan.entries.map((one) => one.outputPath).join(', ')}`,
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
const existing = rendered.get(path);
|
|
203
|
-
if (existing !== undefined) return existing;
|
|
204
|
-
const bytes = sourceBytes().then((source) => pipeline.resize(source, entry.transform));
|
|
205
|
-
rendered.set(path, bytes);
|
|
206
|
-
// A failed render must not be remembered — the next request comes after the source was added.
|
|
207
|
-
bytes.catch(() => rendered.delete(path));
|
|
208
|
-
return bytes;
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
166
|
export interface AssetRoutesOptions {
|
|
213
167
|
/** App root. The source icon is resolved against it; storage keys never are. */
|
|
214
168
|
readonly root: string;
|
|
215
169
|
readonly storage: Storage;
|
|
216
170
|
/** Replaces `builtinImageDriver` for `/media/*`. Omitted, core's PNG/JPEG pipeline. */
|
|
217
171
|
readonly images?: ImageTransformDriver;
|
|
172
|
+
/**
|
|
173
|
+
* `manifest.webmanifest`, resolved at boot by `pwa-artifacts.ts`. Absent when the app declares
|
|
174
|
+
* `pwa.enabled: false` — and then no route is mounted at all, rather than one answering an empty
|
|
175
|
+
* document: a manifest a browser can fetch is a promise the app is installable.
|
|
176
|
+
*/
|
|
177
|
+
readonly pwa?: PwaArtifacts;
|
|
218
178
|
}
|
|
219
179
|
|
|
220
180
|
/**
|
|
@@ -225,7 +185,9 @@ export interface AssetRoutesOptions {
|
|
|
225
185
|
* this package's own rule forbids. `x dev` owns the runtime half, the diagnostic owns the other.
|
|
226
186
|
*/
|
|
227
187
|
export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
|
|
228
|
-
|
|
188
|
+
// `iconPlan()`, never a second `planIcons` call: the icons this mounts and the icons
|
|
189
|
+
// `manifest.webmanifest` names are one list, or the manifest promises a size nothing mints.
|
|
190
|
+
const plan = iconPlan();
|
|
229
191
|
const render = iconRenderer(options.root);
|
|
230
192
|
|
|
231
193
|
const routes: Route[] = plan.entries.map((entry) => ({
|
|
@@ -260,6 +222,9 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
|
|
|
260
222
|
// container is the dev/prod difference this package's own rule forbids. `favicon.ts` owns what
|
|
261
223
|
// the answer IS — this file only says the app's asset surface is where it hangs.
|
|
262
224
|
routes.push(faviconRoute(options.root));
|
|
225
|
+
// Same rule one asset further along: the icons above are the ones this manifest NAMES, so the
|
|
226
|
+
// two belong to one surface and cannot be mounted from two places without drifting apart.
|
|
227
|
+
if (options.pwa !== undefined) routes.push(pwaManifestRoute(options.pwa));
|
|
263
228
|
|
|
264
229
|
return routes;
|
|
265
230
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// `notify.inboxReadRetentionMs` and `notify.inboxUnreadRetentionMs`, read out of the app's own
|
|
2
|
+
// `app.config.ts`. The sibling of `app-auth.ts`'s `loadSignInPath` and `dev-cache.ts`'s
|
|
3
|
+
// `loadCacheTiers`, and structural for the same reason: `defineConfig` returns a plain object, so
|
|
4
|
+
// a config that resolved through an older core simply has no `notify` section.
|
|
5
|
+
//
|
|
6
|
+
// WHY A LOADER AND NOT A BOOT ARGUMENT: `startServices` has no `AppConfig` — the app's modules
|
|
7
|
+
// import after it, which is the same reason `configureAuthLimiters` takes a factory. A key read
|
|
8
|
+
// here is a key read from the file the operator edited, per boot.
|
|
9
|
+
|
|
10
|
+
// why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
|
|
11
|
+
// `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { INBOX_RETENTION_KEYS } from '@ultimat3/core';
|
|
14
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
15
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The two windows in milliseconds, each `undefined` where the app named none.
|
|
19
|
+
*
|
|
20
|
+
* ABSENT IS A DECISION, not a missing default, and it is the only safe one: an inbox row is a
|
|
21
|
+
* message a person has not read yet, so when it disappears is the app's call (axiom 8). The
|
|
22
|
+
* framework picking a number silently is the failure this whole key exists to avoid.
|
|
23
|
+
*/
|
|
24
|
+
export interface InboxRetention {
|
|
25
|
+
readonly readMs: number | undefined;
|
|
26
|
+
readonly unreadMs: number | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Nothing swept — what a boot with no config file, no `notify` section or no keys resolves to. */
|
|
30
|
+
export const NO_INBOX_RETENTION: InboxRetention = Object.freeze({
|
|
31
|
+
readMs: undefined,
|
|
32
|
+
unreadMs: undefined,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
36
|
+
typeof value === 'object' && value !== null;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Re-screened here rather than trusted from `defineConfig`. That validator runs when the app
|
|
40
|
+
* IMPORTS its config, and this loader imports the module for its export — so a config object
|
|
41
|
+
* assembled by hand, or one that resolved through a core too old to validate the section, reaches
|
|
42
|
+
* this line unchecked. A window that is not a positive finite number reads as absent: refusing
|
|
43
|
+
* would take the sweep over the other four framework tables down with it, and the whole point of
|
|
44
|
+
* the key is that not sweeping is a legal state.
|
|
45
|
+
*/
|
|
46
|
+
const windowOf = (section: Record<string, unknown>, key: string): number | undefined => {
|
|
47
|
+
const value = section[key];
|
|
48
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
|
|
49
|
+
return value;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export async function loadInboxRetention(root: string): Promise<InboxRetention> {
|
|
53
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
54
|
+
// `Bun.file(p).exists()` rather than `existsSync`: this function is already async, so the
|
|
55
|
+
// `node:fs` import buys nothing here — and an import with nothing to say for itself is what
|
|
56
|
+
// `bun run node-imports` refuses.
|
|
57
|
+
if (!(await Bun.file(configPath).exists())) return NO_INBOX_RETENTION;
|
|
58
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
59
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
60
|
+
if (!isRecord(config)) return NO_INBOX_RETENTION;
|
|
61
|
+
const notify = config['notify'];
|
|
62
|
+
if (!isRecord(notify)) return NO_INBOX_RETENTION;
|
|
63
|
+
// Keyed off core's own list rather than two string literals here, so the two names exist in one
|
|
64
|
+
// place. What actually stops a third window being declared-and-never-read is
|
|
65
|
+
// `bun run scripts/config-readers.ts` — every leaf key of `AppConfig` needs a reader or a pinned
|
|
66
|
+
// reason, and it is the guard whose own header calls that "the framework's most repeated defect".
|
|
67
|
+
const [readKey, unreadKey] = INBOX_RETENTION_KEYS;
|
|
68
|
+
return { readMs: windowOf(notify, readKey), unreadMs: windowOf(notify, unreadKey) };
|
|
69
|
+
}
|