Ultimate

10.x → 11.0.0, entry by entry

Seven breaking entries, from a shutdown, cache and disclosure sweep. Four are compile errors the moment you upgrade. Three are not — and one of those changes what a CDN is allowed to store for a signed-in visitor, so read entries 3, 4 and 5 even if nothing here fails to compile. No app.config.ts key moves in this major, so there is no config edit to start with. No codemod.

#SurfaceCosts you an edit if
1isrKey(url, locale) takes the negotiated locale, and it is part of the store keyyou call isrKey — the document a multi-locale isr route serves changes either way
2IsrStore gains a required markStale(path)you implement IsrStore yourself
3HttpConfig.drainTimeoutMs is number | null, default nullyou read the resolved field — and the drain budget moves 15s → 25s for an app that declared neither
4an unclassified 5xx problem document carries no exception texta client reads title / detail / cause off a 500
5a request carrying an identity gets private, max-age=0; every shared response varies on cookie and x-timezonenever — a CDN leak, closed. Personalised pages stop being shared-cacheable, which is the fix
6initialsOf(name, locale) takes a required localeyou call initialsOf directly; <Avatar> is unchanged
7@ultimat3/pwa deletes RetryPolicy, DEFAULT_RETRY, retryDelayMs, shouldRetry and BackgroundSyncOptions.retryyou imported one from @ultimat3/pwa. @ultimat3/jobs exports two of those names and is untouched

Entries 1, 2, 6 and 7 — a compile error the moment you upgrade

1. Pass the negotiated locale to isrKey. TS2554, one argument short.

- const served = await isr.serve(isrKey(url), () => renderPage(url));
+ const served = await isr.serve(isrKey(url, ctx.locale), () => renderPage(url));

isrKey is on @ultimat3/render/server, where 9.0.0 put it. The locale rides in a reserved query parameter — __x_locale, exported as ISR_LOCALE_PARAM — and not as a prefix, because routePathOf splits a key at its ?: an es:/blog key matches no route, so descriptorFor answers undefined and a declared revalidate: { ttl } silently becomes tag-only.

One entry per path served visitor 2 the document negotiated for visitor 1 — <html lang>, every t() — for the whole TTL, and s-maxage told the CDN to do the same. toResult emits vary: accept-language now for the CDN half; the rest of the shared key comes from @ultimat3/http's cache-headers stage, which sees the actor this function cannot.

The time zone is deliberately not a dimension: a locale set is declared and bounded, a zone list is not. A date on an isr page belongs in a zone the page itself names, or the page belongs in ssr.

2. Implement markStale in place. Only a custom IsrStore pays this — memoryIsrStore() has it.

  const store: IsrStore = {
    get: (path) => map.get(path),
    set: (entry) => { map.delete(entry.path); map.set(entry.path, entry); },
+   markStale: (path) => {
+     const entry = map.get(path);
+     if (entry === undefined) return false;
+     map.set(path, { ...entry, stale: true });   // in place: the position IS the eviction order
+     return true;
+   },
    delete: (path) => { map.delete(path); },
    paths: () => [...map.keys()].sort(),
  };

Never set({ ...entry, stale: true }) — that read-modify-write is the defect the member exists to end. set means "this page was just generated" and a store is entitled to order eviction by exactly that, so marking through it made the stalest page the newest: a tag bust protected the pages that most needed regenerating.

Second half, and it costs nothing: regenerate samples a cache fence before the render and does not store an entry the fence invalidated. A bust landing mid-render was previously erased by store.set({ stale: false }), and for a tag-only route isFresh is true forever — so the process served pre-write HTML for the rest of its life.

6. Pass the locale to initialsOf. TS2554.

- initialsOf(member.displayName)
+ initialsOf(member.displayName, useUi().locale)

<Avatar> reads useUi().locale itself, so a component tree pays nothing. A bare toLocaleUpperCase() reads the runtime's default locale — a server's LANG, a browser's UI language, never the request's — so one Turkish name uppercased to İ on the server and I in the browser, out of identical props. @ultimat3/ui has no ambient locale to fall back on, by rule.

7. Delete the import; there is nothing to replace it with. TS2305.

- import { backgroundSyncSource, DEFAULT_RETRY, type RetryPolicy } from '@ultimat3/pwa';
+ import { backgroundSyncSource } from '@ultimat3/pwa';

- backgroundSyncSource({ flushEndpoint, retry: { ...DEFAULT_RETRY, maxAttempts: 5 } });
+ backgroundSyncSource({ flushEndpoint });

BackgroundSyncOptions is { flushEndpoint?: string } and nothing else — in this release. 21.0.0 removes it as well, together with the flush route; see entry 8 of 20.x → 21.0.0. This package schedules no retry and never did: the one-shot sync handler rejects and the platform decides when to wake it again. Of the policy only maxAttempts reached the emitted worker, as a SYNC_MAX_ATTEMPTS constant nothing read, and X_PWA_SYNC_INCOMPLETE's fix: told the reader to raise pwa.backgroundSync.retry.maxAttempts — a key PwaConfig has never carried, because backgroundSync is a boolean. Error codes already says so.

@ultimat3/jobs is a different package carrying two of those names. RetryPolicy and DEFAULT_RETRY are still exported from it, still read by the worker, unchanged. Only pwa's copies are gone, and a RetryPolicy on a job() is not one of them.

Entries 3, 4 and 5 — nothing fails to compile, and a caller can see the difference

3. drainTimeoutMs is number | null, and null means "this app did not say".

