@ultimat3/cli 17.0.0 → 18.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 CHANGED
@@ -8,6 +8,7 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
8
8
  | stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
9
9
  | stderr | `write-line.ts`'s `writeErrorLine` — the same loop on fd 2, for a line that is not the command's answer. A `CommandResult` declaring `stream: 'stderr'` is routed there by `dispatch.ts`'s `sinkFor`, and `x mcp serve --transport stdio` is the one case: its fd 1 carries JSON-RPC frames, so the `✓ mcp stdio serving 13 tools` line rendered after the loop was a malformed frame. Neither renderer carries `stream`, exactly like `hold` |
10
10
  | Boot logs under `--json` | `dispatch.ts` calls core's `setLogStream('stderr')` when `args.json` is set, once, for all thirty commands. `x db migrate --json` printed the boot logger's `ultimate migrate applied` and then the command's own object, so `json.load` raised on the second document. A server's stdout stays its log stream; this is the CLI process only |
11
+ | Test execution | `test-shards.ts`'s `testArgs` — ONE `bun test --parallel=N`, never N processes this repo packs itself. It did pack them, largest-first greedy over file SIZE, and the packer was deleted for buying **nothing**: four interleaved runs each on the 1296-file unit corpus gave 58.2/60.0/65.0/66.5s hand-packed against 54.5/57.8/61.7/64.5s under `--parallel=8`, within noise, because both are work-bound — 436.7s of file time is a 54.6s floor on 8 workers and the slowest single file is 20.5s. A greedy pack of 1296 small items lands near-optimal by accident. `--timings` is refused on the same evidence (#342). `--parallel` implies `--isolate`, so the per-file module registry is unchanged, and the per-worker database is too: `@ultimat3/testing`'s `workerId` already read `BUN_TEST_WORKER_ID`, which Bun sets 1..N. `ULTIMATE_TEST_WORKER` is set only for a single-shard `x test --worker I` rerun, which is one process |
11
12
  | Numeric flags | `flag-number.ts` — one reader for `--port` / `--workers` / `--shard`. A bare `Number.parseInt` accepts `4abc` and answers `NaN`, which turned three checks into ones that cannot fail |
12
13
  | Shell quoting | `shell-quote.ts`'s `quoteArg` — every value the CLI pastes into a `fix:` or a reproduce line, `exec.ts`'s missing-program refusal and `test-shards.ts`'s reproduce command both. A name holding a space or a `;` interpolated bare is an instruction that runs something else |
13
14
  | Missing positionals | `MissingPositionalError`, never `BadFlagError` (names a flag that does not exist) and never `UnknownCommandError` (says a known command form is not one). Its `example` is a REAL invocation — `x g route <name>` in a shell is a redirect |
@@ -273,11 +274,28 @@ image of the wrong thing.
273
274
  `page.pageErrors()` are bounded rings over the whole SESSION, so a shared one files state A's
274
275
  console errors under state B — and per-state attribution is the half of the artifact that gates.
275
276
 
