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:
| Surface | The edit |
|---|---|
on delete now reaches the generated SQL. Any app that ever declared references(…, { onDelete }) generates different DDL | run 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":
| Surface | The 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 parameter | delete 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 extensionOf | delete the import; parseImageQuery reads the format off the query |
@ultimat3/realtime no longer exports qidOf or canonicalJson | change 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:
| Surface | The edit |
|---|---|
adminResource no longer pluralises an entity name | set 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 kind | none, 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 locale | delete 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 locale | delete 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 Z | send 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 drivers | pass 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 dispatcher | none, 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 suspended | none. "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, once | none. 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:
| Surface | The edit |
|---|---|
ColumnDescription / ReferenceDescription gain onDelete: OnDelete | null | add 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-key | a switch over DriftKind with no default no longer compiles |
BranchInfo gains base: string | null | re-type if you built the shape by hand |
| Five generators write typed test filenames | re-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.