Skip to content

chore: version packages - #245

Merged
adesege merged 1 commit into
mainfrom
changeset-release/main
Sep 20, 2026
Merged

adesege merged 1 commit into
mainfrom
changeset-release/main

Conversation

@github-actions

@github-actions github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.

Releases

[email protected]

Minor Changes

  • a753e55: Move routing onto plain Hono with lazy OpenAPI generation, add declarative response caching on Cloudflare Workers Caching, and add per-path locale detection.

    Routing and validation

    • Build the router on plain Hono with per-route validation and lazy OpenAPI generation. Validation is attached only to routes that declare params, query or body, so a schema-less route pulls in none of it: a hello-world worker drops from 944 KB to 504 KB raw. ctx.param(), ctx.query() and ctx.body() are unchanged.
    • Validate request and response schemas asynchronously, so a schema may carry a refinement that reaches a database, a cache or a service binding to decide whether a value is acceptable. Such a refinement runs inside the request's DI scope, so it needs no context threaded into the schema, and a failure now surfaces as a 400 carrying the refinement's message rather than a 500 with no field detail. Fully synchronous schemas are unaffected.
    • Accept full schema metadata in describe() and named(), not just a description string — example, examples, title and deprecated all flow through to the generated OpenAPI document.
    • Add route visibility groups. @Controller and route options take a groups: string[] label list, exposed on each route's schema metadata, so the OpenAPI routeFilter can scope a document by group instead of by path string.

    Response caching

    Add declarative HTTP response caching through the new stratal/response-cache entry. On a cache hit the Worker never runs, so no CPU is billed.

    • @Cacheable({ ttl, browserTtl, swr, tags, vary }) on GET and HEAD routes emits CDN-Cache-Control and Cache-Control alongside Cache-Tag. browserTtl defaults to ttl, which is what makes a repeat visit free rather than a round trip; set browserTtl: 0 where retraction has to be reliable, since tags and ctx.cache.purge() reach the shared cache and nothing else.
    • @PurgesCache({ tags, pathPrefixes, purgeEverything }) purges after a 2xx or 3xx. The purge is awaited and a failure is rethrown as CachePurgeError, rather than leaving the cache silently inconsistent with the database.
    • ResponseCacheModule.forRoot({ defaults }) supplies ttl, swr and vary for every @Cacheable route. Defaults never make a route cacheable on their own — @Cacheable stays mandatory.
    • Interpolate {param.*}, {query.*}, {data.*} and {partition.*} into cache tags, with a .* suffix fanning an array out to one tag per element. A rendered tag must be printable ASCII with no space, comma or double quote and at most 1024 bytes, or it throws InvalidCacheTagError — slugify any request-derived value before interpolating it.
    • Cache guarded and per-tenant routes with @Cacheable({ partitionBy: [...] }). Export cachedEntrypoint(stratal) from stratal/workers alongside your default export, then configure gateway: { entrypoint: 'Cached' } with partitions and primers. Partitioned reads are forwarded to that entrypoint, which places the resolved partitions in the part of the cache key that cannot be bypassed. gateway.entrypoint is type-checked against your Worker's real exports once you have run wrangler types. A guarded route is only ever cacheable with a non-empty partitionBy, and a partition that fails to resolve runs inline and is stamped private, no-store rather than being cached publicly.
    • Put a response's representation in the cache key with gateway: { keyBy: [...] }, so one URL answered two ways is two entries rather than one entry with two variants. An Inertia app behind a gateway should set keyBy: INERTIA_VARY_HEADERS, exported from @stratal/inertia; without it those pages stop caching.
    • Strip X-RateLimit-* from a response a shared cache may store. They describe one caller's budget, so on a shared response they are replayed to every other caller, and a cache hit never runs the throttle to count them down. The limit is still consumed and enforced; only the reporting is withheld. Adds isSharedCacheable and PER_CALLER_RATE_LIMIT_HEADERS.
    • Requires "cache": { "enabled": true } in wrangler.jsonc, Wrangler 4.69.0 or newer, and a compatibility_date of 2026-07-06 or later. Without those a @Cacheable route is served uncached and stamped private, no-store, and the reason is logged once per entrypoint.
    • New errors: ResponseCacheConfigError, CachePurgeError, InvalidCacheTagError.

    Storage

    • Add head(), list() and deleteMany() to StorageService. head(path, disk?) reads an object's size, content type, etag, upload time and custom metadata without transferring its body, or null when nothing is stored there. list(options?, disk?) pages objects under a disk-relative prefix and carries truncated and cursor, so loop while truncated is true to cover a whole prefix. deleteMany(paths, disk?) deletes in bulk instead of one call per file.
    • Stop serving arbitrary stored content types inline from downloads. An object stored as text/html, or as a scriptable image/svg+xml, previously executed against whatever session fetched it, since objects are served from the application's own origin. Only application/pdf, image/png, image/jpeg, image/gif and image/webp now render inline; everything else returns as an attachment. Every download also carries X-Content-Type-Options: nosniff and a sandboxing Content-Security-Policy.
    • Fix downloads of keys containing a space, a non-ASCII character, # or ? — most user-supplied filenames — being reported as missing, and stop a key containing a control character producing a malformed header. Non-ASCII filenames are preserved.

    Internationalisation

    • Add per-path locale detection: detection accepts a (path) => options resolver, alongside I18nModule.forRootAsync and a strategy-aware ctx.setLocale. Different areas can now use different strategies — a path-localized public site with a cookie-localized /admin panel, say — which is necessary when an area's session cookie is path-scoped. Only routes whose path resolves to strategy: 'path' get a /:locale variant; everything else is served at its bare path with no change to URL builders. The resolver must be a pure function of the path, since it is consulted both at boot and per request.
    • The cookie strategy scopes the locale cookie by the resolved cookieOptions, so a per-path cookie area writes { path: '/admin' }. Plain strategy: 'cookie' behaviour is unchanged.
    • Fix localized multi-segment URLs matching the wrong route when two or more locales are path-prefixed, which could produce a redirect loop on a homepage that redirects elsewhere.

    Quarry CLI

    • Run the Quarry host on Miniflare 5. Quarry now requires Wrangler 4.124 or newer. Local state still lands in .wrangler/state/v3/<plugin>, so existing KV, D1, R2, Durable Object and cache state carries over and is still shared with a running wrangler dev.
    • Stream command output to the terminal as it is produced rather than only after the command finishes, so long-running commands show progress live.
    • Source process.env into worker vars and secrets, so CI and scripted runs that pass config through the environment no longer fail validation on a missing binding. Local runs with a .dev.vars are unchanged.
    • Stop failing with The Workers runtime failed to start on a worker that declares a Cloudflare Workflow. Workflow bindings are stripped from the host and logged — trigger workflows from the worker that defines them.
    • Fix mcp:serve and mcp:tools failing to start: both built the OpenAPI document from the root container, but commands run in a request scope and the document needs the request-scoped OpenAPI config service.
    • Match the Workers socket contract in the Node polyfill, so closing a socket resolves once it is closed and a TLS upgrade returns the upgraded socket. Sending mail through the CLI was the common path affected.

    Other fixes

    • Limit.distinctBy(value) counts distinct values in the window instead of requests, for caps like "ten different courses a day".
    • Honour a Response returned by a short-circuiting middleware even when an outer middleware forwards control with await next() and discards the result. An early ctx.redirect(...) was previously dropped, leaving the request unfinalized and throwing "Context is not finalized". Next is widened to () => Promise<Response | void> so a forwarding middleware can return next() without a cast.
    • Let errors contribute structured fields to their own log entry through an overridable reportContext() hook on ApplicationError. A failed validation now logs which field failed and why, where it previously logged only a generic line.
    • Stop /openapi.json failing when a route schema contains a type with no JSON Schema representation, such as z.custom, z.date or z.set. Those emit an empty schema instead of throwing, so one unrepresentable field no longer takes down the whole document.
    • Fix route registration failing when the router module is evaluated more than once, for example under a bundler or an SSR module runner.
    • Declare openapi3-ts as a direct dependency, which a clean install such as CI could not otherwise resolve.

    Breaking Changes

    • The validation API is zod/mini. The z re-export from stratal/validation is removed. Import schema builders directly from zod/mini using named imports and replace classic chaining with the functional API: z.string().min(1).optional() becomes optional(string().check(minLength(1))). stratal/validation still exports cuid2 and withZodI18n, plus describe() and named() for descriptions and OpenAPI component ids, since zod/mini has no .describe() or .meta().
    • OpenAPI documents are generated lazily, on the first request to the docs endpoint. OpenAPIService.getSpec() becomes getSpec(container) and is async — update any direct call. routeFilter is now a metadata predicate (route: RouteSchemaMeta) => boolean instead of (path, pathItem); filter on route.groups or route.meta rather than on the path string.
    • Every response now carries an explicit Cache-Control header. Routes without @Cacheable are stamped private, no-store. This affects every app, not only those adopting caching: Cloudflare applies heuristic freshness to a response carrying no Cache-Control at all, caching a 200 for two hours, so the explicit header is what keeps an uncacheable route uncached. Routes that set their own Cache-Control are left alone — if you relied on a response having none, set one explicitly.
    • CacheService.put is now fire-and-forget and can no longer report failure. It schedules the write, resolves immediately and logs a rejection instead of throwing, so try { await cache.put(...) } catch { … } now sees success even when the value was never stored. A cache is best-effort, and a KV write can add hundreds of milliseconds to a request, so this is the right default — but move any write that must not be silently lost to CacheService.putDurable / TieredCacheService.putDurable, which await the write and throw on failure. delete is unchanged and remains durable and awaited.
    • Storage downloads no longer render arbitrary content types inline. Only application/pdf, image/png, image/jpeg, image/gif and image/webp render inline; everything else downloads as an attachment. If you relied on another type rendering in the browser, serve that content from a separate origin, where a compromise cannot reach the application's session.
    • Guards now deny when canActivate returns false, with GuardRejectedError (403), instead of the return value being ignored. Audit your canActivate implementations before upgrading — requests that previously reached the handler now 403. GuardRejectedError is also re-exported from @stratal/framework/guards.
    • Quarry requires Miniflare 5, which comes in with Wrangler 4.124 or newer. Apps on an older Wrangler must upgrade before npx quarry will start.