276
- **The picture is the VIEWPORT, not a crop.** `@ultimat3/scraping`'s `CaptureRequest` is `fullPage`
277
- alone, so there is no clip rectangle to ask for; the framing knob is the state's own `viewport`,
278
- passed to `launch()` as `defaultViewport` through `LocalBrowserOptions.options`. That is why there
279
- is one browser per declared viewport, memoised. The verdict names it as a blind spot rather than
280
- implying a crop it did not perform.
277
+ **The picture is the CROP TARGET, `As of 2026-08-26`** — the readiness probe's own box, which is
278
+ the selector the manifest declared or the island's host element. Measured on `examples/dummy`
279
+ before it: 720x560 for a component whose box the verdict reported, in the same run, as 688x104.
280
+ `CaptureClip` had been on the port since #336 and `island-shot.ts` passed none, and this paragraph
281
+ said the port "takes no clip rectangle" — so did the verdict's own `blind` list, which is a blind
282
+ spot naming a capability the tool has, the same lie as one hiding a gap. The state's `viewport` is
283
+ still what the page is LAID OUT in, so there is still one browser per declared viewport, memoised;
284
+ it is no longer what the picture is.
285
+
286
+ **The clip is translated, not copied.** `getBoundingClientRect()` answers VIEWPORT coordinates and
287
+ a capture clip is in PAGE coordinates; they agree only at the origin, which is the one case a
288
+ harness happens to be in and is a rule nothing enforces. So the probe returns `scroll` beside `box`
289
+ and `clipFor` adds them — a component below the fold would otherwise crop a band it is not in, with
290
+ a picture that looks like a picture and nothing anywhere to report it. `box` keeps meaning the DOM's
291
+ own answer, because that is what the verdict publishes.
292
+
293
+ **Both themes are photographed by emulating the PREFERENCE.** `page.colorScheme(target.theme)`
294
+ before the navigation, so the first paint already has it. The harness's `data-theme` attribute stays
295
+ — it is right for a component that READS a theme it does not own — but it is the OUTCOME of a theme
296
+ decision, and a component that resolves `'system'` itself deletes it on mount: `x shot --island`
297
+ reported four pictures and wrote two, byte-identical, same md5 (#338). Re-setting the attribute
298
+ after readiness is not the repair; it photographs a state the component would never reach.
281
299
 
282
300
  **`loadApp` does not import a states file**, for the reason it does not import an island: it
283
301
  registers no primitive, and importing it would put `@ultimat3/testing` in the server module graph of
@@ -573,6 +591,8 @@ regex and `+` is a quantifier — `n1` is what actually selects these tests.
573
591
  | `db-branch.ts` | what a branch IS: the closed verb set, the name it takes on disk and in `pg_database`, and list/create/drop per mode |
574
592
  | `cmd-db-branch.ts` | `x db branch`'s wiring alone — which verb, which refusal, and the one connection an external clone runs on |
575
593
  | `db-finding.ts` | one thrown value → one `Finding`, shared by `cmd-db.ts` and `cmd-db-branch.ts` |
594
+ | `db-accept-created.ts` | `acceptCreatedTables`: the post-migrate report minus the tables the applied migrations' own SQL creates — the half `@ultimat3/db`'s `unexpectedTable` names |
595
+ | `db-subscribes.ts` | `replicaIdentityTables`: the tables `x db gen` grants `REPLICA IDENTITY FULL`, read off each live query's declared `subscribes:` — and `X_QUERY_SUBSCRIBES_UNKNOWN` for a name no entity's table matches |
576
596
  | `drift.ts` | `checkSourceDrift`: the `.hash` sidecar the `drift` step compares, no database needed |
577
597
  | `schema-diff.ts` | what two GENERATED snapshots disagree about, as data — the pure half |
578
598
  | `schema-drift.ts` | `checkMigrationDrift`: entity declarations against the newest `.snapshot.json`, and the composition the `drift` step and `x doctor` both read |
@@ -674,6 +694,47 @@ is only the channel each has. `ROLE=migrate` logged and exited 0 until it did no
674
694
  whose only signal is the exit code reported success over a schema nobody can reconstruct, which is
675
695
  the failure the post-migrate check exists to catch.
676
696
 
697
+ **`x db gen` emits `REPLICA IDENTITY FULL`, and the set is DECLARED rather than derived**,
698
+ `As of 2026-08-26` (#357). `@ultimat3/realtime` refuses a live subscription to a table without it —
699
+ logical replication carries no old row on an UPDATE, so no patch can be computed — and for two
700
+ years nothing in the framework emitted one. It could not be derived, and that is the load-bearing
701
+ fact: the relation name lives inside the query's `sql:` callback, which no generator can invoke
702
+ without valid input (`describeSql` says so itself — "`null` when no sample input was supplied").
703
+ So a live query DECLARES it (`subscribes:`, `@ultimat3/query`), the declaration is machine-checked
704
+ against the resolved `shape.entity` on the first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`), and
705
+ `db-subscribes.ts` reads it off `describeQueries()` — the same source `frameworkSources` copies onto
706
+ `QueryFact.subscribes`, one hop earlier, because building the manifest here would re-load the app
707
+ and demand a `package.json` that `x db gen` has never needed.
708
+
709
+ **The third `subscribes:` refusal is this package's, because no other tier can ask it.**
710
+ `@ultimat3/db` keeps only the declared names an entity's table matches and DROPS the rest — it has
711
+ no way to tell a typo from a table another migration owns — and `@ultimat3/query` holds no table
712
+ catalog at all. So `subscribes: ['posts', 'user']` granted the identity to `posts`, dropped `user`
713
+ in silence, and read as granted. `X_QUERY_SUBSCRIBES_UNKNOWN` refuses it BEFORE anything is
714
+ written, naming the query and offering the tables the app does declare. It is checked after
715
+ `loadApp`'s findings, never before: a module that would not import leaves the registry short, and
716
+ every name whose entity lives in it would then look like a typo.
717
+
718
+ **And it accepts a table the migrations it just applied demonstrably created**, `As of 2026-08-26`
719
+ (issue #345). A snapshot records only what ENTITIES declare, so a table created by a HAND-WRITTEN
720
+ migration reached no sidecar and was `unexpected-table` on every deploy forever — with a `fix:`
721
+ that generated an empty migration, because `x db gen` diffs the entity registry against the newest
722
+ snapshot and the table is on neither side. `@ultimat3/db` fixed the wording; `acceptCreatedTables`
723
+ (`db-accept-created.ts`) is the half that file's `unexpectedTable` names, and it is composed around
724
+ `checkDrift` inside `runMigrations`, so `x db migrate` and `ROLE=migrate` accept the same set.
725
+ **Only `unexpected-table`, and only for a name a migration's SQL creates** — which is what keeps it
726
+ an acceptance rather than the check switched off: a table absent from the snapshot produces exactly
727
+ one difference (`diffSchema` reports it and never compares its columns), and a table nobody
728
+ declared and no migration created is still reported, cause and `fix:` intact. The evidence is the
729
+ applied list itself: `migrate()` runs first, so every file on disk has been applied by the time the
730
+ question is asked. The verb phrase is read ANCHORED off the raw statement, which is the whole
731
+ protection — a `create table` can only be at position 0 by being one, so `values ('create table
732
+ ghost')` opens with `insert` and a comment-only chunk is not a statement at all. A `stripSqlNoise`
733
+ pass was written first and deleted: it could not change one answer, and a defence that cannot fail
734
+ is one nobody can test. Everything the anchor admits and the name grammar does not — a comment
735
+ between the keywords, a `temp` table, a qualifier naming a schema `checkDrift` never introspected —
736
+ contributes nothing, which reports drift that could have been accepted and never the reverse.
737
+
677
738
  **The `drift` step asks a third thing, off the same directory and with no database either: is every
678
739
  destructive statement declared?** `db-destructive.ts` reads each committed migration through
679
740
  `migrations.ts` — the reader `x db migrate` applies from, because a rail checking a list the
@@ -751,8 +812,15 @@ decisions behind that shape:
751
812
  Regenerating is exactly what *discards* these statements, so the command every other db code
752
813
  answers with is the one this one must not lead with — `X_MIGRATION_UNGENERATABLE`'s `CLI_FIXES` row
753
814
  is `x verify --only drift`, and the re-declare branch (an enum is a text column plus a check
754
- invariant) rides behind an em-dash because it is available for some of the statements and never
755
- for `REPLICA IDENTITY FULL`, which nothing in the framework emits.
815
+ invariant) rides behind an em-dash because it is available for some of the statements and not all.
816
+ **`REPLICA IDENTITY FULL` was the statement with no second branch, and stopped being one on
817
+ 2026-08-26** (#357): a live query declares the relations it is patched from (`subscribes:`),
818
+ `db-subscribes.ts` reads them off the same registry the manifest is projected from, `x db gen`
819
+ emits the ALTER and `@ultimat3/db` records it on the snapshot so it is emitted once. The re-declare
820
+ branch covers it now: declare `subscribes:` and regenerate. A statement already committed is a
821
+ different question and still counts — `GENERATABLE_FORMS` (`@ultimat3/db`) matches a leading verb
822
+ phrase and does not carry this one, measured at 7 found / 7 declared on `examples/dummy`'s
823
+ `0001_init.sql`, `As of 2026-08-26` — so the marker branch remains the only remedy for SQL on disk.
756
824
 
757
825
  **`x db gen` reports what it could not write, and exits 0.** `GeneratedMigration.unrendered` reached
758
826
  the committed `.sql` as a `-- UNRENDERED` comment and nothing else read it; `db-generate.ts` now
@@ -813,6 +881,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
813
881
  | `dev-runtime.ts` | start the rest on top of it and install the remaining accessors (storage, mail, transport) |
814
882
  | `dev-cache.ts` | which cache tiers this process reads through, and the cross-instance invalidation hop |
815
883
  | `dev-purge.ts` | the hourly retention sweep: which framework tables this boot owns, the `purge()` job over them and the `task` that fires it |
884
+ | `dev-notify-retention.ts` | `notify.inboxReadRetentionMs` / `inboxUnreadRetentionMs` off the app's own `app.config.ts` — the sibling of `loadSignInPath` and `loadCacheTiers`, because `startServices` holds no `AppConfig` |
816
885
  | `dev-sync.ts` | the `sync` role: its live-query registry, who is dialling it, and the socket it owns |
817
886
  | `runtime-overrides.ts` | the one field a host hands the framework a driver through |
818
887
  | `sync-authenticator.ts` | the app's HTTP authenticator, seen as the sync node's |
@@ -824,6 +893,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
824
893
  | `favicon.ts` | `/favicon.ico`: the app's own file, and the bytes the framework answers with when there is none |
825
894
  | `dev-hooks.ts` | the pipeline's `authorize` seam, decided from the app's own `Policy` objects |
826
895
  | `dev-replica.ts` | which boot gets a standby, and the one middleware frame that opens the read scope |
896
+ | `dev-replicator.ts` | the `replicator` role: the feed selected, locked and pumped — and `replicatedRelations()`, the entity TABLES it filters on |
827
897
  | `dev-roles.ts` | `--role` selection plus start/stop for `web`, `sync`, `worker`, `scheduler` |
828
898
  | `dev-dashboard.ts` | the `DevSources` hooks only this process can answer, and the two CLI panels |
829
899
  | `dev-traces.ts` | core's spans → the `/_x` timeline's request traces |
@@ -912,6 +982,19 @@ The per-TENANT subscription cap is deliberately unset, and **both halves of it a
912
982
  nothing — and no default is defensible when one tenant is a person and the next is five thousand
913
983
  seats. The per-socket 128 stands because a socket is one browser tab.
914
984
 
985
+ **The change feed is filtered by TABLE, never by entity name**, `As of 2026-08-26`.
986
+ `replicatedRelations()` (`dev-replicator.ts`) is the one projection, and both of its readers are
987
+ catalog readers: `PgReplicationStream` keeps a change only when `#entities.has(relation.name)` and a
988
+ pgoutput Relation message names the table, while `warnPartialIdentity` matches the same list against
989
+ `pg_class.relname`. An entity NAME is the framework's own registry key — a cache tag, a policy and
990
+ `x entities describe` are all keyed by it — and `entity('user', { table: 'users' })` makes the two
991
+ different strings. It passed `.name`, so a renamed table matched on neither side: **every change
992
+ skipped** and a replica-identity warning that could never fire, with no error anywhere. Invisible to
993
+ every fixture in the tree, because `table` defaults to the name verbatim and all six entities in
994
+ `examples/dummy` have `name === table` — `dev-replicator.test.ts` uses `billingAccount` on
995
+ `billing_accounts` for exactly that reason, and proves the value through the real call chain:
996
+ `assertIdentifier` refuses `billingAccount` before any connection and accepts `billing_accounts`.
997
+
915
998
  `trustProxy` is read from `TRUSTED_PROXY_HOPS` in `startWeb`, the way `PORT` and `ROLE` are read: it
916
999
  is a fact about the deployment, not an app config choice, and one image runs behind an ingress in
917
1000
  one cluster and behind nothing on a laptop. Without it `ctx.ip` is the ingress's socket address on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "17.0.0",
3
+ "version": "18.0.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,7 @@
28
28
  "LICENSE"
29
29
  ],
30
30
  "engines": {
31
- "bun": ">=1.3.0"
31
+ "bun": ">=1.4.0"
32
32
  },
33
33
  "scripts": {
34
34
  "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -37,34 +37,34 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "17.0.0",
41
- "@ultimat3/admin": "17.0.0",
42
- "@ultimat3/ai": "17.0.0",
43
- "@ultimat3/auth": "17.0.0",
44
- "@ultimat3/cache": "17.0.0",
45
- "@ultimat3/core": "17.0.0",
46
- "@ultimat3/db": "17.0.0",
47
- "@ultimat3/entity": "17.0.0",
48
- "@ultimat3/flags": "17.0.0",
49
- "@ultimat3/http": "17.0.0",
50
- "@ultimat3/i18n": "17.0.0",
51
- "@ultimat3/jobs": "17.0.0",
52
- "@ultimat3/mail": "17.0.0",
53
- "@ultimat3/manifest": "17.0.0",
54
- "@ultimat3/mcp": "17.0.0",
55
- "@ultimat3/money": "17.0.0",
56
- "@ultimat3/notify": "17.0.0",
57
- "@ultimat3/policy": "17.0.0",
58
- "@ultimat3/pwa": "17.0.0",
59
- "@ultimat3/query": "17.0.0",
60
- "@ultimat3/realtime": "17.0.0",
61
- "@ultimat3/render": "17.0.0",
62
- "@ultimat3/schema": "17.0.0",
63
- "@ultimat3/scraping": "17.0.0",
64
- "@ultimat3/seo": "17.0.0",
65
- "@ultimat3/storage": "17.0.0",
66
- "@ultimat3/testing": "17.0.0",
67
- "@ultimat3/time": "17.0.0",
40
+ "@ultimat3/action": "18.0.0",
41
+ "@ultimat3/admin": "18.0.0",
42
+ "@ultimat3/ai": "18.0.0",
43
+ "@ultimat3/auth": "18.0.0",
44
+ "@ultimat3/cache": "18.0.0",
45
+ "@ultimat3/core": "18.0.0",
46
+ "@ultimat3/db": "18.0.0",
47
+ "@ultimat3/entity": "18.0.0",
48
+ "@ultimat3/flags": "18.0.0",
49
+ "@ultimat3/http": "18.0.0",
50
+ "@ultimat3/i18n": "18.0.0",
51
+ "@ultimat3/jobs": "18.0.0",
52
+ "@ultimat3/mail": "18.0.0",
53
+ "@ultimat3/manifest": "18.0.0",
54
+ "@ultimat3/mcp": "18.0.0",
55
+ "@ultimat3/money": "18.0.0",
56
+ "@ultimat3/notify": "18.0.0",
57
+ "@ultimat3/policy": "18.0.0",
58
+ "@ultimat3/pwa": "18.0.0",
59
+ "@ultimat3/query": "18.0.0",
60
+ "@ultimat3/realtime": "18.0.0",
61
+ "@ultimat3/render": "18.0.0",
62
+ "@ultimat3/schema": "18.0.0",
63
+ "@ultimat3/scraping": "18.0.0",
64
+ "@ultimat3/seo": "18.0.0",
65
+ "@ultimat3/storage": "18.0.0",
66
+ "@ultimat3/testing": "18.0.0",
67
+ "@ultimat3/time": "18.0.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/app-root.ts CHANGED
@@ -7,7 +7,19 @@ import { BunVersionError, NotInAppError } from './errors';
7
7
 
8
8
  export const APP_CONFIG_FILE = 'app.config.ts';
9
9
  export const MANIFEST_FILE = 'x.manifest.json';
10
- export const REQUIRED_BUN = '1.3.0';
10
+ /**
11
+ * The floor the shipped `x` enforces, and it must not sit below what `x` EMITS. It said `1.3.0`
12
+ * through 2026-08-27 while `x test` spent `bun test --isolate` — a flag Bun introduced in
13
+ * **1.3.13** — so a user on a Bun this file declared supported got an unknown-flag failure out of
14
+ * the gate's dominant step, with `x doctor` reporting the runtime as fine. `--parallel` arrived in
15
+ * the same release and is emitted now.
16
+ *
17
+ * `1.4.0` rather than `1.3.13` because a floor is a claim about a runtime somebody TESTED: CI pins
18
+ * `1.4.x`, both images build on `oven/bun:1.4-*`, and the per-worker database rests on
19
+ * `BUN_TEST_WORKER_ID`'s numbering, probed on 1.4.0 and on nothing older. `scripts/bun-pin.test.ts`
20
+ * holds this to the same series as every other pin.
21
+ */
22
+ export const REQUIRED_BUN = '1.4.0';
11
23
 
12
24
  export interface AppRoot {
13
25
  readonly dir: string;
package/src/cmd-dev.ts CHANGED
@@ -46,6 +46,7 @@ import { msg } from './messages';
46
46
  import type { CommandResult, Finding } from './output';
47
47
  import { findingFrom } from './output';
48
48
  import { flagString } from './parse';
49
+ import { loadPwaArtifacts } from './pwa-artifacts';
49
50
  import { metricsPortFor } from './serve';
50
51
  import { loopFacts, loopFinding, loopNotice } from './statement-loop';
51
52
 
@@ -171,6 +172,11 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
171
172
  };
172
173
  const panels = devPanels(dashboard).map((panel) => panel.key);
173
174
 
175
+ // Resolved once, before the first route: the manifest's bytes and the three head elements that
176
+ // name it. `undefined` for an app that is not installable, and then nothing is mounted and no
177
+ // document changes — the 0kb baseline is not spent on a `<link>` to a file that does not exist.
178
+ const pwa = await loadPwaArtifacts(options.root);
179
+
174
180
  const routes: readonly Route[] = [
175
181
  ...devDashboardRoutes(dashboard),
176
182
  // The same API table the container serves: a read that answers here and 404s in production
@@ -179,7 +185,11 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
179
185
  // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
180
186
  // variants every `srcset` promises. Mounted before the app's own routes so a page route can
181
187
  // never shadow `/icons` or `/media`.
182
- ...assetRoutes({ root: options.root, storage: runtime.storage }),
188
+ ...assetRoutes({
189
+ root: options.root,
190
+ storage: runtime.storage,
191
+ ...(pwa === undefined ? {} : { pwa }),
192
+ }),
183
193
  ...storageRoutes({ storage: runtime.storage }),
184
194
  // The chunks the documents below name. Mounted before the app's routes for the reason
185
195
  // `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
@@ -193,7 +203,11 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
193
203
  islands: () => state.islands,
194
204
  states: () => loadIslandStates(options.root),
195
205
  }),
196
- ...appRoutes({ buildId, resolveIsland: (file) => state.islands.resolverFor(file) }),
206
+ ...appRoutes({
207
+ buildId,
208
+ resolveIsland: (file) => state.islands.resolverFor(file),
209
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head }),
210
+ }),
197
211
  ];
198
212
 
199
213
  const replicaOverride = replicaOverrides(undefined, services.db, options.env);
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({
@@ -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