What the app declaredDrain deadline beforeNow
nothing15,000ms25,000ms — core's own DEFAULT_DEADLINE_MS
configureLifecycle({ deadlineMs: 600_000 })15,000ms — reverted by the next line of boot600,000ms
defineHttpConfig({ drainTimeoutMs: 5_000 })5,000ms5,000ms

createServer calls configureLifecycle({ deadlineMs }) only when the app declared one (packages/http/src/server.ts:101). Unconditional, with defineHttpConfig defaulting the number, that line reverted the exact edit X_SHUTDOWN_TIMEOUT's own fix: prints — silently, in every process that serves web.

Edit only if you relied on the 15s default — write it down:

- defineHttpConfig({ rateLimit: { scope: 'process' } })
+ defineHttpConfig({ rateLimit: { scope: 'process' }, drainTimeoutMs: 15_000 })

The INPUT field is still number | undefined, so a declaration compiles unchanged. The RESOLVED field is number | null, so const ms: number = config.drainTimeoutMs is TS2322 — that is the compile half, and it reaches only a caller that reads the merged config back.

4. An unclassified 5xx says nothing about the exception that caused it.

MemberOn a 5xx nobody classifiedOn a coded refusal
type, status, code, fix, docs, requestIdunchangedunchanged
titleunhandled server errorthe code's own title
detail, causeone fixed sentence pointing at this process's logs, under the request idthe authored cause

"Unclassified" is X_INTERNAL, or a code with no row in @ultimat3/http's table and no registerErrorStatus row — deliberately not status >= 500, which would have blanked X_DRAINING's one instruction. dev: true is unchanged, and the text is not lost: the error-map stage logs it as a redactable field and reports every 5xx to the error monitor, both keyed by requestId.

A pg message quoting the rejected row, a driver message quoting the DSN, went to any non-HTML client in production. error-page.ts had locked the browser out of exactly this, so the two audiences disagreed about one condition.

Edit only if a client parsed those members. Match on code, correlate on requestId. A 5xx of your own that should keep its authored cause needs a status of its own — that is what makes it classified:

registerErrorStatus({ X_PAYMENTS_UNREACHABLE: 502 });   // from @ultimat3/http, once at boot

5. A request carrying an identity is private, max-age=0, whatever the handler declared.

- cache-control: public, max-age=0, s-maxage=30, stale-while-revalidate=300
+ cache-control: private, max-age=0

The cache-headers stage reviews a cache-control the handler wrote instead of standing down: a declaration offering the response to a shared cache (public, or an s-maxage) plus a non-anonymous actor is replaced. immutable is the one exception — it asserts the body is a function of the URL alone, which a content-addressed island chunk or image is, and demoting those re-downloads every chunk on every navigation for every signed-in user.

An anonymous shared response is unchanged except that it now carries vary: accept-language, cookie, x-timezone. Both halves are needed: private for the identified request, vary: cookie for the shared one.

What was happening: ssrHeaders offers any route without a policy to a CDN for 30 seconds, and meta.auth is 'public' | 'required' — so the commonest page in any app, public but greeting you by name when you are signed in, is a 'public' route whose own header said s-maxage. That is the shape x g route --surface app scaffolds.

No edit, and expect the shared-cache hit rate on personalised pages to go to zero — that is the fix, not a regression. A route that really is a function of the URL alone says so: cache-control: public, max-age=31536000, immutable.

Fixed in the same release, and none of it costs an edit

Read these if you built a workaround for one.

FixWhat stops happening
the framework's CSP admits its own hydration runtime in productionno island booted after deploy, anywhere the policy is enforced. script-src was 'self' 'wasm-unsafe-eval' with no hash while the runtime is an inline module — report-only under x dev, enforced in a container. startWeb now hashes the seven HYDRATE_RUNTIME_BODIES into script-src, as it already did for styles
a browser gets an error page, not problem+jsona 404 or a 500 rendering the internal cause and the author-facing fix: into a visitor's window. Copy is the catalog's errors.* keys; override per status with apps/web/site/errors/<status>.html, and x dev keeps the overlay
worker, scheduler and sync drain in two phases, and holdUntilShutdown reaches the exitone accept hook spending the whole budget before "stop listening" and "stop upgrading" had been invoked at all — 4 hooks started, none finished — and an overrun wedging the process until the kubelet's SIGKILL, where the job lease lapsed and another worker re-ran it. Behaviour change: a job outrunning configureLifecycle({ deadlineMs }) is abandoned (jobs.worker.drain-abandoned) and the queue redelivers it, where the teardown used to hang forever with the driver open. Raise the budget past your slowest job
a worker's fleet slot is released before the driver closes, and a renewal interval is unrefeda concurrency: 1 job unclaimable by the replacement pod for a full visibility window after every deploy, and a refed interval holding a drained process open until SIGKILL
the scheduler re-asserts leadership before every task, not once per roundthe tail of a round dispatching under a lease another node already took. The occurrence key does not absorb it: SQL_ENQUEUE's conflict target is partial over the live states, so a duplicate landing after that job finished inserts a new row and the handler runs twice
a replayed backfill batch writes no ledger row4,800 x_backfills UPDATEs before a resumed 5M-row sweep read a single new row, on every attempt, inside the visibility lease
a server render gets a live client instead of a 500a page whose body reads a live query failing on the server; it renders its loading branch and the browser takes over on hydrate. mutate() / drain() there are X_LIVE_SERVER_RENDER
createLogger({ level: 'verbose' }) is refused at constructionan unknown level failing open — every level emitted
one documented first run, and it is bin/setupcd myapp && x dev failing on X_BUILD_FAILED because x new installs nothing — which eight doc pages, and x new's own closing line, told the reader to do. wiki/Installation.md also listed six x new flags that do not exist

Edit this page on GitHubThis page as markdown