@stratal/[email protected]

Minor Changes

  • a753e55: Add cursor pagination, share permissions with the client for Inertia access control, and add a Workers-safe database pool factory.

    Cursor pagination

    Add db.$cursor for reading a list one page at a time, positioned by an opaque cursor rather than an offset.

    const page = await db.$cursor.thread.findMany({
      cursor: ctx.query("cursor"),
      take: 20,
      orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
      where: { userId },
    });
    // → { data, perPage, cursorName, cursor, nextCursor, prevCursor }
    • Rows added, removed or updated around the reader do not shift the page, so walking a list neither skips a row nor repeats one.
    • orderBy is required and must end in a unique column, so tied rows do not share a position. where, select, include and omit work as on the model's own findMany, and select narrows the result type.
    • Pass the result to @stratal/inertia's ctx.scroll() as it is, or return it from a JSON route. Cursors are opaque — pass back the one a result gave you.
    • A transaction client carries the same reader, and db.$cursor.$from({ findMany }, …) pages a UNION or a raw statement.
    • Distinct from ZenStack's own cursor argument, which is offset-based and correct only while the list is unchanged.

    Access control and auth

    • Share the current user's permissions and roles automatically once accessControl is configured, so the client can gate on them. This backs the <Can>, <Cannot>, <HasRole> and <HasNoRole> components and the useCan, useRole and useAccess hooks in @stratal/inertia, with permission strings and role names type-checked against a generated registry.

    • Add AUTH_GATEWAY_PRIMERS, exported from @stratal/framework/auth, so guarded and per-tenant routes can use @Cacheable({ partitionBy: [...] }). The response-cache gateway resolves partitions outside the app's middleware chain, so a resolver calling ctx.user() would otherwise throw on every request:

      ResponseCacheModule.forRoot({
        gateway: { entrypoint: "Cached" },
        primers: AUTH_GATEWAY_PRIMERS,
        partitions: { user: (ctx) => ctx.user().id },
      });
    • Carry the cookies a session read issues through to the response. The Set-Cookie Better Auth writes while reading a session was previously discarded, so under session.cookieCache the cached-session cookie was minted on every request and reached the browser on none, and a session passing session.updateAge never delivered its extended expiry — a browser's copy expired on the schedule it was first given rather than sliding. Cookie names the handler has already written are left alone, so sign-out still clears them.

    • Fix role reads and writes failing for any app whose ZenStack user model is not named exactly User. Setting a user's role, reading another user's roles, checking a permission and listing a user's permissions all threw when the model resolved to a different accessor, such as a pluralized Users. Changing a role now also refreshes that user's sessions, so it takes effect immediately.

    • Adapt the Better Auth rate-limit bridge to the new atomic consume storage. createBetterAuthRateLimitStorage() now returns { consume }, and records expire after the rule's own window instead of a fixed day, so stale counters no longer linger in KV. Accuracy follows the configured store, exactly as Stratal's own throttling does: exact in memory, best-effort on KV, where concurrent writes from different edge locations may undercount. If you pass your own rateLimit.customStorage, it must now implement consume — Better Auth no longer accepts get/set.

    Database

    • Add createPoolFactory(env, makePool) to @stratal/framework/database, which chooses connection topology from the environment instead of hard-coding it. Write const pool = createPoolFactory(env, () => new Pool(config)), then dialect: () => new PostgresDialect({ pool }). By default it returns a fresh pool per resolution, which is mandatory on the Workers runtime, where a pool opened in one request's I/O context cannot be reused by a later one without the runtime cancelling the cross-request I/O and hanging the request. The pool is created lazily on first query, so nothing opens a socket at module scope. In production Hyperdrive fronts these pools, so they never accumulate.
    • Resolve a connection's database client once per request instead of once per injection. The client was transient, so a request resolving a controller, a guard and four services built six clients over six pools for work that shares a single I/O context. Every entrypoint already runs inside a request scope, so no caller changes. Sharing one client also makes the reentrant-$transaction guard effective across services, where separate clients could previously deadlock on a small pool.
    • Await the configuration factory in DatabaseModule.forRootAsync. A factory that actually returned a promise handed initialization a Promise and it walked undefined connections. An asynchronous factory now works as documented, which is what lets a consumer put a generated schema behind an import() rather than evaluating a large schema module while the isolate starts.
    • Make disposing a shared test-harness database connection idempotent, so shutdown no longer logs "Called end on pool more than once". Fresh-per-resolution pools used in dev, staging and production are unchanged.

    Breaking Changes

    • The validation API is zod/mini. The z re-export is gone from the validation surface this package re-exports. Import schema builders directly from zod/mini using named imports and replace classic chaining with the functional API: z.string().min(1).optional() becomes optional(string().check(minLength(1))). Use describe() and named() from stratal/validation for descriptions and OpenAPI component ids.
    • OpenAPI documents are generated lazily, on the first request to the docs endpoint. OpenAPIService.getSpec() becomes getSpec(container) and is async, and routeFilter is now a metadata predicate (route: RouteSchemaMeta) => boolean instead of (path, pathItem).
    • Guards now deny when canActivate returns false, with GuardRejectedError (403), instead of the return value being ignored. Audit your canActivate implementations before upgrading — requests that previously reached the handler now 403. GuardRejectedError is re-exported from @stratal/framework/guards, so apps that standardise on that path can instanceof it without a second import.
    • A custom Better Auth rateLimit.customStorage must implement consume, replacing the previous get/set pair.

