Repository navigation
v27.7.0
Fixed
Tier 0 — core. Tier 1 — cache. Tier 3 — realtime. Tier 5 — cli.
-
NATS is no longer a boot dependency of a role that only publishes to it. Under
realtime: { transport: 'nats' }every role awaited the first dial before it bound a socket, and that dial never gave up:openNatsClientsetwaitOnFirstConnect, under which nats@2.29.3 retries a failed first dial past its attempt counter, forever. Aweb,workerorschedulerpod that restarted while NATS was down (an OOM, a node drain, a rollout, a scale-up) therefore hung in boot: pages, webhooks and jobs were down for a bus those roles only send "re-read" events to, andhealth.readiness: 'process'could not help because no listener existed yet. The role now decides (runtime-bus.ts,selectTransport(env, realtime, { use })):syncand the replicator still need the bus and await it;web,worker,schedulerand the dev MCP host dial it in the background on a jittered backoff (500 ms doubling to 30 s) and boot at once. Nothing to change in an app. The table is Realtime → What each role needs from the bus. -
A
syncnode or a replicator without the bus fails fast and says why. The dial is retried for 15 s (BUS_CONNECT_WAIT_MS) and then refused withX_TRANSPORT_UNAVAILABLE, whose cause names the server, the wait and the last attempt's own failure, and whose fix names the variablerealtime.urlEnvpoints at. It used to sit silent until something killed it. Each attempt carries a 4 s connect timeout (DIAL_TIMEOUT_MS; the library's is 20 s), so a server that accepts the socket and says nothing still fails attempts inside the wait and the refusal'slast attempt:is never empty. -
A publish with no live connection is refused at once, not buffered. While the background dial is in flight,
publishChannelEventand the cache-invalidation broadcast reject withX_TRANSPORT_UNAVAILABLEimmediately. A publish made after the client's socket has closed is refused the same way, in every role: thenatsclient buffered it without bound and replayed the lot when the server returned, and it has no option that forbids the buffer, so the transport reads the client's own connection flag before it hands a publish over. One window is left and is stated rather than hidden: a connection whose packets are silently dropped reads as connected until two pings go unanswered — now 10 s apart (PING_INTERVAL_MS,MAX_PINGS_OUT), so within 30 s where the library's default is six minutes — and a publish made inside it is buffered and sent on reconnect. Publishing resumes with no restart when the bus is back, on first connect and after every loss. -
A cache bust made while the bus is away reaches the other replicas when it returns. The replica that made it, Redis and the CDN are cleared as before and the broadcast lands in the report's
errors(cache.invalidate.partial). The refused wire tags are now kept — de-duplicated, at most 1,024 (MAX_DEFERRED_TAGS) — and published on the transport's next reconnect, or after the next publish the bus accepts; past the bound the process owes one flush-all marker ({"flush":"all"}onx.cache.invalidate) instead of a queue, so nothing grows with an outage. What is owed is published by one settle at a time and taken before its publish — put back if the bus refuses — so overlapping reconnects send a batch once and a bust refused mid-publish is never cleared by a success that did not carry it. And every process drops what it holds in its own heap when its connection comes back, or when its boot subscribe lands after a refusal: it heard no peer for a window nobody measured. That isflushProcessTiers()on@ultimat3/cache—CacheTier.clear()on the in-process tier (lruTierimplements it; a shared tier does not), every tag-revalidated ISR page marked stale (a peer's bust reaches those pages by this broadcast and by nothing else), and every fill in flight fenced out. The cost is one cold in-process cache per replica per NATS outage. A replica running an older build ignores the marker and waits for its TTLs, as it always did. -
The replicator rides out a NATS restart. A publish refused because the bus is away ended the run on the spot — stream stopped, lock released, backoff, re-read from the last lsn,
/readyzfalse throughout — which the refusal above would have made the cost of every blip. The handler now retries the same envelope three more times over 2 s (PUBLISH_RETRY_DELAYS_MS), fenced after each wait, and only then ends the run for the takeover loop.stop()ends a wait in flight at once. A refusal that is not the bus being away (a payload over the server's limit) is not retried. -
Only a
syncnode requires JetStream at boot. The presence bucket was asserted on every dial of every role, so a publisher failed on a nats-server without-js. It is asserted at the dial where presence is served and on first use oftransport.sharedeverywhere else (NatsTransportOptions.presenceBucket). -
A dead bus no longer takes a publishing role out of the ingress.
/readyzin the default'dependencies'mode answered 503 on everywebpod at once while NATS was down. A readiness check now has a third answer,'degraded'(registerReadinessCheck(name, check, { onFailure: 'degraded' }),ReadinessStatus): reported by name, never a 503 on/readyzin either mode, and a 503 on/readyz?deep=1. Thetransportcheck isdegradedonweb,workerandschedulerand staysfailingonsyncand the replicator. A monitor that must see a bus outage on a web pod reads?deep=1, as Deployment already says to. -
The boot line says where the bus is.
ultimate startedcarries abusfield —nats(up),nats(connecting)orin-process(ServedApp.bus) — andultimate busis logged withbus: 'nats(up)'each time the connection lands. A dial that keeps failing is reported on attempts 1, 2, 4, 8, … rather than once per attempt, and so iscache.broadcast.subscribe-failed; that subscribe is woken by the bus coming up instead of waiting out its own backoff. -
realtime.enabled: falsenever waits for the bus. With no realtime role to start, no process of such an app awaits the dial,ROLE=syncincluded (it is refused byrolesUnderRealtime, as before, and now without first waiting on NATS). The connection is still opened in the background undertransport: 'nats', because cache invalidation between replicas crosses it. -
Types that changed, though nothing here is
BREAKING. Reading code is unaffected; code that builds or exhausts one of these needs an edit.ReadinessStatusis now'ok' | 'degraded' | 'failing', so an exhaustiveswitchover it (aneverdefault) stops compiling until it handles'degraded'.TransportSelectiongained the requireduseandstate(), so a hand-built one (a test double) must add both;selectTransportis the only thing in the framework that builds one.ServedApp.busis optional.CacheTiergained the optionalclear(). New names:ReadinessCheckOptions,BusUse,BUS_CONNECT_WAIT_MS,NatsTransport.connectInBackground(),connect({ withinMs }),NatsTransportOptions.presenceBucket/dialTimeoutMs,flushProcessTiers,FLUSH_ALL_TAG.
Tier 4 — render. -
A locale-prefixed
isrpage revalidates. The ISR store key keeps the routed locale prefix (/en/blog/a?__x_locale=en, one document per URL), and the lookup that finds the key's ROUTE stripped the query and not the prefix — so/en/blog/amatched no route while/blog/amatched/blog/:slug. Every prefixed page was stored with nottland joined no tag:invalidateTagsand the route's ownrevalidate.ttlboth expired the default locale's copy and left the prefixed one, which answered a withdrawn post with a 200 (or a 404 stored before publication) for the life of the process, advertised the tag-onlys-maxage=60under a route that declared its own, and carried noSurrogate-Key, so a CDN purge missed it too. The route is now matched on the path with its routed prefix removed (unlocalizedPath, the split the router's own match is built on); the key is unchanged, so entries stay one per locale and a regeneration renders in the entry's locale. A segment naming no routed locale is still not stripped. Nothing to change in an app: prefixed pages take their route'sttl,tagsand purge keys from the first render after the upgrade. A page gated on the clock (publishedAt <= now) is busted by no write when the instant passes; it needs attl, and thettlalone now expires it in every locale. -
The reference app proves it.
examples/dummy's blog isisrunder two routed locales, and nothing asked a prefixed page to change:apps/web/e2e/blog-locales.e2e.test.tspublishes a post over HTTP and reads/blog,/es/blogand the article under both spellings (a stored 404 first) from the served app.
Tier 5 — cli.
x gno longer corrupts a commented role map.x g entity/x g resource/x g policyadd permissions toapps/web/shared/roles.ts, and found each list's end with the first]in the raw text and its entries with a quote regex: an apostrophe in a comment ("the firm's") was a string delimiter, so the comments between two of them were rewritten into permissions and the file no longer loaded. Every list a generator grows — a role'sgrants,definePermissions([…]), adefineApilist, the handle's entity set — is now located on the masked source (source-list-edit.ts): a list holding a comment gains rows and no existing byte moves, a comment-free one is re-wrapped the way Biome prints it, a spread entry survives, and the result is read back before it is written. An edit that does not read back as "the same list plus the new entries" isX_PERMISSION_UNGRANTED/X_DB_HANDLE_UNREGISTEREDnaming the lines to add, never a write.- A permission is declared only where it can be granted. In a role map with no
adminrole,x g entitydeclared<table>:read|write|deletein the firstdefinePermissions([…])beside a finding that said nothing was granted — three names no role held. It now declares none of them, and one that anydefinePermissions([…])call already declares is not declared again in the first. x g entity <second> --feature <feature>writes the feature's second entity. It wasX_GENERATE_CONFLICTon the first entity's files, with a--forcethat would have overwritten the first table. A slice whoseentity.tsdeclares another table now getsentity-<name>.ts+repo-<name>.ts(and a test beside each), registered on the handle, in the role map and in the admin labels like a first entity;data.nextnames its own table. The same entity again is still the conflict — as is anentity.tswhose table the generator cannot read (a constant, an interpolation): only a file that readably declares other tables hosts a sibling.- No
packages/db/src/client.tsin a db package of the app's own. The handle was created in any app that lacked the file — including one whosepackages/db/src/index.tsexports no handle (a schema and a seed over@ultimat3/db's client), where nothing imports it. It is created only where the index names./clientor there is no index; otherwiseX_DB_HANDLE_UNREGISTEREDon the index says what the generated repo expects, and nothing is written there. x g --dry-runlists every file the run touches. It listed the files a generator creates and none it edits: the role map, the typed handle, the API index, two manifests and the i18n index changed under a plan that never named them. The dry run now runs the same edits against the plan in memory (generate-follow-ups.tsover a disk seam):data.filesis the run's list,data.editsthe subset that already exists,data.refreshesthe committed contracts a real run re-projects, and a finding the run would report (X_PERMISSION_UNGRANTED,X_DB_HANDLE_UNREGISTERED,X_ADMIN_RESOURCE_UNWIRED) makes the dry runok: falsetoo. A real run's list also namespackages/i18n/src/index.tswhen it changed it.x g admin:page --permission admin:blog:writeis accepted. The flag required one colon exactly, which refused every namespaced permission (admin:<area>:<verb>) thoughPermissionis${string}:${string}anddefinePermissions()takes it. Two segments or more; an empty segment, a space or a quote is stillX_CLI_BAD_FLAGwith a runnable example.- The same scan, in the other registrars.
apps/web/api/index.ts: a comment between two entries of adefineApilist was split into entries, and a new list in a one-linedefineApi({ … });was written outside the call (now skipped, the file untouched).packages/db/src/client.ts: a}in a comment closed the entity set.packages/i18n/src/index.ts: alocales: { … }in a comment was the one edited.apps/admin/app/admin/admin.ts: a brace in a string label miscounted the call.
Commits
- fix(realtime): NATS is not a boot dependency of a role that only publishes; a sync node without the bus fails in 15 s, coded; a publish with no live connection is refused, never queued (#755)
- fix(render): a locale-prefixed isr page revalidates (#756)
- fix(cli): x g edits source lists syntax-aware; a feature's second entity; no stray db client; --dry-run lists every file; three-part permissions (#754)