Ultimate

3.0.0 → 4.0.0, entry by entry

Twenty-five BREAKING — entries. Most are one of two shapes: a declaration nothing read, deleted rather than implemented, and a surface that answered the wrong thing, corrected. Full rationale per row in CHANGELOG.md's 4.0.0 section.

Start here — these three change behaviour whether or not you edit anything:

SurfaceThe edit
on delete now reaches the generated SQL. Any app that ever declared references(…, { onDelete }) generates different DDLrun x db gen and read the diff before migrating. Every add constraint this framework had ever emitted dropped the rule, so the database has been refusing deletes under a declared cascade. Drift also gains changed-foreign-key, whose fix: hands over a drop constraint / add constraint pair — add constraint alone is 42710 on a name already taken
llm()'s cache.semantic.scope receives { input, ctx } and defaults to the calling actor, not 'global'scope: (input) => input.orgId → scope: ({ ctx }) => ctx.actor.orgId ?? 'none', or delete scope and take the default. A semantic lookup is a cosine nearest-neighbour with no tenant predicate, so the old shared store answered one tenant with another tenant's completion — reproduced at similarity 1.0. A deliberately shared cache must now say so
reapBranches() skips branches whose base is not current_database()none, and re-read it if you run two Ultimate apps on one Postgres: listBranches() walks pg_database for the whole server, so one nightly sweep was dropping the other app's branches. A pre-4.0 marker records no base and is now skipped rather than dropped; the next createBranch writes it down, so it self-heals with no migration

Deleted because nothing read them — in every case the edit is "delete the option":

SurfaceThe edit
CaptureOptions.timeoutMs and CaptureRequest.timeout (@ultimat3/scraping)delete them. The port required a timeout, page-over-target.ts threaded it, and no driver honoured it
ScrapeTarget.click's index parameterdelete it. It was unreachable from the public vocabulary — ScrapeFrame.click takes (selector, options?) and has no index — and the two drivers disagreed on it
PrecacheAsset.critical (@ultimat3/pwa)delete it. buildPrecacheManifest never copied it, and the documented promise ("critical assets are precached even if large") was vacuous — there is no size filter at all
PERIODIC_SYNC_TAG, BackgroundSyncOptions.periodicMinIntervalMs (@ultimat3/pwa)delete them. Periodic Background Sync was never implemented in any sense: no listener, no registration, no capability flag
realtime.heartbeatMs (RealtimeConfig)delete the key — RealtimeConfig is now { enabled, tier, transport, urlEnv }. The socket beat is new LiveClient({ heartbeatMs }) (browser code, which cannot read server config) and the presence beat is derived. There is no runtime refusal: section() copies unknown keys through, so a stale key is silently inert (25.0.0 refuses it at boot)
@ultimat3/seo no longer exports extensionOfdelete the import; parseImageQuery reads the format off the query
@ultimat3/realtime no longer exports qidOf or canonicalJsonchange the import: queryHash from @ultimat3/query, canonicalJson/fingerprint from @ultimat3/core. No live subscription re-keys — the two spellings differed only on values JSON cannot carry

Corrected, because they answered the wrong thing:

SurfaceThe edit
adminResource no longer pluralises an entity nameset path: explicitly if you relied on the doubled URL. Every entity in both tracked apps is already named plural, so entity('orgs') was served at /admin/orgses. Which plural a name takes is an app's convention, not a mechanism the framework can own (axiom 8)
A local disk's signed URLs carry the registered disk name, not the driver kindnone, if you use defineStorage — it calls registerAs(diskName) at boot. A disk registered as uploads used to 404 every signature it had just written
ordinal(value) takes no localedelete the second argument. It picked the plural category with your locale and appended the English suffix regardless, so ordinal(1, 'de') was '1th'
registerFrameworkCatalog() and registerMailCatalog() take no localedelete the argument. defineCatalogs called them once per locale, seating the English-only catalog under every locale an app declared — an app shipping only es served English chrome with isMiss reading false, which is a fallback locale chain the i18n package forbids by name
t.date refuses a date-time with no offset and no Zsend 2026-08-19T10:00:00Z. 2026-08-19T10:00 resolved against the host process's zone, so one wire value meant a different instant on each pod — reachable from a request through coerceQuery, and published as format: 'date-time', which RFC 3339 requires an offset for
in with a non-array operand matches no rows on both driverspass an array. It matched one row in Postgres (the scalar was wrapped) and none in memory; in with a NULL in the list disagreed in the other direction, and the SQL now emits (col in (…) or col is null)
isValidCron / parseCron refuse an unsatisfiable day/month pair ('0 0 30 2 *')fix the expression; the refusal names the pair. It used to parse clean and then burn ~184ms of blocking CPU per tick in the scheduler's leader loop before throwing
createRateLimiter({ now }) → createRateLimiter({ clock }){ config, now: () => t } → { config, clock: { now: () => new Date(t) } }. Callers that passed neither are unaffected
requiresApp is enforced by the dispatchernone, unless a script matched on the old message. Outside an app, x secrets set and its siblings now answer X_NOT_IN_APP
NackOptions.countsAsAttempt: false no longer files a job suspendednone. "Do not burn an attempt" and "this is a step.sleep suspension" were one flag, so the worker's limiter and job.concurrency sheds pushed rows out of ready — and queue_depth / queue_oldest_ready_seconds under-reported because of it
A read whose input carries a Date, Map or Set gets a new cache key and cursor scope, oncenone. Object.keys(date) is [], so every date rendered {} and one key answered for every date window a read ever served. Affected cursors answer X_CURSOR_INVALID once with "request the first page again"; ordinary inputs are byte-identical

Type-level, for hand-built literals and exhaustive switches:

SurfaceThe edit
ColumnDescription / ReferenceDescription gain onDelete: OnDelete | nulladd the field to hand-built description literals (a test fixture, a custom generator). null is Postgres' no action and is the old behaviour
DriftKind gains changed-foreign-keya switch over DriftKind with no default no longer compiles
BranchInfo gains base: string | nullre-type if you built the shape by hand
Five generators write typed test filenamesre-run the generator, or rename by hand. x verify selects a suite by filename, so a generated contractTest(…) inside a plain *.test.ts ran under unit while x test contract answered X_TEST_NO_FILES — a step that passed by having nothing to run. x g action/x g mutator now also write <name>.contract.test.ts, x g query --live writes <name>.live.test.ts, and x g job/x g task/x g backfill write <name>.job.test.ts

One migration to run: the x_jobs idempotency index gains the tenant. It was (name, idempotency_key) while the row already carried tenant_id. x db migrate applies it.

Edit this page on GitHubThis page as markdown