@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/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
@@ -311,26 +329,120 @@ to know about everything — so the join is here, and it is the same rule
311
329
  | `e2e-evaluate.ts` | the closure→string crossing, which is the only lossy edge in the adapter |
312
330
  | `e2e-errors.ts` | one constructor per refusal |
313
331
  | `e2e-dom-fixture.ts` | a document small enough to hold in a test and real enough to RUN the expressions above |
314
-
315
- **Absent by default, and that is a requirement rather than a state.** CI has no Chrome. Nothing here
316
- runs until `installE2eDriver` is called, so `hasE2eDriver()` still answers `false` and the gate's
317
- `e2e` step still refuses instead of passing over a browser it does not have.
332
+ | `cdp-browser.ts` | the two doors: `openE2eBrowserIfAvailable()` (undefined when there is no browser) and `openE2eBrowser()` (refuses by name), and the close that undoes both halves |
333
+ | `cdp-launch.ts` | which Chrome, and starting it — the candidate list, the flags, and the endpoint read off its stderr |
334
+ | `cdp-connection.ts` | CDP over Bun's own `WebSocket`: request framing, reply correlation by `id`, one-shot event waiters, the per-call deadline |
335
+ | `cdp-e2e-page.ts` | `E2eBrowserPage`'s five methods over an attached, flattened session |
336
+ | `cdp-errors.ts` | one constructor per way the browser half refuses |
337
+
338
+ **Absent by default, and that is a requirement rather than a state.** Nothing here runs until
339
+ `installE2eDriver` is called, so `hasE2eDriver()` still answers `false` and the gate's `e2e` step
340
+ still refuses instead of passing over a browser it does not have. This paragraph also said "CI has
341
+ no Chrome" until 2026-08-27, and that is false and was the reason issue #390's fourth requirement
342
+ — a real browser check — was recorded as out of reach: GitHub-hosted `ubuntu-latest` ships one at
343
+ `/usr/bin/google-chrome`, preinstalled, with no download step and no new dependency.
344
+
345
+ **The browser is RAW CDP over Bun's own `WebSocket`, and carries no dependency.**
346
+ `packages/scraping/src/cdp-port.ts` declares a ~25-method port because `ScrapePage` is a full
347
+ scraping surface and its intended implementation is `puppeteer-core`. `E2eBrowserPage` is FIVE
348
+ methods, and CDP's wire format is one JSON object with an `id` — so the whole thing an e2e driver
349
+ needs is four small modules, which is why `x test e2e` needs nothing installed that `bun install`
350
+ did not already put there. `e2e/cdp-browser.e2e.test.ts` drives a real Chrome against a real
351
+ `Bun.serve` and asserts all five methods; `openE2eBrowserIfAvailable()` answering `undefined` is
352
+ what makes it a SKIP on a laptop without one rather than a red step.
353
+
354
+ **The load EVENT is the completion signal, never `Page.navigate`'s reply.** Measured on Chrome 150:
355
+ a navigation that swaps the render process — `about:blank` → `http://localhost:<port>/`, the most
356
+ ordinary one there is — loads the page, hits the server and answers a later `Runtime.evaluate` from
357
+ the new document, and the navigate frame **never comes back at all**. A driver that awaited the
358
+ reply waited out its full deadline on every first navigation. So `cdpConnect().once()` registers a
359
+ `Page.loadEventFired` waiter BEFORE the send, and the reply is raced against it — still read, but
360
+ only for `errorText`, which is the one place a refused navigation is named.
361
+
362
+ **A CDP call is deadlined and a close settles every call in flight.** Without that, a suite whose
363
+ browser died waits out one full deadline per call and reports a timeout, where the true fault is a
364
+ dead browser. The four codes are four repairs, which is why they are not one:
365
+ `X_CDP_BROWSER_MISSING` (install one), `X_CDP_LAUNCH_FAILED` (read the browser's own stderr, which
366
+ the cause carries), `X_CDP_CALL_FAILED` (look at the page), `X_CDP_TIMEOUT` (raise the deadline).
318
367
 
