# 20.x → 21.0.0, entry by entry

**Twenty-seven entries.** Entries 1–3, 5, 8, 9, 11–17, 20–23 and 27 are compile errors. Entry 24
is a stale file one rebuild replaces, entry 25 is a `budgets` finding, and entry 26 is capacity
planning, not code. Entry 10
throws when the module loads, and entry 18 when `docker compose up` starts. Entry 19 is a
number that means something else. Entries 4, 6 and 7 compile
unchanged and answer differently: a wider `isSuperseded`, a coded error where a bare `TypeError`
or `SyntaxError` used to arrive, and a new response body for non-framework HTTP clients. Entry 2
also changes what `'last-write-wins'` keeps, and entry 8 leaves two error codes that nothing
throws. 21.0.0 is the client data layer: one record store per tab, one HTTP seam and one socket
per origin. The entries are `CHANGELOG.md`'s `21.0.0` section.

| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | `AsyncState` | you import it from `@ultimat3/ui`. It moved to `@ultimat3/core` unchanged, and ui does not re-export it. `import type { AsyncState } from '@ultimat3/ui'` → `import type { AsyncState } from '@ultimat3/core'`, plus `@ultimat3/core` in that workspace's `dependencies` if it is missing. `AsyncRegion`, `asyncBranch`, `AsyncBranch` and `AsyncFlags` stay in ui |
| 2 | a mutator's `conflict: custom(merge)` | you wrote a `merge`. It now receives the local and server **rows** of the record, not the mutator's parsed outputs: `custom<PostRow>((local, server) => ({ ...server, title: local.title }))`. The output-shaped merge never ran. Realtime's rebase holds rows, so it dropped the policy without a word and used its default. Also: `resolveConflict` moved to `@ultimat3/core` (its answer is `Row`, so narrow it); `Conflict`, `CustomConflict` and `strategyOf` are gone from `@ultimat3/action`, and `strategyOf(c)` is `typeof c === 'string' ? c : c.kind`. **No compile error warns you** that `'last-write-wins'` now keeps the local row only when its `updatedAt` is newer than the server's. A row with no numeric clock resolves to the server's |
| 3 | `ConflictLike`, `custom`, `CustomMerge`, `MergeArgs` or `ConflictStrategy` from `@ultimat3/realtime` | you import any of them. Types: `import type { ConflictPolicy } from '@ultimat3/core'`. Realtime's own `custom(({ local, base, server }) => …)` becomes action's `custom((local, server) => …)`: no `base`, and the merge must return a row with a string `id`, or the rebase throws `X_REBASE_CONFLICT`. **Delete any branch that returned `null` to accept a delete.** The merge is no longer called when the server deleted the row or the client never held one; the server's answer lands as it is |
| 4 | `isSuperseded(error)` | you took `true` to mean `X_SUPERSEDED`. It is also `true` for `X_CLIENT_SCOPE_CHANGED`, a read in flight when the page changed principal. Code that branches on `isSuperseded` needs nothing; code that needs the specific code reads `error.code` |
| 5 | `QueryRequestFailedError`, `QueryProblem` from `@ultimat3/query` | you import either. A query's failure is a plain `UltimateError` now: `if (e instanceof QueryRequestFailedError)` → `if (isUltimateError(e))`, then switch on `e.code`. A query's non-2xx with no framework code is `X_CLIENT_TRANSPORT_FAILED`, not `X_RPC_FAILED`, so update a match on the old code |
| 6 | a typed client's network fault, non-JSON 2xx, or non-2xx with no framework code | you catch `TypeError`, `SyntaxError` or `RpcFailedError`, or match `'X_RPC_FAILED'`, around `rpc()`, `.client()`, `queryClient()` or `@ultimat3/storage`'s `uploadFile()` on its no-XHR fallback (`fetchSignedPut`). All of them are `X_CLIENT_TRANSPORT_FAILED` now: `if (isUltimateError(e) && e.code === 'X_CLIENT_TRANSPORT_FAILED')`. `RpcFailedError` still exports and nothing throws it, so an `instanceof` check against it compiles and never matches. `RemoteActionError` (the server's own code) and `X_CONTRACT_DRIFT` are unchanged |
| 7 | the body and OpenAPI `200` of an action whose output references an entity row | something other than an `@ultimat3/*` client reads it: a generated SDK, `curl`, a test posting with `fetch`. Under `x-ultimate-records: 1` the output is `body.data`, beside `records`. Regenerate an SDK from the new `openapi.json`. `rpc()` and `.client()` strip the envelope, so typed callers need nothing |
| 8 | `@ultimat3/pwa`'s background sync | you pass `backgroundSync: { flushEndpoint }` to `generateServiceWorker`, import `DEFAULT_FLUSH_ENDPOINT` or `BackgroundSyncOptions`, call `backgroundSyncSource(opts)`, or mounted `/_x/outbox/flush` yourself. Delete the option and the imports, call `backgroundSyncSource()` with no arguments, and delete the route. The worker now posts `OUTBOX_DRAIN_MESSAGE` to open tabs, and the page's outbox replays. `pwa.backgroundSync: true` in `app.config.ts` is unchanged. **Also delete any branch matching `X_PWA_SYNC_FLUSH_FAILED` or `X_PWA_SYNC_INCOMPLETE`:** both stay registered, nothing throws them, and such a branch compiles and never runs |
| 9 | `ClientScope.principal` | you narrow it with `!== null` and use it as a `string`. It is `string \| null \| undefined` now. `undefined` is an unscoped page, rendered for nobody, for which nothing is persisted. Test `typeof principal === 'string'`, and give a `switch` an `undefined` arm |
| 10 | a mutator declaring `conflict: 'last-write-wins'` | its entity has no **number** `updatedAt` the server writes (a `timestamp()` string does not count), or its output carries no entity row. It now throws `X_MUTATOR_CLOCK_MISSING` when the module loads. Add `updatedAt` as a number column (epoch ms) that the server writes on every update, or declare `conflict: 'server-wins'`. Without a clock the server row already won every time, so `'server-wins'` changes no behaviour |
| 11 | `setLiveClient`, `clearLiveClient`, `hasLiveClient`, `LiveClient`, `ClientSocket` | your island builds a `LiveClient`. Delete the socket adapter, the sync-URL module, `new LiveClient(…)`, `client.connect()` and `setLiveClient(client)`. Add `installRealtime({ signal: createSignal })` in `mount` before the first render. `hasLiveClient()` → `hasPageSocket()`. `client.subscribe(topic, fn)` → `useChannel(decl, params, { onEvent, onPresence })` or `usePresence(decl, params)` |
| 12 | `useLive`, `LiveRows`, `LiveInput`, `liveHookFor` | you read a live query. `useLive<Row>({ name }, input)` → `useQuery<Row>({ name, live: true }, input)`. `feed.state() === 'live'` → `feed().status === 'ready'`, the rows are `feed().data`, and `unsubscribe()` → `release()`. Delete `liveHookFor` bindings |
| 13 | `IdentityMap`, `privateScope`, `rowKey`, `RowScope`, `RowKey` | you import them. `RecordStore`, `recordKey(type, key)`, `RecordKey`; read one record with `useRecord(type, key)` |
| 14 | `createOpfsLocalStore`, `RebaseLog`, `reconcile`, `rebaseFrame`, `strategyName`, `serverRenderLiveClient`; the journalling `MemoryLocalStore` / `LocalStore` | you pass a store, queue or log to the client, or use them in a test. Delete them: the optimistic apply needs nothing now. `MemoryLocalStore` and `LocalStore` still export, but name the **persistence** store now (`rows`, `write`, `queue`, `wipe`), not 20.x's journal (`apply`, `rollback`, `commit`), so code written against the old shape stops compiling. Durable offline writes ship: an entity declared `persist: true` is restored from IndexedDB by the page boot (`openLocalStore`, `pageLocalStore`), and its queued mutations replay through `pageOutbox()` |
| 15 | `useMutation`, `useMutationQueue`, `MutatorLike` | you call `drain()`, set `entity:` on a `MutatorLike`, or re-query after a write. Delete `drain()` and `entity:`. The call resolves with the action's output, so read it directly. Writes go over HTTP, so a mutation now persists where it used to answer `X_NOT_IMPLEMENTED`. With no response at all (`meta.failure: 'network'`) the call resolves `undefined` and the write is queued; code that needs the output checks for it. A `'status'` or `'body'` failure still rejects |
| 16 | sync protocol 3; `createSyncNode({ onMutate })`, `MutationHandler` | you run a `sync` node. Delete `onMutate`, and redeploy clients and nodes together: a v2 client and a v3 node refuse each other with `X_PROTOCOL_VERSION` |
| 17 | `topic` / `Topic` from `@ultimat3/realtime/server` | you import it there. Import it from `@ultimat3/realtime` |
| 18 | Compose deploys: `docker-compose.prod.yml`, the scaffold, both tracked apps | you deploy with Compose. `web` now refuses to start without `SYNC_URL`. Set `SYNC_URL=ws://<host>:3001/_x/sync` in `.env.production` and run Compose with `--env-file .env.production` (`x deploy` does), or proxy `/_x/sync` to `sync` and set `SYNC_URL=wss://<host>/_x/sync`. A copied compose file adds the same line to `web` |
| 19 | `x verify --json` → `data.durationMs` | you sum step times or read the total as a sum. It is wall time now; the static steps overlap the serial suites |
| 20 | the `presence` frame, `PresenceFrame` | you read rosters. Declare the room `channel(name, { …, events: true })` and read `readPresence(frame.event)` in its events handler; a roster is an `events` frame `{ presence: op, members, total? }` now |
| 21 | `ChannelHub#guard`, `subscribe(socket, topic)`, `publish(topic)`, `publishFrame`, `channelFrame`, `TopicGuard*`, `nodeId`, `{ kind: 'topic' }` | you run a hub. Declare each channel with `channel(name, { params, policy, row?, catchUp, records?, events? })`. Build the hub as `new ChannelHub({ transport, sockets })`. `guard` → the declaration's `policy`; `publish(topic, event)` → `hub.publishEvent(decl, params, event)`. Delete `nodeId`. An undeclared name is `X_TOPIC_FORBIDDEN` |
| 22 | `LocalTable`, `LocalRow`, `tx.<type>.insert(row)` / `upsert(row)` / `update(id, …)` | you write rows in a `local` twin, or type one. Tables are addressed by record key: `tx.posts.insert(post.id, post)`, `upsert(key, row)`, `update(key, patch)`, `delete(key)`, and `get(key)` / `all()` to read. `LocalRow` is gone: `LocalTable<Post>` |
| 23 | `cdpE2ePage`, `CdpE2ePageOptions` from `@ultimat3/cli` | your e2e suite opens a page itself. Use `openE2eBrowser()` and its page tab, `session.newTab()` for a second tab, or `cdpE2eTab({ … })` on an existing connection |
| 24 | `.x/build-stats.json` | you ran `x build` before upgrading. Every budgeted route reads `X_BUDGET_UNMEASURED` until `x build --target static` runs again, because the stats file carries the measurement rules that wrote it and v1 files are not read |
| 25 | `hasPageSocket()` as a guard in page code | a module no island imports calls it (an offline banner, an update prompt in a layout). The `budgets` step now reports it as `X_LIVE_ROUTE_NO_ISLAND`. Move the module into an island: `x g island <route-dir> --at <route-dir>`, import it from the island's `mount()`, declare `island({ src })` |
| 26 | a browser's reconnect backoff | you sized a sync node on the 30 s spread of reconnects. Browsers now redial within 4 s (`equal` jitter from 500 ms). Check the node's `AcceptBudget` sheds a SIGKILL herd arriving in 2–4 s. Nothing to change in app code |
| 27 | `LaunchedBrowser.endpoint` from `@ultimat3/cli` | you launch Chrome yourself for tests. `cdpConnect(browser.endpoint)` → `browser.connection`, which is already answering over the debugging pipe. `cdpConnect(url)` remains for a remote browser |

### Where the sites are

```sh
grep -rnE "import type \{[^}]*AsyncState[^}]*\} from '@ultimat3/ui'" apps packages --include=*.ts --include=*.tsx
grep -rnE "ConflictLike|ConflictStrategy|CustomMerge|MergeArgs|CustomConflict|strategyOf|resolveConflict|custom\(|QueryRequestFailedError|QueryProblem|X_RPC_FAILED|instanceof (TypeError|SyntaxError|RpcFailedError)|isSuperseded|flushEndpoint|DEFAULT_FLUSH_ENDPOINT|BackgroundSyncOptions|outbox/flush|X_PWA_SYNC_|\.principal|last-write-wins|LiveClient|useLive|liveHookFor|IdentityMap|rowKey|privateScope|LocalStore|RebaseLog|reconcile|serverRenderLiveClient|drain\(|onMutate|realtime/server|PresenceFrame|\.guard\(|publishFrame|channelFrame|TopicGuard|nodeId" apps packages --include=*.ts --include=*.tsx
```

The `typecheck` step finds every removed name for you. It does not find a `custom(` merge whose
type argument is still the output type: `custom<TRow>` takes any object type, so that merge
compiles and reads `undefined` for every output field that is not a column of the row. The second
grep lists every `custom(`. Read each one.
