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 selected | 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
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 policyThe 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.