# 21.x → 22.0.0, entry by entry

**Twenty-three entries**, the `22.0.0` section of `CHANGELOG.md`. Entries 4, 7, 9, 10, 11, 12, 13, 14, 18, 20 and 21 are compile
errors, and so are parts of 3 (`planDeploy`'s fourth argument), 6 (a declaration with no `policy`)
and 19 (`selectTransport`'s second argument, `'redis'`). Entry 15 throws when a `sync` pod boots,
entry 19 when any realtime role boots with a transport and `NATS_URL` that disagree, and entry 6
when a channel module loads. Entries 1, 2 and 5 compile unchanged and refuse a string they used to
accept; entry 17 changes a `meta.code`, and entry 23 throws where a query silently answered `[]`.
Entry 3 also installs a **new** helm release beside the old one unless you name it. Entry 16 is a
dependency you can drop, entry 22 is one `x db gen` if `drift` asks for it, and entry 8 needs no
edit. 22.0.0 is plan 101's deep sweep: every framework package, both tracked apps and the deploy
path.

**TypeScript 7 from here on — move it before this hop.** 22.0.0 added
`packages/cli/src/cdp-shot-keys.ts`, and `@ultimat3/cli` ships its `.ts` source: under TypeScript 6
with `exactOptionalPropertyTypes` it fails TS2769 at 26:43, in your typecheck, on every release from
22.0.0 through 31. `bun add -d typescript@^7.0.2` in every workspace that declares it — what `x new`
has scaffolded since 20.0.0 (`TYPESCRIPT_VERSION`). Not a `CHANGELOG.md` entry, so not counted above.

| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | `t.date` | you send a date that is not ISO-8601 in shape (`'March 14, 2026'`, `'3/14/2026'`, `'12'`). It is refused at validation now (`coerceQuery` no longer rewrites it). `new Date` read it at the host's local midnight, so the stored day depended on `TZ`. Send `'2026-03-14'`. Epoch-ms numbers and `Date` objects are unchanged |
| 2 | `timestamp()` columns | you write a string with no `Z` and no offset (`'2026-03-14T09:00'`) or a non-ISO one. Refused now; it used to be parsed in the host's zone. Write `'2026-03-14T09:00:00Z'` or a `Date` |
| 3 | `x deploy --method helm`, `planDeploy` | you deployed with helm before. The release is now named after `app.config.ts`'s `name`, not the literal `app`, so the next deploy installs a **second** release beside the old one. Keep the old one with `x deploy --method helm --release app`, or `helm uninstall app` after the new one is up. The deploy now waits for the rollout (default `--timeout 15m`) and reports `data.rollout`. `planDeploy(image, 'helm', root)` needs a fourth argument, `{ release, namespace, timeout }`. `--release`, `--namespace` and `--timeout` are refused on `--method compose` |
| 4 | `invokeAdminAction({ … expectedConfirmation })` | you pass `expectedConfirmation`. Delete it; the gate derives the token from the action's entity and `subject.id`, and the browser still echoes `confirmationToken(entity, id)` in `confirmation`. With both fields omitted a destructive action used to run unconfirmed; it is refused now |
| 5 | `@ultimat3/ui` `DateTime`, `toDate` | you pass a non-ISO string (`'August 14, 2026 09:00'`, `'8/14/2026'`). Pass an ISO date, a date-time with `Z` or an offset, or a `Date` |
| 6 | `channel(name, { … })` with no `policy` | a declaration omits it. It is a type error and throws `X_CHANNEL_DECLARATION_INVALID` at load. A public channel says so: `policy: allow('public')`. `ChannelDescription.policy` is `string`, no longer `string \| null` — delete a `null` branch |
| 7 | a custom `QueueStore` or `LocalStore` | you implement one. `QueueStore.save(state)` → `write(change: QueueChange)`, and `LocalStore.saveQueue(scope, state)` → `writeQueue(scope, change)`: one change per mutation, by key. Whole-queue records a 21.x page wrote are converted on first read, so queued writes survive the upgrade. The shipped stores need nothing |
| 8 | the page's SharedWorker | nothing to edit. The worker is named per build now (`workerName(scope, buildId)`, internal), so tabs of two deploys never share a socket host |
| 9 | `ChangeOp` | you `switch` over it exhaustively. Add `case 'truncate':` — a rowless change: a `TRUNCATE` empties the affected live windows and starts a new epoch on every open channel topic |
| 10 | `Result`, `Ok`, `Err`, `ok`, `err`, `map`, `mapErr`, `isOk`, `isErr`, `tryCatch`, `unwrap`, `unwrapOr` from `@ultimat3/core` | you import any of them. Gone: `throw` an `UltimateError` and `try`/`catch` it |
| 11 | `X_USERS_TABLE`, `X_SESSIONS_TABLE`, `X_ACCOUNTS_TABLE`, `X_VERIFICATIONS_TABLE`, `X_API_KEYS_TABLE`, `X_USERS_MIGRATION_1_3` from `@ultimat3/auth` | you import them, or pasted them into a migration. Delete both: every boot applies `AUTH_TABLES` (the 1.3 upgrade included, as `add column if not exists`) |
| 12 | internals removed from package barrels | you import a runtime value that no other package used — `CHANGELOG.md`'s `22.0.0` section lists them per package (auth, ui, core, http, entity, query, mcp, ai, mail, notify, pwa, render, scraping, manifest). None is an error class, a code table or a documented API. Copy the constant into your app, or use the documented API it served |
| 13 | the e2e driver: `installE2eDriver`, `e2eFixtures`, `startE2eApp`, `e2eApp`, `e2eBaseUrl`, `e2eBrowser`, `openE2eBrowser`, `cdpConnect`, `cdpE2eTab`, `findChrome`, `launchChrome`, the `Cdp*Error` / `E2e*Error` classes and their types, from `@ultimat3/cli` | you import any of them. Import them from `@ultimat3/testing`; the test preload is `@ultimat3/testing/e2e-preload`. `FRAMEWORK_SCRIPTS` and `FRAMEWORK_INLINE_SCRIPTS` stay on `@ultimat3/cli`. The `X_E2E_*` / `X_CDP_*` codes are unchanged |
| 14 | `entityRow`, `camel` from `@ultimat3/realtime/server` | you decode WAL tuples yourself. `entityRow(physical)` → `entityRow(relation, physical, 'before' \| 'after')`; it decodes through the registered entity (`decodeRow`, `.column()` renames and money included) and refuses a table with no registered entity (`X_REPLICATION_PROTOCOL`). `camel` is gone |
| 15 | a `sync` role on a real database | it has no reachable change feed. It refuses to boot with `X_REALTIME_TOPOLOGY`; it used to come up healthy and deliver nothing. Set `NATS_URL` on `web`, `sync` and one `replicator`, or leave `sync` off. (`x dev --role sync,replicator` runs both in one process, for development.) The scaffolded chart (`roles.sync.enabled: false`) and Compose file (`replicas: 0`) ship `sync` off, with the enable recipe beside the switch; a chart or compose file you copied earlier keeps whatever it had |
| 16 | `x shot`, `x shot --island`, the `ui.*` MCP tools | you installed `puppeteer-core` for them. They drive Chrome over raw CDP on the e2e step's launcher now: `bun remove puppeteer-core` if nothing else imports it. `X_SHOT_BROWSER_MISSING` now means "no Chrome to launch" — `export CHROME_PATH=<binary>` where Chrome is not at `/usr/bin/google-chrome` or the other probed paths, or pass `--cdp-url`. A request to a host off the allow list is refused inside the browser and recorded as `refused: "host"` in the verdict |
| 17 | `ui.interact` step failures | you match `meta.code` against `X_SCRAPE_*`. A failed step carries `X_SHOT_ELEMENT_MISSING`, `X_SHOT_ELEMENT_UNREADY` or `X_SHOT_KEY_INVALID` now |
| 18 | `backoffDelay` from `@ultimat3/realtime` | you import it. Import core's: `import { backoffDelay } from '@ultimat3/core'`, and pass `attempt: n + 1` — realtime's copy counted from 0, core's counts from 1. A failed channel catch-up now retries after the base wait, not twice it |
| 19 | `realtime.transport`, `NATS_URL`, `selectTransport` | your deploy relied on `NATS_URL` alone to pick the bus. `realtime.transport` decides now: `'nats'` dials the variable `realtime.urlEnv` names and refuses the boot (`X_CONFIG_INVALID`) when it is unset — it used to fall back to in-process in silence — and `'memory'` with `NATS_URL` set refuses too. Set `transport: 'nats'` where the fleet shares a bus, or unset `NATS_URL`. `realtime.enabled: false` now starts no `sync` node and no replicator. `transport: 'redis'` (never built) no longer typechecks. `selectTransport(env)` → `selectTransport(env, { transport, urlEnv })` |
| 20 | `startLiveReplicator`, `LiveReplicator`, `LiveReplicatorOptions` from `@ultimat3/testing` | you import them — a live-query test or a hand-rolled dev boot. Import them from `@ultimat3/realtime/server`; `@ultimat3/testing`'s `subscribe` fixture needs nothing |
| 21 | `@ultimat3/cli`'s barrel | you import anything from it but `newCommand`, `dbCommand`, `verifyCommand` and the documented API — 236 internals are gone (command objects, scan internals, report helpers, option types). `maskLiterals` / `stripComments` → `import { maskLiterals, stripComments } from '@ultimat3/core'`. The CLI no longer depends on `@ultimat3/scraping`, so a workspace that reached scraping through it declares it itself |
| 22 | `x db gen`'s schema hash | `x verify --only drift` reports drift right after the upgrade with no entity edited: the hash now uses core's `canonicalJson`. Run `x db gen` once. Neither tracked app needed it |
| 23 | a query's `sql: () => from(…)` over rows its loader `select`ed | the query filters (`where`) or sorts (`orderBy`) on a column the loader's `select({ … })` leaves out. That read used to answer `[]` in silence; it throws `X_QUERY_COLUMN_UNSELECTED` (`QueryColumnUnselectedError`) now. Add the column to the loader's `select`, or drop the filter if the loader already applies it. The reference app's `publicPostSlugs` was one — the blog prerendered no article until `publishedSlugs` selected `status` and `publishedAt` |

### Where the sites are

```sh
grep -rnE "from<|from '@ultimat3/cli'|LiveReplicator|puppeteer|X_SCRAPE_|backoffDelay|selectTransport|transport: 'redis'|expectedConfirmation|planDeploy\(|\bResult<|\b(tryCatch|unwrapOr|mapErr)\(|X_(USERS|SESSIONS|ACCOUNTS|VERIFICATIONS|API_KEYS)_TABLE|X_USERS_MIGRATION_1_3|saveQueue|entityRow\(|\bcamel\b|case 'delete':" apps packages --include=*.ts --include=*.tsx
grep -rnE "from '@ultimat3/cli'" apps packages scripts --include=*.ts | grep -E "e2e|cdp|Chrome|E2e|Cdp"
grep -rnE "channel\(" apps packages --include=*.ts | grep -v policy
```

The `typecheck` step finds every removed name, `planDeploy`'s arity and a missing `policy`. It does
not find a non-ISO date string in a fixture, a seed or a client — those fail at validation, so run
your contract and e2e suites — nor a helm release still named `app`.
