Ultimate

11.x → 12.0.0, entry by entry

Sixteen breaking entries, in three groups. Eight are compile errors the moment you upgrade (4, 5, 6, 8, 9, 13, 14, 16). Four need an action before or at the deploy and nothing fails to compile (1, 2, 3, 11) — every persisted pagination cursor stops working, rows sharing a sort value change order, one index migration, and sync nodes and browser clients must ship together. The last four are visible only to a caller at runtime (7, 10, 12, 15).

No app.config.ts key moves, because the surface this major opens never had one: AppConfig has never carried an http member, so configureHttp() is an addition and not a migration — see the last table. No codemod: every entry names its own manual edit.

#SurfaceCosts you an edit if
1every cursor minted before 12.0.0 is X_CURSOR_INVALIDyou persisted a cursor — in a URL, a job payload, a client store. Nothing fails to compile
2the primary-key tiebreak takes the last declared key's directionyou depended on the order of rows sharing a sort value. Nothing fails to compile
3an index declaring where or order is renamed <table>_<cols>_<hash8>_idxyou declared one — a migration, before the deploy
4a physical column or table name must be [a-z_][a-z0-9_$]*, at most 63 bytesyou wrote .column('createdAt') or an entity(name) / table that is not lower-snake
5Repo gains aggregate and approximateCount; ReadBuilder gains five terminalsyou implement Repo or Driver yourself
6Operator gains contains, contained-by, overlaps, has-keyyou switch exhaustively over Operator
7introspect() returns app tables onlyyou read its output, or assert how many catalog queries it issues
8rateLimitKey is deleted; RateLimitConfig.tenantBucket is requiredyou called rateLimitKey, or built a full RateLimitConfig
9Ctx gains a required deadlineAtyou hand-build a Ctx — a test fixture, a custom host
10traceHeaders() sends the remaining request budgetnever for the caller; a downstream service now receives x-request-timeout-ms
11PROTOCOL_VERSION 1 → 2, and five realtime exports are deletedyou imported one — and every deployment redeploys clients and sync nodes together
12MCP rate limits are enforced, 120 read / 20 write per minute per actoran agent exceeded them; it was previously unmetered
13memoryAuditSink() is bounded at 1,000 and discards oldest-firstyou used it as a system of record, or implement MemoryAuditSink
14RouteBudget.css, .cls and .tbt are deletedyou declared one — it was ignored, and now it does not compile
15claim({ queues: [] }) is refused; the memory driver's claim is asyncyou call a JobDriver directly
16DoctorProbe gains a required database()you implement DoctorProbe

Entries 4, 5, 6, 8, 9, 13, 14 and 16 — a compile error the moment you upgrade

4. Spell every physical name lower-snake. X_INVARIANT_VIOLATED at entity(), before any statement runs.

- createdAt: timestamp().column('createdAt'),
+ createdAt: timestamp().column('created_at'),

A derived name is unaffected: columnName is meta.name ?? snake(property) and snake() lower-cases, so createdAt: timestamp() still writes created_at. What changed is that the derived branch is now checked too — for three majors only meta.name was, so a property named n" , "x" text); drop table t; -- put a real drop table inside a generated create table. Quoting is not a defence against a value that can close the quote. entity(name) and table go through the same assertion.

5. Implement the two new Repo members, or stop hand-rolling one. TS2739.

  const repo: Repo<Post> = {
    findById, findMany, insert, update, delete: remove, count, countBy,
+   aggregate: (fn, column, args) => driverAggregate(fn, column, args),
+   approximateCount: async () => null,        // `null` is "never analysed", and always legal
  };

approximateCount may answer null unconditionally — that is the documented value for a table nobody has ANALYZEd, and every caller already handles it. aggregate cannot be stubbed the same way: a wrong number is worse than no number, so raise X_AGGREGATE_UNSUPPORTED if you will not implement it.

6. Widen the switch. TS2366, or a silent fallthrough if it had a default.

    case 'is-not-null': return sql`${col} is not null`;
+   case 'contains':      return sql`${col} @> ${bind}`;
+   case 'contained-by':  return sql`${col} <@ ${bind}`;
+   case 'overlaps':      return sql`${col} && ${bind}`;
+   case 'has-key':       return sql`${col} ? ${bind}`;

The four exist so a declared json() or arrayOf() column is no longer write-only from the query language.