319
368
  **`evaluate` is the edge that cannot be lossless.** `PageLike.evaluate` takes a closure and every
320
369
  browser port in this framework takes a string, so what crosses is `Function.prototype.toString()`
321
370
  and nothing else. A zero-parameter closure naming only page globals is supported; a native or bound
322
371
  function, a declared parameter and a method shorthand are refused STATICALLY, before a byte leaves;
323
372
  a binding the page does not have comes back named, from the page's own `ReferenceError`. Measured on
324
- Bun 1.4.0 and load-bearing: **Bun's transpiler folds `wanted === 3` to `!0` before `toString()` ever
373
+ Bun 1.3.14 and 1.4.0 alike — re-measured on both when the repo moved back to the 1.3 series, because a version-stamped claim that names one runtime is unread evidence on the other — and load-bearing: **Bun's transpiler folds `wanted === 3` to `!0` before `toString()` ever
325
374
  runs**, so a captured PRIMITIVE can vanish from the source and never fail at all, while a captured
326
375
  reference always survives as its name. No static rule in this process can see the difference — which
327
376
  is why the refusal is raised from the page's answer rather than from a scan of the source.
328
377
 
329
- **Three of `E2eFixtures`' four members refuse, deliberately.** `offline()` and `online()` need a CDP
330
- method for the browser's own network state and `CdpPageLike` declares none; `update()` needs a second
331
- build served under a new id, which is a fact about the server. A fixture that silently no-opped would
332
- make the assertion after it read as proof — `offline()` followed by "the fallback rendered" is the
333
- app's ONLINE page passing an offline test.
378
+ **One of `E2eFixtures`' four members still refuses, and it is the one that is not a port gap.**
379
+ `update()` needs a second build served under a new immutable build id, which is a fact about the
380
+ SERVER, and no page port has ever been able to speak for one. `offline()`/`online()` FORWARD — to
381
+ `E2eBrowserPage.offline`, which `cdp-e2e-page.ts` implements as
382
+ `Network.emulateNetworkConditions` and `@ultimat3/scraping` implements through
383
+ `CdpPageLike.setOfflineMode`. They refused until 2026-08-27 on a reason the tree contradicted on
384
+ the day it was written. A fixture that silently no-opped would make the assertion after it read as
385
+ proof — `offline()` followed by "the fallback rendered" is the app's ONLINE page passing an offline
386
+ test — so an `E2eBrowserPage` that declares no `offline` still gets the refusal, now naming the
387
+ method the double is missing rather than a capability the framework does not have.
388
+
389
+ ## The service worker is emitted here, because the emitter needs facts only a build has
390
+
391
+ `@ultimat3/pwa` shipped `generateServiceWorker`, `buildPrecacheManifest`, `offlineFallbackSource`,
392
+ `backgroundSyncSource` and `pushSource` since it existed, and every one had **zero callers** outside
393
+ its own package. So `pwa.offline`, `pwa.backgroundSync`, `pwa.push` and every route's own `offline:`
394
+ were declarations with no build behind them, and no Ultimate app worked offline however its config
395
+ was written (#390). `sw-artifacts.ts` is the caller.
396
+
397
+ **Why here and not beside the manifest.** `loadPwaArtifacts(root)` needs a root and a config file;
398
+ the worker needs the ROUTE TABLE and the ISLAND BUNDLE as well — facts only a booted app and a
399
+ finished build have. Splitting them keeps `loadPwaArtifacts` callable before either exists, which
400
+ `x doctor` and the icon writer rely on. The route table is `describeRoutes()`, the one projection
401
+ `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a route added to the app
402
+ cannot be missing from the precache manifest.
403
+
404
+ | Surface | What it does with the worker |
405
+ |---|---|
406
+ | `cmd-dev.ts` | mounts `/sw.js` and `/x-sw-register.js`; built ONCE at boot and deliberately not rebuilt on the watcher tick — a worker that changes under a page it already controls is the update path, and re-emitting one per keystroke exercises it on every save |
407
+ | `serve.ts` | the same two routes in the container, from the same call |
408
+ | `prerender.ts` | writes both as FILES into the export — a static host runs no route table, so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the artifact |
409
+
410
+ **Registration is an EXTERNAL script, never inline**, and that is a CSP fact rather than a
411
+ preference: `startWeb` computes a `script-src` sha256 per inline script, so an unhashed one is
412
+ blocked in the container while passing report-only under `x dev` — which is how the hydration
413
+ runtime shipped broken once already.
414
+
415
+ **`sw.js` is served `no-store` with `Service-Worker-Allowed: /`.** A cached `sw.js` is a worker that
416
+ cannot be replaced: the browser re-fetches it to decide whether an update exists, and an
417
+ intermediary answering the old bytes pins every client to the deploy that shipped them. Without the
418
+ header the browser refuses to let a worker served from `/` control `/` — the failure `assertScope`
419
+ cannot see, because the scope a REGISTRATION asks for has to be allowed by the script's own response
420
+ and not only by its path.
421
+
422
+ **`api/` and `shared/` never cross.** An API response is a JSON document whose freshness is the
423
+ app's business, and precaching one serves a stale answer to a client that had a network; `shared/`
424
+ is not a URL at all. The filter is a `flatMap` rather than `filter().map()` because the predicate
425
+ does not narrow `surface` for the map that follows it, and a cast would hide the day a fifth surface
426
+ arrives.
427
+
428
+ **`pwa.push` is read and still wires nothing, and it says so.** `generateServiceWorker` emits a push
429
+ handler only when a VAPID key comes with the capability, there is no `pwa.vapid` config key, and it
430
+ drops the handler in SILENCE otherwise. `pushWarning` is this module's own finding, reported through
431
+ `x build --json`'s `serviceWorkerWarnings` — `jobs.driver`'s shape one package over, refused the same way.
432
+
433
+ **The browser check is what let any of this ship.** #390's fourth requirement was *"a real browser
434
+ check that the emitted worker installs, activates and serves the fallback offline. Until it exists,
435
+ do not ship the worker"* — a bad `sw.js` is sticky in a way a manifest is not.
436
+ `e2e/service-worker.e2e.test.ts` registers the emitted file in a real Chrome, waits for it to take
437
+ control, takes the network away, and asserts that a runtime route with nothing cached renders the
438
+ offline document.
439
+
440
+ **And it found the driver bug first.** `E2eFixtures.offline()` did not take the SERVICE WORKER
441
+ offline: a worker fetches on its own CDP target, the condition was only ever set on the page's, and
442
+ a `networkFirst` route the cache had never seen still answered from the network. So an offline
443
+ assertion made on a PWA tested nothing. `cdp-e2e-page.ts` now auto-attaches worker targets and
444
+ carries the condition onto each, including one that attaches AFTER `offline(true)` — the ordinary
445
+ case for a PWA.
334
446
 
335
447
  ## The `errors` step enforces the error contract
336
448
 
@@ -573,6 +685,8 @@ regex and `+` is a quantifier — `n1` is what actually selects these tests.
573
685
  | `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
686
  | `cmd-db-branch.ts` | `x db branch`'s wiring alone — which verb, which refusal, and the one connection an external clone runs on |
575
687
  | `db-finding.ts` | one thrown value → one `Finding`, shared by `cmd-db.ts` and `cmd-db-branch.ts` |
688
+ | `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 |
689
+ | `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
690
  | `drift.ts` | `checkSourceDrift`: the `.hash` sidecar the `drift` step compares, no database needed |
577
691
  | `schema-diff.ts` | what two GENERATED snapshots disagree about, as data — the pure half |
578
692
  | `schema-drift.ts` | `checkMigrationDrift`: entity declarations against the newest `.snapshot.json`, and the composition the `drift` step and `x doctor` both read |
@@ -674,6 +788,47 @@ is only the channel each has. `ROLE=migrate` logged and exited 0 until it did no
674
788
  whose only signal is the exit code reported success over a schema nobody can reconstruct, which is
675
789
  the failure the post-migrate check exists to catch.
676
790
 
791
+ **`x db gen` emits `REPLICA IDENTITY FULL`, and the set is DECLARED rather than derived**,
792
+ `As of 2026-08-26` (#357). `@ultimat3/realtime` refuses a live subscription to a table without it —
793
+ logical replication carries no old row on an UPDATE, so no patch can be computed — and for two
794
+ years nothing in the framework emitted one. It could not be derived, and that is the load-bearing
795
+ fact: the relation name lives inside the query's `sql:` callback, which no generator can invoke
796
+ without valid input (`describeSql` says so itself — "`null` when no sample input was supplied").
797
+ So a live query DECLARES it (`subscribes:`, `@ultimat3/query`), the declaration is machine-checked
798
+ against the resolved `shape.entity` on the first subscribe (`X_QUERY_SUBSCRIBES_DRIFT`), and
799
+ `db-subscribes.ts` reads it off `describeQueries()` — the same source `frameworkSources` copies onto
800
+ `QueryFact.subscribes`, one hop earlier, because building the manifest here would re-load the app
801
+ and demand a `package.json` that `x db gen` has never needed.
802
+
803
+ **The third `subscribes:` refusal is this package's, because no other tier can ask it.**
804
+ `@ultimat3/db` keeps only the declared names an entity's table matches and DROPS the rest — it has
805
+ no way to tell a typo from a table another migration owns — and `@ultimat3/query` holds no table
806
+ catalog at all. So `subscribes: ['posts', 'user']` granted the identity to `posts`, dropped `user`
807
+ in silence, and read as granted. `X_QUERY_SUBSCRIBES_UNKNOWN` refuses it BEFORE anything is
808
+ written, naming the query and offering the tables the app does declare. It is checked after
809
+ `loadApp`'s findings, never before: a module that would not import leaves the registry short, and
810
+ every name whose entity lives in it would then look like a typo.
811
+
812
+ **And it accepts a table the migrations it just applied demonstrably created**, `As of 2026-08-26`
813
+ (issue #345). A snapshot records only what ENTITIES declare, so a table created by a HAND-WRITTEN
814
+ migration reached no sidecar and was `unexpected-table` on every deploy forever — with a `fix:`
815
+ that generated an empty migration, because `x db gen` diffs the entity registry against the newest
816
+ snapshot and the table is on neither side. `@ultimat3/db` fixed the wording; `acceptCreatedTables`
817
+ (`db-accept-created.ts`) is the half that file's `unexpectedTable` names, and it is composed around
818
+ `checkDrift` inside `runMigrations`, so `x db migrate` and `ROLE=migrate` accept the same set.
819
+ **Only `unexpected-table`, and only for a name a migration's SQL creates** — which is what keeps it
820
+ an acceptance rather than the check switched off: a table absent from the snapshot produces exactly
821
+ one difference (`diffSchema` reports it and never compares its columns), and a table nobody
822
+ declared and no migration created is still reported, cause and `fix:` intact. The evidence is the
823
+ applied list itself: `migrate()` runs first, so every file on disk has been applied by the time the
824
+ question is asked. The verb phrase is read ANCHORED off the raw statement, which is the whole
825
+ protection — a `create table` can only be at position 0 by being one, so `values ('create table
826
+ ghost')` opens with `insert` and a comment-only chunk is not a statement at all. A `stripSqlNoise`
827
+ pass was written first and deleted: it could not change one answer, and a defence that cannot fail
828
+ is one nobody can test. Everything the anchor admits and the name grammar does not — a comment
829
+ between the keywords, a `temp` table, a qualifier naming a schema `checkDrift` never introspected —
830
+ contributes nothing, which reports drift that could have been accepted and never the reverse.
831
+
677
832
  **The `drift` step asks a third thing, off the same directory and with no database either: is every
678
833
  destructive statement declared?** `db-destructive.ts` reads each committed migration through
679
834
  `migrations.ts` — the reader `x db migrate` applies from, because a rail checking a list the
@@ -751,8 +906,15 @@ decisions behind that shape:
751
906
  Regenerating is exactly what *discards* these statements, so the command every other db code
752
907
  answers with is the one this one must not lead with — `X_MIGRATION_UNGENERATABLE`'s `CLI_FIXES` row
753
908
  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.
909
+ invariant) rides behind an em-dash because it is available for some of the statements and not all.
910
+ **`REPLICA IDENTITY FULL` was the statement with no second branch, and stopped being one on
911
+ 2026-08-26** (#357): a live query declares the relations it is patched from (`subscribes:`),
912
+ `db-subscribes.ts` reads them off the same registry the manifest is projected from, `x db gen`
913
+ emits the ALTER and `@ultimat3/db` records it on the snapshot so it is emitted once. The re-declare
914
+ branch covers it now: declare `subscribes:` and regenerate. A statement already committed is a
915
+ different question and still counts — `GENERATABLE_FORMS` (`@ultimat3/db`) matches a leading verb
916
+ phrase and does not carry this one, measured at 7 found / 7 declared on `examples/dummy`'s
917
+ `0001_init.sql`, `As of 2026-08-26` — so the marker branch remains the only remedy for SQL on disk.
756
918
 
757
919
  **`x db gen` reports what it could not write, and exits 0.** `GeneratedMigration.unrendered` reached
758
920
  the committed `.sql` as a `-- UNRENDERED` comment and nothing else read it; `db-generate.ts` now
@@ -813,6 +975,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
813
975
  | `dev-runtime.ts` | start the rest on top of it and install the remaining accessors (storage, mail, transport) |
814
976
  | `dev-cache.ts` | which cache tiers this process reads through, and the cross-instance invalidation hop |
815
977
  | `dev-purge.ts` | the hourly retention sweep: which framework tables this boot owns, the `purge()` job over them and the `task` that fires it |
978
+ | `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
979
  | `dev-sync.ts` | the `sync` role: its live-query registry, who is dialling it, and the socket it owns |
817
980
  | `runtime-overrides.ts` | the one field a host hands the framework a driver through |
818
981
  | `sync-authenticator.ts` | the app's HTTP authenticator, seen as the sync node's |
@@ -824,6 +987,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
824
987
  | `favicon.ts` | `/favicon.ico`: the app's own file, and the bytes the framework answers with when there is none |
825
988
  | `dev-hooks.ts` | the pipeline's `authorize` seam, decided from the app's own `Policy` objects |
826
989
  | `dev-replica.ts` | which boot gets a standby, and the one middleware frame that opens the read scope |
990
+ | `dev-replicator.ts` | the `replicator` role: the feed selected, locked and pumped — and `replicatedRelations()`, the entity TABLES it filters on |
827
991
  | `dev-roles.ts` | `--role` selection plus start/stop for `web`, `sync`, `worker`, `scheduler` |
828
992
  | `dev-dashboard.ts` | the `DevSources` hooks only this process can answer, and the two CLI panels |
829
993
  | `dev-traces.ts` | core's spans → the `/_x` timeline's request traces |
@@ -912,6 +1076,19 @@ The per-TENANT subscription cap is deliberately unset, and **both halves of it a
912
1076
  nothing — and no default is defensible when one tenant is a person and the next is five thousand
913
1077
  seats. The per-socket 128 stands because a socket is one browser tab.
914
1078
 
1079
+ **The change feed is filtered by TABLE, never by entity name**, `As of 2026-08-26`.
1080
+ `replicatedRelations()` (`dev-replicator.ts`) is the one projection, and both of its readers are
1081
+ catalog readers: `PgReplicationStream` keeps a change only when `#entities.has(relation.name)` and a
1082
+ pgoutput Relation message names the table, while `warnPartialIdentity` matches the same list against
1083
+ `pg_class.relname`. An entity NAME is the framework's own registry key — a cache tag, a policy and
1084
+ `x entities describe` are all keyed by it — and `entity('user', { table: 'users' })` makes the two
1085
+ different strings. It passed `.name`, so a renamed table matched on neither side: **every change
1086
+ skipped** and a replica-identity warning that could never fire, with no error anywhere. Invisible to
1087
+ every fixture in the tree, because `table` defaults to the name verbatim and all six entities in
1088
+ `examples/dummy` have `name === table` — `dev-replicator.test.ts` uses `billingAccount` on
1089
+ `billing_accounts` for exactly that reason, and proves the value through the real call chain:
1090
+ `assertIdentifier` refuses `billingAccount` before any connection and accepts `billing_accounts`.
1091
+
915
1092
  `trustProxy` is read from `TRUSTED_PROXY_HOPS` in `startWeb`, the way `PORT` and `ROLE` are read: it
916
1093
  is a fact about the deployment, not an app config choice, and one image runs behind an ingress in
917
1094
  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": "19.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": "19.0.0",
41
+ "@ultimat3/admin": "19.0.0",
42
+ "@ultimat3/ai": "19.0.0",
43
+ "@ultimat3/auth": "19.0.0",
44
+ "@ultimat3/cache": "19.0.0",
45
+ "@ultimat3/core": "19.0.0",
46
+ "@ultimat3/db": "19.0.0",
47
+ "@ultimat3/entity": "19.0.0",
48
+ "@ultimat3/flags": "19.0.0",
49
+ "@ultimat3/http": "19.0.0",
50
+ "@ultimat3/i18n": "19.0.0",
51
+ "@ultimat3/jobs": "19.0.0",
52
+ "@ultimat3/mail": "19.0.0",
53
+ "@ultimat3/manifest": "19.0.0",
54
+ "@ultimat3/mcp": "19.0.0",
55
+ "@ultimat3/money": "19.0.0",
56
+ "@ultimat3/notify": "19.0.0",
57
+ "@ultimat3/policy": "19.0.0",
58
+ "@ultimat3/pwa": "19.0.0",
59
+ "@ultimat3/query": "19.0.0",
60
+ "@ultimat3/realtime": "19.0.0",
61
+ "@ultimat3/render": "19.0.0",
62
+ "@ultimat3/schema": "19.0.0",
63
+ "@ultimat3/scraping": "19.0.0",
64
+ "@ultimat3/seo": "19.0.0",
65
+ "@ultimat3/storage": "19.0.0",
66
+ "@ultimat3/testing": "19.0.0",
67
+ "@ultimat3/time": "19.0.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/app-root.ts CHANGED
@@ -7,7 +7,28 @@ 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, so the floor may never fall below that patch.
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
+ * **Lowering it to 1.3.14 was tried on 2026-08-27 and refused**, and the argument for trying was
23
+ * sound — `--isolate` and `--parallel` are 1.3.13 features, no package here calls a 1.4-only API
24
+ * (`bun run typecheck` is clean against `@types/bun@1.3.14`), and `>=1.4.0` therefore bars Bun 1.3
25
+ * users for a capability the framework does not use. What refused it is a Bun 1.3.14 defect, not
26
+ * the paperwork: a service shutdown against a destroyed database never resolves there
27
+ * (`queue.stop()`, reproduced by `dev-runtime.live.test.ts`), so an app on a runtime this line
28
+ * declared supported would hang on graceful shutdown the moment its database went away. The full
29
+ * measurement is in `.github/actions/setup/action.yml`; read it before lowering this.
30
+ */
31
+ export const REQUIRED_BUN = '1.4.0';
11
32
 
12
33
  export interface AppRoot {
13
34
  readonly dir: string;
@@ -0,0 +1,100 @@
1
+ // One responsibility: compose the three halves — find a browser, connect to it, attach a page —
2
+ // into the one object `installE2eDriver({ page })` takes, plus the way to shut it down.
3
+ //
4
+ // **Absent is a SKIP, never a failure, and that is a requirement rather than a state.** A CI box
5
+ // with no Chrome must not turn the `e2e` step red for a reason unrelated to the change, which is
6
+ // the rule `packages/cli/CLAUDE.md` already states about `x shot`, `x pr` and `x ci`.
7
+ // `openE2eBrowserIfAvailable` is that door; `openE2eBrowser` refuses by name for a caller that has
8
+ // already decided a browser is required.
9
+
10
+ import { finiteCount } from '@ultimat3/core';
11
+ import type { CdpConnection } from './cdp-connection';
12
+ import { cdpConnect } from './cdp-connection';
13
+ import { cdpE2ePage } from './cdp-e2e-page';
14
+ import { CdpBrowserMissingError } from './cdp-errors';
15
+ import type { LaunchedBrowser } from './cdp-launch';
16
+ import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
17
+ import type { E2eBrowserPage } from './e2e-page';
18
+
19
+ /** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
20
+ export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
21
+
22
+ export interface E2eBrowser {
23
+ readonly page: E2eBrowserPage;
24
+ /** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
25
+ close(): void;
26
+ }
27
+
28
+ export interface OpenE2eBrowserOptions {
29
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
30
+ readonly timeoutMs?: number | undefined;
31
+ }
32
+
33
+ /**
34
+ * Screened HERE, before a browser exists, and not where it lands. It becomes three deadlines — the
35
+ * launch, the handshake and every CDP call — and `Number(process.env.E2E_TIMEOUT ?? '')` is `NaN`
36
+ * for an unset variable and is not nullish, so `??` keeps it: a `setTimeout` given `NaN` fires at
37
+ * 1ms in this Bun, which makes every call report `X_CDP_TIMEOUT` against a browser that was
38
+ * answering. A misdiagnosis reported as a test failure is worse than the failure.
39
+ */
40
+ const budget = (options: OpenE2eBrowserOptions): number =>
41
+ finiteCount('openE2eBrowser', 'timeoutMs', options.timeoutMs ?? DEFAULT_CDP_TIMEOUT_MS);
42
+
43
+ const compose = (
44
+ launched: LaunchedBrowser,
45
+ connection: CdpConnection,
46
+ page: E2eBrowserPage,
47
+ ): E2eBrowser => ({
48
+ page,
49
+ close(): void {
50
+ // The socket first: closing the process out from under an open connection makes every
51
+ // in-flight call report "the browser closed the CDP connection", which is true and useless.
52
+ connection.close();
53
+ launched.close();
54
+ },
55
+ });
56
+
57
+ /**
58
+ * Launch a browser and attach one page to it. Refuses with `X_CDP_BROWSER_MISSING` when there is
59
+ * nothing to launch — the caller that wants a skip asks `openE2eBrowserIfAvailable` instead.
60
+ */
61
+ export async function openE2eBrowser(options: OpenE2eBrowserOptions = {}): Promise<E2eBrowser> {
62
+ const timeoutMs = budget(options);
63
+ const executable = await findChrome(options.env ?? process.env);
64
+ if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
65
+ return openLaunched(executable, timeoutMs);
66
+ }
67
+
68
+ /** `undefined` when this machine has no browser. Every other failure still throws. */
69
+ export async function openE2eBrowserIfAvailable(
70
+ options: OpenE2eBrowserOptions = {},
71
+ ): Promise<E2eBrowser | undefined> {
72
+ const timeoutMs = budget(options);
73
+ const executable = await findChrome(options.env ?? process.env);
74
+ if (executable === undefined) return undefined;
75
+ return openLaunched(executable, timeoutMs);
76
+ }
77
+
78
+ /**
79
+ * The half both doors share. Each step undoes the ones before it on the way out: a Chrome that
80
+ * launched and then refused the CDP handshake would otherwise be left running, holding its profile
81
+ * directory, for the rest of the test process — one leaked browser per failing suite.
82
+ */
83
+ async function openLaunched(executable: string, timeoutMs: number): Promise<E2eBrowser> {
84
+ const launched = await launchChrome({ executable, timeoutMs });
85
+ let connection: CdpConnection;
86
+ try {
87
+ connection = await cdpConnect({ endpoint: launched.endpoint, timeoutMs });
88
+ } catch (error) {
89
+ launched.close();
90
+ throw error;
91
+ }
92
+ try {
93
+ const page = await cdpE2ePage({ connection, loadTimeoutMs: timeoutMs });
94
+ return compose(launched, connection, page);
95
+ } catch (error) {
96
+ connection.close();
97
+ launched.close();
98
+ throw error;
99
+ }
100
+ }