@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.
Files changed (45) hide show
  1. package/CLAUDE.md +194 -17
  2. package/package.json +30 -30
  3. package/src/app-root.ts +22 -1
  4. package/src/cdp-browser.ts +100 -0
  5. package/src/cdp-connection.ts +211 -0
  6. package/src/cdp-e2e-page.ts +209 -0
  7. package/src/cdp-errors.ts +56 -0
  8. package/src/cdp-launch.ts +130 -0
  9. package/src/cmd-dev.ts +28 -2
  10. package/src/cmd-doctor.ts +1 -1
  11. package/src/cmd-test.ts +20 -9
  12. package/src/compile-externals.ts +11 -4
  13. package/src/db-accept-created.ts +207 -0
  14. package/src/db-generate.ts +18 -1
  15. package/src/db-subscribes.ts +81 -0
  16. package/src/db-ungeneratable.ts +14 -2
  17. package/src/dev-assets.ts +19 -54
  18. package/src/dev-notify-retention.ts +69 -0
  19. package/src/dev-purge.ts +47 -2
  20. package/src/dev-render.ts +20 -4
  21. package/src/dev-replicator.ts +19 -1
  22. package/src/dev-runtime.ts +12 -3
  23. package/src/dev-services.ts +8 -0
  24. package/src/e2e-driver.ts +35 -17
  25. package/src/e2e-page.ts +15 -3
  26. package/src/error-codes.ts +20 -0
  27. package/src/icon-assets.ts +74 -0
  28. package/src/index.ts +46 -4
  29. package/src/island-harness-script.ts +8 -1
  30. package/src/island-shot.ts +37 -4
  31. package/src/island-verdict.ts +16 -4
  32. package/src/mcp-errors.ts +15 -0
  33. package/src/messages.ts +5 -1
  34. package/src/prerender.ts +41 -1
  35. package/src/pwa-artifacts.ts +230 -0
  36. package/src/serve.ts +24 -1
  37. package/src/static-report.ts +46 -3
  38. package/src/sw-artifacts.ts +162 -0
  39. package/src/sw-routes.ts +53 -0
  40. package/src/templates/naming.ts +11 -0
  41. package/src/templates/scaffold-app.ts +58 -7
  42. package/src/templates/scaffold-repo.ts +17 -1
  43. package/src/test-shards.ts +150 -116
  44. package/src/ts-scan.ts +6 -1
  45. 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, `planShards` clamps only to the file count, and `runParallel` `Promise.all`s them —
37
- // one Bun process per test FILE, each with the framework module graph and a cloned database.
38
- // `--worker` is an index into that split, so the same bound holds it (the exact upper index is
39
- // `workers - 1`, refused a line below by the check that knows the real width).
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 processes, one isolated database per worker',
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: `process count (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
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: 'rerun only shard I of the same split — reproduces a CI worker failure locally',
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
- const workers = Math.max(1, Math.min(requested, files.length));
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({
@@ -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
- * That skew is closed `As of 2026-08-20`: CI pins `1.4.x` (`.github/actions/setup/action.yml`),
15
- * `docker/Dockerfile` builds on `oven/bun:1.4-slim` and `scripts/setup.ts` holds contributors to
16
- * 1.4.0, so every builder now takes the second branch. The external stays regardless — it is the
17
- * `--compile` graph that must not reach an unresolvable `require`, on either Bun.
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
+ }
@@ -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: describeEntities(),
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
+ }
@@ -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
- * and never for `REPLICA IDENTITY FULL`, which nothing in the framework emits, so a `fix:` naming
62
- * only the second branch would be an instruction half its readers cannot carry out.
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
- * The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
30
- * it and this file reads it — one constant, because a second spelling is an app that passes the
31
- * diagnostic and still serves no icons. PNG, not SVG: core's pipeline decodes PNG and JPEG only.
32
- */
33
- export const ICON_SOURCE = 'apps/web/site/icon.png';
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
- const plan = planIcons({ sourceIcon: ICON_SOURCE, outDir: ICON_BASE_PATH });
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
+ }