8. rateLimitSpends answers a LIST, not a key. TS2305.

- const key = rateLimitKey(route, ctx);
- await limiter.assert(key, bucket);
+ for (const spend of rateLimitSpends(route, ctx, config)) {
+   await limiter.assert(spend.key, spend.bucket);
+ }

One request spends the caller's bucket and then the tenant's, stopping at the first refusal. The old builder answered actor else org else ip, exclusively — and the anonymous actor answers null for both of the first two — so no HTTP request ever spent an org bucket. RateLimitConfig gains a required tenantBucket: string | null; null is "this app has no per-tenant allowance", which is the default and the previous behaviour.

9. Add deadlineAt to a hand-built Ctx. TS2741.

  const ctx: Ctx = {
    requestId, traceId, locale, tz, buildId, role, actor, now,
+   deadlineAt: null,        // null is "no deadline", which is what a job or a test has
  };

createContext({ … }) already defaults it, so only a literal pays. It is what remainingBudgetMs(ctx) reads and therefore what entry 10 propagates.

13. memoryAuditSink() is not a system of record. The interface break is TS2739 on size/dropped; the behaviour break is silent.

- setAuditSink(memoryAuditSink());
+ setAuditSink(postgresAuditSink({
+   executor: { query: (text, values) => db().query({ text, values }) },
+ }));

Past 1,000 records the memory sink drops the oldest on every write, so an audited action can run, succeed, be recorded and leave nothing behind. { maxRecords } raises the bound and does not remove it. dropped is non-zero exactly when the sink is telling you it is the wrong one. x_audit is applied at boot beside the jobs, idempotency and rate-limit tables, so there is no migration to write.

14. Delete the budget key. TS2353.

  budget: {
    js: '40kb',
-   css: '12kb',
-   cls: 0.1,
  },

There is nothing to replace them with. All three were declared on the route contract, flattened away by registerRoute — which projects a budget to budgetJs + budgetLcp and nothing else — and read by no consumer anywhere, so a declared CSS budget was ignored while the budgets step reported green. A new budget key is now a build error until the descriptor projects it (_EveryBudgetKeyIsProjected, packages/render/src/type-pins.tsx). budget.lcp survives and is published, not enforced: nothing in the build observes a paint.

16. Implement DoctorProbe.database(). TS2741. Only a hand-written probe pays — x doctor's own is unchanged.

  const probe: DoctorProbe = {
    bunVersion, root, port, production, devCursorSecret, devStorageSecret,
+   database: () => probeDatabase(process.env['DATABASE_URL']),
  };

x doctor answered "shippable" while probing the web port alone: a wrong password or a database that does not exist accepts the socket and refuses the session, which a port check cannot see. It now probes both ports and the database.

Entries 1, 2, 3 and 11 — do this before the deploy

1. Drop every persisted cursor. Nothing fails to compile; a stored cursor is refused at decode with X_CURSOR_INVALID.

Where a cursor livesDo this
a URL a client holdsnothing — request the first page (after: null) and re-mint
a job payload, a resumable inBatches() positionre-enqueue from the start, or from a business key of your own
a client store, a saved viewclear it on the version bump

Two changes make an old cursor unreadable, and both were forced by one defect. A timestamp() sort key is now carried as a microsecond epoch rather than an ISO string, because ORDER BY evaluates timestamptz at microsecond precision while the seek treated a whole millisecond as one equality class — two different equality classes over one page boundary. Reproduced against Postgres 16: three rows inside one millisecond, uuid-v7 ids, orderBy('createdAt','desc').limit(1) returned 1 of 3 rows and stopped, every time. And every entry is tagged — ~ for an absent value, ! before a present one — so an absence can be told from the text that spells it, which is what nullable sort keys need.

The seek is now a plain < / > / = against $n::timestamptz; nextMillisecond and the >= v and < v + 1ms window are gone. A read ordered by a timestamp() column carries one extra output column on the wire, "<col>$US" — a name no entity can declare, stripped by decodeRow, so it reaches no caller's row. Only a test that asserts SQL text sees it, which is exactly the suite that hid this defect for three majors.

2. The default total order is uniform-direction.

- ORDER BY created_at DESC, id ASC
+ ORDER BY created_at DESC, id DESC

totalOrder appends the primary key in the last declared key's direction rather than always ascending, so orderBy('createdAt','desc') runs created_at desc, id desc. Rows sharing a sort value come back in the opposite order to before, and no page is lost either way — the seek matches the order it is built from.

Two things follow. A mixed-direction order was un-indexable by this framework's own index DSL, so the default one could never be served by a declared index. And a uniform order is now emitted as a row comparison (a, b) < ($1, $2), measured on PG16 as an Index Only Scan against BitmapOr + Sort for the or-chain. The or-chain remains for an order you wrote as mixed yourself, and for one whose keys are not all NOT NULL: a row comparison has no null ordering, so a NULL on either side makes the whole comparison unknown.

3. Rename the indexes that declare where or order. A declared index is matched by name, so the old one is not dropped and the new one is not created until you say so.

x db gen "rename partial and ordered indexes"   # then read the emitted up/down before applying
-- what the generated migration looks like, one pair per affected index
alter index posts_author_id_idx rename to posts_author_id_9f2c1ab4_idx;

The discriminator is sha256("<order>|<where>"), first 8 hex. Plain and unique() names are unchanged, deliberately: Postgres names a column-level unique() index <table>_<column>_key itself, so a discriminator there would make the generator emit a second create unique index for an index that already exists (42P07), and a foreign key's own index is deduped against a hand-declared one by the plain name.

Without it, two different partial indexes on one column were one name — posts_author_id_idx for both where status = 'published' and where status = 'draft' — and the second was dropped with no error, no warning and no drift finding. So this migration may create an index you declared years ago and never had. A name over 63 bytes is now refused at declaration rather than truncated by the server in silence.

11. Redeploy clients and sync nodes together. PROTOCOL_VERSION moves 1 → 2 and a skewed peer is refused with X_PROTOCOL_VERSION, in both directions — a cursor rides the client's subscribe and the node's snapshot, and the deleted fields were decoded through str() / num(), which throw on absence. There is no rolling window in which the two versions interoperate.

- import { digestOf, DIGEST_UNVERIFIED, fnv1a } from '@ultimat3/realtime';
+ // nothing replaces them

LiveCursor.digest and LiveCursor.count are gone with them. Every snapshot ran canonicalJson over every row and hashed it for a value no code path read — a full serialize-and-hash of every result set, per live query, per reconnecting socket, in the restart storm this package is benchmarked on. count would have been wrong had it ever gained a reader: advance seeds its set from the already-truncated ids, so a delete past CURSOR_ID_LIMIT never decremented it. @ultimat3/flags and @ultimat3/ai keep their own fnv1a and are untouched.

Entries 7, 10, 12 and 15 — a caller can see the difference

7. introspect() returns app tables only.

RelationBeforeNow
an ordinary or partitioned tablereturnedreturned
a view, a materialised view, a foreign tablereturnedexcluded
anything Postgres records as extension-owned (pg_depend, deptype = 'e')returnedexcluded
a table someone created by handreturnedreturned, and still unexpected-table

IntrospectOptions.exclude no longer decides the set on its own — it narrows what survives the rule above, and cannot bring an excluded relation back. This is what makes a stock managed Postgres deployable: create extension pg_stat_statements in public is the CNPG, RDS, Supabase and Neon default, and its view read as unexpected-table with x db gen "add pg_stat_statements" as the printed fix — so every deploy failed terminally and following the fix would have written an extension's internal view into the app's migration set. Ownership rather than a name prefix, because that rule covers the view and misses PostGIS's spatial_ref_sys.

Edit only if a test asserts the statement count: it issues four catalog queries where it issued three.

10. A downstream service now receives x-request-timeout-ms. No edit on the calling side — traceHeaders() is spread by both typed clients before your own headers, so an explicit value still wins.

SituationHeader sent
in a request with 12s left of its budgetx-request-timeout-ms: 12000
in a request whose budget is spentnone — never 0, which the far side reads as "the caller asked for nothing"
in a job, a test, a browsernone; there is no ambient deadline

The receiving end may only be shortened by it: resolveTimeoutMs takes the minimum of its own configured budget and the header. Before this, a 30s gateway budget already spent to t=29 handed the next service a fresh 30s, so work ran for another half minute holding a pool slot and a vendor connection after the caller's socket had already been answered X_TIMEOUT.

12. MCP callers are metered. 120 read and 20 write per minute, per actor, per class — X_MCP_RATE_LIMITED, 429, with Retry-After.

mcpHttpRoute({ server, resolveToken, rateLimits: { read: 600, write: 60 } });
// or defineAppMcp({ …, rateLimits: { read: 600, write: 60 } })

A tools/call naming a destructive: true tool spends write; so does any call this server cannot resolve, fail-closed, so a probing client never gets the cheap bucket. Everything else, initialize included, spends read — a coarse per-route rule would have thrown an agent off on its handshake. The key names the actor and never reaches the caller.

Behind more than one replica, pass the store too — the default counts per process, which is honest for x mcp serve and a lie for N replicas behind one URL, each enforcing the full allowance on its own:

mcpHttpRoute({ server, resolveToken, rateLimitStore: postgresRateLimitStore({ executor }) });

X_MCP_RATE_LIMITED is its own code and not X_RATE_LIMITED because the knob differs: that one's fix: names the HTTP pipeline's buckets, which do not govern this route, so raising them would run and change nothing.

15. Name the queue. X_JOB_CLAIM_QUEUES_EMPTY from both drivers.

- await driver.claim({ queues: [], limit, visibilityTimeoutMs, workerId });
+ await driver.claim({ queues: ['default'], limit, visibilityTimeoutMs, workerId });

An empty list named no queue and meant two different things: every queue on the memory driver, the default queue on Postgres, with ClaimOptions.queues documenting neither. Each meaning is silently wrong in the other's deployment — one takes work this worker was never configured for, the other drains nothing and reads as an idle queue. There is no third meaning to pick.

createWorker passes exactly one queue per pass, so only an embedder calling a driver directly is affected. The memory driver's claim is now async to raise the refusal, so a caller that read its return synchronously gets a Promise.

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

Read these if you built a workaround for one.

ChangeWhat it means
read replicasDATABASE_REPLICA_URL plus a withReplicaReads(fn) scope. Opt in twice and byte-identical when unconfigured. Read-your-writes is the rule, not an option: one write at any depth pins the rest of the scope to the primary, and a transaction is always the primary's. Three consecutive replica failures park it for ten seconds. The URL must name a read-only standby
configureHttp()the entire HTTP tuning surface — CORS origins, body limit, request timeout, max in-flight, rate-limit buckets — was reachable from no app config key that existed. AppConfig has never had an http member, so every fix: line naming http.<key> in app.config.ts resolved against nothing. Call it at module scope in a file under apps/*/. rateLimit.scope stays boot-owned
a durable audit sinkpostgresAuditSink({ executor }), append-only, no purge — retention is a legal question with a different answer per app. The row is a fixed allow-list and never a walk of the Ctx, which on an HTTP surface carries the caller's Authorization and Cookie
aggregates and containmentsum / avg / min / max / approximateCount, and four containment operators. min/max on text is refused (Postgres orders by collation, JS by code unit); avg over money is refused, naming sum() + count()
nullable sort keys orderasc nulls last / desc nulls first, with the null position carried in the cursor. Only a nullable primary-key column is still refused — null = null is unknown, so the tiebreak cannot break a tie. The refusal also moved to plan time: it used to fire only when a next page existed, so it was green on 15 seeded rows and X_INVARIANT_VIOLATED on the first real read
a policy gate step, twentiethevery permission an app grants or requires must be one it declares. the scaffold shipped an app that answered X_PERMISSION_UNKNOWN on two of its three routes — status 500 — under a green gate. It skips in the framework monorepo, which declares no roles
X_MANIFEST_MISSINGan app root with no x.manifest.json fails the manifest step. Nothing ever ran x manifest, so the file did not exist in any app x new produced while the step reported green. Run x manifest once and commit it
scripts/declaration-readers.tsevery leaf key of every primitive declaration needs a reader in shipped source. 173 leaves across 18 roots, ratchet at zero
MCP minLength / maxLength count code pointsthe validator counted UTF-16 code units while the schema that publishes those numbers counts code points, so an astral-character argument was passed and then refused by the action's own parse, or refused outright on a bound the agent had obeyed
x i18n add <locale>, x dev --port Na locale file that turned the gate red printing a fix that repaired nothing; and a dev server dying on port N+1 with a caught Error rendered into the cause and X_CLI_UNEXPECTED rather than a stable code

Edit this page on GitHubThis page as markdown