Patch Changes

@stratal/[email protected]

Minor Changes

  • a753e55: Add build-time SSR exclusion, client-side access control and ctx.scroll() for infinite scroll, and make Inertia pages cacheable.

    Build-time SSR exclusion

    • Add ssrExclude to the stratalInertia() Vite plugin. Client-only pages and their heavy dependencies were previously always bundled into the worker, because the SSR page glob pulled in every page; disabling SSR at runtime skipped rendering but still shipped the code.

      stratalInertia({ ssrExclude: ["Admin/**", "Reports/Heavy"] });

      Patterns are matched against the page name, where * is a single segment and ** any number. Excluded pages are dropped from the worker bundle and rendered client-only, while the browser bundle still includes them so they hydrate normally.

    • Apply ssrExclude to an array-form page glob such as import.meta.glob(['./pages/**/*.tsx', '!...']), keeping the negative patterns it already had. Only the single-string form worked before, so an array-form resolver silently kept every page in the worker bundle. A glob that cannot be rewritten now emits a build warning naming the file.

    • Rewrite import.meta.glob resolvers that pass a second argument, such as { eager: true }, preserving those options.

    Client-side access control

    • Add the <Can>, <Cannot>, <HasRole> and <HasNoRole> components plus the useCan, useRole and useAccess hooks, on a new @stratal/inertia/react/access entry. They are gated on permissions the server shares automatically once accessControl is configured, and permission strings and role names are type-checked against a generated registry.

    Infinite scroll

    Add ctx.scroll(callback, options?) for Inertia v3 infinite scroll, which makes @inertiajs/react's <InfiniteScroll> work against a Stratal route. Until now the page carried no scroll metadata at all and the component threw before rendering.

    return ctx.inertia("notes/Index", {
      notes: ctx.scroll(() => this.service.paginate(page), { matchOn: "id" }),
    });
    • The identifiers are derived; there is nothing to restate. Two shapes are recognised directly: the offset shape of paginatedResponseSchema, and @stratal/framework's db.$cursor result. Any other shape throws UnrecognizedScrollShapeError rather than guessing, because a wrong next page reads to the client as "no more pages" and silently truncates the list. Pass metadata to name the identifiers for a third-party shape.
    • The prop value keeps its paginator shape and only the rows under wrapper (default data) accumulate. Options are wrapper, matchOn, pageName and metadata.
    • matchOn has never deduplicated anything. Entries were emitted in a form the client resolved to no prop, so every merge fell back to plain concatenation. A row that changes between two pages of a merged list is now collapsed instead of appearing twice.
    • X-Inertia-Reset is now honoured. The header was parsed and discarded, so a prop the client named in it was joined to rather than replaced.
    • Adds the assertInertiaScrollProp(prop, expected?) assertion and exports UnrecognizedScrollShapeError alongside the scroll option and metadata types.

    Caching

    • Cache partial reloads, and with them every ctx.defer() prop, by declaring the Inertia protocol headers in Vary on every response. Deferred props are delivered by a follow-up partial reload, and those were refused outright, so a page that defers its expensive work kept all of that work uncached and caching bought close to nothing. Adds the INERTIA_VARY_HEADERS export naming the set. Vary now lists these names on every Inertia response, where it previously listed only X-Inertia, so anything asserting on that exact header value needs updating.
    • Skip caching for pages that cannot be shared between callers: a page carrying flash data or a once() prop is not cached. On a cache hit the SSR render is skipped entirely, so a cached page costs no render.
    • Narrow into a nested prop on a partial reload instead of answering with the whole of its parent. only: ['auth.user'] asks for one field of auth; sending all of auth is the payload the partial reload was made to avoid. Prop metadata now names every entry by its full path, so a defer() nested under another prop is advertised where the client will look for it.

    Server rendering and dev runtime

    • Add a prepare(page) hook to createInertiaSsrApp, which runs once per render(page) call and hands its result to setup as prepared. It exists so a request-scoped value can reach the tree without a module-level variable — a Workers isolate serves many requests concurrently, so module-level "current request" state is a cross-request leak. Omit it and prepared is undefined, which the type now enforces.
    • Recycle the dev worker when its memory reaches a threshold, fixing frequent dev-server crashes in large apps. Under sustained HMR the dev isolate's heap grows until it hits the V8 limit and the worker aborts, which the browser shows as "Fetch failed". quarry inertia:dev now keeps the dev server alive, with a default threshold of 900 MB configurable through --heap-limit=<MB>. Supervision runs on macOS and Linux; elsewhere it is disabled with a warning.
    • Strip react-dom's unused legacy synchronous server renderer from the worker SSR bundle, dropping around 197 KB raw from a minimal app. SSR is streaming-only, so renderToString and renderToStaticMarkup are not available in the worker.
    • Fix every SSR page returning a 500 with ReferenceError: require is not defined or module is not defined under the Workers dev and SSR runtime. React 19's server entry, react-dom/client, the ORM data layer and the email renderer all reach CommonJS through packages excluded from Vite's optimizer, so their conditional require reached the worker runtime unconverted. An app that happened to import react-dom elsewhere was unaffected, while a minimal app failed on every request.
    • Fix a guest SSR render failing at app init with createPoolFactory is not a function under a linked or portal checkout.
    • Export DocumentRendererService, which renders a built Page into an HTML document and owns the single decision between streaming SSR and a client-only shell. InertiaService and @stratal/inertia-modal both delegate to it, so anything rendering an Inertia document outside those paths should inject the token rather than duplicate the branch.

    Fixes

    • Answer a version mismatch with a real 409 instead of a 500. The mismatch branch set a status and headers but returned no response, so configuring version turned every stale client into a server error rather than the reload the check exists to trigger.
    • Send the current asset version on that response, so the client can tell "this client is out of date" apart from an ordinary external redirect. The cancelable location event now reports versionChange: true, and async visits are left alone instead of reloading the page underneath a background request; both were previously unreachable.
    • Reconcile the client head on a visit that only changed the props of the component already on screen. Closing a modal is exactly that shape, so the head previously kept the level's title while the address had moved back to the page's.
    • Type the shared page props of an app that registers Inertia from a config namespace. inertia:types read sharedData and accessControl out of src/app.module.ts alone, and only as a literal, so an app composing its modules elsewhere or passing config.asProvider() had every shared prop reach pages as {} and access control never resolve. Both are now read wherever the registration lives, and a provider argument is followed back to the factory it came from.
    • Pick up ctx.modal() calls in the type generator the same way as ctx.inertia(). If you hand-wrote prop types for a modal page, remove them and let the generated type be the only source.
    • Fix two type-generator bugs that gave page props the wrong types: ctx.share() calls were not detected at all, and shared props wrapped in always(), defer(), optional(), merge() or once() were typed as the wrapper instead of the value it resolves to.
    • Stop inlining the full i18n message-key union into page-prop types, which can shrink generated declaration files by an order of magnitude on apps with large key sets. Nullable and optional key unions no longer defeat detection, and props covering the full key set reference MessageKeys from stratal/i18n.
    • Add SeoService.contributed(), which reports whether anything has called ctx.seo() on this request — what a caller rendering one page over another needs in order to keep the underlying page's metadata instead of overwriting it with the defaults.
    • Add InertiaService.resolveProps() and partialRequestFor(), so a caller assembling its own page can resolve props with the same semantics render() applies.

    Breaking Changes

    • ssr.disabled is removed from InertiaModule.forRoot({ ssr }). Replace it with the Vite plugin's ssrExclude, which both skips SSR and drops the excluded pages from the worker bundle: stratalInertia({ ssrExclude: ['Admin/**'] }).
    • ctx.withoutSsr() and the withoutSsr context variable are removed. SSR exclusion is now build-time and declarative, so there is no per-request runtime opt-out.
    • Vary now lists every Inertia protocol header on every response, not just X-Inertia. Update anything asserting on that exact value.
    • The validation API is zod/mini. The z re-export is gone from the validation surface this package re-exports. Import schema builders directly from zod/mini using named imports and replace classic chaining with the functional API: z.string().min(1).optional() becomes optional(string().check(minLength(1))).
    • OpenAPI documents are generated lazily, on the first request to the docs endpoint. OpenAPIService.getSpec() becomes getSpec(container) and is async, and routeFilter is now a metadata predicate (route: RouteSchemaMeta) => boolean instead of (path, pathItem).

Patch Changes

@stratal/[email protected]

Minor Changes

  • a753e55: Stack modal routes above one another with the stack held by the browser, server-render modal levels, and make the prop helpers work inside ctx.modal().

    Nested stacks

    • A level nests when its base names the modal route below it; otherwise it starts a fresh stack. Each level owns its URL — path and query string — so a direct visit or refresh renders the whole chain, and closing a level lands where it was opened from, with the query it was opened under.
    • A level keeps a stable identity while it stays open at the same URL, so a refresh, or a level opening above it, does not remount it and lose in-progress form state.
    • Opening a sheet is one request that renders one component. The page behind it is rendered only for a direct visit or a refresh — the one case with nothing already on screen — so a sheet reached by a redirect no longer costs a second render of the page beneath it.
    • <ModalLink> opens a sheet and carries the visit options that keep the page behind it in place.
    • useModal() gives you modal, depth, isTop, close(), closeAll(), refresh(), reload() and visit(), and browser Back closes exactly one level. refresh() takes router.get's options, reload() takes router.reload's, and close()/closeAll() take router.visit's, so a caller can time the visit it started rather than the next one to finish. All are properties holding closures, so destructuring one is safe.
    • refresh(query) re-reads the open level under a refined query — applying a filter, a sort, or a code the server prices. The level is recognised by its own URL, so a refinement never reads as a second sheet of the same route opening.
    • isModalBackground(ctx) tells a route it is being rendered as the page beneath a modal, so a route that answers clients with a redirect can render instead. Without it that redirect is followed back to the modal and surfaces as ModalBaseCycleError, which is added here and thrown when a base chain leads back to a route already in it. A client cannot make isModalBackground answer true.

    Closing a level

    • Closing lands on the page or the level the sheet was opened from, which stays mounted — so regions that were showing content keep showing it instead of falling back to a placeholder, and the page keeps the props it holds. That landing costs one request.
    • Where a level closes to is decided once, when it opens, and travels with the level, so a refresh of the sheet no longer loses the query the list beneath was filtered by. A level lands on the page, never on another level that is still open: a visit made from a sheet sends that sheet as its Referer, so the two could previously aim at each other and no amount of closing ever reached the page.
    • Closing repeatedly unwinds the whole stack rather than reopening the level that just closed, and dismissing a whole stack lands the same way as closing the outermost level.
    • The first browser Back after closing lands where the close already landed, so it appears to do nothing.
    • Rows a level had already loaded survive a close instead of restarting from the first page, and the level below is handed back under the identity it already had — so its scroll metadata and merge target stay at the path its mounted components read.

    Prop helpers, scroll and SSR

    • defer, merge, once and scroll now work inside ctx.modal(). A level was previously built from the raw argument and never run through prop resolution, so none of them had any effect; all four now behave in a sheet exactly as they do on a page.

      return ctx.modal(
        "Parent/Index",
        {
          items: ctx.scroll(
            () =>
              db.$cursor.item.findMany({
                cursor,
                take: 20,
                orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
              }),
            { matchOn: "id" },
          ),
        },
        { base: "/parent" },
      );
    • <Deferred> and <InfiniteScroll> re-exported from @stratal/inertia-modal/react resolve data against the modal they render in, so the same JSX works in a sheet and on a page and no caller composes the wire path itself.

    • A partial reload that names nothing about the level leaves it alone, so the client keeps what it holds and the level's outstanding defer() props are not advertised a second time — re-announcing them made the client fetch each one again for every reload the surrounding page made. A partial reload naming a level's props resolves just those.

    • Modal levels now server-render. <Modal /> previously initialised its stack as empty state and filled it in two effects, so no level ever appeared in server-rendered HTML; levels now resolve before the tree renders and reach first paint. The open stack is also held outside the page component, so a background-page remount no longer destroys every open sheet.

    • Pass createInertiaApp's resolve through withModals() in both the client and SSR entries. There is no bootstrap step to run before hydrating and no provider to wrap the tree in.

      createInertiaApp({
        resolve: withModals((name) => pages[`./pages/${name}.tsx`]()),
        setup: ({ el, App, props }) => hydrateRoot(el, <App {...props} />),
      });
    • This makes first-paint modal content possible; it does not make every modal's markup appear. A level whose content renders inside a Radix Portal still will not appear in server HTML, since createPortal is an inherently client-side DOM operation. If you want a level's content in first paint, that level has to render without a Portal.

    • Render a modal route's background page client-only when that page is excluded from SSR through ssrExclude. A direct visit or refresh of such a modal route previously failed with Page not found and a 500.

    • Keep a sheet on screen while the level replacing it loads, rather than leaving the screen with no sheet for as long as the component takes to arrive.

    Testing

    Import @stratal/inertia-modal/testing alongside @stratal/inertia/testing. Eight assertions, chainable like the Inertia family, plus two readers:

    await response.assertModalComponents(["Parent/Index", "Parent/Edit"]);
    await response.assertModalProp("item.id", "42");
    
    const level = await response.modalLevel<{ items: Item[] }>();
    expect(level.props.items).toHaveLength(1);
    • assertModal(callback?) / assertNoModal(), assertModalComponent(component, depth?), assertModalComponents(components), assertModalCount(count), assertModalDepth(depth), assertModalProp(path, expected, depth?), assertModalOnly(), plus modalLevel<TProps>(depth?) and modalLevels<TProps>().
    • assertModalBase() and assertModalClose() assert what a level sits over and where closing it lands — a wrong close target is otherwise invisible until someone taps Close and does not arrive.
    • Not one of them names a payload field, so a later payload change costs you nothing. Only a direct visit or a refresh reports what sits beneath, so the whole-stack assertions fail on any other response rather than reporting 1.
    • resetModalState() empties everything the package holds outside the React tree, so a test runner sharing the module across tests does not carry one test's open sheet into the next.
    • modalPropPath(prop) is exported for a test naming a level's prop path.

    Fixes

    • ctx.seo() on a modal route now reaches the page. A level's metadata is written to the prop the client head-sync reads and injected into the document head on a direct visit, so the sheet's own title and description apply while its URL is the address. It was previously discarded outright, and a direct visit rendered no SEO tags at all. A level that never calls ctx.seo() leaves the background page's metadata untouched.
    • A modal route answers with the flash the request carries, instead of an empty one. A submission that flashed its result and redirected into a sheet — a purchase outcome, a confirmation — previously lost it outright.
    • Fix <InfiniteScroll> inside a level requesting the same page over and over without adding rows, and silently stopping after a sheet opened over it is closed. A scroll fetch is now recognised as the level it came from asking for the next page of its own prop, and a scrolled level keeps the URL it was opened under so neither the close target nor a later refresh drifts a page at a time.
    • Leaving a level that holds an <InfiniteScroll> no longer throws about a missing scroll prop. The subscription now ends with the level, however the level was left — closing it, the back button, a link elsewhere — and a level still open keeps the rows it had loaded.
    • A level is recognised under either spelling of its path, so an app appending a trailing slash no longer draws a second copy of a sheet it already has open.
    • Leave an open level alone when a response is not addressed to it. A partial reload for a prop of the page beneath a modal — a poller, a defer() prop of that page — was answered with the level attached and carrying no props, and the client seated that empty level back on the chain, so the sheet lost every prop it held and a level reading one as it renders went blank with an uncaught TypeError.
    • Errors this package raises — a failed background fetch, a base chain that cycles — now read as English sentences rather than raw message keys. The modal.* keys are exported, so an app running i18n can translate or override any of them. A base answering 2xx with a body that is not a page reports a 502 carrying the parse failure as its cause.
    • Keep server-only code out of the @stratal/inertia-modal/react entry, so pages hydrate in development. The entry reached request handling that runs on node:async_hooks; a production build dropped it as unused, but a development build shipped it to the browser where it cannot resolve, so no page rendering Modal hydrated.
    • Keep the render of the page beneath a modal inside the isolate that issued it, rather than forwarding it to a response-cache gateway where the background marker means nothing — which refused a base page that answers with a redirect, and could store that render under the visitor's own cache key.

    Breaking Changes

    • ctx.inertiaModal(component, props, { baseURL }) is replaced by ctx.modal(component, props, { base }). Rename the call and the option at every call site, including modal routes whose background is another modal route — there is no separate call for those.

    • MODAL_VISIT and MODAL_REFRESH are removed. Replace <Link {...MODAL_VISIT}> with <ModalLink>, and a MODAL_REFRESH visit with refresh() from useModal().

    • useModal().redirect() is renamed to close(). Update const { redirect } = useModal() to const { close } = useModal(), and any onClick={redirect} to onClick={close}.

    • useModal() no longer returns show or props. Read modal instead: it is undefined outside a modal, and carries the level's props.

    • useModalPropPath is removed. A level's props sit at a fixed path, so reload({ only: ['items'] }) from useModal() names them by bare name.

    • prepareModalComponents, rememberModalComponents, clearModalComponents and ModalComponentsContext are removed. Pass resolve through withModals() instead. A test suite that called clearModalComponents between tests wants resetModalState().

    • modalPropPath moves to @stratal/inertia-modal/testing and takes only a prop name.

    • ModalNestingLimitError, ModalPayloadMismatchError and ModalRequestHeaderError are removed, along with every modal request header. Nothing about the stack travels to the server any more.

    • A response carries one modal, at page.props.modal, addressed at modal.props.<name> with no key in the path; the keyed and positional containers are both gone, and ModalData.nativeBack with them. No runtime code outside the package reads the payload, so this affects only tests asserting on it directly — move those onto the assertions above rather than onto the new field names:

      -expect(body.props.modal.stack[0].component).toBe('Parent/Edit')
      -expect(body.props.modal.stack[0].props.item.id).toBe(target.id)
      +await response.assertModalComponents(['Parent/Edit'])
      +await response.assertModalProp('item.id', target.id)
    • Modal levels now server-render, where they were previously always drawn in after hydration. A consumer relying on client-only mounting inside a level — a useLayoutEffect that assumed it would never run on the server, say — should read this as a behaviour change, not a fix.

Patch Changes

@stratal/[email protected]

Minor Changes

  • a753e55: Give each test file its own leased database, drain deferred work before a test finishes, and supply the cache and gateway bindings the runtime never populates.

    Database isolation

    • Give every test file its own database, leased from a fixed pool of worker slots rather than created per file. Each file takes the lowest free slot, <base>_w_<slot>, and gets a fresh clone of the migrated template in it; the slot frees when the pool disposes the file's isolate. A run therefore holds at most as many databases as it runs files at once, so a long suite no longer piles up one database per file until Postgres runs out of disk mid-run.
    • Per-file isolation is deliberate: the Workers test pool isolates storage per file and can run a worker's files concurrently, so any database shared across files corrupts under CI latency. Within a file, tests reset state through truncateDb or the reset engine.
    • createTestDatabaseGlobalSetup accepts a one-time prepare hook to bake expensive baseline state — seed data, a default tenant schema — into the template once, so every file's database inherits it through the clone instead of rebuilding it per test.
    • createTestDatabaseGlobalSetup now returns a teardown, so a run reclaims what it created instead of leaving the leftovers for the next run's setup to find. Consumers pass it to globalSetup exactly as before and need no change. The sweep is connection-guarded and skips a slot that is currently leased, so a database cloned but not yet connected to is never dropped by a concurrent process.
    • truncateDb(name?, opts?) accepts a ResetOptions preserve-list; migration tables matching _prisma% are always preserved.
    • Default database-isolation projects to a 30 second hook timeout, since cloning a template under a full worker slot routinely exceeds Vitest's 10 second default and fails with "Hook timed out in 10000ms". This is a floor, not a ceiling: an explicit hookTimeout, fileParallelism or isolate on the consuming project is now respected instead of being overwritten.
    • Share one database pool per connection in the harness and tear it down exactly once. The harness runs against a direct Postgres with no Hyperdrive to multiplex, so a fresh pool per resolution accumulated until parallel files exhausted the server's connection limit with "sorry, too many clients already". Disposing a connection no longer logs "Called end on pool more than once".

    Bindings and lifecycle

    • Supply the ctx.cache binding so cache-decorated routes are testable with no configuration. Neither Miniflare nor workerd populates it, so without this a single @Cacheable or @PurgesCache route would fail an app's entire suite on the first request. Test.createTestingModule() installs a stub by default: @Cacheable routes return real Cache-Control and Cache-Tag headers, and purges succeed, recording each PurgeSpec in call order on module.cache.purges. Pass cache: false to opt back into the unconfigured runtime.
    • Supply a ctx.exports stub by default so adopting the response-cache gateway does not break existing suites. Assert forwarded requests and their resolved partitions through module.gateway.loopbacks. The stub answers to any export name, so a passing suite is not what proves your configured entrypoint is correct — the type check against your Worker's exports is.
    • Drain work a request defers through ctx.waitUntil before fetch() resolves, mirroring the Workers runtime. A non-blocking listener's deferred database write previously stayed in flight past the response and could still be running at the next request or at teardown, where disposing that resource hung the suite past the hook timeout.
    • Drain deferred work in close() before tearing the app down. fetch() already drained per call, but the websocket, SSE and Quarry helpers share the same queue, so a suite using only those could reach teardown with writes still in flight and race the connection pool's disposal.

    Testing surface

    • Test.createTestingModule() accepts trailingSlash and versioning and passes both to the Application it builds, taking the same shapes as on the Stratal constructor. A testing module never runs the app's entry file, so an app configuring either there previously had it in production only — its suite asserted URL shapes that configuration would never emit. Both stay unset by default.
    • Support head(), list() and deleteMany() in fake storage, so the new storage methods are exercisable without R2. list() returns one object per page unless a limit is passed, so a caller that ignores cursor fails in tests instead of undercounting against a real bucket, and contentType and metadata are omitted unless includeMetadata: true, matching what R2 returns.
    • Stop module.inertia sending a hard-coded X-Inertia-Version. It sent '1', so any app configuring a real asset version had every request read as a stale client and answered with a 409 — an entire Inertia suite failing on a value the tests never chose. A test that wants the mismatch path can ask for it with module.inertia.withHeaders({ 'X-Inertia-Version': 'stale' }).
    • Fix chunked uploads to the fake storage service failing with ReadableStream is disturbed when the body is a single-use stream, which is the shape a chunked upload delivers.
    • Keep stratalTest() typed against a single Vite instance. Vitest and @stratal/inertia resolved two different copies, which surfaced as a Plugin that would not assign to Plugin, an "excessive stack depth" comparison, and a missing test key on UserConfig.

    Breaking Changes

    • There is now a single database isolation model. The shared and database isolation toggle is gone, along with the isolation option on both stratalTest({ database }) and createTestDatabaseGlobalSetup. Pass stratalTest({ database: {} }) to enable isolation and delete any isolation: option.
    • stratalTest({ database }) now requires isolate: true and throws on isolate: false.
    • createTestDatabaseGlobalSetup now requires schema. Add it if you were relying on the previous default.
    • @cloudflare/vitest-pool-workers is now @cloudflare/vitest-plugin. Update the dependency, any direct import of it, and the types entry in your test tsconfig.json. npx @cloudflare/codemods vitest:pool-workers-to-vitest-plugin does all three. A config that only calls stratalTest() needs no change beyond the dependency. stratalTest() and its options are unchanged, and the integration supports Vitest 4.1 and later.

Patch Changes

@stratal/[email protected]

Patch Changes

  • a753e55: Release alongside the rest of the packages; nothing changed in this one.

    Every Stratal package is versioned as one fixed group, so @stratal/feature-flags is republished at the same version as the packages it builds on rather than being left behind. Its peer ranges are open-ended, so an existing install keeps resolving — upgrade only to keep one aligned set of versions across the framework.

  • Updated dependencies [a753e55]

  • Updated dependencies [a753e55]

@adesege adesege left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark

Details
Benchmark suite Current: 4a953f1 Previous: ccb3f17 Ratio
test/benchmarks/request-response.bench.ts > Request/Response > simple GET - 200 36969.844135137144 ops/sec (±1.32%) 44355.827899388205 ops/sec (±1.52%) 1.20
test/benchmarks/request-response.bench.ts > Request/Response > GET with route params - 200 45996.258581652866 ops/sec (±0.62%) 49077.55133303189 ops/sec (±1.07%) 1.07
test/benchmarks/request-response.bench.ts > Request/Response > POST with JSON body - 201 16285.125619036411 ops/sec (±1.18%) 24127.579263272528 ops/sec (±1.28%) 1.48
test/benchmarks/request-response.bench.ts > Request/Response > POST invalid body - validation error 4010.3241256510996 ops/sec (±3.11%) 4332.798367697519 ops/sec (±3.08%) 1.08
test/benchmarks/request-response.bench.ts > Request/Response > GET unknown route - 404 5999.544886524008 ops/sec (±1.44%) 6877.491973422911 ops/sec (±1.38%) 1.15
src/__benchmarks__/application.bench.ts > Application - Bootstrap > constructor only 369828.7943581282 ops/sec (±0.64%) 367439.1967778971 ops/sec (±2.58%) 0.99
src/__benchmarks__/application.bench.ts > Application - Bootstrap > full initialize() 36418.94472465844 ops/sec (±1.20%) 40916.51636711715 ops/sec (±2.82%) 1.12
src/__benchmarks__/application.bench.ts > Application - Service Resolution > resolve service after bootstrap 42820.22578676439 ops/sec (±1.37%) 46221.77899438597 ops/sec (±1.51%) 1.08
src/__benchmarks__/application.bench.ts > Application - Multi-Controller Bootstrap > initialize with 5 controllers (8 routes) 42603.526163578375 ops/sec (±0.72%) 47297.584538015675 ops/sec (±0.94%) 1.11
src/__benchmarks__/application.bench.ts > Application - Multi-Controller Bootstrap > resolve service after multi-controller bootstrap 41885.811932707955 ops/sec (±0.65%) 46712.398512126 ops/sec (±0.86%) 1.12
src/di/__benchmarks__/container.bench.ts > Container - Registration > register class provider 4271033.145793519 ops/sec (±1.68%) 3659523.004609583 ops/sec (±0.26%) 0.86
src/di/__benchmarks__/container.bench.ts > Container - Registration > registerSingleton 4026860.8724799873 ops/sec (±2.13%) 3674735.919157303 ops/sec (±0.23%) 0.91
src/di/__benchmarks__/container.bench.ts > Container - Registration > registerValue 4580004.16800319 ops/sec (±2.24%) 3643329.0527346027 ops/sec (±0.97%) 0.80
src/di/__benchmarks__/container.bench.ts > Container - Registration > registerFactory 4670144.692362833 ops/sec (±0.74%) 3731349.291036125 ops/sec (±0.79%) 0.80
src/di/__benchmarks__/container.bench.ts > Container - Resolution > resolve class token 1407599.000603626 ops/sec (±0.42%) 1231183.1036989056 ops/sec (±0.71%) 0.87
src/di/__benchmarks__/container.bench.ts > Container - Resolution > resolve symbol token 1424788.6749439533 ops/sec (±0.41%) 1316018.4734202325 ops/sec (±0.72%) 0.92
src/di/__benchmarks__/container.bench.ts > Container - Resolution > resolve value token 1933069.095325256 ops/sec (±2.28%) 1783899.5774658315 ops/sec (±0.30%) 0.92
src/di/__benchmarks__/container.bench.ts > Container - Resolution > resolve singleton token 1352330.2527890466 ops/sec (±0.43%) 1315930.5814242992 ops/sec (±0.55%) 0.97
src/di/__benchmarks__/container.bench.ts > Container - Resolution > isRegistered check 2106508.9635955375 ops/sec (±0.20%) 1806360.778904609 ops/sec (±0.19%) 0.86
src/di/__benchmarks__/container.bench.ts > Container - Conditional Binding > when().use().give().otherwise() 2462564.419034541 ops/sec (±3.00%) 1989351.3462046375 ops/sec (±2.25%) 0.81
src/di/__benchmarks__/container.bench.ts > Container - Conditional Binding > when() with cached predicate 2439603.0290414556 ops/sec (±0.63%) 2077218.3049918814 ops/sec (±0.64%) 0.85
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Registration > register single module 1353256.167691235 ops/sec (±0.62%) 1318623.2747570868 ops/sec (±0.43%) 0.97
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Registration > register 3-level module tree 679841.4819609317 ops/sec (±0.44%) 674368.8967327109 ops/sec (±0.61%) 0.99
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Registration > register dynamic module (forRoot) 1132211.8075242015 ops/sec (±0.39%) 1033574.8692689876 ops/sec (±0.46%) 0.91
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Initialization > initialize with lifecycle hooks 820519.6717920463 ops/sec (±0.57%) 861264.3797109328 ops/sec (±0.67%) 1.05
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Collection > getAllControllers 603021.1461219145 ops/sec (±0.47%) 601067.2979531069 ops/sec (±0.70%) 1.00
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Collection > getAllConsumers 599319.9916095391 ops/sec (±0.44%) 598005.5897686194 ops/sec (±2.06%) 1.00
src/module/__benchmarks__/module-registry.bench.ts > ModuleRegistry - Collection > getAllJobs 602324.2990362238 ops/sec (±0.40%) 606220.5693193878 ops/sec (±0.44%) 1.01
src/router/__benchmarks__/route-registration.bench.ts > RouteRegistration - Configure > register controller with 5 OpenAPI routes 16893.46751790366 ops/sec (±6.80%) 20659.201604495036 ops/sec (±7.81%) 1.22
src/router/__benchmarks__/route-registration.bench.ts > RouteRegistration - Configure > register single-route controller 84336.03405772212 ops/sec (±8.21%) 87549.06480088776 ops/sec (±9.59%) 1.04
src/router/__benchmarks__/route-registration.bench.ts > RouteRegistration - Configure > register multiple controllers 17238.784700155928 ops/sec (±6.85%) 20055.348642388202 ops/sec (±7.48%) 1.16
src/router/__benchmarks__/route-registration.bench.ts > Route Sorting > sort 10 routes by specificity 334083.91447458765 ops/sec (±1.72%) 348297.84898624144 ops/sec (±1.02%) 1.04
src/router/__benchmarks__/route-registration.bench.ts > Route Sorting > sort 50 routes by specificity 63716.075009950684 ops/sec (±0.38%) 66342.982298651 ops/sec (±0.49%) 1.04
src/router/__benchmarks__/route-registration.bench.ts > Route Sorting > sort 100 routes by specificity 30807.01343620418 ops/sec (±0.38%) 32949.193140155745 ops/sec (±0.54%) 1.07
src/router/__benchmarks__/route-registration.bench.ts > Param Extraction > extractParamNames - static path 12304712.0558419 ops/sec (±0.13%) 12503091.774915366 ops/sec (±0.09%) 1.02
src/router/__benchmarks__/route-registration.bench.ts > Param Extraction > extractParamNames - single param 1801191.7982674923 ops/sec (±0.44%) 2209847.5226742206 ops/sec (±1.46%) 1.23
src/router/__benchmarks__/route-registration.bench.ts > Param Extraction > extractParamNames - multiple params 1295222.9353269946 ops/sec (±0.65%) 1332989.981337998 ops/sec (±0.60%) 1.03

This comment was automatically generated by workflow using github-action-benchmark.

@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 4a953f1 to 1e4b937 Compare September 20, 2026 19:17
@coderabbitai

coderabbitai Bot commented Sep 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: strataljs/stratal/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 1fc1a85a-e850-4180-b5a2-7c756162265c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@adesege
adesege merged commit 3a58990 into main Sep 20, 2026
2 checks passed
@adesege
adesege deleted the changeset-release/main branch September 20, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant