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.
| # | Surface | Costs you an edit if |
|---|---|---|
| 1 | every cursor minted before 12.0.0 is X_CURSOR_INVALID | you persisted a cursor — in a URL, a job payload, a client store. Nothing fails to compile |
| 2 | the primary-key tiebreak takes the last declared key's direction | you depended on the order of rows sharing a sort value. Nothing fails to compile |
| 3 | an index declaring where or order is renamed <table>_<cols>_<hash8>_idx | you declared one — a migration, before the deploy |
| 4 | a physical column or table name must be [a-z_][a-z0-9_$]*, at most 63 bytes | you wrote .column('createdAt') or an entity(name) / table that is not lower-snake |
| 5 | Repo gains aggregate and approximateCount; ReadBuilder gains five terminals | you implement Repo or Driver yourself |
| 6 | Operator gains contains, contained-by, overlaps, has-key | you switch exhaustively over Operator |
| 7 | introspect() returns app tables only | you read its output, or assert how many catalog queries it issues |
| 8 | rateLimitKey is deleted; RateLimitConfig.tenantBucket is required | you called rateLimitKey, or built a full RateLimitConfig |
| 9 | Ctx gains a required deadlineAt | you hand-build a Ctx — a test fixture, a custom host |
| 10 | traceHeaders() sends the remaining request budget | never for the caller; a downstream service now receives x-request-timeout-ms |
| 11 | PROTOCOL_VERSION 1 → 2, and five realtime exports are deleted | you imported one — and every deployment redeploys clients and sync nodes together |
| 12 | MCP rate limits are enforced, 120 read / 20 write per minute per actor | an agent exceeded them; it was previously unmetered |
| 13 | memoryAuditSink() is bounded at 1,000 and discards oldest-first | you used it as a system of record, or implement MemoryAuditSink |
| 14 | RouteBudget.css, .cls and .tbt are deleted | you declared one — it was ignored, and now it does not compile |
| 15 | claim({ queues: [] }) is refused; the memory driver's claim is async | you call a JobDriver directly |
| 16 | DoctorProbe 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 lives | Do this |
|---|---|
| a URL a client holds | nothing — request the first page (after: null) and re-mint |
a job payload, a resumable inBatches() position | re-enqueue from the start, or from a business key of your own |
| a client store, a saved view | clear 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 DESCtotalOrder 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 themLiveCursor.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.
| Relation | Before | Now |
|---|---|---|
| an ordinary or partitioned table | returned | returned |
| a view, a materialised view, a foreign table | returned | excluded |
anything Postgres records as extension-owned (pg_depend, deptype = 'e') | returned | excluded |
| a table someone created by hand | returned | returned, 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.
| Situation | Header sent |
|---|---|
| in a request with 12s left of its budget | x-request-timeout-ms: 12000 |
| in a request whose budget is spent | none — never 0, which the far side reads as "the caller asked for nothing" |
| in a job, a test, a browser | none; 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.
| Change | What it means |
|---|---|
| read replicas | DATABASE_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 sink | postgresAuditSink({ 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 containment | sum / 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 order | asc 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, twentieth | every 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_MISSING | an 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.ts | every 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 points | the 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 N | a 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 |