Ultimate

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.

#SurfaceCosts you an edit if
1AsyncStateyou 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
2a 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
3ConflictLike, custom, CustomMerge, MergeArgs or ConflictStrategy from @ultimat3/realtimeyou 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
4isSuperseded(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
5QueryRequestFailedError, QueryProblem from @ultimat3/queryyou 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
6a typed client's network fault, non-JSON 2xx, or non-2xx with no framework codeyou 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
7the body and OpenAPI 200 of an action whose output references an entity rowsomething 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 syncyou 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
9ClientScope.principalyou 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
10a 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
11setLiveClient, clearLiveClient, hasLiveClient, LiveClient, ClientSocketyour 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)
12useLive, LiveRows, LiveInput, liveHookForyou 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
13IdentityMap, privateScope, rowKey, RowScope, RowKeyyou import them. RecordStore, recordKey(type, key), RecordKey; read one record with useRecord(type, key)
14createOpfsLocalStore, RebaseLog, reconcile, rebaseFrame, strategyName, serverRenderLiveClient; the journalling MemoryLocalStore / LocalStoreyou 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()
15useMutation, useMutationQueue, MutatorLikeyou 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
16sync protocol 3; createSyncNode({ onMutate }), MutationHandleryou 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
17topic / Topic from @ultimat3/realtime/serveryou import it there. Import it from @ultimat3/realtime
18Compose deploys: docker-compose.prod.yml, the scaffold, both tracked appsyou 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
19x verify --json → data.durationMsyou sum step times or read the total as a sum. It is wall time now; the static steps overlap the serial suites
20the presence frame, PresenceFrameyou 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
21ChannelHub#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
22LocalTable, 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>
23cdpE2ePage, CdpE2ePageOptions from @ultimat3/cliyour 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.jsonyou 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
25hasPageSocket() as a guard in page codea 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 })
26a browser's reconnect backoffyou 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
27LaunchedBrowser.endpoint from @ultimat3/cliyou 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

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.

Edit this page on GitHubThis page as markdown