Skip to content

v27.7.0

Choose a tag to compare

@sebyx07 sebyx07 released this 10 Oct 23:38
· 28 commits to main since this release
dc0bcfb

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: openNatsClient set waitOnFirstConnect, under which nats@2.29.3 retries a failed first dial past its attempt counter, forever. A web, worker or scheduler pod 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, and health.readiness: 'process' could not help because no listener existed yet. The role now decides (runtime-bus.ts, selectTransport(env, realtime, { use })): sync and the replicator still need the bus and await it; web, worker, scheduler and 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 sync node 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 with X_TRANSPORT_UNAVAILABLE, whose cause names the server, the wait and the last attempt's own failure, and whose fix names the variable realtime.urlEnv points 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's last attempt: is never empty.

  • A publish with no live connection is refused at once, not buffered. While the background dial is in flight, publishChannelEvent and the cache-invalidation broadcast reject with X_TRANSPORT_UNAVAILABLE immediately. A publish made after the client's socket has closed is refused the same way, in every role: the nats client 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"} on x.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 is flushProcessTiers() on @ultimat3/cache — CacheTier.clear() on the in-process tier (lruTier implements 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, /readyz false 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 sync node 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 of transport.shared everywhere else (NatsTransportOptions.presenceBucket).

  • A dead bus no longer takes a publishing role out of the ingress. /readyz in the default 'dependencies' mode answered 503 on every web pod 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 /readyz in either mode, and a 503 on /readyz?deep=1. The transport check is degraded on web, worker and scheduler and stays failing on sync and 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 started carries a bus field — nats(up), nats(connecting) or in-process (ServedApp.bus) — and ultimate bus is logged with bus: '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 is cache.broadcast.subscribe-failed; that subscribe is woken by the bus coming up instead of waiting out its own backoff.

  • realtime.enabled: false never waits for the bus. With no realtime role to start, no process of such an app awaits the dial, ROLE=sync included (it is refused by rolesUnderRealtime, as before, and now without first waiting on NATS). The connection is still opened in the background under transport: '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. ReadinessStatus is now 'ok' | 'degraded' | 'failing', so an exhaustive switch over it (a never default) stops compiling until it handles 'degraded'. TransportSelection gained the required use and state(), so a hand-built one (a test double) must add both; selectTransport is the only thing in the framework that builds one. ServedApp.bus is optional. CacheTier gained the optional clear(). 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 isr page 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/a matched no route while /blog/a matched /blog/:slug. Every prefixed page was stored with no ttl and joined no tag: invalidateTags and the route's own revalidate.ttl both 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-only s-maxage=60 under a route that declared its own, and carried no Surrogate-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's ttl, tags and 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 a ttl, and the ttl alone now expires it in every locale.

  • The reference app proves it. examples/dummy's blog is isr under two routed locales, and nothing asked a prefixed page to change: apps/web/e2e/blog-locales.e2e.test.ts publishes a post over HTTP and reads /blog, /es/blog and the article under both spellings (a stored 404 first) from the served app.

Tier 5 — cli.

  • x g no longer corrupts a commented role map. x g entity / x g resource / x g policy add permissions to apps/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's grants, definePermissions([…]), a defineApi list, 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" is X_PERMISSION_UNGRANTED / X_DB_HANDLE_UNREGISTERED naming the lines to add, never a write.
  • A permission is declared only where it can be granted. In a role map with no admin role, x g entity declared <table>:read|write|delete in the first definePermissions([…]) beside a finding that said nothing was granted — three names no role held. It now declares none of them, and one that any definePermissions([…]) call already declares is not declared again in the first.
  • x g entity <second> --feature <feature> writes the feature's second entity. It was X_GENERATE_CONFLICT on the first entity's files, with a --force that would have overwritten the first table. A slice whose entity.ts declares another table now gets entity-<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.next names its own table. The same entity again is still the conflict — as is an entity.ts whose 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.ts in a db package of the app's own. The handle was created in any app that lacked the file — including one whose packages/db/src/index.ts exports no handle (a schema and a seed over @ultimat3/db's client), where nothing imports it. It is created only where the index names ./client or there is no index; otherwise X_DB_HANDLE_UNREGISTERED on the index says what the generated repo expects, and nothing is written there.
  • x g --dry-run lists 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.ts over a disk seam): data.files is the run's list, data.edits the subset that already exists, data.refreshes the 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 run ok: false too. A real run's list also names packages/i18n/src/index.ts when it changed it.
  • x g admin:page --permission admin:blog:write is accepted. The flag required one colon exactly, which refused every namespaced permission (admin:<area>:<verb>) though Permission is ${string}:${string} and definePermissions() takes it. Two segments or more; an empty segment, a space or a quote is still X_CLI_BAD_FLAG with a runnable example.
  • The same scan, in the other registrars. apps/web/api/index.ts: a comment between two entries of a defineApi list was split into entries, and a new list in a one-line defineApi({ … }); 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: a locales: { … } 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)